Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Suivi — état d’avancement & tâches à venir

Document unique de suivi vivant. C’est le point d’entrée « où en est-on / quelle est la prochaine tâche / par où on est passé / qu’a-t-on fait ». Il est tenu à jour au fil des merges (chaque jalon met à jour l’état + coche/ajoute une tâche). Référencé par CLAUDE.md et AGENTS.md.

Rôle des autres documents (pour ne pas les confondre) :

  • INDEX.mdcarte structurelle : ce qui existe, où le trouver.
  • JOURNAL.mdarchive chronologique append-only (le détail session par session, PR par PR). On y écrit l’historique ; on ne le « maintient » pas comme tableau de bord.
  • roadmap-couche1-libc-pal-fr.mdplan détaillé §-numéroté de la clôture couche 1 (les specs/audits y renvoient via « §5.3 P0.x »). Le statut vivant de ce plan est tenu ici.

🗺️ ROADMAP ORDONNÉE — la carte, au 2026-08-15

Lire ceci en premier. Cette section dit où en est chaque front et dans quel ordre reprendre. Elle a été établie en vérifiant le code, pas en relisant des notes : chaque affirmation ci-dessous a été mesurée le 2026-08-15. Les chiffres sont datés parce qu’ils vieillissent — un chiffre sans date est une affirmation qui se périme en silence.

⚠️ Un chantier est INTERROMPU, pas abandonné : air-launchd temps 3b, et le passage d’air-sshd sous sa supervision. Il a été mis de côté le 2026-08-14 pour traiter la règle NO GO et l’outillage manquant. Il reprend en tête de cette roadmap.

Mise à jour du 2026-08-30. 3b a repris : son incrément d’observation est fusionné (#873), et le gel d’air-launchd posé le 2026-08-24 est levé. La section air-launchd ci-dessous est à jour à cette date ; le reste de cette roadmap date toujours du 2026-08-15 et vieillit — un chiffre sans date est une affirmation qui se périme en silence, et celle-ci a quinze jours.

État par front

air-launchd — supervision

TempsÉtatCe que c’est
1 — canal de lancementfaitsix enregistrements, écoutes remises et non décrites (spec v1.0)
2 — mise en cage à l’execfaitcage dérivée de l’octroi, appliquée avant execve
3a — multi-servicefait (#723)balayage de /var/lib/air/services/, bloc service au manifeste, un seul ppoll sur N services
2b-ii — registre AirCom hébergéfait (#723)le lanceur héberge le registre de noms ; dernier mur du plafond d’octroi tombé
bornage cgroup (spec §4.5.1)fait--max-processes / --memory-high, défauts posés sans être demandés, unlimited explicite, cgroup.kill ; crates/air-cgroup + supervise.rs. ⚠️ Cette ligne disait « RESTE » le 2026-08-15 — c’était faux, corrigé le jour même après vérification du code.
3b — dépendances et ordreFAIT — les quatre tâches sont closes (2026-09-07)Voir le détail juste sous ce tableau. Cette ligne disait « EN COURS, au tiers (au 2026-08-30) » jusqu’au 2026-09-07 : cinq PR l’avaient dépassée sans que le suivi bouge.
jalon B (ADR-168)air-sshd sous air-launchdFRANCHI le 2026-09-07Sur carbon, le critère d’ADR-168 D2 est littéralement vrai : air-sshd.service désactivée et supprimée du disque, air-launchd.service active et enabled, et le démon sert une session SSH complète sur le port 22 sous le lanceur. Voir le détail sous ce tableau. ⚠️ Le jalon avait été annoncé franchi deux fois à tort — le 2026-09-04 (le harnais rendait ok par un return, sans que le démon ait jamais démarré) et le 2026-09-05 (la preuve en test confondue avec le critère, qui est un état de MACHINE).
Jalon B — comment il a été franchi (2026-09-07)

L’état de la machine, relevé après la bascule :

air-sshd.service     ABSENTE (désactivée, supprimée du disque)
air-launchd.service  active, enabled          → survit au redémarrage
0.0.0.0:22           air-sshd, pid 3047388    → SOUS le lanceur, cagé
0.0.0.0:52222        OpenSSH                  → accès d'administration, intact

Une session s’ouvre, le shell rend ses commandes, et le code de sortie est 0.

Trois refus dans cette session ne sont pas des défauts — ce sont des preuves que la cage tient : /etc/bash.bashrc et ~/.bashrc sont refusés par Landlock (le manifeste ne les demande pas), et ps meurt d’un Bad system call — un SIGSYS, donc un appel hors du profil seccomp. Ce qui n’est pas déclaré n’est pas accordé (ADR-181).

Ce que le premier lancement réel a coûté, et rapporté. Onze défauts trouvés en une journée, chacun par un refus du noyau, aucun par un test existant — parce que le harnais du jalon B construit sa ServiceSpec en mémoire et ne lit ni le manifeste, ni l’octroi, ni le plafond. Il prouve le chemin d’exécution ; il ne disait rien du chemin d’installation.

Les corrections ont produit : la catégorie seccomp Privsep (17 appels entrés au vocabulaire), le gabarit {home} (ADR-180), le champ transitionalGlibc au manifeste, et la doctrine du bon citoyen (ADR-181). Détail dans #958 et au journal.

Ce qui restait à faire, et qui a été fait

La preuve en test était acquise depuis le 2026-09-05, avec ses deux conditions — ADR-172 D4 (le démon publie son nom) et ADR-174 D4 (les bornes mesurées). Restaient trois points, dont un seul demandait un arbitrage.

#Ce qui manqueNature
1L’unité air-launchd.service n’est spécifiée nulle partfait — ADR-168 D2.1 (arbitrage BDFL) : le lanceur se place dans une feuille de sa propre hiérarchie cgroup, et son unité porte Delegate=yes. Sans cette ligne, la règle no internal processes lui interdit de distribuer pids/memory, et toutes les bornes tombent en silence
2Le manifeste ne demandait ni io_uring, ni le port, ni sa configurationfait le 2026-09-07 (#953) — un manifeste incomplet est un service qui ne peut jamais démarrer, l’octroi ne pouvant excéder la demande
3Rien ne décrit la bascule ni son retourfait — ADR-168 D2.2 (cohabitation : le port distinct d’abord, la vérification, PUIS le retrait) et docs/guides/bascule-jalon-b-fr.md. Sauvegarde complète conservée sous /root/air-jalon-b-sauvegarde-2026-09-07 sur carbon

Le bundle est installé sur carbon (/var/lib/air/services/org.air-desktop.sshd/), son octroi signé sous /etc/air/grants/, et les deux ont été refaits le 2026-09-07 avec le manifeste corrigé — celui d’avant datait du 2026-07-24.

3b — le détail, remesuré contre le code le 2026-09-07

Fait et fusionné :

  • Incrément 1 (#793) — schéma requires/Readiness, refus sur le seul manifeste.
  • Incrément 1b — le graphe : tri topologique déterministe, cycle nommé, fermeture transitive. discovery.rs porte ordre_topologique() et tri_topologique().
  • Observation de published (#873) — AirRegistry::names_published_by(pid) en couche 1, RegistryHost::has_published(pid) en couche 2, et ServiceSpec::readiness porté depuis le manifeste comme requires l’est déjà. Le lanceur constate la publication sur la table ; le service ne la lui annonce pas.

⚠️ Ce paragraphe disait, jusqu’au 2026-09-07 : « supervise_many ignore l’ordre topologique que discovery calcule […] le readiness livré par #873 ne conditionne rien. »

C’était vrai le 2026-08-30, et faux depuis le 2026-09-01. #889 a rendu le démarrage échelonné le jour même, et quatre PR ont suivi. Le laisser sept jours de plus aurait fait recommencer un travail déjà fait — c’est exactement le coût que ce document existe pour éviter, et il l’a payé cette fois-ci.

Ce que le code fait aujourd’hui, vérifié ligne à ligne :

  • supervise_many.rs ne lance un service qu’après avoir constaté que chacun de ses requires est présent, non échoué et prêt (boucle for besoin in &requires) ;
  • rafraichir_pret interroge RegistryHost::has_published(pid) pour un service readiness = Published, et dit le franchissement (Report::DevenuPret) ;
  • EXPECT_READY ne subsiste que dans quatre commentaires qui racontent son retrait — aucun code ne le pose ni ne le lit.

Reste, dans l’ordre imposé par l’ADR :

#TâcheADR
2.1supervise_many suit l’ordreFAIT le 2026-09-01 (#889, démarrage échelonné)D3
2.2brancher has_publishedFAIT le 2026-09-02 (#909) : le lanceur DIT le franchissementD2
2.3le délaiMESURÉ le 2026-09-05, sur raspi-srv-2 (Pi 4) : READY 16 ms au pire, STOP 2 ms, 20 tours, 0 écarté. Relevé reproduit à l’identique le 2026-09-07. Les bornes restent à 30 000 / 10 000 ms — arbitrage BDFL du 2026-09-03, et parce que la mesure porte sur un démarrage tiède (cf. ADR-174 D4)D6
2.4retirer EXPECT_READYFAIT : il ne reste que quatre commentaires qui en racontent le retraitD2
2.5arrêt en ordre inverseFAIT le 2026-09-01 : stop_everyone procède par passes, et la décision vit dans la fonction pure arretablesD5
2.6la mort d’une dépendanceFAIT le 2026-09-01 : rendre_a_l_attente, décision dans la fonction pure dependances_perdues ; gouverné par ADR-171 (remise en attente, crédit à la faute, transitivité par les passes)D4

(La contrainte « 2.4 ne peut pas venir avant 2.1+2.2 », qu’ADR-161 D2 posait — « le retrait n’intervient qu’avec la mise en œuvre de published, jamais avant » —, a été respectée : les trois sont tombées dans cet ordre.)

3b est donc close. La dernière case, 2.3, l’était depuis le 2026-09-05 sans que ce document le sache — ADR-174 D4 porte le relevé et l’arbitrage. Un second relevé, lancé le 2026-09-07 pour vérifier que la première mesure n’était pas un coup de chance, rend les mêmes chiffres au millième près (16 ms / 2 ms au pire, 20 tours, 0 écarté, même Pi, même noyau). Une mesure reproduite vaut mieux qu’une mesure citée.

Décisions prises et à ne pas rouvrir :

  • Le gel d’air-launchd (décision BDFL du 2026-08-24) est LEVÉ le 2026-08-30. Les deux dérogations SECOND-PATH qu’il retenait — appel direct à air_poll::poll au lieu de la porte — tombent avec lui. Le domaine poll est alors entièrement médié.
  • La marge du délai de D6 : un ordre de grandeur au-dessus du pire observé, arrondi à une valeur lisible. À appliquer une fois le chiffre mesuré, jamais avant.
  • La mesure sur le Pi passe par un job CI workflow_dispatch sur le label air-aarch64, jamais par SSH : un balayage lancé à la main a tué un job de CI le 2026-08-17.

air-sshd / air-ssh — le passage sous air-launchd

Le code est déjà compatible, et c’est le fait le plus important de ce front. air-service est par conception la couture v1(systemd) → v2(air-launchd) ; AirLaunchdHost existe (crates/air-service/src/launchd.rs) ; et air-sshd appelle déjà air_service::detect(), qui aiguille statiquement entre les deux superviseurs. Il n’y a donc rien à réécrire dans le démon.

Ce qui manque est ailleurs, et c’est du déploiement :

  1. le bundle org.air-desktop.sshd — l’identifiant et un manifeste existent (crates/air-sshd/src/bundle_provision.rs), reste à le produire et l’installer ;
  2. l’octroi sur chaque machine (air-bundle grant, geste d’administration humain) ;
  3. l’installation sous /var/lib/air/services/<bundleId>/ pour que le lanceur le trouve ;
  4. la retraite de l’unité systemd crates/air-service/units/air-sshd.service, qui reste aujourd’hui le superviseur réel sur les trois machines.

air-ssh (client) et air-sshd (démon) sont tous deux livrés par le paquet air-sshd.

Lignes de commande

Six paquets livrés : air-account, air-keystore, air-bundle, air-launchd, air-sshd (deux binaires : air-ssh + air-sshd), air-agent.

air-bundle porte cinq verbes : developer-key, grant, install, revoke, show. La politique de saisie du mot de passe administrateur est alignée sur les trois outils d’administration (invite quand l’entrée est un terminal, lecture brute sinon), depuis une implémentation unique dans air-admin-gate.

Il n’existe pas de commande air unifiée. Ce n’est pas un manque constaté mais un choix non tranché : à décider si les verbes doivent un jour se ranger sous un point d’entrée unique.

Couches, contrôleurs et managers (ADR-149 / ADR-152)

cargo xtask check-controleurs : PASS, 91 crates scannées, 19 managers déclarés. Le cliquet xtask/controleurs-reference.toml inventorie 23 entrées d’écart sur 21 crates (air-libc-capi et air-libc-spawn en cumulent deux), dont 5 managers absents — et 19 crates déclarent role = "manager" quand 14 en portent un :

Baisse du 2026-08-21 : air-terminal (AirTerminalManager, 21 opérations coordonnées) et air-account (AirAccountManager, 38 opérations — la plus grosse dette du registre) sont soldés, et tous deux raccordés à air-security. 26 → 24 entrées, 24 → 22 crates, 11 → 13 managers portés.

Baisse du 2026-08-22 : air-device (AirDeviceManager) — le moins cher des huit, sa surface objet était complète et seul le point d’entrée manquait. 24 → 23 entrées, 22 → 21 crates, 13 → 14 managers portés. Le registre ne peut que rétrécir.

CrateNote
air-thread14 opérations libres
air-config-compile10 opérations libres
air-crypto7 opérations libres
air-config4 opérations libres
air-sandboxmécanisme livré, médiateur absentdébloqué le 2026-08-22 (ADR-150 D12/D13) : plus de dépendance de décision

air-security — le vocabulaire commun de la sécurité active — est né de ces deux fermetures : écrits en parallèle, les deux managers avaient produit deux vocabulaires ET deux formes pour dire la même chose. Chaque domaine ne garde désormais que la liste de ses opérations sensibles.

Les deux sont des managers additifs : les fonctions libres restent en route parallèle jusqu’au descellement unique de fin de campagne, comme air-uring et air-keystore. Détail dans JOURNAL.md, y compris les trois défauts latents que le manager ferme (injection de ligne dans la projection /etc/passwd, mode 0600 sur une base qui doit être monde-lisible, garantie de mode que l’appelant ne peut pas donner) et l’écart d’ADR-067 constaté au passage.

Les autres écarts se répartissent en bridge-vers-controleur (7), controleur-fuite (4), vocabulaire-impur (3), syscall-hors-couche-1 (2) et service-consomme (2) — relevé contre le registre le 2026-08-22, l’ancien compte annonçant 4 bridges au lieu de 7. Le registre rétrécit, il ne se remplit pas : y ajouter une ligne pour faire passer une PR est une régression.

La couture SecurityManager (ADR-089) entre désormais dans chaque manager du rang 1 au moment où il est écrit — décision BDFL du 2026-08-21, première pose dans AirAccountManager. L’ADR passe a-faireen-cours : le seam existe, le moteur reste hors sujet (ADR-089 D5 le diffère explicitement). Question ouverte consignée dans statuts.toml : le trait vit aujourd’hui dans air-account — le hisser dans une crate partagée est une décision transverse, à trancher avant le troisième manager.

L’ordre, et pourquoi celui-là

  1. Monter le nightly épinglé.FAIT le 2026-08-15 — pin à nightly-2026-07-11, le plus ancien corrigé (dichotomie faite). Le patch PAL a été porté : deux morceaux de contexte dans os/fd/owned.rs, et sys/sync/once/mod.rs où l’amont a introduit une sélection cfg_select! dont air était absent — d’où un no_threads refusant de compiler sur une cible AVEC fils. Rust 1.99 exige en outre -Z json-target-spec. Prouvé : le reproducteur rend 0 sur le nouveau pin et 139 sur l’ancien, et les six paquets Debian se produisent en 4 min.
  2. air-launchd temps 3bEN COURS depuis le 2026-08-20 (PR #793, ouverte et bloquée). ADR-161 a été rectifié sur trois points le 2026-08-20 après vérification contre le code : l’arbitrage de requires passe par l’empreinte du manifeste et non par une famille d’Entitlements (D1) ; le canal de readiness que l’ADR croyait écarter existait déjà, inerte et inatteignable, et sera retiré avec la mise en œuvre de published (D2) ; le refus au chargement est par service et transitif, jamais global (D7). Détail dans JOURNAL.md. Rédaction d’origine ci-dessous. ADR-161 écrit le 2026-08-15 (dépendances au manifeste arbitrées par l’octroi ; « prêt » = publication du nom dans le registre AirCom que le lanceur héberge déjà ; une seule relation requires ; une dépendance morte arrête ses dépendants ; arrêt en ordre inverse ; délai de « prêt » borné, valeur provisoire non mesurée). Reste la mise en œuvre : deux champs de schéma, un séquenceur sur le ppoll unique, et les refus au chargement. Le bornage cgroup n’en fait pas partie : il est déjà fait.
  3. Passer air-sshd sous air-launchd — bundle, octroi, installation, retraite de l’unité systemd. Ne dépend pas de 3b pour un service unique.
  4. Trancher les 27 symboles au cliquet ABI : 20 ré-exports air_value_* (décision prise : cesser de les exporter, suppose que libair_object_c.so se lie à libair_value_c.so) et 7 helpers découverts le 2026-08-14 (air_fmt_*, air_libc_*), non tranchés.
  5. Les managers absents — il en reste 7 : air-account a été soldé le 2026-08-21. Le suivant par charge est air-terminal (21 opérations libres), qui est aussi la clé de tout l’applicatif texte (ADR-008/ADR-026). air-sandbox reste bloqué tant que sa surface dépend de l’octroi.
  6. Les 40 DEPENDENCIES.md manquants, puis check-dependencies --bloquant.
  7. Les 46 blocs unsafe de production sans justification, puis viser clippy::undocumented_unsafe_blocks (le régime dur, qui voit l’arbre syntaxique).
  8. air-elf (ADR-159) — le codec de couche 1, puis la vérification du seuil d’exécution dans air-bundle : aujourd’hui rien ne distingue un static-PIE linux-air d’un binaire glibc étranger.
  9. Variadiques en Rust (ADR-160 D1/D2) + gate check-toolchain (D3). c_variadic est stabilisé en Rust 1.99 (1ᵉʳ octobre 2026) : le coût « premier #![feature] du dépôt » disparaît au passage à cette version.
  10. cargo xtask deb en CI — l’empaquetage n’est éprouvé que par des lancements manuels.

Décisions qui appartiennent au BDFL

  • Monter le nightly malgré le portage du patch PAL (point 1).
  • required_signatures sur main : toujours désactivée. Un gate ne peut pas y suppléer — un runner n’a pas les clés publiques des contributeurs, il constate qu’une signature est apposée, jamais qu’elle est valide.
  • docs/principes-ingenierie/ est IMMUABLE et affirme que air-abi-check, air-symver et air-deprecation-tracker sont « introduits dès la phase 0 ». Aucun n’a jamais existé. À réconcilier par RFC (ADR-015). Le même document décrit un test qui « charge un binaire de référence compilé contre une version antérieure » — ce que les tests abi_conformance ne font pas : ils compilent contre le header courant.
  • Une commande air unifiée : à trancher, ou à écarter explicitement.

🛡️ RÈGLE NO GO + quatre gates qui manquaient (2026-08-14)

En tête de CLAUDE.md et d’AGENTS.md : un outil ou un référentiel affirmé existant et qui ne l’est pas arrête tout ; un agent qui écrit qu’un sujet est traité doit avoir tenu cette promesse en l’écrivant. Onze occurrences en trois jours l’ont payée.

Ce que l’inventaire a rendu — quatre gates écrits (#733 et suivant) :

GateRégimePremier passage
check-dcobloquanthistorique conforme — la règle tenait, sans filet
check-abibloquant392 déclarations figées ; rupture d’ABI désormais refusée
check-dependenciesconsultatif40 crates en écart sur 91, 23 sans DEPENDENCIES.md
check-safety-commentsconsultatif46 blocs unsafe de production sans justification sur 1909

Prochaines tâches issues de ce chantier — dette nommée, pas refoulée :

  1. écrire les 40 DEPENDENCIES.md manquants, puis passer check-dependencies --bloquant ;
  2. justifier les 46 blocs unsafe de production, puis viser clippy::undocumented_unsafe_blocks ;
  3. décision BDFL : activer required_signatures sur main — un gate ne peut pas y suppléer, un runner n’a pas les clés publiques des contributeurs ;
  4. décision BDFL : docs/principes-ingenierie/ (IMMUABLE) affirme que air-abi-check, air-symver et air-deprecation-tracker sont « introduits dès la phase 0 ». Aucun n’a jamais existé. À réconcilier par RFC (ADR-015).

✅ DÉPLOYÉ — les trois machines portent la chaîne complète (2026-08-14)

speedy, carbon et raspi-srv-2 portent les six paquets, dont air-bundle et air-launchd pour la première fois. Trois clés de signature distinctesc6a36f1b…, cb11040e…, a1aabea4… : chaque machine signe ses propres octrois, aucune ne valide ceux d’une autre. air-launchd tourne sur aarch64, ce qui n’avait jamais été éprouvé.

Ce que le déploiement a prouvé, et que les tests ne pouvaient pas dire : l’idempotence de la clé machine a survécu à une vraie mise à jour de paquet (et non à un second appel de test) — « déjà présente — inchangée », empreinte identique, fichier non réécrit.

Ce que le déploiement a RÉVÉLÉ — trois défauts que le vert de la CI masquait : rien ne générait la clé de signature (le postinst d’air-sshd échouait donc sur toute machine) ; cargo xtask deb ne compilait plus depuis #627 ; et rien ne pouvait installer un bundle de service. Corrigés par #727 et #729.

Ce qui reste, et n’appartient pas à un agent : l’émission des octrois. Le gate d’ADR-101 est actif sur les trois machines (vérifié) et réclame le mot de passe administrateur sur la première ligne de stdin. Un shell root ne suffit pas à accorder — c’est le but.

printf '%s\n' '<mot-de-passe-admin>' | sudo air-bundle grant --bundle <id> --<droit> …

Angle mort nommé : la CI des PR ne lance pas cargo xtask deb. Elle aurait attrapé deux des trois défauts ci-dessus. C’est le meilleur rapport valeur/effort du moment — il empêche une classe entière au lieu de la découvrir après coup.

🆕 ADR-157 — le lanceur supervise ce qu’il trouve (2026-08-13)

Chantier multi-service, temps 3a. Le dernier reste nommé du temps 3 de la spec du lanceur qu’aucun ADR ne décidait — et le blocage de l’étape 2b-ii : héberger le registre AirCom dans un lanceur mono-service donnerait un registre à client unique.

Le découpage n’est pas de commodité : 2b-ii n’a pas besoin des dépendances, seulement de plusieurs services vivants dans un même lanceur. 3a livre cela ; 3b (dépendances, ordre, arrêt inverse) aura son propre ADR.

D1 le lanceur balaie /var/lib/air/services/<bundleId>/ et supervise ce qu’il trouve, pas ce qu’on lui dit — acceptable parce que l’octroi arbitre. D2 un bloc service optionnel dans le manifeste (point d’entrée parmi les exécutables empreintés, arguments, redémarrage) ; sans lui, pas de supervision — deviner serait la tolérance que le Principe 11 refuse. D3 démarrer n’est pas un privilège : ce bloc est une déclaration, pas une demande de droit, et l’invariant demande↔octroi ne s’y applique pas. D4 le lancement par argv devient un second chemin marqué (ADR-149 D10) — ne pas le marquer reproduirait ce qu’ADR-153 a dû défaire pour le champ 7. D5 l’ensemble supervisé est borné, le ppoll vit sur la pile.

🆕 ADR-156 — air-sandbox cible l’ABI Landlock v7 (2026-08-13)

La règle d’abord : on n’expose pas ce dont on a besoin, on expose ce que le noyau offre. Un développeur qui écrit pour Air doit y trouver toute la capacité de confinement d’un noyau 6.x/7.x, pas la part qu’Air a consommée.

Le constat qui déclenche. air-sandbox n’expose ni REFER (v2), ni le scoping (v6), ni l’audit (v7) — alors que le plancher d’Air (6.12) vaut v6. Le manque n’est plus une prudence, c’est un retard.

Et un écart mesuré : deny_all() ne gère que les droits v1. Or un droit non géré est un droit non restreint — mécanisme exact de l’EXECUTE du 2026-08-12. Une cage qui se présente comme une fermeture totale ne restreint donc aujourd’hui ni TRUNCATE, ni REFER, ni IOCTL_DEV. Ce n’est pas une faille démontrée — seccomp borne le verbe pour les profils en service — mais c’est un écart entre ce que la cage annonce et ce qu’elle gère, soit précisément ce qu’ADR-112 existait pour supprimer.

Cinq décisions : cible v7, qui se déplace avec le noyau (une v8 n’exigera pas de nouvel ADR) ; ce qui est au plancher est exigé (Air refuse, il ne dégrade pas) et ce qui est au-dessus est sondé ; deny_all() gère tout ce que l’ABI constatée permet ; le scoping reçoit sa propre surface (ce n’est pas un droit par chemin) ; l’audit est opt-in.

Évolution additive de la couche 1 — elle rejoint l’inventaire du re-sceau unique.

✅ Étape 2 (AirCom) — CLOSE : le lanceur héberge le registre, le dernier mur tombe (2026-08-13)

2b-ii est livrée. launch crée une paire de sockets par service et en dépose un bout avant le fork, par le sixième enregistrement du canal ; la boucle multi-service guette l’autre bout et sert Register/Lookup ; admit pose la politique au lancement, forget la reprend à la mort constatée — jamais à l’arrêt demandé, un service qui refuse de s’arrêter gardant son pid donc ses droits. Côté service, air-com::RegistryClient publie et joint dans le vocabulaire du manifeste (publish/connect), la traduction vers register/lookup restant chez le lanceur.

La capability est remise à TOUS les services, même sans règle aircom : la tenir n’accorde rien, et une remise conditionnelle obligerait le service à deviner s’il doit l’attendre — donc à tolérer les deux cas, donc à pouvoir se tromper.

Le mur aircom du plafond tombe, et c’était le dernier : plus aucune famille d’octroi n’est refusée faute d’applicateur (ADR-150, option B).

Un doublon retiré en chemin : le registre répondait en b"oui"/b"non", alors qu’air-com-proto définissait déjà RegistryResponse. Le type existant est aussi le meilleur — il ne confond NotFound et Denied que pour un demandeur non habilité, là où un refus uniformément muet aurait condamné tout client à réessayer indéfiniment un refus définitif.

Le blocage qui a été levé (constat du 2026-08-13, conservé)

Les deux moitiés additives sont livrées : la politique (#714 — la famille aircom d’un octroi devient GrantedServices, connectallow_lookup, publishallow_register, oubli à la mort constatée) et le vocabulaire du canal (#715 — sixième enregistrement, capability remise en SCM_RIGHTS).

Reste l’hébergement, et il est bloqué. air-launchd supervise un seul service par invocation (parse_arguments rend un ServiceSpec). Or un registre existe pour mettre plusieurs services en relation : l’héberger aujourd’hui donnerait un registre à client unique, dont la propriété essentielle ne serait jamais exerçable en production — la « permission que rien n’applique » que l’option B d’ADR-150 refuse.

Le prérequis n’appartient pas à la campagne des managers : c’est le passage à plusieurs services, que la spec du lanceur liste dans les restes de son temps 3 (« dépendances, ordre, redémarrage »). Le découvrir avant de construire vaut mieux que de livrer un hôte intestable.

L’étape 4 (devices) n’a pas ce blocage : un octroi de périphérique se traduit en règles Landlock sur des chemins, par service, au lancement. Vérifié : landlock_policy_for ne traite aujourd’hui que la famille filesystem. Le travail est réel mais autonome — c’est la suite naturelle.

🆕 ADR-155 — la chaîne de construction du kernel est décidée (2026-08-13)

En préparation de l’image installable. Cinq décisions, prises dans cet ordre parce que chacune contraignait la suivante :

D1 le kernel se bâtit avec Clang/LLVM (make LLVM=1), et GCC n’est pas ajouté par-dessus. D2 il y a du Rust dans le kernel — ce qui fait basculer le cadre : la question cesse d’être « quelle chaîne » pour devenir « une chaîne, ou deux », puisque LLVM est requis quoi qu’il arrive (rustc est LLVM, bindgen utilise libclang). Choisir GCC n’aurait donc pas retiré LLVM du lot : cela y aurait ajouté GCC. D3 Air ne livre que son propre kernel. D4 une seule rustc pour le userland et le kernel. D5 parité de version LLVM entre clang et rustc — condition pour que kCFI couvre le Rust et que le LTO s’étende à tout le noyau.

Ce que D3 dissipe : le plancher 6.12 (LTS) est un plancher d’exécution — ce sur quoi le userland tourne — et n’a jamais rien dit du kernel qu’Air bâtit. La précision est désormais posée dans macro-architecture-fr.md, au point exact où le 6.12 est énoncé.

Ce que GCC aurait apporté ne pèse pas : latent_entropy et stackleak ne sont actifs par défaut ni l’un ni l’autre — options opt-in, CONFIG_GCC_PLUGINS, coût en performance —, et leurs substituts existent des deux côtés (INIT_STACK_ALL_ZERO, entropie matérielle). Bâtir avec GCC ne les donne pas : cela donne le droit de les activer.

Prix assumé de D4 : monter de version devient un geste couplé, userland et kernel ensemble. D3 le rend payable — sans contrat de compatibilité de modules, c’est une manœuvre coordonnée, pas une rupture.

Vigilance contre-intuitive, écrite dans l’ADR : le risque n’est pas une rustc trop ancienne mais trop récente — le Rust du kernel s’appuie sur des fonctionnalités instables du langage. Air épingle 1.96.0 ; l’appariement naturel est un noyau contemporain.

Reste à outiller : un gate vérifiant que la rustc épinglée est celle que le noyau accepte (make LLVM=1 rustavailable). Sans lui, D4 et D5 ne vivent que dans un document.

✅ ADR-153 — CLOS (2026-08-13)

#702 → #705 mergées. Le champ 7 de /etc/passwd a cessé d’être une porte d’exécution : ce qu’une session lance vient désormais d’un artefact signé, inventoriable et révocable, arbitré par le monitor avant la commande. Le second chemin d’ADR-149 D10 a disparu au lieu d’être marqué.

Prochaine tâche : l’interblocage des pompes de redirection (forward_demux_blocking::a_forward_joins_a_session_already_under_way), reproduit le 2026-08-12 — deux fils dans un ppoll sans échéance, 2 h 22 de blocage. #706 le rend observable (borne + message nommant l’attente).

La lecture du code (note dédiée) a éliminé trois hypothèses — demande de SIGHUP perdue, SIG_IGN hérité à travers l’exec, monitor qui cesse de lire pendant qu’il doit une annonce — et en a établi une autre : le test attend davantage que ce que le pilote promet. session_blocking.rs énonce que si la mort du processus n’est jamais constatée, le canal reste semi-fermé et les redirections continuent d’être servies — état conforme du point de vue du démon. Mais collect_session_end attend le CLOSE sans condition.

Le rouge intermittent n’est donc peut-être pas un défaut du démon, mais un test qui attend une garantie que personne n’a donnée. Ce qui reste à trancher — shell non mort, ou mort non constatée — n’est pas décidable par lecture ; #706 le dira à la prochaine occurrence.

Question 3 tranchée le 2026-08-13 — ADR-153 n’a plus aucune question ouverte. Le Subsystem SFTP ne rejoint pas la famille, et la raison est structurelle : il ne lance aucun programme. Il est servi en processus dans le worker, cage déjà posée, sans même une variante dans le protocole de médiation (« il ne se lance pas, il se sert »), et aucun chemin de sous-système n’est configurable. L’y faire figurer créerait une règle sans référent — ce que le plafond refuse ailleurs, et pour la même raison.

Cela ne le laisse pas sans gouvernance : ce qu’il fait est borné par la cage, par MaxSessions, et par le fait qu’il ne détient aucun descripteur. C’est la distinction que l’ADR pose lui-même — la cage borne ce qu’un programme fait, pas lequel démarre. Rouvrirait la question : un Subsystem désignant un binaire externe.

ADR-153 — le provisionnement, et un défaut trouvé en l’écrivant (2026-08-13)

Le paquet pose le bundle et un octroi VIDE. Sans octroi du tout, air-sshd ne démarre pas : une mise à jour laisserait la machine sans démon. Avec un octroi vide, il démarre, sert ses redirections, et n’ouvre aucune session tant que l’administrateur n’a rien admis. Un octroi vide n’accorde rien — il n’usurpe donc aucune décision ; il rend l’état sûr et visible, ce qui est exactement ADR-153 D5. L’administrateur qui installe est devant la machine : sa session en cours survit, et il accorde dans la foulée.

Qui signe le manifeste — décision BDFL du 2026-08-12. Une clé « manifestes de service » naît dans le keystore et signe localement ; [ADR-010] l’autorise (« développeur : self-signed possible »). Elle est distincte de la clé de machine, parce qu’ADR-150 tient séparés deux rôles : le développeur demande, la machine accorde — une seule clé ferait de la machine l’auteur de sa propre demande. Phase transitoire déclarée et datée : la cible reste une clé de release du projet, signant à la construction ; le coût assumé en attendant est que l’artefact de manifeste diffère d’une machine à l’autre, donc n’est pas reproductible au sens d’ADR-025.

bundleVersion ne suit PAS la version du démon. L’octroi épingle le condensat du manifeste : s’il changeait à chaque version, chaque mise à jour invaliderait l’octroi de l’administrateur. Il porte donc la révision de la déclaration, et ne bouge que si ce qui est demandé change — auquel cas invalider est précisément ce qu’il faut faire.

⚠️ Un défaut réel trouvé en écrivant ce code. write_entitlements n’écrivait pas la famille session. Le lecteur savait la lire, l’intersection la calculer, l’invariant la comparer — mais tout octroi de programme de session la perdait en franchissant le disque, en silence. En production : un administrateur accorde /bin/sh, l’artefact ne le porte pas, toute session est refusée. La fonctionnalité entière d’ADR-153 n’aurait jamais marché.

Aucun test unitaire ne pouvait l’attraper : ils exerçaient l’émission et la lecture séparément. C’est la traversée qui prouve, pas les deux moitiés. Le test ajouté couvre les cinq familles — ce qui est arrivé à l’une peut arriver à la suivante — et il a été vérifié mordant. Corrigé dans #704 avant merge.

ADR-153 — le champ 7 cesse d’être une porte d’exécution (2026-08-12)

Incrément 2, en deux temps.

2a — le vocabulaire quitte le lanceur. Le module bundle représentait 8 049 des 8 310 lignes d’air-launchd (97 %), dans une crate qui déclare « n’expose rien de réutilisable ». Faire lire son octroi à air-sshd — un démon root — depuis le superviseur aurait rendu cette contradiction vraie une troisième fois. Extrait en air-bundle : un déplacement, aucun changement de comportement, 236 tests répartis sans perte. La séparation a rendu mesurable ce qui n’était que déclaré — air-config n’est qu’une dépendance de test du cœur pur, qui reçoit des readers déjà validés.

2b — air-sshd devient consommateur d’octroi. Il lit le sien au démarrage sous l’identifiant org.air-desktop.sshd — le premier identifiant de bundle réel d’Air, qui fixe donc la convention org.air-desktop.<service>. Le monitor oppose la liste admise au champ 7 avant la commande (D4) : ForceCommand contraint désormais ce qu’on exécute à l’intérieur d’un programme admis, au lieu d’être la seule borne. Le refus est muet vers le client et tracé au journal avec les trois éléments qu’ADR-153 exige — le compte, le programme refusé, et la liste admise, sans laquelle un octroi mal orthographié se diagnostiquerait par devinette. Le mur du plafond posé à l’incrément 1 tombe, et air-bundle grant --session-program accorde enfin.

Les tests d’arbitrage ont été vérifiés mordants : la garde neutralisée, les trois tests de refus échouent et celui d’acceptation passe toujours — ce qui distingue un arbitrage d’une panne.

⚠️ Conséquence d’exploitation, à dire avant de déployer. Pas d’octroi, pas de service (tranché par le BDFL le 2026-08-11). Une machine mise à jour sans avoir reçu son octroi ne démarrera plus air-sshd. Le message de refus porte le geste qui répare, parce que celui qui le découvre n’a, par construction, plus de session pour aller lire la documentation — mais il faut provisionner avant, pas après. Les trois exécuteurs (speedy, carbon, raspi) sont concernés.

Questions 1 et 2 tranchées : famille vide par défaut, pas absente (option B d’ADR-150) ; aucun cas particulier pour nologin/false — non admis, ils font refuser la session, ce qui est exactement le résultat observable de leur exécution, obtenu un cran plus tôt. Reste la question 3 (Subsystem SFTP), à trancher avec ADR-119.

ADR-153 — la cinquième famille existe, et le plafond la retient (2026-08-12)

Incrément 1 : le vocabulaire et l’invariant. La famille session entre dans le schéma (SessionRule { program }), dans le lecteur (chemin absolu, sans .., doublons refusés, borne par famille), dans l’intersection d’issuepar présence, pas par verbes : une règle de session n’a rien à cocher, elle est là ou elle n’y est pas —, dans le refus anti-méprise d’install, et dans l’affichage d’air-bundle. Le rapprochement est l’égalité stricte du chemin : /usr/bin ne couvre pas /usr/bin/alize, sinon tout ce qu’un paquet déposera demain dans ce répertoire serait admis d’avance.

Le plafond REFUSE tout octroi non vide de cette famille, et c’est délibéré. Le vocabulaire existe, mais personne ne l’applique encore : air-sshd lance toujours le shell du champ 7 sans consulter d’octroi. Laisser passer ce que rien n’applique donnerait un octroi qui a l’air d’accorder et n’accorde rien — une promesse écrite dans un fichier. Le mur est visible, daté, et tombera avec l’applicateur, comme celui du réseau est tombé le 2026-08-10. Même raison pour l’outil d’administration : il montre la famille (un manifeste peut déjà la demander, et un affichage qui l’omettrait ferait décider sur une vue incomplète) mais n’offre aucun drapeau pour l’accorder.

Question ouverte n° 1 tranchée : la famille est vide par défaut, pas absente (option B d’ADR-150). Absente, chaque lecteur devrait décider ce que l’absence veut dire — « rien n’est admis » ou « la question ne se pose pas » — et deux lecteurs finiraient par choisir différemment.

Prochaine tâche — incrément 2 : air-sshd devient consommateur de son propre octroi, le monitor résout le programme avant la commande, et le mur du plafond tombe. Restent à trancher les questions 2 (nologin/false) et 3 (Subsystem SFTP, avec ADR-119).

✅ ADR-010 — le chantier de la disposition est clos (2026-08-12)

Les huit tâches de l’instruction sont mergées (#684 → #701) : lecteur gardé, activation par --bundle, émission d’octroi avec refus anti-méprise, clé de machine dans air-keystore, outil d’administration air-bundle, ADR amendé, et convergence de la garde de confiance de fichier en couche 1. L’entrée ci-dessous, qui annonce « rien n’est encore implémenté », décrivait l’état du 2026-08-12 au matin — elle est conservée telle quelle pour ce qu’elle documente (la décision de disposition et l’écart trouvé en la concevant), pas comme état courant.

L’écart EXECUTE y est corrigé : la politique Landlock ajoute désormais d’office le droit d’exécution sur le programme du service, ce qui fait entrer EXECUTE dans handled_access_fs — et rend donc non exécutable tout le reste. Mesuré au passage : un binaire dynamique exige aussi le droit sur son interpréteur ELF, ce qu’aucune lecture du code n’annonçait.

🧭 ADR-010 — la disposition du bundle est tranchée, et elle referme un écart (2026-08-12)

Décision BDFL. Instruction complète : instruction-disposition-bundle-fr.md. Rien n’est encore implémenté — c’est l’incrément 6 de la spec couche 2.

La disposition : <racine>/<bundleId>/ contenant manifest.aircfg, manifest.signature.aircfg, executables/, libraries/, resources/. Trois racines — /usr/lib/air/bundles/ (image, verity), /var/lib/air/applications/, /var/lib/air/services/. Le répertoire ne porte pas le suffixe .airapp : le schéma dit explicitement pourquoi kind est dans l’artefact — « le distinguer dans l’artefact évite qu’il se déduise du chemin d’installation, qui est plus facile à influencer ». L’octroi : /etc/air/grants/<bundleId>.aircfg, 0600 root:root — parce que l’enveloppe accorde la lecture de tout /etc/air à toute application signée, et qu’un répertoire d’octrois énumérable annulerait le refus muet d’ADR-150 D4. .airservice partage la disposition entièrement : deux dispositions donneraient deux lecteurs pour un seul schéma.

⚠️ Écart de sécurité trouvé en concevant, et non encore corrigé. ceiling.rs annonce « aucune exécution nulle part … c’est ce qui casse la chaîne classique télécharger puis exécuter ». La propriété n’est pas appliquée : AirLandlockPolicy::handled_access() (air-sandbox/src/landlock.rs:325) construit handled_access_fs par union des droits des règles présentes ; aucune enveloppe n’accordant execute, EXECUTE n’est pas géré, et Landlock ne borne que ce qu’on lui déclare géré. La catégorie seccomp Exec étant accordée d’office, une application confinée peut écrire un binaire dans /var/lib/<bundleId> et l’exécuter. Ce n’est pas une élévation de privilèges — la cage survit à l’execve et le binaire y tombe — mais c’est la négation du seuil d’exécution d’ADR-046 D6. Constat lu dans le code, pas encore démontré par un test ; l’écrire est la tâche n° 1 de l’incrément.

La disposition est ce qui le referme : le lanceur posera d’office une règle Landlock EXECUTE par exécutable déclaré au manifeste (pas par répertoire — le manifeste lie chacun à son SHA-256, donc seul ce qui est signé s’exécute). Même justification structurelle que SyscallCategory::Exec, déjà d’office : un bundle qui ne peut pas exécuter son propre binaire n’est pas confiné, il est mort. Effet de bord recherché : dès qu’une règle porte EXECUTE, tout le reste du système de fichiers cesse d’être exécutable.

Ce que la décision retire à ADR-010 : le bundle y était « autonome et déplaçable ». Il reste déplaçable comme forme de distribution, il n’est plus exécutable en place — un répertoire à la fois inscriptible par l’utilisateur et exécutable annulerait tout le reste, et /media est de toute façon monté noexec. Rétrécissement acquis ; ADR-010 étant pré-ouverture publique, l’édition directe est autorisée et la phrase sera amendée.

Règle de nommage rendue explicite : la documentation est en français, les noms sur le disque sont en anglais (grants/, executables/, manifest.aircfg) — l’usage l’était déjà (hostkeys.aircfg, passwd.aircfg, air.grant), il est désormais dit.

🧭 ADR-010 — deux traces corrigées avant d’ouvrir le chantier (2026-08-12)

En cherchant par où reprendre, une vérification a démenti le cadrage retenu la veille (« ADR-010 est le préalable, rien n’est commencé »). Une bonne moitié d’ADR-010 est implémentée depuis le 10 août, sous le drapeau ADR-150 : le vocabulaire des quatre familles d’entitlements, leur lecture validée et bornée, la signature développeur, l’applicateur air-launchd, et la pose effective de la cage (#655, #656, #657, #659, #663). Deux traces disaient le contraire et ont été corrigées :

  • la table §6 de air-manifeste-octroi.md annonçait l’incrément 4 « à faire » et le 5 « bloqué » — mergés depuis deux jours. L’en-tête et le §1 du même document affirmaient aussi que le lanceur « ne vérifie encore aucune signature et ne cherche encore aucun octroi ». Document réconcilié en entier, et la question ouverte n° 1 (largeur des enveloppes) n’y est plus présentée comme ouverte : elle est tranchée (#657, option B) ;
  • statuts.toml donnait ADR-010 pour a-faire — « couche ou sous-système non commencé ». Corrigé en en-cours, avec l’inventaire de ce qui est fait et de ce qui reste.

Ce qui reste est le périmètre propre d’ADR-010, et il est intact : la disposition sur disque d’un .airapp, le chemin de l’octroi (laissé ouvert par la spec §5ter.6), l’installation qui l’émet, .airservice, et les deux niveaux de signature au-dessus du développeur (notarisation, politique locale). Le manque se mesure : spec.granted n’est renseigné que depuis les tests — aucun chemin de production ne mène du disque à un octroi arbitré. C’est l’incrément 6 de la spec couche 2.

Prochaine tâche : la disposition du bundle. Trois décisions à prendre avant tout code — le nom et l’emplacement du manifeste dans le bundle, le chemin de l’octroi hors du bundle, et si .airservice partage la disposition ou en a une. En réflexion chez le BDFL au 2026-08-12.

La leçon de méthode est la même qu’en #682 : un état qui n’est pas relu ment sans que personne mente. Le code avait avancé de trois PR au-dessus de sa propre spec.

✅ Le shell d’Air décidé, la quiescence corrigée, le gate eBPF rendu honnête (2026-08-12)

Trois chantiers nés d’une question du BDFL : « lors d’une connexion SSH via air-sshd, quel shell est exécuté sur l’hôte ? »

ADR-153 — le programme de session est OCTROYÉ (#680). La réponse à la question était : celui du champ 7 de /etc/passwd. Donc une décision d’exécution que l’arbitrage d’ADR-150 ne regarde pas — un second chemin ADR-149 D10 jamais reconnu — et un binaire glibc que seul TransitionalGlibc fait vivre : le seuil d’exécution tenait partout sauf sur le chemin le plus emprunté. Décidé : cinquième famille d’entitlements session, chemins absolus et exacts (pas de glob), décision dans le monitor, programme résolu avant la commande. Le champ 7 devient une préférence, l’octroi la permission. Tranché par le BDFL : pas d’octroi, pas de service — le besoin de l’administrateur distant se traitera hors bande, jamais en abaissant le niveau de sécurité de la machine.

ADR-154 — le shell d’Air s’appelle alize (#680). Pas POSIX : l’insécurité est dans la spécification, et un bash réécrit en Rust porterait les mêmes injections. La règle structurante est D2 — une expansion ne produit jamais de nouveaux mots, qui supprime moins une classe d’injection que l’obligation de vigilance parfaite. D7 répond à l’objection du BDFL (« ne serions-nous pas une surface d’attaque pour notre propre système ? ») : sur Air un shell n’est pas privilégié, le confinement vient de la capability, pas de la pauvreté du langage. Nom propre choisi contre un nom en sh, qui promettrait un sh ; -c reste une ligne interprétée, parce que git, rsync et scp en dépendent.

La quiescence io_uring (#681) — et le défaut annoncé à l’étape 0 n’était pas le bon. Le drop d’un Reactor coûtait 6,4 s et fuyait le fd d’anneau. Le diagnostic d’abord publié (« l’eventfd n’est jamais annulé ») a été démenti par la mesure : un réacteur ayant tourné se détruisait déjà proprement, en 137 µs, sans rien fuir. La vraie cause est plus générale — le slab compte une opération dès sa mise en file, le noyau ne la connaît qu’après l’enter ; une opération jamais soumise était invisible à sync_cancel et au drainage. quiesce() soumet désormais d’abord. 6,4 s → 40 µs, zéro fuite, suite air-uring 12,9 s → 0,11 s. Correctif en couche 0, donc valable pour tout appelant d’IoUring — pas seulement le réacteur, contrairement à ce que le correctif prévu en couche 1 aurait fait.

Le gate eBPF rendu honnête (#682). #681 a été refusée par un gate qui ne la concernait pas : trois lignes d’ebpf.rs couvertes ou non d’un run à l’autre, sur la même machine et en root dans les deux cas. La cause n’était pas eBPF mais le silence — les trois sites annonçaient leur saut par un eprintln! que libtest avale pour un test qui passe ; l’un ne disait rien du tout. Trois temps : la branche du curseur de continuation ne dépend plus d’aucune capacité (un fd invalide suffit — le curseur est écrit avant l’entrée noyau) ; le saut devient exigible là où on le déclare (AIR_EBPF_BATCH_REQUIRED=1 ⇒ échec portant l’errno, scopé x86_64 car un noyau ARM peut légitimement ignorer le lot) ; et la variable traverse sudo explicitement, car sur l’exécuteur primaire sudo -E répond « preserving the entire environment is not supported » et laisse tout tomber. Sans ce troisième temps le gate ne se serait jamais armé sur la machine qu’il vise : le défaut aurait été reproduit dans son propre correctif.

Ce que la nuit a appris sur la méthode. Deux diagnostics faux en deux jours, tous deux plausibles et non mesurés. Un troisième constat — le segfault d’air-libc-c — était une fausse alerte : déclaré et expliqué de longue date dans xtask/src/barrier.rs (cdylib pur dont la cible --lib, forcée par llvm-cov --branch, force-lie des #[no_mangle] pthread qui surchargent les verrous de la glibc du processus de test) ; 10 tests d’intégration verts, conformité ABI exigée en CI. D’où deux règles : un diagnostic non mesuré est une hypothèse déguisée, et avant de crier au trou de gate, chercher si l’exclusion est déclarée.

Dettes ouvertes : la cause du refus de lot eBPF par carbon reste inconnue (le prochain refus la nommera, avec son errno) ; et ci.yml justifie la restauration de propriété de CARGO_HOME par une préservation d’environnement qui, sur carbon, n’a pas lieu — prémisse fausse, conclusion à réexaminer.

✅ Managers dus — étape 1 : AirKeystoreManagerFAIT (2026-08-11)

Deuxième des dix. Contrairement au pilote, celui-ci a trouvé deux choses en cours de route, et la seconde est un défaut de sécurité.

Le keystore ne savait pas où vivent ses propres magasins. Les chemins canoniques (hostkeys.aircfg, known_hosts.aircfg, trusted_ca.aircfg, revoked.aircfg) étaient déclarés dans air-sshd, couche 2 ; les cinq familles d’artefacts exposaient les mêmes quatre opérations libres prenant un chemin fourni par l’appelant, et neuf crates en dépendent. La première divergence est un trou muet : un démon qui lit ses révocations là où l’outil d’administration n’écrit pas, ce sont des révocations qui ne prennent jamais effet.

La garde de lecture était posée sur le mauvais artefact. known_hosts — matériel public, dont le pire cas est un épinglage erroné — était gardé de bout en bout (propriétaire, liens symboliques, type de fichier, droits d’écriture) par du code vivant dans le client SSH. hostkeys.aircfg, qui porte la clé privée d’hôte, et ca_key.aircfg, qui forge les certificats, passaient par un read_store() de deux lignes sans aucune garde. L’asymétrie était exactement à l’envers, et sa cause est que la protection avait été écrite là où le besoin s’était fait sentir, pas là où le domaine vit — l’illustration exacte de ce que la posture d’anticipation vise à empêcher.

Ce que le manager décide, et que l’appelant ne décide plus : (l’appelant demande un artefact, pas un chemin) ; à qui il obéit (chaque lecture est gardée avant qu’un octet ne soit décodé, NOFOLLOW à l’ouverture pour qu’aucune fenêtre ne s’ouvre entre vérifier et lire) ; et ce qu’il refuse de deviner — un magasin absent rend None, jamais une liste vide, parce que « rien de révoqué » ne s’infère pas d’un fichier manquant.

S’y ajoutent artifact_path() et inventory(), que personne ne réclamait : dire est un magasin et ce qu’il contient sans l’ouvrir est ce dont un outil d’administration et le provisionnement de première amorce (ADR-121) ont besoin.

Question ouverte d’ADR-108 tranchée : doter la crate du médiateur, plutôt qu’amender §1. L’en-tête du crate promettait « jamais des free-functions » alors qu’il n’exposait que ça ; il dit maintenant l’état réel.

La migration est FAITE, et la route parallèle n’existe plus. Rupture de couche 1 autorisée par le BDFL le 2026-08-11 et consommée. Les 48 appels d’opérations — air-sshd, air-keystore-cli, air-cert-issue — passent par la porte ; les load/save par artefact n’ont pas été rendues privées mais supprimées, le compilateur les ayant déclarées mortes une fois le dernier appelant parti.

(Le chiffre de 106 annoncé d’abord était faux : il comptait les lignes contenant air_keystore::, donc les imports de types et les références en doc. La règle de couche porte sur les opérations, pas les types — ADR-149 D1.)

Trois arbitrages du BDFL en cours de route :

  • --store conservé, par une dérogation nommée (at_explicit_path) qui déroge sur , jamais sur ce qu’on vérifie ;
  • 0640 retenu pour le magasin personnel, et porté en couche 1 plutôt que dans le client ;
  • 0640 étendu à trusted_ca et revoked : rien ne les lit sans privilège. Seul le magasin d’hôtes connus administré reste 0644 — sa diffusion est sa fonction, et le resserrer ferait échouer chaque connexion de chaque compte.

Reste ouvert : le groupe dédié (0640 root:air-users) pour atteindre « zéro other partout », qui demande le provisionnement de comptes d’ADR-121.

Une revue adverse a trouvé le défaut central avant le merge, et c’était le même que celui qu’on prétendait corriger : la garde ne contrôlait que le droit d’écriture. Pour un artefact secret, ce qui compte d’abord est qui peut lire — la graine Ed25519 est stockée en clair. Un hostkeys.aircfg retrouvé en 0644 (restauration d’archive sans permissions, install -m 644) était déclaré digne de confiance : clé privée d’hôte lisible par tout compte local, et l’épinglage known_hosts n’y voit rien puisque la clé présentée est la bonne. La règle héritée d’un fichier public avait été appliquée telle quelle à un secret — la conception réactive, une couche plus bas. Corrigé : AirStoreTrust::ExposedSecret, et deux tests qui tombent quand on débranche la garde.

Quatre autres constats de la même revue, corrigés : un repli fail-open dans la lecture (que la couche 2 avait déjà remplacé par du fail-closed, avec sa justification écrite) ; trois trous de zéroïsation sur le chemin des secrets, dont les copies abandonnées par les réallocations du tampon ; inventory() qui ouvrait en lecture et échouait donc en bloc hors root, alors qu’il existe pour l’administration — il ouvre désormais en O_PATH ; et rooted() qui permettait de désarmer la garde du magasin système en une ligne.

Registre xtask/controleurs-reference.toml : une entrée de moins (26).

✅ Managers dus — étape 0 : AirUringManagerFAIT (2026-08-11)

air-uring déclarait role = "manager" sans en porter aucun : premier des dix managers dus relevés par ADR-152, et le seul à n’avoir aucune opération libre — donc rien à rendre privé, donc aucune rupture. C’est pourquoi il ouvre la série : il éprouve le motif sur le cas où il ne coûte rien.

Le motif, qui vaudra pour les neuf autres : le manager est additif. L’ajouter ne casse personne ; ce qui casserait, c’est de retirer les routes directes — et ce retrait est réservé au descellement unique de fin de campagne (ADR-077 : « en un seul cycle »). D’ici là, Reactor::new/ReactorPool::new/Reactor::from_pooled restent une route parallèle, nommée comme telle dans leur documentation.

Ce que le manager apporte, et qui n’existait pas — la surface est conçue pour le développeur qui l’utilisera, pas pour l’appelant qui existe aujourd’hui (posture BDFL du 2026-08-11 : anticiper, pas réagir) :

  • Demander avant de s’engager (support()). Jusqu’ici, savoir si io_uring était utilisable exigeait d’ouvrir un réacteur et d’interpréter un Errno : poser une question demandait d’accomplir l’acte. La réponse est maintenant un AirUringSupport — opérations disponibles, propriétés négociées, buffers en anneau — sondé, jamais déduit d’une version de noyau.
  • Un vocabulaire qui répond à la bonne question. AirAsyncOperation nomme ce que le réacteur sait armer, pas la table d’opcodes : IORING_OP_RECV peut être là sans que la réception en flux le soit (elle exige en plus les buffers en anneau).
  • Des bornes et un défaut (AirRingEntries). Le minimum vaut 2, et ce n’est pas un goût : le réacteur réserve une entrée à son eventfd d’éveil, si bien qu’un anneau d’une entrée produit un réacteur incapable d’armer quoi que ce soit — accepté par le noyau, silencieux, indébogable. Un test l’établit des deux côtés de la frontière.
  • Dimensionner depuis l’affinité, pas depuis le nombre de cœurs (open_reactor_pool_per_processor) : un service restreint à deux processeurs n’ouvre plus seize réacteurs.

air-async (couche 2) passe désormais par cette porte, ce qui a fermé le piège au passage : air_async::reactor(1) refuse au lieu de rendre un runtime muet.

⚠️ Trouvé en mesurant : détruire un Reactor coûtait 6,4 s (créer en coûte 79 µs) et fuyait le descripteur de l’anneau. Défaut préexistant d’air-sys-syscall, qui expliquait à lui seul la durée des suites d’air-uring. ✅ CORRIGÉ (#681) — mais la cause annoncée ici (« la lecture permanente d’eventfd n’est jamais annulée ») était fausse : la mesure a montré qu’un réacteur ayant tourné se détruisait déjà proprement. Le vrai défaut était une opération mise en file et jamais soumise, invisible au noyau donc à l’annulation comme au drainage — d’où un correctif en couche 0 (et non en couche 1 comme prévu), valable pour tout appelant d’IoUring. Voir l’entrée du 2026-08-12.

Registre xtask/controleurs-reference.toml : une entrée de moins (le registre ne peut que rétrécir).

✅ ADR-150 incrément 1 — le manifeste et l’octroi existent — FAIT (2026-08-09)

Les deux artefacts d’ADR-150 sont désormais du code : schéma Cap’n Proto (air.manifest, air.grant), lecture validée et bornée, et l’invariant qui fait tout le sens de la décision — l’octroi n’accorde jamais plus que le manifeste ne demande — écrit et testé sous ses angles d’attaque.

Aucun parseur n’a été écrit. Les deux artefacts sont des domaines du registre, ce qui leur donne le compilateur texte→binaire existant (borné, fuzzé, 100 %) au lieu d’une seconde surface d’analyse syntaxique près d’une décision de privilèges. La promesse de D2 — « le développeur rédige en texte, le lanceur ne lit que le binaire » — est prouvée de bout en bout.

Statut ADR-150 : a-faireen-cours. Spec : specs/layer-2/air-manifeste-octroi.md.

Inc. 2 fait aussi : la signature détachée (Ed25519) établit qui présente un artefact. Elle porte sur l’artefact complet, enveloppe comprise — sans quoi les mêmes octets se ré-envelopperaient sous un autre domaine avec une signature valide — et le message signé est étiqueté par domaine, parce que rien n’oblige la clé du développeur et celle de la machine à différer.

Inc. 3 fait aussi : l’émission ne prend pas « ce qu’on accorde » mais « ce qu’on propose », et rend l’intersection avec la demande — l’invariant devient inviolable par construction au lieu d’être vérifié après coup. Démontré par property-based testing, qui a au passage exhibé un vrai défaut : deux règles pour le même chemin rendaient la demande ambiguë ; elles sont désormais refusées à la lecture.

Inc. 4 fait aussi : l’arbitre enchaîne signature → octroi → enveloppe. L’enveloppe de l’image est une entrée — ce qui manque est une valeur, pas un mécanisme, et c’est ce qui a permis de faire cet incrément malgré le blocage. Le refus est muet : Refusal ne rend rien en s’affichant, le motif s’obtient par for_journal().

La largeur des enveloppes par défaut est TRANCHÉE (2026-08-10, option B) : enveloppes filesystem écrites, familles sans applicateur vides par défaut — un octroi qui les invoque est refusé. L’instruction a trouvé le vrai obstacle : le vocabulaire déclare quatre familles, air-sandbox n’en applique qu’une. Elle a aussi révélé que l’étape 3 ne pouvait pas employer l’égalité stricte des chemins (une image est écrite une fois, pour des applications inconnues) — d’où la couverture par gabarit, sans glob.

Le grain seccomp est TRANCHÉ aussi (2026-08-10, grain A) : catégories nommées (Baseline / FdPassing / Filesystem), dérivées de l’octroi en couche 2. L’instruction a montré que la structure existait déjà, non nommée et dupliquée à la main — un test d’égalité stricte la rend désormais vérifiée, sans toucher à la surface scellée d’air-sandbox.

Landlock v4 est FAIT (2026-08-10), sur les trois couches : le réseau se borne par ports (Landlock) et par verbes (catégorie seccomp Network). Le refus sur ABI insuffisante est explicite — jamais une dégradation silencieuse. L’enveloppe Air ouvre 80 et 443, rien en écoute, et pas le 53.

Au passage, un trou refermé : les 62 numéros de syscalls d’air-sandbox n’étaient vérifiés par rien ; check-syscalls les couvre désormais (265 au lieu de 203).

Reste sans applicateur : AirCom et les périphériques — un octroi qui les invoque est refusé.

✅ Temps 2 du lanceur — la cage est POSÉE (2026-08-10)

Le lanceur n’écrit plus un verdict qu’il jette : profil seccomp composé depuis l’octroi et politique Landlock (chemins + ports) sont appliqués dans l’enfant, juste avant l’execve, et fail-closed — une cage qui ne peut pas être posée fait sortir l’enfant en 125 sans rien exécuter.

Deux propriétés qui ne se devinent pas :

  • execve est toujours permis (catégorie nommée Exec) : une cage posée du dehors s’installe à l’instant qui précède l’execve du service. Cela n’ouvre rien — la cage survit à l’execve et suit le programme.
  • Un binaire lié à la glibc ne démarre pas confiné, et c’est le résultat recherché (décision BDFL du 2026-08-10) : ne s’exécute sur Air que ce qui est recompilé contre la libc d’Air. Reste à faire : des profils permissifs transitoires, nommés comme tels et supprimables d’un bloc en v1.0, le temps que l’outillage en ligne de commande et les shells soient portés.

Ce qui reste explicitement à câbler : la porte par laquelle un octroi arrive dans une ServiceSpec — chargement du bundle, puis arbitrage. Le mécanisme est complet et prouvé de bout en bout par les tests (launch() pose réellement la cage) ; ce qui manque est le chemin qui va d’un .airapp sur le disque jusqu’à spec.granted. La ligne de commande ne portera jamais d’option --allow-… : une cage négociable depuis le shell n’en serait plus une.

✅ Temps 3c (i) du lanceur — le redémarrage — FAIT (2026-08-09)

Le lanceur relance : politiques no / on-failure (défaut) / always, recul qui double (100 ms → 30 s, interruptible), crédit de 5 échecs consécutifs rendu par un démarrage qui tient 10 s. Jamais relancés : un arrêt demandé et une violation du contrat.

Le lanceur garde ses écoutes — changement délibéré. Les relâcher rendait le redémarrage impossible (port libéré, pas de SO_REUSEADDR) ; en les gardant, les connexions arrivées pendant le redémarrage attendent dans la file du noyau au lieu d’être refusées.

Reste 3c-ii : plusieurs services, dépendances, ordre. Bloqué par le manifeste d’ADR-150 — décrire N services et leurs dépendances demande un artefact, pas un --exec répété.

✅ Bornes par défaut posées — le bloquant de publication est LEVÉ (2026-08-09)

Directive BDFL du 2026-08-09 : « il faut impérativement mettre des valeurs à ces barrières avant que l’API soit publique. On ne laisse pas sortir nos versions sans limites. » Une borne « à décider plus tard » devient un défaut de fait dès la première version publiée, et plus personne ne la révise.

Ce qui est mesuré (speedy, 2026-08-09, service réel) : air-sshd consomme 4,00 tâches et ~819 Kio par connexion, exactement linéaire, sans fuite. Au repos : 1 tâche, 1,5 Mio.

Ce qui n’est pas borné, et qui décide du chiffre : le plafond de concurrence (DEFAULT_MAX_PREAUTH_TOTAL = 256) ne couvre que la fenêtre pré-auth — l’AdmissionGuard rend sa place au succès de l’authentification. Les connexions établies ne sont bornées par rien. Pire cas garanti : 1 + 4×256 = 1025 tâches (~205 Mio) ; au-delà, ouvert.

Valeurs retenues (BDFL, 2026-08-09) : pids.max = 2048 et memory.high = 512 Mio, appliquées par défaut — un service non borné n’existe plus. 2048 couvre les 256 connexions pré-auth (1 + 4×256 = 1025) plus ~256 sessions établies ; 512 Mio couvre plus de 600 connexions à 819 Kio.

memory.high, pas memory.max : au-delà, le noyau freine et récupère — il ne tue pas. Un service qui fuit devient lent et visible, ce qui est ce qu’on veut d’une défense.

Retirer une borne se demande explicitement (--max-processes unlimited) : 0 est refusé, c’est une faute de frappe, jamais une intention.

Reste ouvert, sans blocage : exprimer ces valeurs en fraction de la capacité (systemd : DefaultTasksMax = 15 % de threads-max) pour qu’elles suivent d’elles-mêmes du Raspberry Pi 4 au serveur.

✅ Temps 3b du lanceur — les bornes — FAIT (2026-08-09)

pids.max (la bombe à fourche) et la comptabilité (pids.current, memory.current, memory.peak). Option --max-processes ; aucune borne par défaut, la largeur des enveloppes n’étant pas tranchée (question ouverte n° 1 d’ADR-150).

Ce que le chantier a appris. Poser une borne exige que le cgroup parent du service soit vide de processus. Le lanceur déménage donc dans un sous-cgroup dédié, ses services devenant ses frères. Écrire +pids sur un cgroup peuplé est accepté par le noyau puis casse toute migration vers ses enfants — une API qui, appelée depuis un terminal, rendait le cgroup de la session inutilisable. Elle refuse maintenant avant d’écrire.

Les bornes sont ÉPROUVÉES (contrairement au confinement du 3a) : sous systemd-run --user --scope, aucun test ne saute. Dans un terminal ordinaire, ils se sautent en le disant.

Reste du temps 3 : plusieurs services — dépendances, ordre, redémarrage (3c).

✅ Temps 3a du lanceur — le cgroup — FAIT (2026-08-09)

AirCgroupManager (crate couche 1 nouvelle, air-cgroup) : le lanceur confine son service dans un cgroup, et n’y laisse rien vivre après l’arrêt. C’est la seule frontière que le noyau tienne — un groupe de processus est une convention qu’un setsid quitte, un cgroup non.

Pas un descellement de couche 0 : les cgroups se pilotent entièrement par le système de fichiers. Un seul additif c1 l’accompagne (AirCommand::join_cgroup, 14ᵉ), qui fait rejoindre le cgroup à l’enfant, avant son execve — donc avant qu’il ait pu forker, ce dont dépend entièrement cgroup.kill.

Deux leçons de la journée, toutes deux trouvées par l’exécution : le cgroup n’est pas un dernier marteau mais une clôture (le premier jet ne tuait l’arbre que si le service refusait de mourir — donc jamais dans le cas courant) ; et un nom de cgroup dérivé du seul PID du lanceur aurait fait tourner un second service sans frontière.

Reste du temps 3 : les bornes (pids.max contre la bombe à fourche, memory.max), la comptabilité, puis plusieurs services (dépendances, ordre, redémarrage).

✅ A-2 du lanceur — additif couche 1 — FAIT (2026-08-09)

air-launchd ne visait que son enfant direct : un service qui fork laissait des fils vivants avec la copie héritée de l’écoute — port occupé, redémarrage impossible, processus non supervisés. C’était la limite qui comptait pour un opérateur.

Le service est désormais lancé chef de sa session (AirCommand::new_session, 13ᵉ additif couche 1) : il est chef de son groupe, tout ce qu’il fork y naît, et l’arrêt vise le groupe. L’additif est un découplagesetsid existait déjà dans la crate, mais lié à login_terminal, qui exige un PTY et écrase les trois flux standard.

Sûreté du ciblage : un groupe se désigne par le PID de son chef, et ce PID ne peut pas être recyclé tant que le lanceur n’a pas récolté l’enfant (il tient son pidfd). Le groupe visé est le sien ou plus rien (ESRCH ⇒ repli sur le pidfd) — jamais celui du lanceur.

Ce qui reste au temps 3 : un petit-fils qui appelle setsid à son tour quitte le groupe. La frontière que le noyau tient, c’est le cgroup.

make_directory_all — additif couche 1 — FAIT (2026-08-09)

Le seul vrai downgrade qu’ait trouvé la revue adverse de l’épinglage est fermé : sous umask 0, std::fs::create_dir_all faisait naître ~/.config/air/ en 0777. Qui peut écrire ce répertoire peut y remplacer le magasin d’hôtes connus, donc décider à quelles machines son propriétaire se connecte. Désormais 0700, quel que soit l’umask.

AirFileSystem::make_directory_all(path, mode)12ᵉ additif couche 1 non scellé (inventaire). Chaque répertoire créé reçoit son mode par un fchmodat2 no-follow après le mkdirat ; ce qui existait déjà n’est pas retouché. Alternative écartée : umask(0) autour de la création — état process-global, donc une course échangée contre une dépendance à l’environnement.

Au passage, une note de suivi corrigée : elle affirmait qu’air-filesystem n’exposait aucune création de répertoire à mode explicite. C’était faux — make_directory existe. Ce qui manquait était la forme récursive.

air-ssh known-hostsFAIT (2026-08-09)

Un utilisateur ne pouvait pas ajouter un hôte dans son propre magasin sans le mot de passe de root : le gate d’ADR-101 s’applique à air-keystore known-hosts add|remove avant de regarder --store. Pire, un compte ordinaire échoue même avant le gate (il ne peut pas lire /etc/air/shadow.aircfg), et contourner par sudo casse le magasin — le fichier renaît possédé par root, que la garde de lecture refuse ensuite.

Décision BDFL : aucun gate relâché. Le verbe air-ssh known-hosts add|remove écrit le magasin personnel sous l’identité de son propriétaire ; air-keystore garde le magasin administré derrière son gate. Chaque magasin est derrière l’outil qui a l’identité qu’il faut. Au passage, le message de refus « la clé a changé » cesse de conseiller une commande que son destinataire ne peut pas exécuter.

✅ La demi-fermeture ne ferme plus le canal — FAIT (2026-08-08)

Le tunnel inverse concurrent perdait des connexions, silencieusement et seulement sur certaines machines — donc invisible en CI. Ce n’était pas une course : les trois pompes serveur émettaient CHANNEL_EOF et CHANNEL_CLOSE dès qu’une lecture rendait 0, c’est-à-dire sur une simple demi-fermeture — le geste normal de tout protocole requête/réponse. RFC 4254 §5.3 réserve le second à la fin du canal ; le canal disparaissait donc avant la réponse.

Corrigé aux trois endroits : forward_blocking (pilote privsep, livré), forward_pump (-L monolithique) et tcpip_forward (-R monolithique). Le client, lui, appliquait déjà la bonne règle depuis toujours (client_forward::finish_pump) — ce sont les pompes serveur qui avaient divergé. Quatre tests existants encodaient le défaut ; trois nouveaux le prouvent mordant, dont deux face à un vrai client OpenSSH (le nôtre est trop tolérant pour le voir).

✅ Épinglage des clés d’hôte nues — FAIT (2026-08-07)

air-ssh n’épinglait aucune clé d’hôte nue : sans magasin de CA d’hôtes configuré — le cas nominal — il acceptait n’importe quel serveur. Le verdict HostTrust::PlainKey renvoyait à un chemin known_hosts qui n’existait pas, et les deux appelants de production le jetaient. Le socle (air-keystore::hosts, le schéma, le programme air-keystore known-hosts) existait déjà en entier : il n’avait jamais été câblé.

Désormais : deux magasins, l’administré de la machine (chemin fixe, lecture seule, verdict prioritaire) puis le personnel du compte (knownHostsPath, écrit à la première vue). Clé changée ou révoquée ⇒ refus sans appel, pendant la poignée de main. Hôte jamais vu ⇒ empreinte affichée et accord explicite exigé sur un terminal, refus à défaut. Chaque saut d’un rebond est épinglé sous son propre motif.

Ce qui n’est pas garanti, et qui est écrit partout comme tel : la première connexion reste non protégée — limite du modèle, pas reste de travail. L’acquis est la détection du changement de clé. Clôture consignée dans ADR-118 §A6 (FR + EN).

🔵 CAMPAGNE DOCTRINE & CHEMIN v1.0 (2026-08-03 → 08-04) — REPRENDRE ICI. Deux journées de doctrine, d’audit de conformité et de cadrage. Tout est mergé ; ce qui suit est l’état des décisions et des blocages, dans l’ordre où ils commandent la suite.

Ce qui a été gravé. ADR-149 (architecture de code : contrôleurs, managers, contrat d’API, déclaration role, convention // SECOND-PATH:) — ADR-150 (manifeste demande / machine octroie, artefacts binaires, ordre d’arbitrage signature → octroi → enveloppe, refus muet, deux portes distinctes) — l’amendement D6 d’ADR-046 (plus d’exécution non confinée ; PID 1 seule exception ; refus par défaut) — le Principe 10 (cloisonnement) et le Principe 11 (entrée stricte, sortie lisible) — et l’état de conformité des Managers dans l’amendement d’ADR-077.

Décision de placement (2026-08-04) : air-launchd est en COUCHE 2, requis dès la v1.0. Cadrage et découpage en trois temps : notes/air-launchd-couche2-roadmap-fr.md.

✅ Temps 1 du lanceur — FAIT (2026-08-04), critère de sortie atteint

air-launchd existe et tourne — sur la pile linux-air, les deux arches. Une seule ligne d’air-sshd a changé : SystemdHost::from_env()air_service::detect(). Le test d’intégration et le fuzz tournent sur la pile hôte gnu et ne valident que la logique ; la preuve qui engage est on-target, en static-pie sans libc C : air-launchd a lancé le témoin et air-sshd sur x86_64 (carbon) et sur aarch64, vrai Raspberry Pi 4 (raspi-srv-1) — écoute remise par SCM_RIGHTS et comptée par le service, READY reçu sur le canal (aucune variable d’environnement posée), bannière SSH-2.0-Air_0.7.0 servie sur le socket hérité, arrêt honoré. Détail et réserves : specs/layer-2/air-launchd.md §6.

Le contrat d’activation est Air, pas systemd (décision BDFL 2026-08-04) : un SOCK_SEQPACKET sur le descripteur 3, cinq enregistrements à disposition fixe et bornée (air-launch-proto, cœur pur sans-IO, fuzzé 11,1 M exécutions), écoutes remises par SCM_RIGHTS, disponibilité et ordre d’arrêt sur le même canal. Aucune variable d’environnement — là où systemd en fait parser trois (Principe 11). Le nombre d’écoutes n’est pas annoncé, il est constaté : le noyau fait foi, pas le pair. Spec : specs/layer-2/air-launchd.md.

Un piège fermé, trouvé par l’exécution et non par la revue : le masque de signaux d’un superviseur est hérité à travers fork ET execve ; air-sshd démarrait donc SIGTERM bloqué et ne mourait qu’au SIGKILL. D’où l’additif couche 1 ADR-151 (AirCommand::inherit_fd

  • clear_signal_mask). Aucun tag posé : ces deux additifs rejoignent l’inventaire collectif (10ᵉ et 11ᵉ), le re-sceau restant unique à la complétion d’air-ssh/air-sshd.

Durcissement de la supervision (2026-08-06, revue adverse de la pile). Le pilote traitait un refus du cœur comme une raison de mourir, en lâchant son enfant : huit octets de charabia sur le canal et le service se soustrayait à sa supervision — orphelin réparenté à init, seul détenteur de l’écoute, donc redémarrage impossible (EADDRINUSE, le lanceur ne pose pas SO_REUSEADDR). Corrigé sur les trois plans, spec §4.4/§4.5 : (1) un refus produit un Report::ProtocolViolation et enchaîne sur la voie d’arrêt — le lanceur sort proprement, ayant arrêté ce qu’il supervisait (les deux sites, lecture nominale et drainage) ; (2) LaunchedService porte un Drop qui tue et récolte si la fin n’a pas été constatée — l’invariant devient vrai par construction, y compris sur panique ; (3) PR_SET_PDEATHSIG(SIGKILL) sur l’enfant, pour le cas où le lanceur ne vit plus. Les trois correctifs sont prouvés « mordants » (test retiré → rouge). Fermé au passage : la troncature silencieuse du seqpacket (tampon à MAX_RECORD_LEN + 1 des deux côtés) — la spec §2.5 promettait « aucune tolérance », ce qui était faux sur ce chemin.

🔧 Outillage de l’architecture — FAIT (2026-08-05)

ADR-152 : cargo xtask check-controleurs. Les 83 crates déclarent leur role — vocabulaire arrêté par le BDFL le 2026-08-05 : 28 bridge, 18 manager, 16 controleur, 10 vocabulaire, 4 service, 3 user-cli, 3 runtime, 1 syscall. Huit règles vérifiées, dont « un bridge n’accède jamais à un contrôleur », « seule la couche 1 consomme les syscalls » et « ni un démon ni un outil n’expose d’API réutilisable ». Cliquet sur xtask/controleurs-reference.toml : 28 écarts inventoriés, nouveaux signalés, orphelines dénoncées. Consultatif dans la barrière et en CI ; bloquant quand le registre sera vide.

📋 Les 26 écarts = la matière de l’ADR de reprise (prochaine tâche)

FamilleNombreCe qu’il faut faire
manager-absent10écrire le manager dû (air-uring est le moins cher : 0 opération libre)
bridge-vers-controleur7rebrancher le pont sur un manager — 2 sont intra-couche 2
controleur-fuite4manager à écrire, ou consommateur de couche 2 à corriger
vocabulaire-impur3sortir les opérations du vocabulaire
syscall-hors-couche-12air-libc-capi (advisory ADR-087) et air-libc-spawn (dev seulement)
service-consomme2air-agent (lié par air-sshd, livrée — extraire la bibliothèque de protocole) et air-launchd (dev seulement)

Hors gate, à trancher dans l’ADR de reprise : le découpage d’air-sshd. La crate produit deux binaires de natures opposées — air-sshd (démon) et air-ssh (outil utilisateur) — plus une lib de 96 opérations libres. Or role se déclare par crate : aucune valeur ne peut être vraie tant qu’elle porte les deux. ADR-095 avait déjà tranché « deux binaires séparés » pour le packaging ; la taxonomie le redit depuis l’architecture.

Reste à décider : quand basculer le gate en --bloquant.

➡️ La prochaine tâche

La cage est posée (temps 2, fait le 2026-08-10). La suite, dans l’ordre arrêté avec le BDFL le même jour :

(0) ✅ Les façades de couche — FAIT (2026-08-11, #669). air-layer-0/1/2 : trois crates vides qui déclarent ce qu’est une couche (ses crates, et les features nécessaires pour la mesurer isolément). Elles remplacent la table de features de #665 — la déclaration vit désormais dans un manifeste relu en revue. cargo xtask check-facades, bloquant, garantit que façade et layer = N coïncident : la duplication est vérifiée, pas latente. Rôle facade ajouté au vocabulaire ; deux règles d’architecture et le générateur d’index amendés (une façade ne consomme rien et ne compte pas parmi les crates d’une couche).

(a) ✅ Les profils permissifs TRANSITOIRES — FAIT (2026-08-10). Catégorie TransitionalGlibc, établie par la mesure (strace -c sur sh/ls/sed/mkdir/ cat/grep + un pipeline complet), demandée par --profil-transitoire-glibc et jamais dérivée d’un octroi. Un test garantit qu’aucune de ses 17 variantes n’a fui ailleurs : la suppression en v1.0 est donc vérifiée, pas promise. Coût assumé et écrit : ioctl, clone, vfork.

(b) ➡️ PROCHAINE TÂCHE : distribution de la capability AirCom par le lanceur (qui donne du même coup son applicateur à la famille aircom et supprime le danger de réutilisation du pid dans air-registry, le lanceur détenant un pidfd), puis l’instruction des deux rouges intermittents (remote_forward, keepalive), puis la reprise de V2.6.

Reste ouvert sur le temps 1, par ordre d’intérêt :

  1. Le lanceur ne sait pas arrêter un service qui fork (spec §4.5). Il ne vise que l’enfant direct ; PDEATHSIG ne s’hérite pas aux petits-enfants. Un air-sshd qui fork ses sessions laisse des fils vivants avec la copie héritée de l’écoute : port occupé, redémarrage impossible, processus non supervisés en vie. C’est la limite qui compte pour un opérateur, et son correctif complet est le cgroup du temps 3. Demi-mesure écartée : setsid sur l’enfant + kill(-pgid) demanderait d’exposer setsid sans terminal de contrôle sur AirCommand — un additif couche 1 hors de celui qu’ADR-151 a autorisé (voir « dettes » plus bas).
  2. Le deadline_ms du Stop n’est pas exposé au service (il ne sait pas combien de temps il a pour draîner).
  3. air-sshd n’arme pas sa source d’arrêt, donc il cède au SIGTERM d’insistance au lieu de draîner — occasion manquée, pas défaut du contrat.
  4. Un seul service par lanceur.
  5. Flot de Status non borné hors drainage. Un service en boucle sature le journal du lanceur. Le contenu est borné et anti-injection (validé UTF-8, sans caractère de contrôle) ; c’est un DoS de volume seulement, et il vise le journal, pas le lanceur. À refermer par une limite de débit quand le journal structuré arrivera.

✅ Le blocage qui commandait beaucoup — LEVÉ (2026-08-10)

La largeur des enveloppes par défaut (question ouverte n° 1 d’ADR-150) est tranchée : enveloppes filesystem écrites, familles sans applicateur vides par défaut — un octroi qui les invoque est refusé. Le mécanisme de cage existait déjà (ADR-112, fait, en production dans air-sshd) ; ce qui manquait était de savoir quoi poser, et on le sait.

Ce qui suit reste la trace de l’instruction qui a mené à la décision — conservée parce qu’elle porte un prérequis toujours ouvert (le portail), pas parce que quelque chose est encore bloqué.

Piste instruite : un plancher « exister et rendre compte », des incréments grossiers et nommés, fork/exec hors plancher sauf incrément « outils de développement et shells » (tranché BDFL). Et un prérequis découvert, toujours ouvert : le portail — l’utilisateur désigne, un service de confiance rend un descripteur — sans lequel toute application demandera un accès fichier large et le modèle d’octroi se dégradera. Air n’a de portail que pour les applications étrangères (xdg-desktop-portal-air, exception D-Bus d’ADR-001), aucun pour les siennes.

📌 Autres chantiers instruits, prêts à démarrer

  • AirCom, surface Rust sûre par défautnotes/aircom-surface-rust-sure-conception-fr.md. Le transport tient (~11 200 lignes, SEQPACKET, capabilities par SCM_RIGHTS async) ; c’est la surface qui manque. AirValueOwned existe et n’est utilisé nulle part ; #[air_class] ne génère pas encore les méthodes (AO.4). Cinq blocs unsafe dans les deux exemples air-notifyd. Critère mécanique : grep -c unsafe rend 0.
  • Simplification du rendez-vous AirCom : le total_len transmis est redondant — les valeurs qui font foi sont dans l’en-tête du segment. Le dériver du descripteur supprime un message et une hypothèse de confiance.
  • Exemples de référence : crates/air-com/examples/meteo_{station,console}.rs — 34 et 23 lignes, zéro unsafe, exécutés sur carbon. À faire évoluer quand la couche 2 sera complète.
  • AirCgroupManager (temps 3 du lanceur) : n’existe pas. Pas un descellement — les cgroups se pilotent par le système de fichiers.

⚠️ Dettes ouvertes, à ne pas perdre

  • AirCommand::new_session() — additif couche 1 à demander (2026-08-06). Exposer setsid sans terminal de contrôle est ce qui manque pour que le lanceur puisse signaler le groupe de son service plutôt que son seul enfant direct (spec §4.5). Aujourd’hui setsid n’est atteignable, sur le chemin exec, que par login_terminal(slave) — qui exige un PTY et écrase les trois flux standard. air_signal::send_signal_to_process_group existe déjà : c’est la pose du groupe qui manque, pas le tir. air_process::spawn_process sait le faire (set_session, SpawnProcessGroup::Own) mais rend un Pid nu, sans pidfd. Arbitrage BDFL requis (l’inventaire couche 1 est gelé au-delà d’ADR-151) — ou bien la question tombe avec le cgroup du temps 3, qui est la vraie réponse.
  • unsafe en couche 2 dans air-service : 5 blocs (2026-08-06). Les 3 existants, plus les 2 d’AirLaunchdHost::from_descriptor (BorrowedFd::borrow_raw de sonde, puis OwnedFd::from_raw_fd d’adoption du descripteur 3). Les // SAFETY: sont corrects et un // SECOND-PATH: (ADR-149 D10) est posé au point d’usage, mais ADR-149 D2 veut l’unsafe en couche 0 et nulle part ailleurs. La bonne porte est une primitive couche 1 adopt_inherited_fd (sonder, puis rendre un OwnedFd ou rien) — non écrite, l’inventaire couche 1 étant gelé. À rouvrir avec l’additif ci-dessus.
  • Planchers de branches absolus restants (2026-08-05). Le plancher 78 du mode COMPLET est remplacé par une non-régression contre xtask/couverture-reference.toml (référence relevée en CI, tolérance 0.10, ratchet à +0.25) : il avait dérivé au-dessus de toute mesure réellemain lui-même mesurait 77,42 % en local et 77,72 % en CI — et bloquait donc toutes les PR. Restent sur un 78 absolu et non calibré : la barrière locale (xtask/src/barrier.rs) et le mode --layer 0|1. Chacun demande sa propre référence (les périmètres ne sont pas comparables) ; à traiter séparément. Cf. docs/CI.md § « Branches : non-régression ».
  • ADR-118 — le volet CLIENT n’existe nulle part (constat du 2026-08-04, au moment de passer l’ADR en fait). Les 8 incréments mergés sont tous serveur ; le §4 (options client IdentitiesOnly, ForwardAgent, CanonicalizeHostname, ServerAliveInterval) et le §2 côté client (blocs Match) n’ont aucune ligne de code : ssh-config.capnp ne porte aucun de ces champs, et -A n’est qu’un drapeau de ligne de commande (#627), pas une option de configuration. Ces options avaient été rattachées à ADR-116 — qui a été clos sans elles. Soit un incrément dédié (extension additive de SshConfig/HostBlock), soit un arbitrage explicite de mise hors périmètre. IdentitiesOnly a une vraie conséquence : sans lui, le client épuise le plafond de 6 essais (ADR-094).
  • ADR-118 — le volet CLIENT : ouvert le 2026-08-04, CLOS le 2026-08-05 (c1, c2, c3). Le constat de la veille (« aucune ligne de code ») est entièrement levé.
    • c1 — ForwardAgent par hôte + -a : HostBlock.forwardAgent en union à trois états (unspecified/yes/no, unspecified = le zéro capnp ⇒ rétro-compat prouvée), SUPPORTED_VERSION inchangé à 1 ; drapeau -a/--no-forward-agent et précédence -a > -A > config > défaut fermé (-A+-a ⇒ erreur) ; CLI config set forward-agent yes|no|unset --host <motif> + JSON à trois états ; e2e réel contre sshd d’OpenSSH. Guide : guides/air-ssh-config-client-fr.md. Fast-follow (revue adverse, avant merge) : garde de propriété/permissions sur ssh.aircfg — lecture en O_NOFOLLOW + statx par descripteur, refus si le fichier n’appartient pas à l’uid effectif, s’il est inscriptible groupe/autres (0644 passe), ou s’il s’agit d’un lien symbolique ; écriture en 0600 par write_atomic_with_mode. Précédent OpenSSH (« Bad owner or permissions »), et le fichier désigne aussi les magasins de révocation d’hôte (ADR-109/138) — la garde valait donc déjà avant -A, ForwardAgent l’a rendue exigible. Plus : l’allumage par la config n’est plus muet (une ligne stderr nommant le bloc, rien quand -A est explicite, rien quand aucun relais n’est demandé). Fast-follow n°2 (2e revue adverse, avant merge) — le défaut majeur n’était pas dans ce que la garde contrôle mais dans ce qu’elle n’atteignait jamais : O_NOFOLLOW ne dit rien du type de l’entrée, et open(O_RDONLY) sur un FIFO bloque indéfiniment ⇒ un mkfifo posé par le tiers du scénario ci-dessus figeait chaque air-ssh de la victime, avant toute vérification. Corrigé par le duo O_NONBLOCK (pour atteindre la garde) + refus du type non régulier (pour décider) — l’un ne suffit pas sans l’autre — avec un test à attente bornée (une régression échoue, elle ne fait plus pendre la CI). Plus : Err(_) => Ok(None) discriminé (ENOENT/ ENOTDIR = pas de config ; tout le reste = refus, comme la docstring le promettait) ; couple (motif, bloc) de ForwardAgent dérivé du bloc (zip) au lieu d’un unwrap_or_default() qui fabriquait le verdict que le type devait rendre impossible ; lecture fail-closed sur un compte rendu incohérent du noyau.
    • c2 — ServerAliveInterval / ServerAliveCountMax (2026-08-05, branche feat/cfgclient-c2-server-alive) : détection de serveur mort sur les deux moteurs clients (shell/exec et moteur -N/-L/-R, celui qui en a le plus besoin). La décision est partagée avec le serveurKeepalivePolicy / KeepaliveTracker::step de crate::keepalive, écrits une fois, employés aux quatre points d’intégration : il n’y a pas deux sémantiques de keepalive dans le dépôt. Seul l’ordonnanceur diffère, et l’ADR-118 §5 dit vrai côté client : le client construit un vrai air_async::Runtime, le keepalive y est une future de plus sur un Timeout io_uring — là où le worker serveur, sans réacteur (seccomp sans io_uring_*), n’avait que l’échéance de son ppoll. Ce n’est pas une divergence de style mais la conséquence de l’endroit où chacun vit ; dit dans les deux modules et dans le guide. Modèle : défaut du poste (SshConfig) et surcharge par hôte (HostBlock), composés champ par champ (un bloc peut raccourcir le seul intervalle sans figer le compteur du poste). Deux portées, là où ForwardAgent n’en a qu’une — parce qu’un keepalive n’ouvre et ne ferme aucun accès : il ne fait que borner le temps qu’une connexion morte occupe des ressources. Précédence --server-alive-* > bloc > poste > désactivé ; --server-alive-interval 0 éteint ce que la config allume. Piège du zéro capnp, traité deux fois : l’intervalle du poste est un UInt32 nu (son 0 est le défaut), mais le compteur est une union absent/count (absent = le zéro du discriminant ⇒ 3, jamais 0) et les surcharges de bloc en sont aussi (inherit/valeur — sans quoi un 0 de bloc, qui éteint, serait indistinguable du silence d’un bloc hérité). Un count(0) écrit fait refuser l’artefact entier, aux deux portées et inconditionnellement (côté client les deux réglages vivent dans deux portées qui se composent : un 0 posé ici devient actif dès que l’autre portée fournit un intervalle). ⚠️ Le défaut que seule l’interop réelle a trouvé : air-sshd émet ses sondes en requête globale, mais OpenSSH les émet en requête de canal dès qu’un canal est ouvert (client_alive_check() : channel_find_open() d’abord). Toutes les suites contre pair scripté passaient — elles étaient fidèles à notre serveur — et le test contre un vrai sshd échouait : un air-ssh face à un sshd durci se faisait couper dès qu’une session existait. Les deux formes sont désormais honorées (CHANNEL_FAILURE / REQUEST_FAILURE, RFC 4254 §5.4 et §4), sur les deux moteurs, quel que soit le réglage — répondre n’est pas une option. Les deux tests d’interop sont séparés par sens : mis en tension sur la même connexion, ils passaient aussi avec un client muet (les sondes de l’un sont du trafic reçu par l’autre, ce qui remet son compteur à zéro) — vérifié par mutation. Revue adverse avant merge — quatre correctifs, un résidu nommé. (a) La file des requêtes globales du moteur -N avait d’abord été marquée pour y faire figurer les sondes ; c’était une erreur, défaite : une FIFO apparie par position, donc le marquage n’apportait rien qu’un pair conforme n’obtenait déjà (les sondes partent après tous les -R, leurs réponses dépilent une file vide), et il coûtait une croissance non bornée — une sonde n’était dépilée que par une réponse de requête globale, alors que le compteur du keepalive, lui, est remis à zéro par tout paquet reçu ; un pair qui parle sans jamais répondre aux requêtes globales faisait croître la file d’une entrée par intervalle. (b) ETIMEDOUT ne se diagnostique plus en « pair mort » sur la foi du seul code d’erreur : le connect(2) d’un hôte absorbé par un pare-feu rend le même, et le message mentait sur le chemin par défaut (« 3 sondes … ServerAliveInterval 0 s ») ; la distinction est portée par un type (ConnectOutcome), produit au seul endroit où l’information existe. (c) Réponse de canal restreinte aux canaux confirmés — sur un -L non confirmé, remote_id vaut encore 0 et la trame serait adressée au canal 0 du pair. (d) engine.pending vidée au dénouement à chaud : les pompes en attente d’intégration détiennent un Rc<Engine> que l’Engine détient en retour — cycle inatteignable par la sortie normale (qui exige pending vide), mais pas par celle-ci. Seconde revue adverse (PASS avec réserves) — cinq points, quatre corrigés, deux dettes consignées. (1) Le cycle Rc avait un jumeau côté session, non corrigé, et le commentaire affirmait le contraire : AgentRelay::pending contient elle aussi des pompes qui détiennent le relais qui les détient, et aucune des trois sorties de la boucle de session ne repassait par son take_pending — un canal auth-agent ouvert au tour précédent faisait fuir le relais, ses canaux et les fd de socket d’agent. Vidage après la boucle, ce qui ferme du même geste la sortie TeardownDeadline, pré-existante et atteinte du même défaut. (2) Un test documentait encore le design révoqué au correctif (a) — dernières occurrences de PendingGlobal du dépôt : il annonçait une file « marquée » là où l’appariement est positionnel et repose sur l’ordre que RFC 4254 §4 impose au pair. (3) keepalive_interop.rs promettait une borne dure (timeout externe) qu’il n’appliquait pas : air-ssh y courait dans un Command::output() nu, donc pouvait immobiliser la suite — c’est désormais un vrai timeout(1), dont le code 124 est un échec nommé. (4) Le sshd réel de cette même suite n’était tué qu’au chemin nominal : un assert! rouge laissait un démon en écoute et un authorized_keys dans /tmp sur le runner — jeton RAII SshdGuard. (5) KeepalivePolicy devient un type analysé (Principe 4) : champs privés, try_new faillible pour seul constructeur, accesseurs, et les trois lecteurs s’y ramènent. Un count_max = 0 sous un intervalle actif rend Disconnect à la première échéance, sans avoir jamais sondé ; la règle vivait aux trois sites d’appel, jamais dans le type, et c2 élargissait la surface de trois fonctions publiques de plus. Le comportement ne change pour aucun couple ni aucun message ; le prix visible est trois bras d’erreur inatteignables, marqués COVERAGE (STRUCTURAL, ADR-035).
  • ⚠️ DETTE (classe pré-existante) : l’Outbox du client n’est pas bornée (client_forward.rs). c2 ajoute deux chemins où un message du serveur provoque une émission (réponse aux requêtes globales et de canal, sur les deux moteurs) : un serveur qui inonde de CHANNEL_REQUEST want_reply=true sans jamais lire fait croître la file de sortie. Le facteur d’amplification est inférieur à 1 (≈ 9 octets émis pour ≈ 20 reçus) et la classe est antérieure à l’incrément (le même chemin existe sur main pour les ouvertures de canal). Le plafond appartient à l’Outbox elle-même, pas à chacun de ses appelants : c’est un ticket propre, pas un effet de bord d’incrément.
  • ⚠️ DETTE (héritée de c1) : la fixture « artefact pré-c1 » n’est pas datée. ssh_config/tests.rs::build_pre_c1 emploie le constructeur courant : le message porte le dataWordCount d’aujourd’hui (2 mots), pas celui d’un artefact réellement écrit avant l’incrément (1 mot). La propriété tient — Cap’n Proto rend le zéro d’un champ hors section comme d’un champ absent, et c’est ce zéro que le lecteur doit lire en inherit — mais la preuve porte sur la sémantique, pas sur les octets d’hier. La doc du test le dit désormais ; la preuve par octets demanderait une fixture figée en dur.
  • ⚠️ RÉSIDU NOMMÉ (serveur, hors volet client) : le démon MONOLITHIQUE ne répond pas aux sondes d’un client. Une fois le shell ou l’exec lancé, plus personne n’y dépouille les messages du client (interactive::channel_to_master ignore les requêtes globales ; le chemin exec ne lit rien pendant l’exécution). Un air-ssh avec ServerAliveInterval face à un air-sshd sans --privsep inactif conclut donc à un pair mort à tort. Ce n’est pas une régression (aucun client Air n’émettait de sonde avant c2) et c’est cohérent avec la posture existante — ce même démon refuse déjà de démarrer avec un ClientAliveInterval, son flux io_uring n’ayant pas d’attente bornée : le mode monolithique ne supporte pas le keepalive, dans un sens comme dans l’autre. Écrit dans le module et dans le guide plutôt que laissé à découvrir. Le corriger exige de donner un émetteur à la direction lectrice d’interactive (aujourd’hui une simple RecvHalf, la règle « un seul écrivain » réservant l’émission à l’autre direction) et de faire lire le chemin exec pendant l’exécution : un chantier serveur à part entière.
    • c3 — ProxyJump (2026-08-05, branche feat/cfgclient-c3-proxyjump) — dernier incrément du volet client, campagne close. air-ssh atteint une machine derrière un bastion sans y déposer de clé privée. Rebond purement interne : aucun processus lancé, aucune commande, aucun exec — c’est ce qui le rend acceptable là où ProxyCommand ne l’est pas.
      • La couture existait : le client est générique sur son transport, donc ProxyJump est une quatrième implémentation de SshByteStream (JumpStream, crate::jump_channel) adossée à un canal direct-tcpip. Handshake, confiance d’hôte, auth, session, -L/-R, agent et keepalive tournent au-dessus sans une ligne de modificationclient.rs et client_forward.rs sont intacts.
      • Le point dur était le pompage concurrent : un constructeur séquentiel plus un lecteur et un écrivain par étage dans un seul Unordered (ADR-098), pompes intégrées à chaud par le motif sentinelle Notify de client_forward. Le premier étage est sorti de la boucle : deux instanciations seulement (socket, puis JumpStream), donc aucune récursion générique à boxer.
      • ⚠️ Contrôle de flux — la famille de défauts déjà corrigée deux fois ici. Crédit rendu à mesure que le transport intérieur consomme (pas à l’arrivée), max_packet opposé en réception et en émission. Prouvé par 3 Mio à travers le rebond, in-process et contre un sshd d’OpenSSH en bastion ; vérifié par mutation (sans recrédit : cale à 2 068 992 o contre Air, se fige contre sshd, qui applique vraiment la fenêtre annoncée).
      • Le rebond ne dispense de rien : chaque saut vérifie sa propre clé d’hôte (ADR-109/137, magasins de la config) et s’authentifie séparément (compte et clé du bloc du saut ; à défaut, l’identité de l’invocation — jamais default-identity seule, qui ferait gagner un fichier contre un -i). Testé aux deux étages.
      • Aucun repli direct, testé sur un montage qui pourrait tricher : cible joignable en direct, saut mort, la commande ne s’exécute pas.
      • Chaîne bornée à 8 sauts (développement transitif compris) et cycle refusé — avant tout octet de réseau. ⚠️ Faux positif levé par le bout en bout : le cycle traqué est celui du graphe de configuration, pas une coïncidence de noms (-J 127.0.0.1:2222 vers 127.0.0.1:2200 est légitime) ; tous les tests unitaires passaient, ce sont les binaires livrés qui l’ont trouvé.
      • Modèle : HostBlock.proxyJump @12 :List(JumpHost), liste structurée et non une chaîne à re-parser (Principe 11 — la CLI compile user@host:port). Aucune union, à la différence de c1/c2 : pas de défaut global dont un bloc devrait se démarquer, donc la liste vide est le silence et un artefact pré-c3 se relit à l’octet près. -J [user@]hôte[:port][,…] répétable ; -J none annule (parité OpenSSH). Corollaire assumé : avec -J, la cible n’est plus résolue localement — c’est le dernier saut qui la résout.
      • Aucun additif couches 0/1. ECONNREFUSED aurait été le mot juste pour un canal refusé ; absent de la table Errno scellée, on rend ECONNRESET et le diagnostic utilisateur nomme le saut et la raison.
      • ⚠️ FAST-FOLLOW c3 (revue adverse du 2026-08-05) — une revendication de sécurité RECTIFIÉE, huit mineurs corrigés. La revue a reproduit la preuve de contrôle de flux à l’octet près (2 068 992 o sur 3 145 728 sans recrédit) et validé l’absence de repli, les cycles, le cloisonnement des étages et l’additivité du schéma. Ce qu’elle a trouvé et ce qui a été fait :
        • La revendication qui ne tenait pas. « Chaque saut vérifie sa propre clé d’hôte » se lisait comme une protection contre un bastion compromis. Elle n’en est pas une : evaluate_host_key rend HostTrust::PlainKey devant une clé d’hôte nue, ce verdict n’a aucun consommateur (known_hosts/TOFU non implémenté) et il est jeté au point d’appel. Sans magasin de CA d’hôtes, air-ssh accepte n’importe quelle clé d’hôte — sur un rebond comme en direct. Manque pré-existant et global, pas introduit par c3. Arbitrage BDFL : corriger les TEXTES (module, schéma, guide, statuts.toml, aide de -J), pas durcir -J — le durcir singulariserait le rebond pour une faiblesse globale et rendrait la fonctionnalité inutilisable partout où aucune CA n’est déployée. Le vrai correctif est le chantier TOFU/known_hosts, cap suivant.✅ FAIT le 2026-08-07, voir l’encart « épinglage des clés d’hôte nues » en tête de ce document, et la clôture d’ADR-118 §A6.
        • m1 — l’identité de saut repliait sur la clé de la CIBLE. config.identity porte l’identity du bloc cible ; un Host prod / identity …/prod-only-key / proxyJump bastion présentait donc la clé dédiée à prod au bastion. Comportement corrigé : Config::jump_identity, résolue à part (-i/--key-seed/agent, sinon le défaut du poste), et calculée seulement si un saut en a besoin.
        • m3 — aucune échéance sur le montage de la chaîne. Un bastion qui accepte le TCP, complète KEX et auth puis ne répond jamais au CHANNEL_OPEN figeait air-ssh sans borne (le keepalive ServerAlive* ne couvre que la session intérieure). STAGE_SETUP_TIMEOUT = 60 s par étage, couvrant les quatre moments à la fois.
        • m2/m4 — jump_stages refuse au lieu de dégrader : compte de saut indéterminé (plus d’unwrap_or_default() qui partait avec un user vide sur le fil) et contrôle de longueur explicite au lieu d’un zip qui tronquerait la chaîne — une chaîne tronquée ferait ouvrir la session sur un bastion en croyant être sur la cible. Appelé avant la résolution d’adresse et la connexion (Principe 4).
        • m5/m7/m9 : l’échec d’encodage du CHANNEL_OPEN se propage (plus de canal à charge vide) ; le compte d’un saut est borné (MAX_JUMP_USER_LEN) à la lecture et à l’écriture, comme l’hôte — c’est un champ qui part sur le fil ; write_ssh_config revalide ce qu’il sérialise et rend un Result.
        • m8 — chien de garde de test par PID. Le tueur polait /proc/{pid} puis kill -9 {pid} : entre la moisson et le sondage suivant, le PID peut être recyclé. Le Child est désormais conservé (try_wait/kill), et chaque cas arme un Watchdog de processus comme dans les deux autres suites.
        • Couverture : jump_channel.rs était à 40,5 % de branches (le moteur n’est exercé que par des tests d’intégration, invisibles à la mesure --lib). Onze cas unitaires de plus, dont un lecteur d’étage piloté par un transport en clair qui éprouve message par message ce qu’un saut peut envoyer.
      • 📌 Dette consignée, non corrigée :
        • Outbox non bornée (jump_channel.rs, réponses aux GLOBAL_REQUEST / CHANNEL_REQUEST du pair). Un bastion hostile qui inonde tout en cessant de lire fait croître la mémoire du client. Motif pré-existant (client_forward, client — déjà consigné en c2), mais il vaut ici pour un pair explicitement moins fiable. Le plafond appartient à l’Outbox elle-même : ticket propre, pas un rustine dans jump_channel.
        • Informatif : HostBlock/HostSettings gagnent un champ public proxy_jumprupture source pour un constructeur littéral externe (air-sshd est couche 2, non scellée : autorisé, mais noté). Idem write_ssh_config, qui rend désormais un Result.
    • 🚫 ProxyCommand — hors périmètre, en attente de son propre arbitrage BDFL. C’est une porte d’exécution pilotée par un fichier de configuration (amendement D6 d’ADR-046, ADR-150) : une commande arbitraire lancée sous votre compte au moment où vous tapez air-ssh. L’absence est consignée dans le schéma, le module et le guide, pour qu’elle se lise comme un choix et non comme un oubli. C’est le même motif qui a écarté le critère exec des blocs Match client.
    • 🚫 Trois refus argumentés, tranchés en c1 (détail dans le guide) : IdentitiesOnly est déjà vrai par construction — le constat des 6 essais (ADR-094) porte sur le client OpenSSH, air-ssh n’offre jamais plus d’une identité et refuse devant un agent à ≥ 2 clés sans sélecteur (invariant verrouillé par un test qui le nomme) ; CanonicalizeHostname mettrait une résolution DNS sur le chemin d’une décision de configuration (Principe 11) pour un gain nul tant que la correspondance de motif est exacte ; les blocs Match client ajouteraient exec, une porte d’exécution pilotée par un fichier de config, que refusent l’amendement D6 d’ADR-046 et ADR-150 — le modèle conditionnel client existe déjà et s’appelle le bloc par-hôte.
  • Garde de ssh.aircfg — quatre réserves consignées, non corrigées (2e revue adverse du fast-follow c1, 2026-08-04). Aucune n’est exploitable par un tiers ; toutes sont des écarts entre ce que le code tient et ce qu’on aimerait qu’il tienne.
    • Composants de répertoire non contrôlés. O_NOFOLLOW ne porte que sur le dernier composant : un lien symbolique sur ~/.config ou ~/.config/air n’est pas vu. Le vrai correctif est ResolveFlags::NO_SYMLINKS (crates/air-sys-types/src/fs.rs), qu’AirFile::open n’expose pasadditif couche 1, hors périmètre tant que l’inventaire est gelé. La propriété qui tient en attendant est le contrôle de propriétaire (un non-privilégié ne peut pas créer un fichier possédé par la victime) — parité OpenSSH, qui ne contrôle pas davantage les parents de ~/.ssh/config. Consigné dans le guide.
    • Lecture non bornée (bin/air-ssh.rs, read_to_end) : pas de plafond de taille, alors que AirFileSystem::read_to_bytes(path, Some(limite)) est déjà employé pour authorized_keys dans cette même crate. Non exploitable par un tiers (le fichier appartient à la victime, la garde vient de l’exiger) : auto-infligé. Le débordement du compte rendu du noyau, lui, est corrigé (fail-closed).
    • Octets de config rendus verbatim au terminal (bin/air-ssh.rs, annonce du relais) : un motif de bloc peut porter CR/ANSI. Source semi-fiable — mais config import accepte un JSON d’origine quelconque, donc la fiabilité n’est pas acquise.
    • config export/get/set lisent encore par std::fs::read, sans garde : config get affiche une config que connect refuse. L’outil de diagnostic ment sur ce qui sera appliqué. Ni fuite ni écriture à travers un lien — mais à réconcilier.
  • Bornes de longueur de liste des lecteurs de config binaire (constat du 2026-08-04, au fuzz de c1 ; confirmé par la revue adverse du fast-follow c1) : parse_ssh_config et parse_sshd_config itèrent une liste Cap’n Proto dont la longueur vient de l’artefact, avec les ReaderOptions par défaut pour seule borne anti-DoS. Un artefact de 216 octets dont la longueur de hosts est mutée à 4 194 305 fait travailler le décodeur ~10 s. Côté client, la réserve tombe : aucun chemin réseau n’atteint parse_ssh_config, l’artefact est le fichier de l’utilisateur, et air-ssh n’est pas setuid. Côté serveur, elle tient : un sshd.aircfg corrompu peut faire consommer des centaines de Mio au démon au démarrage. Correctif d’une ligne (ReaderOptions::traversal_limit_in_words resserré), à poser d’un bloc pour les deux lecteurs, pas au fil des incréments.
  • JSON à clés dupliquées : air-json retient la PREMIÈRE, jq/Python la dernière (constat de la revue adverse du fast-follow c1, 2026-08-04). AirJsonValue::get (crates/air-json/src/value.rs) fait un .find() : un objet {"forward_agent": false, "forward_agent": true} se lit false pour Air et true pour les outils du reste du monde. Un fichier d’import peut donc dire une chose au réviseur humain et une autre à Air, sur le réglage le plus dangereux du fichier. ⚠️ Ne pas corriger au fil de l’eau : air-json est en couche 1, et un changement de comportement y est une rupture, pas un additif — relève du re-sceau et d’une décision BDFL. Les deux issues plausibles : dernière occurrence (aligner sur le monde) ou refus du doublon (Principe 11 — la tolérance est la faille) ; la seconde a notre préférence.
  • Aucun gate CI ne prouve que le Rust généré correspond au schéma capnp (constat de la revue adverse du fast-follow c1, 2026-08-04) : crates/air-sshd-schema/regenerate.sh est manuel. La propriété de sécurité « unspecified = zéro du discriminant » vit dans le .rs committé ; le .capnp n’est que de la documentation tant que rien ne les confronte. Correctif : un job qui exécute regenerate.sh puis git diff --exit-code.
  • Additifs couches 0/1 non scellés — inventaire tenu dans notes/inventaire-additifs-couches-avant-re-sceau-fr.md : 9 additifs couche 1 depuis couche-1-v3.8 (tous additifs ⇒ mineur v3.9, pas le majeur d’ADR-123) et 1 additif couche 0 (fchmodat2, #568) resté descellé sans tag. À trancher au re-sceau unique de fin d’air-ssh.
  • ADR-149 D1 est faux pour la couche 5 : ADR-006 rend les couches 3 et 4 absentes en profil air-base, donc « N n’invoque que N-1 » ne tient pas littéralement. Latent — aucune crate en couche 3, 4 ou 5 aujourd’hui.
  • ADR-005 (systemd) toujours contredit par sa propre mise en œuvre.
  • ADR-046 D6 amendé, mais le sort des binaires étrangers reste à écrire dans le détail.
  • Non conformités ADR-149 : air-terminal (aucun contrôleur, 21 opérations), air-socket (module engine public), air-process (toute la séparation de privilèges hors contrôleur — le plus grave), air-signal, plus air-keystore/air-device/air-sandbox sans contrôleur. À mener en un seul cycle de descellement.
  • Gate check-controleurs : à écrire, consultatif d’abord, bloquant ensuite (tranché).
  • epoll : seul manque de couche 0 encore réel.
  • Questions sans réponse : statut d’ADR-077 (fait ou permanent), forme de surface d’air-keystore, contrôleurs spécialisés publics ou internes par défaut.

🟡 CHANTIER EN COURS (2026-08-02) — ADR-147 : LE WORKER DE SESSION TOUJOURS CONFINÉ (option B). La campagne d’intrusion ADR-105 a relevé que le worker post-auth — celui qui parle le transport hostile — n’a aucun filtre seccomp. On ne peut pas le lui poser tant qu’il lance lui-même le shell : un filtre est hérité à travers l’execve, et le serveur ne peut pas savoir d’avance si une connexion demandera un shell (ssh -L … hôte ouvre un forward puis un shell). Inc.1 — FAIT : le lancement de session est médié. Nouveau verbe RPC SpawnSession ; le monitor alloue le terminal (sous l’identité temporaire du compte, le pts reste user-owned), largue, execve, et remet au worker la voie d’I/O et le pidfd par SCM_RIGHTS ; il en est le parent, donc c’est lui qui récolte et pousse l’exit-status. La commande forcée (command="…") est désormais appliquée par le monitor, jamais écrite par le worker. Iso-comportement : la cage n’est pas encore posée. Inc.2 — FAIT : la cage est posée dans TOUS les cas, et avant le premier octet lu du pair — pas seulement avant le démux : la couche connexion parse elle aussi des données que le client contrôle, et rien n’obligeait à la lui laisser parser déconfinée. Le trou d’ADR-105 est fermé : sur une connexion forward-only, le worker passe de Seccomp: 0 à Seccomp: 2, et un test root le lit dans /proc plutôt que de le supposer. Le profil sftp-worker n’est pas élargi — les quatre gestes qui n’y étaient pas ont été retirés (shutdown(2), MSG_PEEK) ou médiés par le monitor (ioctl(TIOCSWINSZ), pidfd_send_signal → nouveau verbe SessionControl). ⚠️ Ces deux derniers sont une découverte de l’inc.2 : ADR-147 §2 affirmait que le profil couvrait déjà tout ce que le démux et ses pilotes appellent, ce qui était vrai des pompes de redirection mais faux du pilote de session (jamais exercé cagé jusqu’ici, SFTP et shell étant exclusifs). L’ADR mérite un amendement sur ce point. Reste : inc.3 (durcissement : profil session-demux sans les syscalls fichier, non bloquant).

🟢 CHANTIER CLOS (2026-08-01) — ADR-146 : LE FORWARDING SERVEUR SOUS PRIVSEP. Jalons V2.6-1a → V2.6-3 streamlocal-L mergés (#579→#596) : canal RPC ForwardConnect, ssh -L servi, plomberie puis ssh -R séquentiel puis concurrent, connect(2) asynchrone côté monitor, démux unifié -L + -R avec écoutes multiples, la session dans le démux (un shell et un -L/-R sur la même connexion), son durcissement non bloquant, la coexistence du SFTP cage comprise, puis ssh -L vers un socket Unix joint sous l’identité temporaire du compte. Dernière brique, mergée : V2.6-3 streamlocal-Rssh -R vers un socket Unix (streamlocal-forward). Le monitor crée le socket demandé sous l’identité TEMPORAIRE de l’utilisateur (AirTemporaryIdentity) : il est user-owned en 0600 et ne peut naître que là où l’utilisateur pouvait déjà écrire — aucune escalade par construction, aucun socket root:root, et le chemin créé est supprimé sur tous les chemins de fin. Détail des jalons : section « EN COURS — ADR-146 » plus bas.

🟢 Où on en est. La vague 1 (ADR-107→113) est close. La vague 2 est entamée — état lu dans statuts.toml, seule source de vérité : air-json (114) et CLI air-keystore (115) faits ; air-agent (116), redirections/mux (117) et config capacités (118 — tous les incréments serveur faits, inc.1→8 ; ne restent que les options client du §4, rattachées à ADR-116) en cours ; extensions SFTP (119), hostbased (120) et provisioning (121) à faire. S’y ajoutent le privsep du forwarding (ADR-146, fait), le confinement du worker de session (ADR-147, en cours — inc.1 et inc.2 faits) et l’isolation par namespaces (ADR-148, à faire).

📍 ADR-118 (config binaire, capacités étendues) — inc.8 fait (2026-08-03) : dernier incrément SERVEUR. Après le bornage du forwarding (inc.1, #575) et le contrôle d’accès serveur (inc.2, #614), le cœur de l’ADR : les blocs Match — une liste ordonnée de règles conditions → surcharges dans le modèle binaire, évaluée par connexion en couche 2. Conditions : users, groups, source (CIDR v4/v6, l’adresse vue par le listener, jamais annoncée par le client) ; toutes ANDées ; un bloc sans condition matche tout. Surcharges : les six champs d’inc.1/inc.2, chacune optionnelle. Sémantique fixée et documentée : on part de la base, on parcourt les blocs dans l’ordre, chaque bloc qui matche applique ses surcharges — la dernière correspondance qui fixe un champ gagne, et l’arbitrage est par champ, pas par bloc. Fail-closed : groupe irrésolu ⇒ le bloc ne matche pas ; bloc ou CIDR malformé ⇒ le démon ne démarre pas. La politique effective est calculée une fois, à l’ALLOW (compte, groupes et adresse y sont établis), puis transportée jusqu’au médiateur de forwarding : le forwarding est désormais par connexion, plus par-démon. Anti-énumération préservée — la résolution des groupes boucle sur la configuration entière (base + blocs) et le leurre dummy_read la paie aussi. Trois points laissés au superviseur, documentés : (a) pas de condition Host (elle supposerait un reverse-DNS qu’ADR-129 §D4 refuse) ; (b) la règle dernière-correspondance ; (c) le transport de la politique effective. Refus de démarrage si des blocs Match coexistent avec privilege-separation désactivé : le chemin monolithique n’a ni monitor pour évaluer, ni médiation pour appliquer — une demi-politique serait pire que le refus. Écriture des blocs : par JSON (config import), la grammaire plate config set ne décrivant pas une structure imbriquée — c’est ce qu’inc.4 a corrigé.

inc.4 (2026-08-03) — les blocs Match deviennent éditables en ligne de commande, et le piège du replace-vide est fermé. Aucune sémantique de politique ne change (évaluateur intact, schéma intact, pas de bump de version) : c’est de l’outillage et de la visibilité. Six verbes, air-sshd config match list|add|set|unset|open|remove <path> …list numérote les blocs (l’index est la priorité), add ajoute en fin (position la plus forte) et refuse un bloc sans condition ni surcharge, unset rend un champ à l’héritage, remove dit qu’il réindexe. Validations partagées avec l’import JSON (un seul parseur de CIDR pour les deux chemins d’écriture). Le garde-fou : sur un bloc Match, allow_users = [] retire la restriction au lieu de la poser (la surcharge remplace ; une liste d’autorisation vide ne filtre personne — parité OpenSSH) ⇒ set refuse la liste vide en orientant vers les deux suites légitimes, un verbe open la pose explicitement (seul chemin CLI vers Some([])), et toute lecture avertitconfig get, config match list et le chargement de la config par le démon, une fois, sur stderr. Le démon démarre quand même : le replace-vide est une politique valide, seulement piégeuse. Inspection pure (aucune I/O de plus, rien par connexion : pas d’oracle ADR-105). Le JSON reste la voie brute (ADR-032). Écriture fail-closed : réencodage → relecture → comparaison avant d’écrire sur le disque. Guide utilisateur : guides/air-sshd-blocs-match-fr.md.

inc.5 (2026-08-03)ForceCommand serveur, base et surcharge Match. Aucune mécanique d’exécution neuve : le monitor appliquait déjà une commande forcée par clé (ADR-128 §2, substituée au lancement — ADR-147 inc.1) ; l’incrément ajoute une source de plus et la fusionne dans la contrainte, sur les deux voies privsep (publickey et certificat, raccourci « politique non restrictive » compris). Précédence : la config prime sur la clé (parité OpenSSH) — l’administrateur de la machine décide de ce qui y tourne ; elle ne rend jamais un shell libre (les deux valeurs en lice sont des commandes), la demande du client part en SSH_ORIGINAL_COMMAND, et une commande forcée tourne sans PTY même sous pty-req. La surcharge Match a trois états — hérite / pose / efface —, encodés Option<Option<…>> et union à trois branches au schéma : « la commande vide » est inexprimable (et un replace vide est refusé au décodage), donc « efface » ne se confond jamais avec une faute de frappe. L’effacement est un relâchement : traité comme le replace-vide d’inc.4 — set ne peut pas le produire, config match open … force-command le nomme, {"clear": true} l’écrit en JSON, et toute lecture l’affiche dès lors que la base impose une commande. Deux décisions de sécurité écrites : (a) la ForceCommand du serveur ne satisfait pas forced-commands-only — la faire compter rouvrirait le login root d’un serveur qui l’avait fermé, par un geste anodin ; (b) ForceCommand + privsep off ⇒ refus de démarrage, comme les blocs Match : le chemin monolithique n’applique aucune commande forcée, pas même celle d’une clé. Preuves : table de vérité complète, aller-retour des trois états, précédence prouvée en process (cert) et en root (clé nue), et e2e root avec un vrai ssh (supplante l’exec client sous -tt, sans TTY, SSH_ORIGINAL_COMMAND correct, aucune fuite vers un compte non visé). Guide : §4 du même document.

inc.6 (2026-08-03)bannière pré-authentification (SSH_MSG_USERAUTH_BANNER, RFC 4252 §5.4). Le texte vit dans l’artefact scellé (banner @16 :Data, doctrine ADR-073/040) et non un chemin de fichier comme chez OpenSSH : le texte devient une donnée de configuration versionnée et checksummée, et l’étage qui l’émet — l’enfant pré-auth, sous cage Landlock deny-all (ADR-124 §D7) — ne peut ouvrir aucun fichier ; il la reçoit par héritage mémoire au fork, jamais par les ConnectionParams (post-auth). Le frame existait depuis U.1 (userauth_banner, message 53) : l’incrément le câble et complète ses preuves. Décision structurante : AUCUNE surcharge Match — la bannière est pré-auth, la conditionner à users/groups obligerait à résoudre un compte avant de l’avoir authentifié, et sa présence deviendrait un oracle d’énumération (ADR-105), soit la fuite que serve_userauth ferme partout ailleurs. Le refus est porté par le type (le champ vit sur SshdSettings, hors du bloc que les MatchRule surchargent) et gardé par un test de structure. Émise une seule fois, juste après le SERVICE_ACCEPT — donc avant que le client n’ait nommé le moindre compte : ni le contenu ni la décision d’émettre ne dépendent d’une entrée client. Étiquette de langue vide (comme OpenSSH). Contrairement à inc.3 et inc.5, aucune réserve de mode : la bannière est émise par serve_userauth, que privsep et monolithique partagent, et elle précède l’ouverture de tout canal (un ssh -N/-L/-R la reçoit). Surfaces : config set <path> banner '<texte>' (un seul argument) / sans valeur (retire, muet — un texte n’exerce aucun contrôle) ; JSON "banner" chaîne ou null, toujours émis (ADR-032), bannière vide refusée des deux côtés (l’artefact ne la distinguerait pas de l’absence). config get échappe les caractères de contrôle : une bannière porteuse de séquences ANSI pourrait réécrire les lignes que config get vient d’imprimer, donc mentir à l’administrateur dans la commande même qui sert à vérifier. Preuves : frame (octets exacts, cas limites), aller-retour binaire + rétro-compat (artefact d’avant inc.6 ⇒ None), JSON/CLI et refus, échappement d’une bannière hostile, e2e transport (bannière avant le SUCCESS, langue vide ; aucun message 53 sans configuration) et e2e root (un vrai ssh(1) l’affiche à travers la chaîne privsep, avec témoin). Guide : §5 du même document.

inc.7 (2026-08-03)MaxSessions serveur, base et surcharge Match, avec un périmètre 0|1 assumé. air-sshd ne sert qu’un canal de session par connexion : c’est codé en dur en couche 2, et à deux endroits — la couche connexion (« only one session channel ») et le démultiplexeur du worker, qui oppose le même refus à une session demandée après qu’une redirection a pris le transport. Le multiplexage multi-sessions est un chantier architectural, hors incrément : > 1 est donc refusé au chargement (le démon ne démarre pas), au config set et à l’import JSON, plutôt que servi comme 1 en silence — Air n’importe aucune config texte d’OpenSSH qui l’y obligerait. Reste la valeur qui fait l’incrément : 0 = compte tunnel-only. Il ne ferme pas le compte, il lui retire le shell (aucun canal de session : shell/exec/subsystem) en gardant tout le forwarding (-L/-R/-N) — le compte de rebond, posable par Match user=backup max-sessions=0. LE piège de l’incrément, et sa parade : ici le zéro capnp n’est PAS le comportement d’avant (0 = « aucune session »), contrairement à tous les autres champs additifs de ce schéma ; un UInt32 nu aurait coupé le shell de tous les comptes à la mise à jour, en silence. D’où une union absent/limit (@17/@18) : absent est le zéro du discriminant, donc ce que rend un artefact muet, et il vaut 1 — préférée au décalage n+1 parce qu’elle se lit sans se souvenir d’un offset et qu’elle est déjà l’idiome du fichier ; même précaution à l’import JSON (clé absente ou null1). La valeur effective (base + Match, calculée à l’ALLOW comme ForceCommand) voyage jusqu’aux deux points d’enforcement : n’en fermer qu’un aurait laissé un chemin vers le shell (ouvrir un -L, puis demander une session). Refus mot pour mot identique à celui d’une session surnuméraire — pas de nouvel oracle de politique (ADR-105). Contrairement à inc.3/inc.5, aucune réserve de mode pour la valeur de base : refuser un canal ne demande aucun privilège, donc le monolithique l’applique aussi (la surcharge Match, elle, hérite de la contrainte d’inc.3). Preuves : table de vérité, aller-retour des deux valeurs et des trois états de surcharge, refus de > 1 (artefact base et bloc, CLI, JSON), rétro-compat (artefact d’avant inc.7 ⇒ 1), les deux enforcements isolés, et e2e root (shell refusé / tunnel servi pour le même compte, plus le témoin d’un bloc visant quelqu’un d’autre). Guide : §6 du même document.

inc.8 (2026-08-03)keepalive serveur (ClientAliveInterval / ClientAliveCountMax), dernier incrément serveur d’ADR-118. Il règle un vrai trou d’exploitation : un pair mort (machine éteinte, capot fermé, NAT qui oublie) ne ferme pas sa connexion — il se tait, et le worker qui le servait reste bloqué jusqu’au redémarrage avec son descripteur, ses écoutes -R et son crédit d’admission. Mécanique OpenSSH : toutes les interval secondes sans un octet reçu du client, une requête globale keepalive@openssh.com avec want_reply ; tout paquet reçu (sa réponse comme n’importe quel trafic) remet le compteur à zéro ; au bout de count_max sondes consécutives muettes, la connexion est fermée. ⚠️ Correction d’un présupposé de l’ADR : §5 annonce « réutilise les timers d’air-async » — faux là où le keepalive doit vivre. Post-auth, la connexion est servie dans le worker confiné, qui n’a aucun réacteur (pas un io_uring_* dans son profil seccomp ; l’exécuteur poll une future une fois, Waker inerte, ADR-129 §D1) : un Runtime::timeout n’y serait jamais réveillé. Le keepalive est donc une échéance de plus dans le ppoll que la boucle fait déjà tourner, à côté de celles des ouvertures en attente — jamais une reprise de contrôle. Réconcilier le texte de l’ADR demanderait un RFC (ADR-015). Deux points d’application, parce qu’une connexion attend à deux endroits : la couche connexion (celle qui n’a encore ouvert aucun canal — un ssh -N, un -L inutilisé) et le démultiplexeur, où la connexion passe le reste de sa vie. Le seam d’I/O gagne un verbe, SshByteStream::wait_readable(timeout) — attendre sans lire —, implémenté par ppoll sur le flux bloquant du privsep ; son false ne dit pas « échéance atteinte » mais « rien appris » (délai, EINTR, réveil vide), c’est l’horloge qui tranche, donc aucun signal ne fait partir une sonde en avance. La décision est une fonction pure sortie des deux boucles (Wait/Probe/Disconnect), éprouvable à la nanoseconde près. Base-only, sans surcharge Match : le keepalive ne décide d’aucun accès, et le refus est porté par le type (il vit hors de l’AccessControlPolicy). Le piège capnp, deuxième round : l’intervalle n’en a pas (0 = désactivé = comportement d’avant), mais le compteur si — son zéro dirait « aucune sonde tolérée » ; même parade qu’inc.7, une union absent/count (@20/@21) dont la branche zéro vaut 3, et la combinaison incohérente (count_max 0 + intervalle actif) est refusée sur les trois surfaces (CLI, JSON, chargement). Monolithique : refus de démarrage (io_uring n’offre aucune attente bornée — ses sondes ne partiraient jamais), comme pour inc.3 et inc.5. Non-régression par construction : interval = 0 n’injecte aucune échéance et laisse recv_packet sur son chemin d’avant. Preuves : décision pure, sonde sur le fil + fermeture dans les deux boucles avec témoin d’un pair qui répond, arithmétique du ppoll (plus proche des deux échéances), config (aller-retour binaire + JSON, rétro-compat 0/3, refus de cohérence et de bornes, CLI), refus du monolithique, et e2e root : un vrai ssh -N -L silencieux cinq secondes reste servi (il répond aux sondes), le même client figé par SIGSTOP voit sa chaîne privsep terminée, et le témoin keepalive éteint ne la termine pas. Guide : §7 du même document.

Prochaine action : les options client (IdentitiesOnly par défaut, ServerAliveInterval côté air-ssh — rattachées à ADR-116). Hors périmètre, documentés comme suivi : le multiplexage multi-sessions (MaxSessions > 1, chantier architectural), les motifs glob (admin*, user@host), les listes DenyUsers/DenyGroups, et un bloc Match all (sans condition) posable en CLI — il ne s’écrit aujourd’hui que par config import, puisque add ne pose aucune surcharge et refuse donc le bloc vide.

⚙️ Outillage documentaire (2026-08-01, #587/#588). Le journal de bord (JOURNAL.md) avait cessé d’être tenu après la PR #315 — 263 PR accumulées. Rattrapé, consigne inscrite dans CLAUDE.md et AGENTS.md (tout agent, LLM ou humain, le tient), et outillée : scripts/verifier-journal.py échoue au-delà de 10 PR sans nouvelle entrée. Au passage, la CI ne relance plus les jobs de code sur un diff sans code Rust/C/C++/Swift, et scripts/test-ci-perimetre.sh devient une étape obligatoire du job changes — cf. CI.md.


Vague 1 — ✅ CLOSE (rappel d’avancement) :

  • [1] air-keystore (ADR-108) — ✅ FAIT (mergé #463 ; CLI ADR-115 close #536). Nouveau manager de domaine couche 1 (cycle de vie des clés/secrets). Bricques faites (tout vert sur notre std linux-air, fmt/clippy/layers/C-surface OK) :
    • (a.1) wire (codec fil SSH possédé, borné anti-hostile) + formats::public_key (clé publique OpenSSH : parse/encode, round-trip, interop ssh-keygen). — (a.2) keys (paire Ed25519 gérée : génération CSPRNG, from_seed, sign, zeroize ; délègue air-crypto). — (a.3) formats::private_key (import openssh-key-v1 : PEM+base64+wire bornés, validations de cohérence graine/pubkey/ blob/padding, toutes branches de rejet via conteneur synthétique). — (b partiel) harnais cargo-fuzz des deux parseurs (300 k runs verts).
    • (store) magasins scellés TYPÉS PAR RÔLE FAITS (décision BDFL 2026-07-27) : nouveau crate air-keystore-schema (un capnp par rôle) + module store (enveloppe AIRCFGv1+FNV-1a-64 réimplémentée no_std, octet-compatible air-account ; I/O atomique air-filesystem ; StoreVisibility = permissions par rôle) + les 3 rôles : hosts (known_hosts TOFU 0644, verify_host), revocation (RevokedKeys 0644, is_revoked sans court-circuit), hostkeys (clés d’hôte 0600, graine zeroïsée, péremption is_valid_at, blob revalidé). Additif air-crypto expose_seed (accès nommé à la graine). 70 tests verts sur notre std (capnp + écriture atomique /tmp via le PAL — pipeline scellé COMPLET sans glibc). Couverture crate ~97,7 % lignes.
    • (export + fuzz magasin) FAITS : formats::encode_openssh_private_key (symétrique de l’import ; interop réelle prouvéessh-keygen -y lit notre export ; graine zeroïsée) ; cible cargo-fuzz fuzz_air_keystore_store des 3 décodeurs (400 k runs).
    • Restant air-keystore : ca (avec ADR-109) · déchiffrement passphrase (Argon2) · dudect autorisation · migration air-sshd. La bibliothèque autonome est complète (~14 commits) ; restent l’intégration et ca/certs (ADR-109).
    • MIGRATION air-sshd — clé d’hôte : FAITE (décision BDFL 2026-07-27, déplacement vers le magasin hostkeys.aircfg, [[migration-air-sshd-hostkeys-keystore]]). Étapes 1-3 + JSON faites : air-sshd dépend d’air-keystore ; schéma sshd_config hostKeyStorePath @5 additif (hostKeys @2 déprécié/réservé ADR-012) ; config.rs lit/écrit le champ, invariant « ≥1 clé » relâché (graines OU magasin), compat ascendante structurelle ; daemon load_host_key charge via air_keystore::hostkeys::load sinon repli graines ; JSON host_key_store. 162 tests air-sshd verts sur notre std (air-keystore tiré transitivement, sans glibc), interop OpenSSH inchangée. DÉCISION BDFL 2026-07-27 : authorized_keys RESTE dans air-sshd (magasin binaire mûr ADR-096, fuzzé+dudect’d — on ne refactore pas un sous-système de sécurité éprouvé) ⇒ MIGRATION air-sshd CLOSE. Restant (hors migration) : provisioning écrivant hostkeys.aircfg (ADR-121).
  • [2] certs+CA (ADR-109) — ✅ FAIT (chantier CLOS #529 ; émission + clé CA #528, ADR-140) (air-keystore ADR-108 MERGÉ #463 + migration clé d’hôte). Incréments : (a) codec + vérif de cert ssh-ed25519-cert-v01@openssh.com dans air-ssh-proto (MERGÉ #464, interop ssh-keygen ↔ byte-exact) · (b) magasin des CA de confiance ca dans air-keystore (MERGÉ #465, is_trusted_ca par périmètre) · (c) émission AirSshCertificate::issue (interop prouvée : ssh-keygen -L lit un cert émis par Air) · (d.1) combinateur de vérification verify_certificate (fail-closed : type/signature/CA-confiance/validité/principal/options-critiques/révocation ; politique = fermetures, keystore branché par le toit). (c)+(d.1) MERGÉ #466. · (d.2 SERVEUR) : userauth air-sshd accepte une clé portant un cert (possession = clé certifiée, air-ssh-proto) + CertAwareAuthorizer (vérif vs magasin CA de confiance + révocation, fail-closed) routé dans serve_userauth + démon charge les magasins (config trustedCaPath @6/revokedKeysPath @7) et active réellement l’auth par cert. Rétrocompatible. Vert gnu + notre std (174 lib + 19 newkeys + interop). Branche feat/adr-109-cert-auth. · (d.2.c CLIENT) — FAIT (branche feat/adr-109-inc1-client-host-cert) : la confiance d’hôte par certificat est câblée dans air-ssh. Nouveau module air-sshd::host_trust (étage de décision pur, evaluate_host_key : cert d’hôte ⇒ vérif fail-closed contre les CA d’hôtes du magasin trusted_ca.aircfg périmètre Host — type/signature/CA-confiance/validité/principal = nom d’hôte contacté/options-critiques/révocation ; clé nuePlainKey, chemin known_hosts/TOFU inchangé) ; establish_client_with_host_trust tranche avant NEWKEYS (aucun secret présenté à un hôte refusé) et rend ClientHandshakeError:: HostRejected(raison) ; config client ssh.aircfg additive (trustedHostCaPath @3 / revokedHostKeysPath @4, CLI config set trusted-host-ca|revoked-host-keys, JSON export/import), magasin illisible ⇒ refus de démarrer. Deux durcissements assumés vs OpenSSH : cert d’hôte sans principal ⇒ refus (la liste vide vaudrait pour tout hôte) ; nom d’hôte abaissé en casse ASCII avant comparaison. 24 tests unitaires host_trust + 8 tests bout-en-bout (tests/host_cert_client.rs, vrai handshake avec serveur présentant un cert d’hôte, magasin scellé relu depuis le disque). air-sshd 0.3.1 → 0.4.0 (#522). (Le check « principal = nom d’hôte » de cet incrément a été remplacé par l’identité machine d’ADR-137, ci-dessous.) · (d.2.d IDENTITÉ MACHINE, ADR-137) — FAIT (branche feat/adr-137-identite-machine, décision BDFL 2026-07-29) : une machine s’identifie par sa clé, pas par son nom. Nouvel ADR-137 (RFC de direction ; raffinement de sémantique d’ADR-109, aucun descellement). Nouveau module air-sshd::host_identity (couche 2, pur, zéro I/O) : key_fingerprint (empreinte OpenSSH d’un blob de clé publique) + machine_identity (fpr(K_pub) d’une clé Ed25519, blob construit sur la pile, sans allocation ni chemin d’échec) ; KAT byte-exact contre ssh-keygen -l ; l’empreinte de authorized_keys_cli::fingerprint délègue ici (une seule implémentation dans le crate). Bascule du client : le check « principal = nom d’hôte contacté » cède la place à l’invariant de cohérence de soi (ADR-137 §3) — le client recalcule fpr(K_pub) depuis la clé prouvée au KEX (la clé certifiée) et exige qu’elle figure dans valid_principals ; le nom d’hôte n’entre plus dans la décision (ClientHostTrust::new perd son paramètre hostname, PrincipalMismatch devient IdentityMismatch), et la comparaison est byte-exacte (l’empreinte est de la base64 : la normalisation de casse est retirée, elle ferait collisionner des identités). Tout le reste inchangé : CA d’hôtes de confiance périmètre Host, validité, révocation, options critiques, fail-closed avant NEWKEYS, config ssh.aircfg, clé nueknown_hosts/TOFU. 26 tests unitaires + 9 bout-en-bout ; 5 d’entre eux vérifiés ROUGES sans l’invariant (cert menteur, cert à principal = hostname, casse) ; fuzz fuzz_air_sshd_host_trust étendu. air-sshd 0.4.0 → 0.5.0 (rupture d’API assumée). HORS périmètre de cet incrément (par construction) : l’émission valid_principals = [fpr(K_pub)] (= ADR-109 Inc.4 + CLI) et le postinst qui initialise le keystore + génère la host key (ADR-137 §4 = incrément packaging). Restant : test d’interop réel (ssh OpenSSH avec cert → air-sshd, et air-sshsshd à cert d’hôte, ADR-137 §5 HostkeyAlias) ; présentation d’un cert d’hôte par le serveur air-sshd (aujourd’hui il ne présente qu’une clé nue) ; pinning TOFU des clés nues (knownHosts @5, différé ADR-099 §1) ; (e) CLI = vague 2. · (Inc.3 RÉVOCATION PAR SERIAL + KRL, ADR-138) — FAIT (branche feat/adr-109-inc3-revocation-serial-krl) : on révoque un numéro de certificat sans détenir la clé, et une flotte mixte n’entretient qu’une liste. Nouvel ADR-138 (descellement ADDITIF couche-1-v3.6couche-1-v3.7 — tag à poser par le superviseur). air-keystore : AirRevokedSerial (plage min..max bornes incluses, CA émettrice obligatoire — un serial n’a de sens que chez son émetteur — None = joker « toute CA », serial 0 jamais révocable), AirRevokedCertKeyId, AirRevokedFingerprint, et l’agrégat AirRevocationStore (quatre listes ; c’est LUI qu’on charge/écrit/interroge ; empty() const fn). Schéma capnp additif (serials @2/certKeyIds @3/fingerprints @4) ⇒ magasin antérieur = listes vides, faces historiques inchangées. Nouveau module air_keystore::krl (couche 1, sans-IO ADR-091) : codec KRL OpenSSH en import et export — en-tête, certificates × {serial-list, serial-range, bitmap, cert-key-id}, explicit-key, fingerprint-sha256. Interop prouvée dans les deux sens : KAT = KRL réellement produits par ssh-keygen (OpenSSH_10.2p1 sur carbon) décodés et réécrits octet pour octet, + tests/krl_openssh_interop.rs contre le binaire réel (un export d’Air relu par ssh-keygen -Q -l). Fail-closed intégral (extension critique, serial 0, plage inversée, mpint non canonique, > 65 536 plages, CA non Ed25519 ⇒ refus) ; sections fingerprint-sha1 et signature refusées (différées, ADR-138 §5). Fuzz fuzz_air_keystore_krl4 M runs avec corpus ssh-keygen, zéro crash ; proptest decode ∘ encode = identité. Câblage : une seule règle de révocation de cert (air_sshd::cert_revocation) partagée serveur et client (deux copies supprimées), consultant clé certifiée + serial + key id relativement à la CA signataire, sans court-circuit. Aucun réglage nouveau (le magasin déjà désigné porte les serials). Couverture 100 % lignes sur krl.rs/revocation.rs/cert_revocation.rs. Fix post-revue (F1) : dans krl::to_revocation_store, la borne MAX_REVOKED_SERIAL_RANGES était vérifiée après la boucle d’accumulation — un KRL hostile enchaînant des sous-sections (chacune sous la borne unitaire) matérialisait des millions de plages avant rejet (pic mémoire prohibitif sur Pi 4). Corrigé : borne incrémentale (rejet dès franchissement) + test multi-sous-sections. Merge #524 + tag couche-1-v3.7 par le superviseur après CI verte.
  • (Inc.2 CERT UTILISATEUR SOUS PRIVSEP, ADR-139) — ✅ LIVRÉ (#526, main 91ec7c4). 5 sous-étapes : message RPC dédié AUTHORIZE_CERT (tag 4, blob brut) ; le monitor re-parse
    • verify_certificate + traduction extensions→permissions / force-command→forced_command / source-address→pair ; l’enfant délègue ; garde trusted_ca+privsep levé ; test privilégié root 5/5 (ssh -i cert traverse la chaîne privsep) ; fuzz « le monitor décide un cert » (971k+3k runs, 0 crash). Couche 2 pure, aucun re-sceau. Revue adverse : 0 finding bloquant (2 durcissements transverses fast-follow notés : options critiques dupliquées/hors-ordre dans cert.rs parse [O2] ; principal user vide [O3] déjà traité côté émission par ADR-140 §D5). Suite = Inc.4/Inc.5 (ADR-140).

🔵 ADR-143 (couverture/tests CI PAR COUCHE) — EN REVUE, 2026-07-30

Mise en œuvre de l’ADR-143 (décision BDFL du jour) : le job test-coverage ne teste et ne mesure plus que les couches touchées. Branche feat/adr143-coverage-par-couche. Aucune couche 0/1 touchée (outillage xtask + CI seulement), aucune version bumpée.

  • xtask couvrable-vide --layer Nllvm-cov reçoit -p <crate> pour les seules crates dont [package.metadata.air] layer = N ; aucune règle ne change, seul le périmètre : réconciliation ligne à ligne des crates réconciliés + plancher agrégat + plancher branches s’appliquent à l’identique sur ce qui a été mesuré. Le mode sans --layer reste le mode complet, strictement inchangé.
  • xtask crates-of-layer N [--and-above] [--names] — helper à sortie brute (-p a -p b …) qui donne au shell de ci.yml le périmètre de cargo test. Même source de vérité que check-layers ; scope vide = erreur, jamais un silence.
  • ci.yml — le job changes mappe chaque fichier modifié vers la couche de son crate et sort couche0/couche1/couche2 + full_forced (fichier CI/xtask/rt/manifeste racine/toolchain/politique, ou fichier non rattachable ⇒ mode complet, on ne devine pas). Tests : complet → workspace ; couche 1 → crates 1+2 ; couche 2 → crates 2 seuls (les gros interop réels SFTP/ssh/ABI vivent en couche 2). Couverture : une étape --layer N par couche touchée.
  • Mode complet désormais réservé aux occasions rares : tag (release/scellement), workflow_dispatch, nightly (cron quotidien, resserré depuis l’hebdo), couche 0 touchée, full_forced. RETIRÉ : le déclencheur push main (décision BDFL) — la PR a déjà vérifié sa couche en pré-merge ; le filet devient nightly + tag.
  • Limite assumée et documentée : une couche mesurée seule ne bénéficie plus des tests des couches supérieures ⇒ mesure potentiellement plus sévère (faux échec possible, jamais faux succès). D’où l’absence de --layer 0 en CI : couche 0 touchée ⇒ complet.
  • La PR touchant ci.yml/xtask déclenche elle-même le mode complet : elle se valide en couverture complète. Docs mises à jour : CI.md, outillage-xtask.md.

✅ ADR-109 (certificats SSH + CA) CLOS et DÉPLOYÉ — 2026-07-30

Tous les incréments livrés : Inc.1 (cert hôte client #522), Inc.3 (révocation serial+KRL #524, tag couche-1-v3.7), Inc.2 (cert user privsep #526), Inc.4/Inc.5/postinst (#528, tag couche-1-v3.8). Docs ADR-139 (#525) + ADR-140 (#527). Déploiement .deb air-sshd 0.6.0 RÉUSSI sur les 3 machines (0.3.1→0.6.0) : postinst idempotent (host key préservée, M stable), service actif :2222, auth réelle prouvée sur chacune. M : speedy SHA256:WXPGem4u…JN50, carbon(air-test-host) SHA256:W7EFQDlL…HuI, raspi-srv-2 SHA256:EKgyeLCr…N21E. Réserve ADR-137 §5 CONFIRMÉE puis TRANCHÉE (BDFL 2026-07-30 : documenter la limite, ne PAS affaiblir M par défaut ; chemin opt-in en ADR-115 §6, livré en ADR-115 inc.4) : interop cert d’HÔTE par client OpenSSH inopérante (OpenSSH abaisse la casse du HostkeyAlias, M case-sensitive ne matche jamais) — cert utilisateur OpenSSH prouvé, client Air non concerné. Fast-follow notés : [[durcissement-parseur-options-critiques-cert]] (O2), zeroize transverse keystore (F2/F3). Suite vague 2 = CLI air-keystore (ADR-115).

✅ ADR-141 D1 + D5 (durcissement cert) — MERGÉ (#539), 2026-07-30

Deux items du registre de durcissement ADR-141, tous deux couche 2 (aucune couche 1 touchée, aucune version bumpée) :

  • D1 — air_ssh_proto::cert::parse refuse les options critiques ET les extensions dupliquées ou hors ordre. Le nom lu doit être strictement croissant (octets) : égal = doublon, inférieur = hors-ordre, les deux font échouer l’analyse (fail-closed, aucune valeur jamais écrasée). Règle de PROTOCOL.certkeys appliquée aux deux sections. Parité vérifiée chez les émetteurs réels : ssh-keygen trie (cert_ext_cmp), x/crypto/ssh trie ET applique déjà ce même rejet à l’analyse — aucun certificat légitime n’est cassé (les 2 KATs ssh-keygen du crate restent verts, un test verrouille que leurs extensions sont strictement croissantes). Effet transverse : parseur partagé ⇒ mono + privsep + client + CLI d’un coup. issue refuse en outre un nom en double.
  • D5 — air-keystore cert verify n’énonce plus de contrôle tautologique. (1) CA fournie en .pub : le rapport dit que le périmètre n’a pas été vérifié indépendamment (texte et warnings[] JSON, à l’acceptation comme au refus) — load_trusted_cas rend désormais la provenance (AirTrustedCaOrigin) ; verdict inchangé. (2) certificat utilisateur : --principal exigé (choix « exiger » plutôt qu’« avertir » — rien n’est perdu, cert inspect reste le verbe d’inspection) ; le jeton JSON principal_origin: first-principal disparaît, cette provenance n’étant plus atteignable. Certificat d’hôte inchangé (identité machine recalculée, ADR-137 §3).

Trouvé en passant (défaut préexistant du round-trip reserved) : corrigé — voir l’entrée ADR-141 D9 ci-dessous.

🔵 ADR-141 D9 (round-trip reserved du certificat) — EN REVUE (#540), 2026-07-30

Le fuzz de #539 faisait tomber l’assertion de round-trip de fuzz_air_ssh_cert sur un certificat à reserved non vide : parse jetait le champ (_reserved), serialize_to_be_signed en réécrivait un vide, donc encode() n’était pas byte-exact (139 → 135 octets). Défaut PRÉEXISTANT vérifié sur main, sans rapport avec D1 (l’entrée n’a ni option ni extension). Impact sécurité nul : la vérification porte sur les octets to_be_signed capturés depuis le blob d’origine (reserved réel inclus), jamais sur une resérialisation — défaut de correction, pas de sécurité.

Correctif (couche 2, additif) : AirSshCertificate gagne un champ privé reserved, conservé à l’analyse et ré-émis tel quelencode(parse(blob)) == blob byte-exact pour tout certificat valide. issue émet toujours un reserved vide (parité OpenSSH — le champ ne sert qu’à préserver ce qui est analysé) ; AirCertParams inchangé, parseur toujours fail-closed, D1 et la vérification de signature intacts. Tests : certificat réellement signé au reserved non vide (ré-encodage byte-exact + signature vérifiée + re-parse identique), cert émis byte-égal au blob à reserved vide, KATs OpenSSH verts, fuzz 0 crash. Registre ADR-141 annoté : D1/D5 ✅ (#539), D9 ajouté et ✅ (#540). Aucune version bumpée.

🔵 ADR-141 groupe C — intégrité du gate (D6 + D8) — EN REVUE (#542), 2026-07-30

Couche 2 seulement (air-keystore-cli), aucune version bumpée, aucun re-sceau.

  • D6 — la base de credentials du gate n’est plus nommable par l’appelant. --admin-base disparaît de la surface CLI : argv et payload --json-input le refusent désormais comme argument inconnu, sur les lectures comme sur les mutations. En production, admin_base est une fonction #[cfg(not(test))] qui rend le chemin système en dur (air_admin_gate::DEFAULT_BASE_DIR = /etc/air, là où air-account gère shadow.aircfg). L’injection de test passe par un champ #[cfg(test)] de CliEnvironment : absent du binaire livré ⇒ la redirection n’est pas seulement interdite, elle est inexprimable. Test central : deux bases actives — la « système » injectée et un piège dont l’appelant connaît le secret — ni l’argv ni le payload ne font du piège le juge (secret du piège refusé, seul celui de root dans la base système autorise). Effet de bord utile : les tests ne dépendent plus en silence du /etc/air de la machine qui les exécute.
  • D8 — ca create / cert issue-* : non-gate CONFIRMÉ, comportement inchangé, motif écrit dans le code : ce sont des artefacts à chemin nommé (façon ssh-keygen), pas des mutations d’un magasin de confiance système (les seuls gatés, ADR-115 §3) ; un chemin système y est déjà protégé par les permissions FS (EACCES, CLI non privilégiée).

Registre ADR-141 : plus aucun correctif en attente (D1/D5 #539, D9 #540, D2/D3/D7 #541, D6+D8 #542). Seul D4 reste ouvert en tant qu’option (politique « principal explicite obligatoire », divergence assumée d’avec OpenSSH) — à ouvrir sur décision, pas une dette.

✅ ADR-115 (CLI air-keystore) CLOS — 2026-07-30

Les quatre incréments sont livrés : inc.1 squelette + orchestration partagée (#532), inc.2 mode JSON agent-safe (#533), inc.3 magasins + gate admin ADR-101 (#534), inc.4 clôture (cette PR). Aucune couche 1 touchée sur toute la série, aucune version bumpée.

  • (ADR-115 inc.4 — CLÔTURE : retrait de la CLI provisoire d’air-sshd, compat HostkeyAlias, key generate) — EN REVUE (branche feat/adr-115-inc4-cloture). (4.1) La CLI provisoire d’air-sshd est RETIRÉE : les verbes air-sshd ca init|show et air-sshd cert issue-host|issue-user (ADR-140 §D6, dette datée) disparaissent avec leur module cert_cli — ils sont relogés dans air-keystore ca create|show / cert issue-host|issue-user depuis l’inc.1. air-sshd ne charge donc plus jamais de clé CA privée, sous aucun verbe. GARDÉS intacts : init-host-key (appelé par le postinst, ADR-137 §4 — le .deb n’est pas cassé), config, authorized-keys, migrate-host-key-store ; version d’air-sshd inchangée, tests non liés verts. L’interop réel d’ADR-140 §D7 (cert_issue_interop.rs) suit l’outil qui émet : déplacé vers air-keystore-cli/tests/, mêmes assertions, même sshd OpenSSH réel. (4.2) cert issue-host --openssh-hostkey-alias-compat (ADR-115 §6, chemin opt-in d’ADR-137 §5) : ajoute lowercase(M) comme second principal, M exact restant le premier — la vérification Air-native (ADR-137 invariant 3) est donc intacte. La CLI AVERTIT du coût (résistance aux collisions réduite, interop OpenSSH uniquement), en texte et dans warnings[] du mode JSON, et cet avertissement remplace celui de la limite — sous l’option, dire que l’interop échouera serait faux. Jamais le défaut. ⇒ La réserve d’ADR-137 §5 a maintenant sa preuve POSITIVE : un troisième test d’interop réel monte le même montage que celui qui échoue (même HostkeyAlias, casse d’origine, StrictHostKeyChecking=yes) et un vrai client OpenSSH accepte le cert d’hôte d’Air. Le pliage de casse était bien la seule cause, et l’option la couvre exactement. (4.3) key generate (-f | --file) <fichier> [--comment] [--force] : paire Ed25519 (CSPRNG kernel via air-random), clé privée openssh-key-v1 en 0600 et clé publique en <fichier>.pub 0644 — façon ssh-keygen -t ed25519 -f. Écriture atomique (le fichier n’apparaît jamais au chemin final avec un mode plus large), tampon privé zeroizé, matériel privé jamais imprimé, refus d’écrasement sans --force (sur le privé ou le .pub — pas de paire dépareillée). -f et --file sont la même option (--file pour un payload --json-input, qui nomme ses arguments sans tirets) ; les donner toutes les deux est un refus. PAS de magasin de clés utilisateur : key list|show|import|export|remove restent absents et le disent (ils supposeraient une couche 1 qui n’existe pas).
  • (ADR-115 inc.1 — SQUELETTE CLI air-keystore + ORCHESTRATION D’ÉMISSION PARTAGÉE) — ✅ LIVRÉ (#532). Deux crates couche 2 neufs, aucune couche 1 touchée, aucune version bumpée : (2.1) air-cert-issue — bibliothèque hébergeant la source unique de l’orchestration d’émission, jusqu’ici enfermée dans air-sshd (cert_issue.rs, host_identity.rs) : émission host principal = M / user, refus d’ADR-140 §D5, identité machine (ADR-137), lecture/écriture des fichiers de clés et certificats, convertisseurs de valeurs (durée, principaux, permit-*, périmètre), et les opérations d’administration rendues en résultats structurés (pas en texte — chaque façade met en forme). air-sshd en est consommateur : ses modules cert_issue/host_identity sont des relais, sa CLI provisoire est inchangée à la lettre (ses 511 tests de lib + l’interop réel restent verts). Effet de bord propre : zeroize n’est plus une dépendance directe d’air-sshd (le code qui lit le magasin d’hôte a déménagé avec son effacement). (2.2) air-keystore-cli — exécutable air-keystore (calqué sur air-account-cli, parsing manuel) : ca create|show, cert issue-host|issue-user|inspect|verify, key fingerprint [--lowercase] (ADR-115 §6 — outil de diagnostic de la limite HostkeyAlias, sans effet de bord). cert issue-host avertit désormais de cette limite à l’émission. cert verify échoue (exit ≠ 0) sur un verdict de rejet et écrit que la révocation n’est pas consultée (inc.3). (2.3) paquet .deb air-keystore ajouté à PACKAGES (sans postinst : créer une CA est un geste explicite — ADR-140 §D4). (Ce qui était alors différé a été livré depuis : mode JSON --json/--json-input en inc.2 (#533) ; revoke/known-hosts/host-trust/expiry + gate admin ADR-101 en inc.3 (#534) ; retrait de la CLI provisoire, --openssh-hostkey-alias-compat et key generate en inc.4.)
  • (Inc.4 ÉMISSION + CLÉ CA PRIVÉE, Inc.5 INTEROP, postinst — ADR-140) — ✅ LIVRÉ (#528, tag couche-1-v3.8). Les cinq sous-étapes sont faites : 4.a additif couche 1 air_keystore::ca_key (AirCaKey, schéma neuf ca_key.capnp, magasin 0600 scellé, may_issue délègue à AirCaScope::covers) — lignes 100 % / branches 100 %, fuzz fuzz_air_keystore_ca_key 16,7 M runs, 0 crash ; fix doc D9 (StoreVisibility disait ca secret alors que trusted_ca.aircfg est public 0644). 4.b orchestration couche 2 air_sshd::cert_issue (principal = M recalculé depuis la clé certifiée, principals explicites côté user, refus d’un user sans principal, refus d’un périmètre de CA insuffisant / extension permit-* inconnue / fenêtre vide). 4.c CLI provisoire air-sshd ca init|show / cert issue-host|issue-user (+ ca show --out pour publier la CA publique) et verbe init-host-key idempotent — relogés par ADR-115. 5.a interop réel : certificat utilisateur PROUVÉ (un sshd OpenSSH réel en TrustedUserCAKeys + AuthorizedKeysFile none accepte notre cert). post.a postinst 0755 qui appelle init-host-key (xtask, table PACKAGES). La clé CA privée reste HORS du démon (émission = admin, processus court ; 0600, Argon2 différé ADR-108 §2). ⚠️ CONSTAT BLOQUANT à trancher (ADR-137 §5) : la réserve était fondée. Le client OpenSSH (10.2p1) abaisse la casse du HostkeyAlias avant de le comparer aux principaux du certificat, or M = SHA256:<base64> est sensible à la casseCertificate invalid: name is not a listed principal. Prouvé par un contrôle interne (même montage, principal en minuscules, CA ssh-keygen pure ⇒ réussit). L’émission d’Air est correcte (ssh-keygen -L la lit) et le client Air n’est pas concerné (il recalcule l’empreinte, sans alias). Seul le pont d’interop @cert-authority + HostkeyAlias est inopérant. Remède candidat = émettre un second principal en minusculessémantique ADR-137, décision superviseur, hors de cet incrément. ⇒ TRANCHÉ depuis (BDFL 2026-07-30) : la limite est documentée (ADR-137 §5) et M n’est pas affaibli par défaut ; le remède existe en chemin opt-in cert issue-host --openssh-hostkey-alias-compat (ADR-115 §6), livré en ADR-115 inc.4 et prouvé par un troisième test d’interop réel. Clôt ADR-109 → déclenche le déploiement .deb versionné sur carbon/speedy/raspi.
  • (note historique, avant merge) Inc.2 avait été CONÇU (mini-ADR ADR-139, aucun code ; Option A tranchée BDFL 2026-07-29). Le constat : toute la vérif de cert utilisateur existe et est fuzzée dans le chemin monolithique, mais le privsep ne la câble pas (proto RPC sans message cert ; MonitorAuthorizedKeys hérite du défaut authorize_cert = false ; le monitor ne charge ni CA ni révocation ; le démon refuse de démarrer avec trusted_ca+privsep). Décision : sous privsep, le monitor décide la confiance, l’enfant ne fait que la crypto. Message RPC dédié AuthorizeCertRequest{username, certificate: blob BRUT} (le monitor re-parse — un enfant hostile ne fabrique pas de confiance) ; monitor s’horloge (anti-rejeu) ; sur Accepted, traduction extensions→permissions / force-command→forced_command / source-address→pair ; tout le reste → DENY muet. CA + révocation chargées au démarrage, héritées COW (données publiques ; asymétrie assumée avec la clé privée post-fork). Levée du garde resolve_privsep + fuzz étendu. Parité stricte avec le monolithique. Couche 2 pure — AUCUN descellement, AUCUN re-sceau (révocation déjà livrée ADR-138/couche-1 v3.7). Sous-étapes 2.a proto → 2.b monitor → 2.c enfant → 2.d démon → 2.e bout-en-bout privsep. Prochaine action : implémenter Inc.2.
  • [3] KEX post-quantique ML-KEM (ADR-110) — ✅ FAIT (5 incréments mergés dans main). Hybride mlkem768x25519-sha256 (ML-KEM-768 + X25519), construction OpenSSH : (a) primitive air-crypto::mlkem (crate ml-kem re-validée, C-free sur linux-air, #469) · (b) re-validation à notre barre — KAT interop OpenSSL bidirectionnel (FIPS 203), fuzz 200 k, dudect |t|=0.63 (décaps constant-time, pas d’oracle) — #470 · (c) combineur hybride sans-IO air-crypto::pqkex (aléa injecté) — #471 · (d.1) cœur KEX air-ssh-proto::kexhybrid (K en string ; curve25519 byte-identique ; fuzz décodeurs 5,8 M runs) — #472 · (d.2) dispatch driver air-sshd + offre mlkem768x25519-sha256 (préféré, repli curve25519) + interop OpenSSH réel : sur carbon (OpenSSH 10.2p1) le vrai ssh négocie l’hybride, franchit NEWKEYS et s’authentifie par-dessus le KEX post-quantique — preuve byte-exacte — #473. Restant (différé) : cert d’hôte côté client air-ssh (chemin KEX) FAIT avec ADR-109 (d.2.c) — l’étage de confiance porte sur le blob K_S, donc indépendant de la méthode de KEX (le pilote client, lui, n’offre encore que curve25519-sha256 : l’hybride côté client reste à câbler).
  • [4] air-log (ADR-111) — ✅ FAIT (4 incréments mergés #474/#476/#478). Sink de logs opérationnels (admin / journalctl), distinct d’AirCom, couche 1 no_std au-dessus d’air-socket, journald natif sans libsystemd C, réutilise l’encodeur du cœur (décision BDFL). (a) AirLogManager (drop-safe, compteur de pertes) + AirLogSink + AirStderrSink (repli anti-injection) + AirLogFields::iter() au cœur · (b) AirJournaldSink (datagramme non bloquant sur air-socket) · (c) AirFallbackSink résilient + interop journalctl réel (émission → relecture) · (d) câblage air-sshd : émission directe des événements opérationnels (connexion, auth OK/échec avec AIR_USER/AIR_PEER/AIR_METHOD) vers journald, surface distincte d’AirCom (SessionAudit à deux surfaces indépendantes, publisher AirCom rendu optionnel). Restant (différé, opt-in) : pont AirCom→journald générique pour tiers.
  • [5] air-sandbox (ADR-112) — ✅ FAIT (dernier de la vague 1, clos #487). Confinement seccomp/Landlock sûr, compilateur BPF maison (sans libseccomp C), profils fail-closed, fin de la fuite couche 0 dans air-process. FAIT & mergé : compilateur seccomp-BPF arch-aware fail-closed (a.1 #477), allow-list sémantique (tables syscalls vérifiées sur les 2 arches, a.2 #480), profils pre-auth/sftp-worker (a.3 #481), spec Landlock (chemins → accès sémantiques, #483). Application au noyau FAIT (§3, branche feat/adr-112-application-noyau) : AirSandbox::apply pose la cage irréversible dans l’ordre no_new_privs → Landlock (restrict_self) → seccomp(2) ; tests privilégiés (enfant forké) prouvant l’effet réel — syscall hors profil → SIGSYS, chemin hors politique → EACCES (couverture apply.rs = CHILD-EXIT documentée). Fuzz du compilateur FAIT (§ #485+ ; cible cargo-fuzz fuzz_air_sandbox_seccomp : totalité + bonne formation BPF — 0 saut hors borne, SockFprog::new accepte tout programme valide ; ~3 M exécutions sans crash). Fail-closed + opt-out journalisé FAIT (§4 ; AirSandbox::enter : Enforce pose la cage ou refuse — fail-closed ; Disabled{reason} tourne sans cage mais journalise l’opt-out via air-log, jamais silencieux ; trait AirSandboxJournal
    • adaptateur AirLogManager). Refonte air-process FAITE (ADR-122 §5) : Inc.1 (#489) additifs couche 0 close_range + capbset_read/capbset_drop ; gate CI (#490) plancher agrégat brut → cargo xtask couvrable-vide --strict exception-aware (fin des faux-échecs privsep) ; Inc.2 (#491) AirPrivilegeDrop prend une cage haut niveau AirSandboxProfile+AirLandlockPolicy via feature sandbox (OFF, hors rustc-dep-of-std) — with_seccomp_filter/with_landlock
    • champs couche 0 retirés, drop_privileges délègue à AirSandbox::applyplus aucun type couche 0 dans la surface publique. Inc.3 = re-sceau (ADR-123, descellement breaking couche-1-v2.4v3.0, 1re rupture c1 ; rupture indolore, 0 appelant ; autorisé BDFL 2026-07-28 ; tag posé par le superviseur après CI ; tag couche-1-v3.0 POSÉ sur 4ba4d26, signé, poussé). Volet 2 CONÇU : ADR-124 (conception avant code) fixe la topologie listener→monitor→enfant pré-auth→worker post-auth, le canal RPC socketpair SEQPACKET (codec bespoke sans-IO fuzzé, monitor traite l’enfant comme hostile), SIGN/AUTHORIZE médiés par le monitor (clé d’hôte + authorized_keys jamais dans l’enfant ; seteuid+O_NOFOLLOW, sans CAP_DAC_READ_SEARCH), transition proxy chiffré (aucun état cipher ne franchit le privilège). Plan V2.1→V2.6. 3 points sécurité TRANCHÉS BDFL 2026-07-28 : SIGN = validation structurelle (pas de recompute intégral de H dans le privilège) ; options authorized_keys = appliquées par le monitor (enfant reçoit un ALLOW déjà contraint) ; uid privsep = compte système non-login _air-sshd (ADR-100/121). V2.1 FAIT : codec RPC monitor air-sshd::monitor::proto (sans-IO, borné, SIGN/AUTHORIZE + réponses, SEQPACKET = 1 datagramme/message) — 20 tests (round-trip + entrées hostiles) 100 % lignes/fonctions, fuzz fuzz_air_sshd_monitor_proto 200 k runs 0 crash. V2.2 FAIT (squelette de processus, sans crypto) : monitor::channel (tuyau SEQPACKET, lu/écrit par read/write pour tenir dans l’allow-list pre-auth, datagramme hors borne rejeté et non tronqué), monitor::service (boucle bloquante mono-fil MonitorPolicy/serve + DenyAllPolicy = défaut sûr), monitor::topology (spawn_privsep : listener → monitor → enfant pré-auth ; cascade PDEATHSIG avec fermeture de la course parent-mort-avant-armement ; reduce_capabilities_to({SETUID,SETGID,CHOWN,KILL}) du monitor avant son fork ; purge close_range des fd hérités avant la cage ; largage vers _air-sshd + cage pre_auth/Landlock deny-all ; refus d’un compte privsep résolvant vers uid 0). Prouvé sous root (tests/privsep_root.rs) : round-trip RPC à travers la frontière de privilège (2 messages A/R, enfant confiné ⇒ le canal tient dans la cage), enfant sous uid _air-sshd, monitor encore root, cage non vacante (un getppid hors profil tue l’enfant avant qu’il ne parle), cascade de mort (tuer le monitor tue l’enfant, témoin par tube). Additifs couche 1 = ADR-125 (descellement additif couche-1-v3.0v3.1, tag à poser par le superviseur) : air-process (set_parent_death_signal/parent_death_signal, close_inherited_fds_except, reduce_capabilities_to), air-socket (AirUnixSeqpacket::write_packet/read_packet), air-sandbox (getresuid/getresgid/getgroups aux profils — sans eux la vérification défensive de drop_privileges, confinée depuis ADR-123, était tuée par seccomp ; AirLandlockPolicy::deny_all()new() ne posait aucun ruleset). Tag couche-1-v3.1 POSÉ (superviseur, additif ADR-065). Fix de revue appliqué : le garde privsep rejette aussi un groupe supplémentaire gid 0 (finding #1) ; finding #2 (monitor ne purge pas ses propres fd — TCB de confiance) → suivi V2.3. Limitation 20/41 LEVÉE (ADR-126, couche-0-v1.14) : Capability nomme désormais les 41 caps du noyau ⇒ reduce_capabilities_to borne le monitor à 4/41 exact (objectif ADR-122 §6 pleinement atteint) ; vérifié sous root (cap_last_cap=40). V2.3 FAIT (SIGN branché — la clé d’hôte quitte l’enfant pré-auth) : seam sans-IO air_ssh_proto::hostkey (HostKeySigner + HostKeySignRequest #[non_exhaustive] + LocalHostKeySigner) ⇒ le cœur ne détient plus de clé, il demande un blob K_S et une signature ; server_kex*_with_signer / Transport::exchange_kex_with_signer/rekey_with_signer ajoutés (les fonctions historiques deviennent l’habillage « signer local » — zéro rupture d’API, chemin non-privsep inchangé). Côté pilote : monitor::sign = MonitorHostKeySigner (enfant : n’a que le blob public, émet un SIGN sur le canal RPC ; refus ⇒ erreur, jamais de signature vide) + HostKeySignPolicy (monitor : détient la clé privée, Debug muet, compteurs saturants). Anti-oracle (§D4, tranché BDFL) : validate_sign_request pure/fuzzable — bannières SSH-2.0-… ASCII imprimable ≤ 253 o, payloads KEXINIT structurellement cohérents (parcours de longueurs sans allocation : byte 20 + cookie + 10 name-lists + bool + uint32, queue tolérée comme le cœur), host_key reçu ≡ clé du monitor sinon refus, H de longueur de hash (32/48/64) ; refus muet sur le fil (motif gardé côté monitor). Finding #2 TRAITÉ : le monitor purge ses propres fd hérités (close_inherited_fds_except) juste après son fork, ne gardant que son bout de canal + le pidfd de l’enfant — hygiène §D8 à double sens. Prouvé sous root : l’enfant confiné obtient une signature qui vérifie contre la clé publique sans détenir la privée, le monitor refuse clé d’hôte étrangère et H mal dimensionné (1 signée / 2 refusées), et un fd hérité hors inherited est fermé dans le monitor (EBADF constaté de l’intérieur). Fuzz fuzz_air_sshd_monitor_sign (10,2 M runs, 0 crash) : validation ≡ décision, jamais de signature pour une clé étrangère, toute signature produite vérifie sur le H reçu. Aucun additif couche 1 (tout existait : close_inherited_fds_except, AirProcess::pidfd) ⇒ rien à re-sceller. Point remonté au superviseur : le fork sans exec fait hériter à l’enfant l’image mémoire de son ancêtre ; la garantie « la clé privée n’est jamais dans l’espace de l’enfant » n’est totale que si la clé est chargée dans le corps du monitor (donc après le fork de l’enfant), ce que le câblage du démon (V2.5) devra acter — le code le permet et le documente. V2.4 FAIT (AUTHORIZE branché — le monitor décide, l’enfant ne lit plus rien) : monitor::authorize = MonitorAuthorizer (monitor : résout le compte via air-account, abaisse son identité effective vers l’uid/gid cible, lit le magasin binaire <home>/.config/air/sshd/authorized_keys sous StrictModes, décide, compte) + MonitorAuthorizedKeys (enfant : implémente le trait AuthorizedKeys en demandantfail-closed sur canal cassé, EOF ou réponse hors protocole ; applique la commande forcée reçue). StrictModes ajouté au lecteur du magasin (read_authorized_keys_strict, réutilisé aussi par FileAuthorizedKeys — durcissement du chemin non-privsep, sans régression) : descente descripteur par descripteur depuis / en O_PATH|O_DIRECTORY|O_NOFOLLOW (anti-TOCTOU et anti-symlink : rien à substituer entre le contrôle et l’ouverture suivante), propriété ∈ {compte, root} à chaque étage, aucun parent inscriptible groupe/monde (sauf sticky, où nul ne peut remplacer l’entrée d’un autre), magasin privé (0o600) et régulier, borne 1 Mio, EINTR retenté explicitement et borné. Additif couche 1 ADR-127 (couche-1-v3.2, tag NON posé) : air_process::credentials::AirTemporaryIdentity — garde RAII qui abaisse l’identité effective (réel/sauvé jamais touchés ⇒ retour garanti sans capability), vide les groupes supplémentaires (fail-closed : moins-disant que le compte), restaure au Drop y compris après panique, et tue le processus si la restauration échouait. Prouvé sous root, dans le monitor réel (tests/authorize_root) : sans abaissement la lecture échoue (le monitor n’a ni CAP_DAC_OVERRIDE ni CAP_DAC_READ_SEARCH), avec elle réussit, et l’euid est revenu à 0 ensuite ; clé listée ⇒ Allow, clé absente / mauvais algorithme / magasin 0640Deny muet et indistinct sur le fil. ⚠ Conséquence opérationnelle à traiter avant V2.5 : avec un umask 002 (groupes privés, défaut courant), un mkdir -p manuel crée ~/.config/air/sshd en 0775refusé par StrictModes (comme le ferait OpenSSH — la règle ne sait pas que le groupe n’a qu’un membre). C’est le cas sur carbon : ~/.config/air et ~/.config/air/sshd sont en 0775 ⇒ un chmod 700 des parents est requis sur les hôtes déployés (speedy/carbon/raspi), sinon le chemin privsep refusera. Les répertoires créés par write_authorized_keys (CLI d’import) sont en 0700 et passent. Aucun auto-chmod : un démon privilégié ne réécrit pas les permissions d’un utilisateur dans son dos. §D5 amendé (inexactitude de rédaction) : le magasin lu est le binaire (ADR-073/096), jamais ~/.ssh/authorized_keys en clair — le parseur texte OpenSSH ne sert qu’au CLI d’import. ⚠ REMONTÉ AU SUPERVISEUR (options authorized_keys) : le point tranché « options appliquées par le monitor » est câblé au bon endroit (décision + AuthorizeAllow{forced_command} sur le fil), mais le schéma v1 ne portait aucune option ⇒ forced_command était toujours None. Deux décisions ouvertes : (a) étendre le schéma (champ additif) ; (b) from= exige l’adresse du pair, absente d’AuthorizeRequest (V2.1) ⇒ additif de proto, ou renoncement explicite. Aucun contournement improvisé. ✅ ARBITRÉ BDFL 2026-07-28 → ADR-128, livré ci-dessous. OPTIONS authorized_keys FAIT (ADR-128, arbitrage BDFL 2026-07-28) — les options existent bout-en-bout : schéma → CLI → monitor → réponse. Périmètre (§1) : command="…" + les 5 restrictions nommées + restrict en champ explicite — pas du sucre d’écriture, sans quoi il perdrait sa promesse « toutes les restrictions, y compris futures » et la fidélité d’affichage du CLI ; d’où les 5 ré-autorisations positives (pty, port-forwarding…) et une seule règle de résolution, autorisé(c) = (permit(c) OU NON restrict) ET NON no(c) (fail-closed : le refus explicite l’emporte). Schéma v2 additif (ADR-012) : AuthorizedKey.options @3 :AuthorizedKeyOptions, id neuf ⇒ un magasin v1 reste lisible (pointeur nul = structure par défaut = aucune option) ; version applicative à version minimale (1 tant qu’aucune clé ne porte d’option — l’ artefact est alors bit-pour-bit celui de la v1 — 2 sinon) ⇒ un binaire antérieur refuse un magasin contraint plutôt que d’en ignorer la restriction, et continue de lire les magasins sans option ; forcedCommand borné à 4 Kio (parse, écriture et CLI). Généré capnp régénéré + committé, byte-identique. CLI : drapeaux add --command/--restrict/--no-*/--permit-*, parseur texte OpenSSH étendu à la liste d’options (virgules, guillemets, échappement \", casse indifférente), list réaffiche ce qui a été écrit, import porte les options. Deux gardes issues du principe « zéro perte muette » : options par drapeaux et dans la ligne = refus (fusionner reviendrait à inventer une priorité, donc une politique) ; réajouter une clé connue remplace son entrée (AddOutcome::Replaced) au lieu de laisser la déduplication avaler la nouvelle politique. Monitor : decide rend un AuthorizeConstraint (commande forcée + permissions résolues), AuthorizeAllow{forced_command, permissions} sur le fil — masque d’un octet, tout bit inconnu rejeté (fail-closed : une capacité future qu’on ne saurait pas appliquer ne doit pas être ignorée). from= NON stocké (§5) : il exige l’adresse du pair (absente d’AuthorizeRequest, et non fiable si elle venait de l’enfant hostile) ⇒ refusé à l’écriture avec un message nommé, et livré en V2.5c d’un bloc (champ + adresse renseignée par le monitor + application) — aucune fenêtre de mensonge (ADR-104 §5(b)). ⚠ Ce que cet incrément ne fait PAS : appliquer. Lancer la commande forcée, refuser pty-req/direct-tcpip/ x11-req relève du worker post-auth (V2.5). La contrainte est décidée et transportée jusqu’au point d’application, jamais présentée comme appliquée. Citation erronée « ADR-096 §6 » corrigée partout (§6 = Production d’artefacts ; la clause exacte est celle des Conséquences), ADR-096 amendé d’un renvoi vers ADR-128. Aucun additif couche 0/1 — tout tient en couche 2. Preuves : round-trip v1 et v2, version minimale prouvée sur l’artefact, les 12 champs d’options exercés un à un, résolution restrict table-driven, bornes refusées aux 3 étages, parseur texte (guillemets/échappements/casse/jetons vides/duplicats), CLI (drapeaux, exclusivité, remplacement, list), monitor (masque, bits inconnus, octet manquant), root (clé contrainte ⇒ ALLOW déjà contraint, clé libre du même magasin ⇒ non contraint). Fuzz : décodeur binaire et codec proto inchangés d’API, parseur texte durci d’un 3ᵉ invariant (les options survivent au sceau, et render_options est la réciproque du parseur). V2.5 CONÇU : ADR-129 (conception) fixe la transition post-auth — le monitor forke le worker (setuid + cage selon service : shell=drop-only, SFTP=cage) ; proxy chiffré (l’enfant garde Transport<S>, tuyau clair passé par SCM_RIGHTS) ; K_S public hérité du listener (privé jamais dans l’enfant, §D4) — pas de message HOSTKEY ; from= enforcé au monitor contre l’adresse du pair fournie par le listener (jamais l’enfant) ; le worker applique les options ; contrat mono-fil honoré. Découpage V2.5a (socle proxy/worker + round-trip clair) / V2.5b (service ssh-connection + options + cage sftp) / V2.5c (from=). 1 point remonté BDFL (le listener transmet l’adresse du pair). V2.5a FAIT (socle de la transition post-auth — la mécanique, sans vraie session) : K_S public est désormais un type (PublicHostKey) exigé par la signature de spawn_privsep, donc matérialisé dans le listener avant tout fork et hérité par l’enfant (§D3) — la clé privée est chargée par le corps du monitor, après le fork. Le monitor crée le tuyau clair (ClearChannel, socketpair SOCK_STREAM), forke le worker (spawn_worker : cascade → purge fd → drop_privileges vers le compte cible, drop-only §D1 ; un compte résolvant vers root est refusé, faute d’ADR sur PermitRootLogin), et passe l’autre bout à l’enfant par SCM_RIGHTS sur le canal RPC (§D2), qu’il relâche aussitôt. Preuves root (transition_root.rs) : l’enfant vérifie une signature contre le K_S hérité alors que la privée n’a jamais existé hors du monitor (clé forgée hors du listener, fichier 0600 root) ; un octet fait l’aller-retour en clair worker↔proxy à travers la frontière de privilège, le worker étant non-root et incapable de regagner root ; tuer le monitor tue le worker. Additif couche 1 = ADR-130 (couche-1-v3.3, tag non posé) : air-socket (AirUnixStream::pair/read_bytes/write_bytes), air-sandbox (AirSyscall::Recvmsg dans pre-auth/sftp-worker — seul syscall socket du profil, irréductible pour SCM_RIGHTS), et surtout air-process — correction d’un fail-open livré depuis V2.2 : le noyau efface PR_SET_PDEATHSIG à tout changement d’identifiants, donc « armer puis larguer » laissait chaque étage privsep sans cascade de mort, en silence (le test de cascade V2.2 tuait le monitor avant le fork ⇒ faux vert). drop_privileges la rétablit avant la cage, avec relecture du parent. 2 points remontés BDFL (cf. ADR-130 §Points remontés). V2.5b FAIT (le worker sert vraiment) : le proxy (proxy.rs) relaie le transport — il ouvre/scelle chaque paquet et ne laisse traverser que la plage ssh-connection (80–100), dans les deux sens ; un tag AEAD forgé ne fait passer aucun octet (ADR-032), un EOF d’un côté ferme l’autre. Le worker (worker.rs) parle ssh-connection en clair sur le tuyau, via la même couche connexion que le démon (serve_connection_with + ServiceMode::Deferred), et applique la contrainte (§D5) : command= réécrit shell/exec/subsystem, no-pty refuse le pty-req, no-port-forwarding refuse direct-tcpip/tcpip-forward. Pour le SFTP, il pose la cage sftp-worker (chdir home → purge fd → Landlock du home + seccomp) avant le premier octet, et sort sans servir si la cage échoue. Découverte structurante : aucun étage privsep ne peut utiliser io_uring (aucun io_uring_* dans les profils ; et un anneau créé avant Landlock échappe à Landlock) ⇒ l’I/O des étages confinés est bloquante (blocking.rs : SshByteStream sur read/write + exécuteur inline) et multiplexée par ppoll — le protocole, lui, ne change pas d’une ligne (il est générique sur le seam). Nouveau message RPC START_SESSION : le monitor ne peut pas distinguer une sonde de clé (RFC 4252 §7) d’une auth réelle, donc forker au premier ALLOW lancerait un worker setuid sur une sonde ; l’enfant demande donc explicitement la transition, et le monitor la recoupe avec son propre ALLOW (une par connexion). Preuves root (privsep_session_root.rs) : un vrai client OpenSSH exécute une commande de bout en bout à travers proxy+worker et la sortie prouve l’uid du compte cible ; command= s’exécute à la place de celle du client ; no-pty est prouvé différentiellement (même scénario, avec et sans l’option) ; une session sftp liste le home sous la cage et se voit refuser /etc ; la cascade de mort survit à la cage (transition_root.rs). Additif couche 1 = ADR-131 (couche-1-v3.4, tag non posé) : air-socket (AirTcpSocket::read_bytes/write_bytes/from_owned_fd), air-sandbox (le profil sftp-worker était inutilisable — écrit sans appelant, il nommait openat/fstat quand la couche 1 n’émet que openat2/statx ; 15 syscalls ajoutés), et air-handle — correction d’un défaut latent depuis ADR-125 : register fermait le descripteur neuf quand son numéro portait une entrée périmée (fd fermé hors registre par la purge close_range), d’où un EBADF sur un fd valide. 3 points remontés BDFL (cf. ADR-131 §Points remontés). V2.5c FAIT (from=, la restriction de source) : champ schéma additif fromPatterns @12 sur AuthorizedKeyOptions (id neuf, ADR-012 — une entrée écrite avant lui rend une liste vide, soit « aucune restriction ») ; parseur de motifs fuzzé (from_pattern.rs, cible fuzz_air_sshd_from_pattern) : IP littérale et CIDR v4/v6, négation !, liste séparée par virgules, sémantique OpenSSH (un motif nié qui matche refuse ; sinon il faut un positif — une liste exclusivement négative n’admet personne). Décision de sécurité assumée : les motifs par nom d’hôte (jokers DNS d’OpenSSH) sont refusés à l’écriture, nommément — les honorer ferait dépendre une autorisation d’un reverse-DNS, que l’attaquant contrôle souvent. Deux durcissements fail-loud de plus : un CIDR à bits hôte (10.1.2.3/8) est refusé plutôt que masqué en silence, et un motif IPv6 v4-mapped aussi (les pairs v4-mapped sont normalisés en v4 avant comparaison, comme OpenSSH — sans quoi from="10.0.0.0/8" ne mordrait jamais sur un client v4 arrivé par un socket dual-stack). Enforcement au monitor (§D4) contre l’adresse du pair du LISTENER — lue à l’accept() (peer_addressfrom_pattern::peer_ip) et héritée au fork dans la fermeture du corps du monitor : AuthorizeRequest n’a, à dessein, aucun champ d’adresse, donc un enfant compromis n’a rien à falsifier. SessionPolicy::new(host_key, peer) exige l’adresse — un câblage ne peut pas l’oublier. Fail-closed partout : adresse inconnue ⇒ refus ; motif illisible ⇒ refus de la liste entière ; magasin portant un motif illisible ⇒ magasin écarté en bloc ; et le backend aveugle à l’adresse (FileAuthorizedKeys, chemin non-privsep du démon actuel, tant que V2.6 n’a pas câblé serve_forever) refuse toute clé portant from= au lieu de l’ouvrir — sans quoi la restriction affichée par list aurait été une promesse creuse (revue de sécurité, constat bloquant corrigé). Enfin la règle de version minimale (ADR-128 §3) est étendue : un magasin portant from= est estampillé 3, que les binaires antérieurs (lecteurs {1,2}) refusent en bloc — un retour arrière de binaire coupe l’authentification au lieu de relâcher la restriction qu’il ne sait pas lire. CLI : authorized-keys add --from "10.0.0.0/8,!10.1.0.0/16" accepté, list réaffiche tel qu’écrit, import OpenSSH round-trip. Preuves root (from_restriction_root.rs, privsep_session_root.rs) : la même clé du même magasin ouvre le compte depuis 10.2.3.4 et est refusée depuis 203.0.113.4 ; l’adresse provient d’un accept() réel ; la topologie privsep complète rend deux décisions opposées pour une requête d’enfant identique au bit près, et un enfant qui glisse une adresse dans les champs qu’il contrôle ne change rien ; enfin un vrai client ssh passe avec from="127.0.0.0/8" et échoue avec from="10.0.0.0/8". Chaque preuve a été vérifiée par mutation (enforcement neutralisé ⇒ tous ces tests tombent). Aucun additif couche 1 : air-socket fournissait déjà peer_address et AirIpAddress. V2.6a FAIT (le démon bascule en privsep — le sous-incrément le plus structurant de la vague) : nouveau module listener.rsserve_forever_privsep(listener, PrivsepContext, handle) monte, à chaque accept(), la chaîne monitor → enfant pré-auth → worker au lieu de servir en mono-processus. SshdConfig.privilegeSeparation en est le sélecteur (le champ élargit son sens : il valait « larguer avant l’execve », il vaut désormais « la séparation de privilèges » — un déploiement qui l’avait demandée l’obtient, sans avoir à penser à réactiver quoi que ce soit), et SshdSettings::default() le met à true : une config neuve décrit un déploiement sûr. (Limite dite : un artefact déjà écrit sans le champ reste en monolithique — un booléen Cap’n Proto absent vaut false ; le rattraper demanderait un champ inversé au schéma.) La clé privée ne touche JAMAIS le listener (ADR-129 §D3, §D4 amendé) : le listener ne tient que K_S public, et il l’obtient lui-même hors-processus (public_host_key_from_store : un enfant éphémère lit le magasin, dérive les 51 octets publics, les écrit sur un tube et meurt) — parce que fork copie toute la mémoire, y compris ce qu’un allocateur a rendu sans l’effacer, et que l’enfant pré-auth est justement le processus supposé hostile. Le monitor charge la clé privée après avoir forké l’enfant, et vérifie qu’elle correspond au K_S annoncé (une rotation du magasin en cours de route ⇒ refus, jamais une identité annoncée qui diverge de l’identité prouvée). Trois refus de démarrage fail-closed : privsep sans magasin de clés d’hôte (les graines embarquées passeraient par le listener), privsep + authorization = deny (le monitor n’a pas de backend « refus total » — démarrer ignorerait la politique la plus restrictive en silence), privsep + CA de confiance (l’auth par certificat n’est pas portée par le privsep : elle serait refusée sans le dire). CLI complétée : config set host-key-store (elle manquait — la migration ADR-108 était impossible sans passer par le JSON). Décisions de câblage assumées et documentées : le crédit d’admission (throttling ADR-105) est tenu jusqu’à la mort du monitor et non plus jusqu’à l’auth (le listener ne sait plus quand l’auth réussit ; entre « rendre trop tôt » — un flood forke alors trois processus par SYN accepté — et « rendre trop tard » — un utilisateur est borné en sessions simultanées —, seule la seconde erreur n’est pas une bombe à fork) ; la mort du monitor est sondée avec un délai qui double jusqu’à 1 s, faute d’attente de descripteur générique dans air-async (la voie native reste ouverte, en additif couche 2) ; enfin le journal perd la trace d’authentification (auth_succeeded/auth_failed, ADR-111) — elle a lieu dans l’enfant, dont la cage Landlock deny-all interdit d’atteindre journald, et la faire remonter par le canal RPC ferait écrire le processus privilégié sur la foi de ce que raconte l’enfant : décision d’observabilité renvoyée à V2.6b, dite plutôt que subie. Défaut corrigé, trouvé par le test : monitor::topology::run_monitor attendait son enfant sans relâcher le canal RPC. Tout refus précoce du monitor (clé d’hôte introuvable…) immobilisait donc l’enfant sur une réponse qui ne viendrait jamais — un refus transformé en blocage, le contraire d’un fail-closed. Le canal est désormais fermé dès que le corps du monitor n’a plus rien à dire. Additif couche 2 : air_async::TcpStream::into_owned_fd (réciproque de from_owned) — sans transfert d’ownership, le seul moyen de confier le socket accepté à l’étage confiné serait de le dupliquer, donc de laisser une poignée de plus sur la connexion dans le processus privilégié. Aucun additif couche 1. Preuves root (daemon_privsep_root.rs, la boucle du démon et non une chaîne montée à la main) : un vrai ssh obtient un exec sous le compte cible (il imprime son uid) ; un vrai sftp liste le home et se voit refuser /etc (la cage mord) ; from= décide — même clé, même client, 127.0.0.0/8 passe et 10.0.0.0/8 refuse, sur l’adresse lue à l’accept() ; et le magasin de clés d’hôte retiré après le démarrage ferme toutes les sessions — preuve par soustraction que la clé privée est lue par connexion, dans le monitor, et non mise en cache par le listener. Réserves de revue V2.5b/c CLOSES : (a) isolation des bits d’option en ServiceMode::InPlace, sur le vrai chemin et différentiellementno-pty (terminal accordé vs pty-req refusé), no-port-forwarding sur les deux portes qu’il ferme (direct-tcpip avec une vraie cible en témoin, et la requête globale tcpip-forward) ; aucun refactor du code de décision ; (b) authorizing_entry resserrée : elle ne rend plus l’entrée mais un candidat AuthorizingEntry, dont la seule sortie est admit(peer) — la faute naturelle (trouver l’entrée, lire ses options, oublier from=) ne compile plus ; (c) CLI durcie : tout drapeau --… inconnu est refusé par verbe (une faute de frappe sur --no-pty produisait une clé sans la restriction, avec un message de succès), et un remplacement de clé nomme les restrictions qu’il lève (from="…", command=, no-pty…) au lieu de dire « entrée remplacée ». ✅ RÉSERVE DE SÉCURITÉ CLOSE (2026-07-29, ADR-132, descellement additif couche-0-v1.15) — voie A retenue : madvise(MADV_DONTFORK) est posé sur les quatre mappings d’anneau à leur création (SQ, CQ, tableau de SQE, et l’anneau de descripteurs des buffers fournis, dans ses deux modes), fail-closed (l’échec du conseil fait échouer la construction du ring). Le processus courant garde ses anneaux — seuls les descendants d’un fork ne les reçoivent plus, ce qui est exactement voulu (les étages privsep sont en I/O bloquante, V2.5b). Aucun symbole ajouté ni modifié : le mécanisme (MadviseAdvice::DontFork, madvise) était déjà scellé, seul le comportement de map_rings change — d’où le descellement. Preuves, vérifiées rouges sans le correctif : (couche 0) trois volets — un MAP_SHARED sans le conseil est hérité (contrôle négatif qui valide la méthode de mesure), le même avec ne l’est plus (SIGSEGV dans l’enfant, parent intact), et sur un vrai ring l’enfant faute sur la SQ et sur le tableau de SQE pendant que le parent soumet un NOP et moissonne sa complétion ; (couche 2, root) uring_fork_isolation_root.rs monte la topologie réelle (spawn_privsep) avec un réacteur vivant dans le listener et lit /proc/<pid>/maps aux trois étages — le listener a ses anneaux (sinon le test serait vacant), le monitor et l’enfant pré-auth ne les ont pas. Tag couche-0-v1.15 à poser par le superviseur. Historique de la réserve (cf. notes/reserve-anneaux-io-uring-herites-au-fork-fr.md) : les anneaux io_uring sont mappés MAP_SHARED et survivent au fork — un enfant pré-auth compromis garde donc une fenêtre en écriture sur la SQ/CQ du listener root, et peut lui faire soumettre des opérations arbitraires. ADR-122 §5 traitait l’anneau hérité par la fermeture du descripteur ; cela empêche l’enfant de soumettre, mais ne démappe rien. V2.6a n’aggrave rien (avant elle, la session entière tournait dans le listener root) mais n’achève pas la séparation. Fermeture recommandée : madvise(MADV_DONTFORK) sur les trois mappings à la création de l’anneau — mécanisme déjà présent en couche 0 (MadviseAdvice::DontFork), aucun usage légitime cassé, mais descellement de couche 0 à décider (ADR-015). Non fait ici : hors mandat de ce sous-incrément — fait depuis par ADR-132 (ci-dessus). V2.6b FAIT (complétude de session sous privsep — les trois différés de V2.5b/V2.6a) : ① le rekey traverse le proxy. Un SSH_MSG_KEXINIT en pleine session n’est plus fatal : c’est le début d’un échange de clés, et l’échange de clés appartient à l’étage qui détient les clés — le proxy. Les deux sens sont câblés : le client demande (le proxy répond et mène le KEX), ou le proxy décide seul quand sa fenêtre de volume est atteinte — c’est cette moitié-là qui garantit le nonce AEAD (numéro de séquence u32), et la déléguer au bon vouloir du pair reviendrait à ne pas l’avoir. La signature du rekey est demandée au monitor comme au premier KEX (le canal RPC reste ouvert post-transition) : le proxy n’a jamais que K_S public, et aucun octet de KEX n’atteint le worker. Trois règles tiennent la correction : après notre KEXINIT, plus rien de la couche connexion ne part vers le client (RFC 4253 §7.1 — le worker se bloque sur son tuyau, rien n’est bufferisé ni perdu) ; avant que le pair ait vu notre offre, ce qu’il envoie encore est relayé normalement ; et on ne se rendort jamais sur un paquet déjà décodable — un rekey interrompt le vidage du décodeur, un pair colle volontiers son NEWKEYS final aux données qui suivaient, et ces octets-là étant déjà lus, aucun ppoll ne les annoncerait (défaut trouvé par la suite de tests sous charge, épinglé depuis par un test déterministe qui colle les deux paquets en une écriture ; sans le vidage, la session s’endort jusqu’au prochain octet du client — qui peut ne jamais venir). Reste hors périmètre, dit : le rekey par le temps (il faudrait un clock_gettime dans une cage qui n’en veut pas ; le volume couvre seul la propriété de sûreté). Additifs transport : send_kexinit/accept_kexinit (les deux moitiés d’exchange_kexinit), complete_kex_with_signer, rekey_responding_with_signer, with_rekey_thresholds (fenêtre explicite — sans quoi un rekey déclenché par le serveur ne serait pas prouvable sans échanger un gibioctet). SSH_ORIGINAL_COMMAND. Sous command="…", la demande du client est exposée au processus forcé (jamais exécutée) — c’est ce qui rend possibles les dispatcheurs (gitolite, rrsync) sans distribuer une clé par commande. Un exec expose sa ligne, un subsystem son nom, un shell rien (convention OpenSSH). Câblé sur les deux chemins (worker privsep et démon monolithique). ③ La piste d’audit d’auth, rendue au monitor (la décision d’observabilité que V2.6a avait explicitement renvoyée ici) : le monitor est le seul étage à la fois privilégié et décisionnaire. Refus → à l’AUTHORIZE refusé (sa décision, prise en lisant authorized_keys en tant que l’utilisateur) ; succès → à la transition accordée, pas à l’ALLOW — parce qu’un client peut sonder une clé sans la signer (RFC 4252 §7) et qu’une sonde emprunte le même AUTHORIZE : journaliser un succès là écrirait qu’on s’est authentifié avant toute signature. Chaque champ vient de la source qui fait foi (adresse du listener, compte accordé par le monitor, méthode publickey). Émission best-effort (un journal ne doit ni ouvrir ni fermer une session) et jamais silencieuse : oplog::operational_log dégrade vers stderr si journald ne répond pas — une trace d’auth absente se lirait « personne ne s’est connecté ». Aucun additif couche 0/1. Preuves, toutes vérifiées rouges sans leur correctif : tests/proxy_rekey.rs (hors root, rôle client réel sur le cœur) — rekey demandé par le client et rekey déclenché par le serveur, session_id conservé, session vivante avant/après, le worker attestant n’avoir vu que la couche connexion, et le paquet collé au NEWKEYS (tuyau client qui retient ses écritures et les pose d’un bloc, puis se tait : si le relais attend un réveil réseau, il l’attend pour toujours) ; privsep_session_root.rs — un vrai ssh -o RekeyLimit=16K transfère ~250 kio à travers la chaîne complète et SSH2_MSG_NEWKEYS received ≥ 2 (l’assertion qui compte : une première version du test se contentait de KEXINIT sent ≥ 2 et passait sans le correctif, un client pouvant demander un rekey en toute fin de session et sortir sans réponse) ; SSH_ORIGINAL_COMMAND prouvé différentiellement (la commande demandée est lue et n’est pas exécutée, et la variable est absente hors commande forcée) ; l’audit relu dans journald (AIR_USER/AIR_PEER_IP/AIR_METHOD), une seule ligne de succès par session (la sonde n’en produit pas) et un from= refusé produit un échec et aucun succès. Restant vague 1 = campagne de tests de sécurité (flood/DoS/brute-force/rejeu/ timing), redirections de port en privsep (le worker n’a pas de réacteur pour porter un pump concurrent — refusées, pas servies à moitié), et le sort de l’auth par certificat sous privsep (message RPC dédié ou certificats hors privsep) = clôture vague 1. Durcissement B2 FAIT — ADR-133 (quota de signatures du monitor) : ferme l’observation non bloquante de la revue de sécurité adverse de la PR #511. La validation du monitor étant structurelle (ADR-124 §D4, tranché BDFL — longueurs 32/48/64 o, cohérence des KEXINIT, égalité de K_S, sans recompute de H, impossible sans le secret DH qui reste dans l’enfant), un enfant pré-auth compromis dispose d’un oracle de signature Ed25519 sous la clé d’hôte sur un hash quasi arbitraire — et il ne l’exerce pas au rythme des rekeys : il spamme des trames SIGN sur le RPC, au débit du seul round-trip. V2.6b n’a pas créé l’oracle (il préexiste à ADR-122/124) mais en a allongé la durée (toute la session, le canal RPC restant ouvert pour re-signer aux rekeys). Décision BDFL : double quota fail-closed par connexion dans HostKeySignPolicy::sign — plafond absolu 1024 signatures + token-bucket rafale 8 / recharge 2 par seconde, évalués avant la validation (le moins cher d’abord), chaque refus restant un SignRefused nu. La primitive de seau est extraite du throttling pré-auth (module interne token_bucket) : une seule implémentation du calcul de recharge, deux usages (throttle.rs la garde, keyée par source). Couche 2 pure : aucun descellement, aucun tag. Preuves, toutes vérifiées rouges sans leur correctif — ① la mesure du finding par un harnais d’enfant simulé compromis parlant le vrai codec RPC : 4096 signatures récoltées sans quota (soit 4 × le plafond, en ~1 s), 8 avec le quota ratifié, sur les mêmes 4096 trames ; ② le plafond refuse fail-closed et ne se recharge jamais ; ③ le débit refuse au-delà de la rafale puis ré-autorise après recharge (temps injecté, comme throttle/tests.rs) ; ④ non-régression — KEX initial + 60 rekeys (un par minute, une heure) et une salve de 8 rekeys rapprochés : zéro refus ; ⑤ mutisme — un refus de quota est octet pour octet le même que le refus de validation sur le fil. Les 8 axes privsep de la campagne (dont B2, la mesure de cet oracle) restent différés à la 2ᵉ campagne complète (décision BDFL 2026-07-29) ; seule cette mesure est menée dès maintenant, en preuve. Déblocage du déploiement 0.3.0 FAIT — ADR-134 (CLI de migration de la clé d’hôte vers hostkeys.aircfg) : le démon 0.3.0 démarre en privsep par défaut et refuse (fail-closed, ADR-129 §D3) les graines host_key_seeds du sshd.aircfg — lues par le listener, or la clé privée ne doit jamais s’y trouver — mais aucune commande ne savait produire le magasin hostkeys.aircfg (ADR-108) que le privsep exige : les machines déployées en 0.2.1 étaient donc bloquées. Nouveau verbe air-sshd migrate-host-key-store <chemin-absolu> [--config <sshd.aircfg>] [--force] : il scelle la clé EXISTANTE (mêmes graines ⇒ même paire Ed25519 ⇒ même empreinte : aucun avertissement « host key changed » sur la flotte), câble host_key_store_path, et vide les graines — c’est le point de sécurité. Ordre critique : le magasin 0600 est écrit avant toute réécriture du sshd.aircfg ⇒ jamais de config à moitié migrée. Logique en bibliothèque (air_sshd::host_key_migration), le binaire ne collecte qu’argv et l’horloge. Couche 2 pure : aucun descellement, aucun tag. Preuves (rouges sans le correctif) : round-trip magasin 0600 décodable à clé publique identique + graines disparues ; idempotence (« déjà migré », --force exigé pour écraser un magasin non vide) ; fail-closed (rien à migrer ⇒ erreur ; magasin non écrivable ⇒ sshd.aircfg intact) ; identité préservée ; et le test root de bout en bout sur le vrai binaire — un sshd.aircfg à graines fait fail-closer le démon, puis, après migration, la chaîne listener → monitor → enfant → worker sert une session ssh réelle (KEX signé par le monitor depuis le magasin migré, clé autorisée, exit-status rendu). ⚠️→✅ Défaut découvert par cette preuve, HORS ADR-134 — CORRIGÉ par ADR-135 : sous le binaire (et non sous la boucle in-process de daemon_privsep_root), le processus lancé par le worker mourait avant son execveair_handle::duplicate refermait le descripteur neuf lorsque le registre portait une entrée périmée pour ce numéro (registry.rs, table.insert(...) dont l’ancienne valeur était droppée au lieu d’être oubliée) ; les entrées héritées puis purgées par le close_range post-fork d’ADR-125 §D8 en laissent. C’était le pendant, dans duplicate, du défaut qu’ADR-131 a corrigé dans register. Conséquence : une session privsep aboutissait (client ssh à 0) mais n’exécutait rien. Aucun test n’exerçait la topologie privsep complète depuis le binaire livré — d’où la découverte tardive.

🟡 EN COURS — ADR-146 : LE FORWARDING SERVEUR SOUS PRIVSEP (V2.6-1 → V2.6-3). Le constat qui l’ouvre : depuis V2.5b, une session privsep refusait toute redirection — le worker n’a ni réacteur, ni socket(2), ni connect(2), et servir à moitié n’était pas une réponse. La conception (ADR-146, #577) tranche l’option B : le worker demande, le monitor décide, connecte, et remet le descripteur (SCM_RIGHTS). Le worker ne gagne aucun droit — il reçoit un socket déjà connecté, et read/write ne sont pas socket/connect.

  • V2.6-1a — le canal RPC ForwardConnect (MERGÉ #579). Un second socketpair SEQPACKET worker↔monitor, créé après le fork de l’enfant pré-auth (qui ne le voit donc jamais) et survivant à la purge de descripteurs. Deux types, pas un canal générique : ForwardRequester (worker) n’expose qu’une opération, ForwardMediator (monitor) traite tout autre message comme une violation de protocole qui ferme le canal. Le monitor rejoue toute la politique sur sa propre mémoire (AllowTcpForwarding, PermitOpen, no-port-forwarding d’ADR-128) et ne résout que de l’IPv4 littérale (aucun DNS dans le privilège : il ferait décider un tiers de ce que root va joindre). Refus sans motif (ForwardRefused n’a pas de champ) : pas de scanner du réseau interne. Canal empoisonné à la moindre désynchronisation. Preuve root (forward_connect_root.rs) : un worker non-root, purgé, largué, joint une cible qu’il n’aurait pas pu ouvrir et relit son écho ; AllowTcpForwarding no et une destination hors PermitOpen l’arrêtent — et la cible ne voit aucune connexion.
  • V2.6-1b — ssh -L SERVI (ce jalon). Le câblage : la boucle connexion, en mode différé, ne refuse plus un direct-tcpip — elle rend un ConnectionOutcome::Forward (cible en octets, non résolue ; aucune confirmation émise, puisque personne n’a encore joint quoi que ce soit). Le worker demande au monitor, puis confirme, puis pompe — dans cet ordre, parce qu’une OPEN_CONFIRMATION dit « ta cible est jointe ». Nouvelle pompe bloquante (forward_blocking.rs) : un seul ppoll sur deux descripteurs, aucun io_uring, aucun fil (la cage n’en veut pas — cf. blocking.rs) ; contrôle de flux dans les deux sens (fenêtre reçue consommée puis recréditée après écriture réelle sur la cible, fenêtre d’émission et maximum_packet_size du client jamais dépassés, dépassement de nos bornes annoncées = canal fermé) ; cible en O_NONBLOCK avec file d’attente bornée par la fenêtre annoncée — c’est ce qui empêche l’interblocage d’un relais mono-fil. Côté worker, la garde ne vérifie que la permission de la clé : la politique du serveur est l’affaire du monitor, seul à en détenir une version opposable. Refus uniforme (politique, hôte irrésoluble, connect en échec : le même CHANNEL_OPEN_FAILURE). Un forward = fin de session (comme le monolithique). Preuves : unitaires (l’outcome est rendu / refusé, et rien n’est promis avant la médiation), pompe (aller-retour intègre, fenêtre et taille de paquet respectées, violation → CLOSE, EOF propagé, tuyau rompu = fin normale), et e2e root — un vrai ssh -N -L traverse listener → monitor → proxy → worker, les octets reviennent intacts d’un écho local, et la cible confirme avoir servi exactement une connexion ; AllowTcpForwarding no et un monitor sans politique armée (défaut fail-closed) l’arrêtent, cible muette.
  • V2.6-2a — la PLOMBERIE du -R : le monitor écoute et accepte (ce jalon). La médiation inversée, et le canal devient bidirectionnel : le monitor peut désormais émettre un message non sollicité, parce que c’est un tiers — le client qui se connecte au port redirigé — qui provoque l’événement. Trois verbes de plus (ForwardListen/ForwardListening|ForwardListenRefused, ForwardCancel/ForwardCancelled) et une poussée (ForwardAccepted{origine, port lié}, accompagnée du socket accepté par SCM_RIGHTS). Le monitor reste l’autorité : même ordre d’étages que la garde monolithique (permits_remote → permission de la clé → PermitListen), refus uniforme (politique, bind impossible, quota : indiscernables). Le socket d’écoute ne sort jamais du monitor — seuls les acceptés traversent, donc le worker n’acquiert ni bind, ni listen, ni accept. Les écoutes entrent dans le même ppoll (pas de fil, pas d’anneau) ; la contre-pression est celle du noyau : avant chaque accept, le monitor demande si le canal du worker accepterait la poussée — sinon il n’accepte pas, et la connexion attend dans le backlog. Rien ne s’accumule dans le processus privilégié, qui ne bloque donc jamais sur un worker lent. Ni sur une écoute (correctif de revue de sécurité) : les sockets d’écoute sont non bloquants (O_NONBLOCK posé après le listen, fail-closed — si la pose échoue, l’écoute est refusée), et l’écoute prête est désignée par un nom stable (ListenerId) et non par son rang. Sans ces deux gardes, un ForwardCancel servi dans le même réveil qu’une connexion entrante décalait les rangs et faisait dormir root dans un accept(2) sur l’écoute voisine — toute la session figée. Bornage MAX_REMOTE_LISTENERS (8) et fauchage de toutes les écoutes à la fin de session (y compris sur violation de protocole). Côté worker, la discipline de lecture est un point unique classant par étiquette, avec file de poussées bornée : une poussée arrivée pendant l’attente d’une réponse ne peut pas être confondue avec elle. Preuve root (forward_listen_root.rs) : un worker non-root, purgé, largué, sert une connexion entrante sur un port qu’il n’aurait pas pu lier ; les trois étages de politique l’arrêtent, aucun port lié. Non câblé dans la garde -R à ce stade — c’est le jalon suivant qui l’y branche.
  • V2.6-2b-i — ssh -R SERVI, en séquentiel (ce jalon). Le câblage de la plomberie 2a : la garde -R, en mode différé, ne refuse plus un tcpip-forward — elle rend un ConnectionOutcome::RemoteForward (bind en octets, non résolu ; aucune réponse émise, puisque personne n’a encore lié quoi que ce soit — et que le REQUEST_SUCCESS d’un bind éphémère doit porter un numéro de port qui n’existe pas encore). Ce bras passe avant la politique du serveur, structurellement : le worker n’en détient qu’une version non bornée, et la consulter donnerait l’illusion d’une décision. Le worker demande l’écoute, puis répond, puis sert — et pour chaque connexion que le monitor lui pousse, il ouvre un canal forwarded-tcpip vers le client (adresse connectée = le port réellement lié ; origine = celle que le noyau a constatée à l’accept4, jamais déclarée) et passe la main à la pompe de 1b/1c, réutilisée telle quelle — pas une seconde pompe « inspirée de » qui aurait perdu le jour même le contrôle de flux, la garde anti-POLLHUP-tournant et le drainage du résidu avant l’EOF que deux revues de sécurité ont durcis. Séquentiel assumé : une connexion servie à la fois, et rien ne s’accumule nulle part — pendant qu’une pompe tourne le worker ne draine pas son canal RPC, donc le monitor cesse d’accepter (contre-pression POLLOUT de 2a) et les entrantes attendent dans le backlog du noyau. Un cancel-tcpip-forward est relayé au monitor (lui seul détient le socket) et termine la session ; la fin du transport, la mort de la médiation et une pompe qui constate le tuyau rompu la terminent aussi — dans les quatre cas, le canal RPC se ferme et le monitor fauche l’écoute. Preuves : unitaires (l’outcome est rendu / refusé, et rien n’est promis avant l’écoute), boucle sur vrais ForwardMediator/ForwardRequester (deux connexions successives servies, refus d’ouverture → tiers fermé et écoute intacte, annulation → écoute lâchée par le monitor), e2e privsep (remote_forward_root.rs : la vraie run_session dans un worker forké non-root) et surtout e2e client réel — un vrai ssh -N -R traverse listener → monitor → proxy → worker, un tiers se connecte au port que root a lié, ses octets ressortent chez le client et reviennent intacts ; AllowTcpForwarding no, PermitListen et no-port-forwarding l’arrêtent, aucun port lié.
  • V2.6-2b-ii — ssh -R CONCURRENT (ce jalon). Le séquentiel devient un démultiplexeur : N canaux forwarded-tcpip vivants sur le même transport, pilotés par un seul ppoll (tuyau clair + canal de médiation + N cibles) dans un seul fil. Le geste central n’est pas la boucle, c’est la factorisation : la pompe durcie de 1b/1c (anti-spin POLLHUP, drainage du résidu avant l’EOF, contrôle de flux dans les deux sens) est extraite en pilote PAR CANAL — le Pump porte désormais son propre local_id, expose on_client_message / on_target_ready / target_interest, et ne possède ni la boucle ni le décodeur du transport. pump_direct_tcpip (-L) en devient l’appelant mono-canal, un mince enrobage : les douze tests d’invariant de forward_blocking sont restés verts sans une ligne de modification — c’est la preuve que l’extraction a déplacé du code sans en réécrire la sémantique. Le démux route chaque message par recipient_channel, ouvre sans attendre (CHANNEL_OPEN puis état pending, la réponse est traitée comme n’importe quel autre message — là où 2b-i bloquait sur la confirmation), et fauche les canaux finis. Trois bornes : 32 canaux simultanés (au-delà, le worker cesse de demander IN sur son canal RPC, la contre-pression POLLOUT du monitor prend le relais et les tiers patientent dans le backlog du noyau), une fenêtre bornée par canal, un tableau de ppoll sur la pile. Identifiants locaux monotones, jamais recyclés : un message tardif ne peut pas s’appliquer au tiers suivant. Amélioration au passage : cancel-tcpip-forward retire le port sans tronquer les canaux ouverts — la session s’achève avec le dernier d’entre eux. Preuves : les invariants de forward_blocking (12), le démux sur vrais ForwardMediator/ForwardRequester (deux tiers servis de front, octets entrelacés dans les deux sens et jamais mélangés ; la fermeture de l’un n’atteint pas l’autre ; un refus d’ouverture non plus ; la borne de 32 retient puis relâche ; la fin de transport ne laisse aucun descripteur), e2e privsep root (remote_forward_root.rs : deux tiers concurrents à travers un worker forké non-root qui n’a jamais appelé accept4) et e2e client réel (privsep_session_root.rs : quatre tiers de front derrière un vrai ssh -N -R, avec une cible d’écho qui accepte les quatre avant d’en servir un seul — un serveur qui sérialiserait ne renverrait jamais le premier écho).
  • V2.6-3 C.5a — le connect(2) sortant devient ASYNCHRONE côté monitor (ce jalon). Le sens -L avait gardé, seul, un syscall bloquant dans la boucle du processus root : le connect. Vers une adresse qui laisse tomber le SYN — pas un port fermé, qui rend un RST immédiat, mais une IP muette —, il y dormait le temps que le TCP abandonne, deux minutes environ pendant lesquelles la session entière était suspendue : plus de service pré-auth, plus d’accept sur les écoutes -R tenues, plus de récolte du worker. Et il suffisait, pour l’atteindre, d’un ssh -L vers une IP injoignable — c’est-à-dire d’une demande que la politique peut parfaitement autoriser (finding tracé de la revue de #582). La médiation se fait désormais en deux temps, exactement comme le sens -R le fait déjà pour accept : serve_once applique la politique — inchangée, même ordre —, lance un connect non bloquant et enregistre un connect en cours sans répondre ; le descripteur rejoint le ppoll unique du monitor, surveillé en écriture (c’est ainsi qu’un connect non bloquant annonce sa fin), et finish_connect constate l’issue par getpeername(2) — une adresse de pair est l’établissement, son absence est l’échec — puis répond, descripteur en tête. Trois propriétés portent la sécurité : borne (MAX_PENDING_CONNECTS = 8, en queue de politique comme le quota d’écoutes ; au-delà, refus sans motif plutôt qu’accumulation), fauchage (RAII : la fin de session ferme les connects en vol comme elle ferme les écoutes) et refus uniforme (politique, résolution, connect perdu d’avance et échec constaté rendent tous le même ForwardRefused — aucun oracle de joignabilité de plus qu’avant). Le worker est INCHANGÉ : request_forward_connect envoie et attend comme avant ; l’attente est simplement devenue la sienne seule. Ce jalon est aussi le prérequis de la coexistence C.5 — un -L dans un démultiplexeur ne doit pas figer les autres canaux. Additif couche 1 minimal : air_socket::engine::is_connect_in_progress (une seule définition d’EINPROGRESS dans le crate, partagée avec connect_timeout). Preuves : le découpage lui-même (servir la demande laisse un connect en cours et n’a rien répondu), une cible muette reproduite sur la boucle locale (file d’écoute pleine → SYN abandonné, sans dépendre d’une route absente) qui établit que le connect reste en vol et qu’une seconde demande est servie pendant ce temps, la boucle serve complète (un ForwardListen et l’enfant pré-auth servis alors qu’un connect est en vol, sous délai borné — un connect bloquant y tiendrait bien au-delà), la borne, le fauchage, le socket rendu au worker redevenu bloquant (cible délibérément silencieuse : un socket non bloquant y rendrait EAGAIN), et la non-régression e2e root (forward_connect_root).
  • V2.6-3 C.5b — le forwarding est UNIFIÉ : -L + -R + écoutes multiples, ensemble (ce jalon). Le worker servait jusqu’ici soit un -L (une pompe mono-canal), soit un -R (le démultiplexeur), jamais les deux, et un seul de chaque : deux chemins pour ce qui, une fois la cible en main, est le même canal. Le démux devient le chemin de forwarding, et porte désormais les deux espèces dans la même table, sous le même ppoll et le même pilote [Pump] — d’où : un -L et un -R actifs en même temps, plusieurs -L successifs sur une session, plusieurs écoutes simultanées (une poussée est routée vers son écoute par le bound_port que le monitor lui joint). serve_direct_tcpip et serve_tcpip_forward_listen disparaissent au profit d’un serve_forward_demux amorcé par l’un ou l’autre premier évènement (ForwardSeed) ; le -L mono-canal (pump_direct_tcpip) est supprimé, et ses douze tests d’invariants — flux dans les deux sens, garde anti-POLLHUP-tournant, drainage du résidu avant l’EOF — sont rejoués à travers le démux, inchangés. Le -L y est asynchrone (acquis de C.5a : le fil ne dort plus sur un connect tiers) et sérialisé — une demande en vol, les suivantes en file bornée : le protocole de médiation ne porte aucun identifiant de corrélation, et deux réponses identiques ne se distingueraient pas (la concurrence par corrélation est une optimisation, pas un prérequis). Côté canal RPC, une réception unifiée (receive_message → connexion accordée / refusée / poussée) classe par étiquette, jamais par ordre d’arrivée : une réponse de -L qui arrive pendant qu’une demande d’écoute attend la sienne est rangée, pas confondue. Bornes : 32 canaux (une ouverture -L en vol réserve sa place — sans quoi le socket qu’on nous rendrait n’aurait plus où aller), 16 ouvertures en file, 8 écoutes ; une connexion poussée que la table ne peut pas porter est mise en réserve (et la lecture du canal RPC cesse) plutôt que jetée. Pas de session ici : un CHANNEL_OPEN session est refusé — c’est C.5c, dont ce démux est le socle. Preuves : coexistence -L+-R (les deux canaux vivants ensemble, octets intacts dans les quatre sens, aucun croisement), sérialisation (le monitor ne voit pas la seconde demande tant qu’il n’a pas répondu à la première, et elle est servie ensuite), deux écoutes simultanées (chaque CHANNEL_OPEN nomme son bind ; annuler l’une laisse l’autre), refus de politique -L sans toucher au -R, refus de tout ce qui n’est pas direct-tcpip (session, streamlocal, inconnu) sans rien demander au monitor, permission de clé opposée aux ouvertures suivantes (et gratuitement), les 12 invariants du pilote rejoués via le démux, les 14 tests -R de 2b-ii inchangés, et — repli de la réserve de la revue #585 — la branche fail-closed de finish_connect prouvée de façon déterministe : un connect qui n’a pas abouti rend ForwardRefused et aucun descripteur ne franchit la frontière de privilège.
  • V2.6-3 C.5c — la SESSION rejoint le démux : un shell et un -L/-R sur la même connexion (ce jalon). Jusqu’ici la session possédait le transport : serve_interactive et serve_program tenaient chacun leur propre boucle ppoll, du premier octet du shell à son exit-status, et une redirection sur la même connexion était donc structurellement impossible — deux boucles ne partagent pas un descripteur. Le maître du PTY (ou le socketpair d’un exec) devient un descripteur de plus dans le ppoll du démux, à côté des cibles des redirections, et le canal de session est routé comme les autres, par son identifiant local. Le dialogue de session, lui, ne bouge pas d’une ligne : il est extrait tel quel dans session_blocking::SessionPump (mêmes lectures, même SIGHUP à la fermeture du client, même window-change, même ordre de clôture exit-statusEOFCLOSE, même fermeture gracieuse par drainage du pair). L’inversion que porte l’incrément : la sortie du shell ne ferme plus la connexion, elle clôt son canal, qui quitte la table — le transport et les redirections décident de la fin. La décision d’une requête de session (no-pty, command="…", sous-système hors liste blanche) est extraite en un seul endroit (connection::decide_session_request) et opposée par les deux étages : une option d’authorized_keys qui ne fermerait sa porte que dans l’un ne la fermerait pas du tout. Amorçage : Service{Shell/Exec} entre dans le démux avec un canal de session déjà lancé (seul le worker connaît le compte, son home et son shell — d’où SessionLauncher, prêté au démux pour les sessions qui naissent en cours de connexion) ; un CHANNEL_OPEN session reçu dans le démux ouvre, négocie et lance de la même façon. Périmètre assumé : le SFTP garde son chemin dédié (serve_sftp_caged, cage posée avant de servir, transport possédé) et est refusé s’il est demandé dans le démux — c’est C.5d ; une seconde session reste refusée, du refus de toujours (« only one session channel »), y compris après la sortie du shell. Preuves : e2e root avec un vrai client OpenSSHssh -L … compte@hôte 'commande' (sans -N) obtient les deux, octets du tunnel intacts pendant que la commande tourne, sortie et code de sortie de la commande rendus au client ; en unitaire, une session ouverte dans le démux pendant qu’un -L sert (et le tunnel survit à la sortie du shell), une redirection qui rejoint une session déjà en cours (et qui n’hérite pas de son identifiant 0), le refus d’une seconde session, le refus du SFTP, et no-pty opposé par le démux ; côté pilote, sortie et code de sortie intacts, ordre de clôture, idempotence de la clôture, EOF client qui ne clôt rien. Non-régression : les 15 tests privsep_session_root (shell, exec, PTY, no-pty, commande forcée, SFTP cagé, rekey, audit) restent verts.
  • V2.6-3 C.5c-fix — le canal de session ne peut plus geler ses redirections (revue de #589). Deux endroits où le pilote de session immobilisait le fil unique du worker — auto-déni de service borné à la connexion de l’attaquant, mais un canal qui peut figer ses voisins n’est pas un canal qui coexiste. (1) La récolte était bloquante : un CHANNEL_CLOSE menait à SIGHUP puis à un wait sans borne, dont un processus qui piège SIGHUP (trap '' HUP) ne sortait jamais. Le pidfd du processus (déjà exposé par air_process::AirProcessclone3 + CLONE_PIDFD) entre désormais dans le ppoll du démux : lisible = mort, et try_wait(WNOHANG) rend la main aussitôt. Un CHANNEL_CLOSE ne fait plus que signaler et marquer le canal ; s’il ne meurt jamais, le canal reste semi-fermé et les redirections continuent d’être servies. Le pidfd quitte le tableau dès la récolte (un pidfd fauché reste lisible : le laisser ferait tourner l’attente à 100 %). (2) L’écriture vers le processus était bloquante : un client qui inonde l’entrée d’un shell qui ne lit pas remplissait le tampon du terminal, et le write(2) suivant s’asseyait. Maître PTY et socketpair sont non bloquants, ce qui ne part pas attend dans une file bornée par la fenêtre annoncée, le descripteur est surveillé en POLLOUT tant qu’elle n’est pas vide, et le crédit n’est rendu qu’après écriture — le canal de session gagne ainsi le contrôle de flux qu’il n’avait jamais eu, celui de forward_blocking. Sémantique inchangée : exit-status (vrai code, une fois), ordre exit-statusEOFCLOSE, SIGHUP, window-change, PTY. Preuves : un processus qui piège SIGHUP + un -L actif → le tunnel relaie après le CHANNEL_CLOSE de session ; un client qui inonde l’entrée d’un shell PTY + un -L actif → le tunnel relaie pendant ; sortie normale → exit-status correct par la récolte pidfd, une seule fois, et rien après.
  • V2.6-3 C.5d — le SFTP coexiste, cage comprise : la DERNIÈRE pièce de C.5. Le sous-système était le dernier service à posséder le transport, pour une raison qui n’était pas la sienne : il se sert cage posée ([ADR-129] §D1), et une cage ne se posait pas depuis une boucle tenant déjà des canaux. Le dialogue SFTP est désormais un pilote de canal (sftp_blocking::SftpPump, même forme que SessionPump/Pump), sans processus ni descripteur : il ne vit que des données du client, ingère au routage et travaille au tour de boucle. Le cœur (air-sftp-proto) et le moteur (sftp::SftpEngine) sont inchangés — c’est la condition de la non-régression. Ce que le pilote gagne : un contrôle de flux dans les deux sens sans jamais attendre (fenêtre de réception opposée, crédit rendu après consommation ; file de sortie bornée, jamais au-delà de la fenêtre du pair — la correction S.5 reprise telle quelle). La cage est posée soit par le worker (SFTP premier service) soit par le démux (SFTP demandé alors que des redirections tournent), toujours avant le premier octet servi, et sa purge préserve exactement le tuyau clair, le canal de médiation et les cibles des redirections déjà ouvertes ; un PTY éventuellement alloué est relâché avant la purge (double fermeture). Échec de pose = fail-closed : sous-système refusé et connexion terminée (EXIT_CAGE_FAILED). Le profil sftp-worker n’est PAS élargi : tout le chemin de redirection (ppoll, read/write, recvmsg, fcntl, close) y est déjà ; les deux seuls gestes qui n’y sont pas — shutdown(2) et le MSG_PEEK de sondage de résidu (recvfrom(2)) — sont supprimés sous la cage plutôt que tentés (l’action par défaut du profil est KILL_PROCESS), au prix documenté d’une demi-fermeture différée et d’un doute résolu du côté conservateur. Les ajouter au profil (tous deux dominés par les close/recvmsg déjà permis) est une décision de politique de sécurité — laissée au BDFL, cf. l’en-tête de forward_demux_blocking. Preuves : en unitaire, une session SFTP cagée et un -L actifs ensemble (dialogue SFTP pendant que le tunnel relaie, dans les deux sens), la liste des descripteurs préservés vérifiée descripteur par descripteur, le tunnel qui relaie après la pose de la cage, la cage refusée qui refuse le sous-système et termine la connexion, 13 tests du pilote SFTP ; en e2e root, sftp(1) + -o LocalForward sur une connexion — le home listé, /etc refusé (la cage mord toujours), les octets qui traversent le tunnel, et un monitor qui rend 0 (un worker tué par sa propre cage se verrait là).
  • V2.6-3 streamlocal-Lssh -L vers un SOCKET UNIX, joint SOUS L’IDENTITÉ DE L’UTILISATEUR (MERGÉ #596). Un direct-streamlocal@openssh.com n’est plus refusé en privsep : il devient un canal -L comme un autre — même file d’ouvertures, même sérialisation, même pilote Pump, même pompe. La seule ligne qui distingue les deux formes est le verbe de médiation émis : nouveau RPC ForwardConnectUnix (tag requête 8, un unique champ borné par sun_path — 107 octets, la même constante que le pilote monolithique ; au-delà la couche 0 tronquerait, donc on refuse sur le fil), réponses ForwardConnected/ForwardRefused inchangées, fuzzé dans fuzz_air_sshd_monitor_proto. Le cœur est côté monitor : la politique d’abord (AllowTcpForwarding(-L) + permission no-port-forwarding de la clé — la même porte que direct-tcpip ; puis PermitOpen dans sa seule lecture honnête pour un chemin : dès que la liste borne, refus, mot pour mot ce que fait la voie monolithique — sans quoi le privsep serait plus permissif que le chemin monolithique sous la même configuration. Une PermitOpen de chemins reste un incrément possible ; quand la liste ne borne pas, la contrainte d’accès est l’identité empruntée, d’une autre nature et plus fine qu’une liste), puis, dans une portée étroite sous air_process::credentials::AirTemporaryIdentity::assume(uid, gid) : socket(AF_UNIX), non bloquant, connectet rien d’autre. La garde restaure root à son Drop, y compris sur erreur ou panique ; son contrat mono-fil est satisfait par construction (le monitor est une boucle bloquante mono-fil, et le type est !Send). Le connect en cours est ensuite poursuivi en root dans le ppoll existant (getpeername), avec la même file et la même borne MAX_PENDING_CONNECTS que le sens TCP : la boucle root ne bloque jamais. Si assume échouait (inatteignable : monitor root plein) → ForwardRefused, aucun socket ouvert. Le refus reste uniforme — politique et connect échoué indistinguables. Ce que cela achète, et qui ne s’observe nulle part ailleurs : (a) SO_PEERCRED du service cible voit l’utilisateur, jamais 0 — un socket qui n’autorise que son propriétaire reste protégé comme il l’était sans redirection ; (b) la traversée FS est celle du compte (fsuid aligné sur l’euid, groupes supplémentaires du compte portés — cf. fast-follow ci-dessous —, ensemble effective des capabilities vidé par le noyau dès que l’euid quitte 0) → aucune escalade : c’est l’invariant « avec son propre uid » du pilote monolithique, préservé en privsep. Preuves : codec + fuzz ; en in-process root, un socket joint dont le pair est l’uid emprunté et un socket dans un répertoire 0700 de root refusé (avec le contre-témoin : root, lui, y arrive) ; en e2e root (tests/streamlocal_forward_root.rs), un service d’écho dans un processus séparé qui rend SO_PEERCRED par son code de sortie, plus la sonde de restauration (une redirection TCP servie après la fenêtre). Contrôle négatif exécuté : en retirant l’abaissement, les deux tests de sécurité échouent — ils discriminent bien. Zéro additif couche 0/1.
  • V2.6-3 streamlocal-Rssh -R vers un SOCKET UNIX, CRÉÉ SOUS L’IDENTITÉ DE L’UTILISATEUR (ce jalon, la DERNIÈRE brique de V2.6). Un streamlocal-forward@openssh.com n’est plus refusé en privsep : le monitor crée et écoute le socket demandé, pousse chaque connexion acceptée, et le worker ouvre vers le client un forwarded-streamlocal@openssh.com — servi par le même pilote Pump, dans la même table de canaux, avec la même contre-pression que le -R TCP. Nouveaux RPC ForwardListenUnix / ForwardCancelUnix (tags requête 9/10) et poussée ForwardAcceptedUnix (tag réponse 13 — une étiquette à elle, parce qu’elle décide du canal SSH à ouvrir ; pas d’origine, une connexion sur socket Unix n’a pas d’adresse de pair). Chemins bornés par sun_path des deux côtés du fil, fuzzés. Le cœur est côté monitor, et c’est la seule opération de tout le forwarding qui écrit quelque part : la politique d’abord (AllowTcpForwarding(-R) + permission de la clé — la même porte que tcpip-forward ; puis PermitListen en fail-closed dès qu’elle borne, mot pour mot la voie monolithique), puis, dans une portée étroite sous AirTemporaryIdentity::assume(uid, gid) : socket(AF_UNIX), bind littéral, chmod 0600, non bloquant, listenet rien d’autre. Les quatre invariants du pilote monolithique sont préservés mot pour mot : (1) le socket est possédé par le compte et ne peut naître que là où le compte pouvait déjà écrire (aucune escalade, aucun socket root:root) ; (2) jamais d’unlink préalable — c’est le bind qui garantit l’absence d’écrasement (EADDRINUSE), puisqu’aucun code ne supprime un chemin que nous n’avons pas créé ; (3) le mode est posé entre le bind et le listen (un socket qui n’écoute pas encore refuse toute connexion : pas de fenêtre exploitable), par un chmod qui ne suit pas le lien final (ADR-144) et dont l’échec défait le bind ; (4) le chemin lié est tenu par un jeton RAII qui le supprime — sous la même identité — à l’annulation, au fauchage de fin de session, à la destruction du médiateur et sur tout échec après le bind : aucune sortie ne laisse de socket orphelin, et seul le chemin exact créé est supprimé. Supprimer en root ferait du monitor une primitive d’effacement arbitraire ; sous l’identité du compte, le pire cas est que l’utilisateur efface ce qu’il pouvait déjà effacer. L’accept, lui, se fait en root — il ne consulte aucun droit FS, le contrôle ayant eu lieu au connect du tiers contre un socket déjà user-owned — avec la contre-pression POLLOUT et le ListenerId stable du -R TCP : la boucle root ne bloque jamais. Preuves : codec + fuzz ; in-process root (socket créé uid=compte, mode 0600, type socket ; chemin occupé refusé et fichier d’autrui intact ; répertoire 0700 de root refusé avec contre-témoin — root, lui, y arrive ; annulation, fin de session et destruction du médiateur qui suppriment ; poussée nommant le chemin écouté) ; démux (tunnel Unix inverse bidirectionnel, refus locaux sans franchir la frontière de privilège) ; e2e root (tests/streamlocal_remote_forward_root.rs) où un tiers sous l’identité du compte traverse le tunnel, plus la sonde de restauration (une redirection TCP servie après la fenêtre). Zéro additif couche 0/1.
  • Fast-follow (revue #596) — PARITÉ DES GROUPES SUPPLÉMENTAIRES en streamlocal. AirTemporaryIdentity::assume vide les groupes secondaires ; la fenêtre streamlocal tournait donc avec le seul gid primaire, et refusait un socket (ou un répertoire, sens -R) que l’utilisateur atteint par un groupe secondaire — alors que le démon monolithique, qui tourne avec le jeu de groupes complet du compte (initgroups), l’accepte. Fail-closed, donc aucune escalade, mais deux politiques d’accès pour la même configuration selon la topologie du démon. L’identité temporaire du monitor porte désormais les groupes du compte : nouvelle variante AirTemporaryIdentity::assume_with_supplementary_groups (couche 1, additif, restauration fidèle des groupes précédents au Drop) et ForwardUserIdentity qui transporte les groupes (bornés, inlinés, gid primaire et groupe 0 écartés), alimentés par la même résolution initgroups que le largage de privilèges du worker. Les autres appelants d’assume (lecture d’authorized_keys) sont inchangés — groupes toujours vidés. Le forwarding TCP n’est pas concerné (aucun DAC de groupe sur un port). Preuves : in-process root et e2e root, aux deux sens, sur un montage où le groupe est la seule porte (répertoire/socket root:<groupe>, bits « autres » nuls) — avec le groupe ça passe, sans le groupe c’est refusé, avec un autre groupe c’est refusé. (Additif couche 1 accumulé, re-sceau à la complétion.)
  • V2.6 est CLOSE : forwarding privsep complet — -L/-R TCP, -L/-R socket Unix, écoutes multiples, coexistence session/SFTP. Couche 2 quasi pure : une seule retouche couche 1 — la cardinalité du tampon de préservation de air_process::close_inherited_fds_except (16 → 48), qui n’est pas une propriété de sécurité (ce qui protège est ce que l’appelant nomme) mais dimensionnait un tri sur la pile trop petit pour « tuyau clair + médiation + 32 cibles ». Aucun descellement, aucun tag.

✅ FAIT — ADR-117 (vague 2 #5) : REDIRECTIONS -L/-R, TCP et socket Unix — ensemble [A] complet. Le client air-ssh gagne son premier moteur multiplexé (canaux concurrents sur un transport partagé, calqué sur le moteur serveur tcpip_forward), et l’ensemble des redirections retenues en [A] :

  • -L TCP (redirection locale, #556) : écoute locale → canal direct-tcpip. Bind loopback-only revérifié au bind. Mode -N (forwarding-only).
  • -R TCP (redirection distante, #557) : tcpip-forward + forwarded-tcpip entrant. Anti-SSRF : un canal entrant n’est accepté que s’il matche un -R demandé+confirmé, et la connexion part uniquement vers la cible locale de la spec (jamais une adresse du serveur).
  • Durcissements (#558) : plafonds de canaux (anti-fuite fd), maximum_packet_size appliqué en réception, unicité (bind, port) des -R. Recrédit de fenêtre serveur (#561) : le pilote direct_tcpip recrédite désormais (Outbox/writer partagé) — débloque les transferts -L > 2 Mio vers un serveur Air.
  • -L socket Unix (direct-streamlocal, #563) + -R socket Unix (streamlocal-forward, #566) : le serveur ne connecte/ne crée qu’un socket Unix au chemin validé (absolu, ≤ sun_path, refus des chemins existants = pas de clobbering), politique de forwarding d’abord. Le pump est factorisé (forward_pump, partagé TCP/Unix).
  • Durcissement anti-TOCTOU (#568, ADR-144, descellement couche 0) : ajout du syscall fchmodat2 (honore AT_SYMLINK_NOFOLLOW, que le fchmodat 3-args ignore) → les perms des sockets streamlocal sont posées sans suivre un symlink ; une falsification locale est détectée (échec EOPNOTSUPP) et refusée. (Tag de re-sceau couche-0-v1.16/couche-1-v3.9 réservé à la certification BDFL.)

Chaque incrément revu en adverse (anti-SSRF, robustesse au pair hostile, aucune panique, isolation par-canal, pas d’accès fichier arbitraire), fuzzé (proto), testé e2e dont interop avec un vrai sshd/ssh-add OpenSSH. Différés actés ([ADR-117] §4) : -D (SOCKS), ControlMaster/ControlPath.

✅ FAIT — Finding sécurité (1re passe d’intrusion, dudect) : ÉNUMÉRATION DE COMPTES PAR TIMING — CORRIGÉE. Le harnais dudect_authorization (ADR-105, niveau-1 local) a révélé que is_authorized fuyait l’existence d’un compte par le temps de réponse (|t| = 295, seuil 10) → énumération possible. Pré-existant (pas introduit par air-agent). Corrigé en trois temps : comparaison constant-time (#554, air_crypto::ct_eq_bytes/subtle, sans court-circuit ; |t| 295 → 0.18) ; lecture factice sur compte inexistant (#555, un magasin leurre root-owned lu et jeté pour égaliser le travail I/O — revue de sécurité adverse OUI) ; chmod anti-TOCTOU (#568, ci-dessus). Résidu niveau-2 (timing réseau) documenté.

✅ FAIT — ADR-116 §3-c inc.c-4 (2026-08-04) : L’INTEROP RÉELLE BIDIRECTIONNELLE — et le défaut de fil qu’elle a trouvé. ADR-116 passe à fait. Les deux sens de l’agent forwarding sont prouvés contre le vrai OpenSSH, par des tests e2e — et l’interop a révélé un défaut qu’aucune des trois suites précédentes ne pouvait voir.

  • Le défaut : un vrai ssh -A envoie auth-agent-req@openssh.com avant son exec, donc pendant que le worker privsep est encore dans la couche connexion (ServiceMode::Deferred) et non dans le démultiplexeur. Cette boucle refusait la requête pour tout le monde ⇒ ssh -A vers air-sshd n’obtenait aucun SSH_AUTH_SOCK. c-2 éprouvait la médiation, c-3 un pair scripté, les unitaires la fonction de décision (correcte) : personne ne faisait passer la requête par le trajet réel d’un vrai client.
  • Le correctif, au plus près : la couche connexion retient la demande et ConnectionOutcome::Service la porte au worker, qui la présente au monitor avant de faire lancer la session (l’ordre qu’exige un SSH_AUTH_SOCK d’environnement) ; le démultiplexeur en hérite (agent_forward_granted). Aucune sémantique de sécurité ne change — le double opt-in reste intégralement opposable, et retenir n’accorde rien. Un want_reply en mode différé reste refusé (répondre « oui » sur une promesse serait mentir ; ni OpenSSH ni air-ssh n’empruntent ce chemin).
  • Direction A — ssh -A d’OpenSSH → air-sshd (tests/agent_forward_interop_root.rs, privsep réel) : le ssh-add -l qui tourne sous le compte de session liste l’empreinte d’une clé que seul l’agent du client détient. Plus les deux refus (sans -A ; sous AllowAgentForwarding no) vus depuis un vrai client. Skip propre sans root / sans _air-sshd / sans OpenSSH — donc sauté en CI, exécuté à la main.
  • Direction B — air-ssh -Asshd d’OpenSSH (tests/agent_forward_openssh_server.rs) : sans root, tourne en CI. C’est OpenSSH qui honore la requête, ouvre le canal auth-agent et laisse ssh-add -l parler à notre agent à travers notre relais.
  • Aucun assert vacant : chaque cas exige un marqueur imprimé par la session distante — sans lui, une absence d’empreinte prouverait aussi bien « le forwarding est fermé » que « la session n’a jamais tourné ». Chien de garde dur partout, processus et répertoires retirés par Drop.

✅ FAIT — ADR-116 §3-c inc.c-3 (2026-08-03) : L’AGENT FORWARDING CLIENT. air-ssh -A / --forward-agent (désactivé par défaut) demande le forwarding (auth-agent-req@openssh.com, want_reply = false — parité OpenSSH) et sert les canaux auth-agent@openssh.com que le serveur ouvre, en les proxyfiant vers l’agent ambiant du poste ($SSH_AUTH_SOCK).

  • Fermé deux fois par défaut : sans -A, aucune requête n’est émise et tout canal auth-agent entrant est refusé (OPEN_ADMINISTRATIVELY_PROHIBITED) — la seconde moitié tient même si la politique serveur est contournée.
  • Relais opaque, sans clé : aucun octet du protocole ssh-agent n’est parsé ni réécrit (bornes : fenêtre RFC 4254 §5.2, MAX_PACKET, 8 canaux concurrents, identifiants réservés dans 1..=8 — jamais celui de la session). Aucune clé privée n’entre dans air-ssh.
  • Le serveur ne choisit aucun chemin local : charge d’un auth-agent vide exigée (Principe 11 — une traîne refuse l’ouverture) ; la cible est fixée à l’invocation. Dégradation propre (avertissement, connexion poursuivie sans forwarding) si l’agent manque, si $SSH_AUTH_SOCK est inexploitable, avec -N, ou avec --air-agent — dont le socket parle le protocole natif d’Air : c’est le pont OpenSSH (inc.3b) qu’il faut exporter pour le relayer.
  • Le moteur de session client devient multiplexé (client.rs) : lecteur, écrivain sérialisé (l’Outbox du moteur -N, partagée), entrée locale, pompes d’agent intégrées à chaud. client_exec/client_shell = deux modes d’un seul moteur.
  • Prouvé : unitaires du relais (7 refus/bornes), décision CLI, frontière -N (le moteur de forwarding refuse un auth-agent), et e2e in-process (tests/agent_forward_client.rs, chien de garde dur) : opt-out, opt-in avec aller-retour ssh-agent octet pour octet, agent injoignable.
  • c-4 (interop réel bidirectionnel) est livré — cf. l’entrée ci-dessus.

✅ FAIT — ADR-116 §3-c inc.c-2 (2026-08-03) : L’AGENT FORWARDING SERVEUR. auth-agent-req@openssh.com, jusqu’ici décodé puis refusé, est servi : le monitor crée le SSH_AUTH_SOCK de la session, l’exporte dans son environnement de login, et le worker ouvre un canal auth-agent@openssh.com vers le client à chaque connexion entrante. Conception ratifiée : notes/agent-forwarding-conception-fr.md.

  • Double opt-in, fail-closed : AllowAgentForwarding (nouveau champ, capnp @22, défaut no, base-only — aucune surcharge Match) et permit-agent-forwarding sur la clé (ADR-128, déjà parsé). Le && vit dans un type (AgentForwardingPolicy::permits), pas au point de décision — l’accroche // SECURITY-MANAGER: (ADR-089, différée) s’y branchera en troisième verrou.
  • Le chemin du socket est choisi par le SERVEUR, et le protocole le rend inexprimable autrement : la RPC monitor gagne FORWARD_LISTEN_AGENT / FORWARD_ACCEPTED_AGENT, tous deux sans aucune charge. Il n’existe donc aucun champ où un client pourrait faire écrire un octet qui atteigne un bind(2). Le monitor tire /run/user/<uid>/air-agent-<32 hexa>.sock (getrandom) et ne le remonte pas au worker — la charge d’un auth-agent étant vide, il n’en a aucun usage.
  • Isolement privsep inchangé : le worker confiné ne bind/listen/accept toujours pas, et aucun syscall n’a été ajouté à sa cage. Le socket naît dans la fenêtre AirTemporaryIdentity du monitor — mot pour mot celle du -R streamlocal — donc user-owned en 0600, avec nettoyage RAII. /run/user/<uid> absent ⇒ refus, et le serveur ne le fabrique pas ; /tmp n’est jamais un repli (décision BDFL).
  • Prouvé sous root (tests/agent_forward_root.rs) : socket 0600 possédé par le compte, tunnel bidirectionnel par un tiers local sous l’identité du compte, socket disparu en fin de session — et les deux refus prouvés avec le répertoire présent, donc là où l’utilisateur écrirait sans peine.
  • c-3 (client) et c-4 (interop réel bidirectionnel) sont livrés — cf. les entrées ci-dessus. La part c d’ADR-116 est close.

✅ FAIT — ADR-116 (vague 2 #4) inc.3 : INTEROP PROTOCOLE ssh-agent OpenSSH — BIDIRECTIONNELLE, prouvée contre OpenSSH réel. La partie (b) d’ADR-116 : parler le protocole ssh-agent d’OpenSSH (SSH_AUTH_SOCK), dans les deux sens. Trois incréments :

  • inc.3a — le codec (#550) : air_agent::ssh_agent_proto, sans-IO (ADR-091). Messages REQUEST_IDENTITIES/ SIGN_REQUESTFAILURE/SUCCESS/IDENTITIES_ANSWER/SIGN_RESPONSE ; trame = préfixe uint32 de longueur inclus (protocole de flux, ≠ le SEQPACKET natif) ; bornes (MAX_MESSAGE_LEN 256 KiB, MAX_FIELD_LEN 64 KiB, MAX_IDENTITIES 256) vérifiées avant allocation ; type inconnu → erreur (fail-closed). Fuzz 2,35 M runs / 0 crash + invariant de canonicité (une trame acceptée est byte-pour-byte celle que l’encodeur aurait produite).
  • inc.3b — le pont serveur (#551) : exposer les clés d’air-agent à un ssh(1)/git classique. Cœur pur answer_openssh_request(request, keyring) + pilote mince AirOpenSshBridge (socket STREAM, SO_PEERCRED owner-only fail-closed, Drop retire le socket). Preuve interop RÉELLE : ssh-add -l/-L d’OpenSSH_10.2 liste la clé via SSH_AUTH_SOCK (empreinte SHA256: + blob -L byte-pour-byte). Additif couche 1 : AirUnixStream::peer_credentials (SO_PEERCRED sur flux) — accumulé, PAS de re-sceau (le sceau couche-1 en vigueur reste posé jusqu’à la fin d’air-sshd).
  • inc.3c — le consommateur client (#552) : air-ssh s’authentifie via un ssh-agent externe. SshAgentClientSigner (impl ClientPublickeySigner) + flag --ssh-agent ($SSH_AUTH_SOCK) / --ssh-agent-key <base64>, exclusif de -i/--key-seed/ --air-agent. take_frame factorisé dans ssh_agent_proto (une seule borne, partagée pont↔client). Preuve interop RÉELLE : air_ssh_authenticates_with_a_key_held_by_the_real_openssh_agentssh-keygenssh-agent -assh-addair-ssh s’authentifie contre un serveur Air ; la clé privée n’entre jamais dans le processus de test ; agent arrêté par ssh-agent -k sur le PID annoncé (aucun pkill). select_identity fail-closed (blob exact, jamais de repli ; 0/≥2 sans sélection → erreur).

Bilan air-agent : (a) agent natif + intégration air-ssh et (b) interop ssh-agent OpenSSH bidirectionnelle sont livrés et prouvés contre l’outillage OpenSSH réel, des deux côtés. Oracle-safe partout (on sert des signatures, jamais de clé privée ; peer-cred SO_PEERCRED fail-closed sur chaque socket). Reste (c) le forwarding d’agent, volontairement différé (opt-in, ADR-116 §3). Suivi inc.2d (mémoriser « cet hôte utilise l’agent » côté config client) toujours ouvert.

✅ FAIT — ADR-116 (vague 2 #4) inc.2 : air-ssh S’AUTHENTIFIE PAR L’AGENT — la fonctionnalité devient utilisable. Trois incréments, du plus abstrait au plus concret, chacun ne changeant rien à ce qui part sur le fil :

  • inc.2a — la couture : trait ClientPublickeySigner (air-ssh-proto) + LocalClientPublickeySigner (clé en mémoire). authenticate_client ne reçoit plus une clé privée mais un signataire. Behavior-preserving par construction.
  • inc.2b — le signataire agent : air_sshd::AgentClientPublickeySigner — liste les clés de l’agent, en sélectionne une (jamais implicitement quand c’est ambigu : plusieurs clés sans empreinte désignée ⇒ erreur, pas un tirage silencieux), délègue la signature. Preuve d’équivalence byte-exacte avec le chemin local (Ed25519 est déterministe, RFC 8032 §5.1.6) : l’agent n’est pas un second chemin d’authentification à auditer, c’est le même, clé ailleurs.
  • inc.2c — le câblage CLI : air-ssh connect --air-agent[=<socket>] [--agent-key <SHA256:…>]. Le socket par défaut suit la même règle que la CLI air-agent ($XDG_RUNTIME_DIR, repli /run/user/<uid>) ; le socket détaché n’est accepté qu’absolu, pour qu’un [SOCKET] optionnel n’avale jamais la cible user@host. --air-agent est exclusif de -i/--key-seed (deux sources d’identité, c’est une ambiguïté sur qui signe — on refuse plutôt que de choisir l’identité de l’humain à sa place). La source d’identité devient un type somme explicite (ClientIdentity), le signataire actif un enum de dispatch (ActiveClientSigner) : un seul chemin d’authentification, quelle que soit la provenance de la signature — et la graine ne devient une clé en mémoire que dans la branche locale. Preuve qui compte (inc.2c) : client_authenticates_against_air_server_through_the_agent — auth publickey réelle contre un serveur Air (socketpair, tout en process sauf le réseau), signée par un agent en processus qui détient la seule copie de la clé, contre la même clé publique autorisée que les tests locaux. L’équivalence d’octets (2b) ne prouvait pas qu’un serveur accepte ; celle-ci le prouve. Couche 2 pure : aucun descellement, aucun tag.Suivi inc.2d — mémoriser « cet hôte utilise l’agent » dans la config client : volontairement non fait ici. Ce n’est pas une ligne : le format est un artefact Cap’n Proto versionné (ssh_config.capnp) ⇒ regénération du schéma, HostBlock/ SshClientSettings, parse/write, export/import JSON, verbes config set, fuzz et tests de round-trip. À traiter comme un incrément à part entière, pas en marge d’un câblage CLI.

✅ FAIT — ADR-116 (vague 2 #4) inc.1 : air-agent, le SERVICE agent SSH keystore-backed — modèle ssh-add, SO_PEERCRED, protocole natif fuzzé. Nouveau crate couche 2 air-agent (binaire + lib, no_std+alloc, zéro dépendance externe hors zeroize déjà au workspace), qui détient les clés utilisateur déchiffrées en mémoire et signe à la demande — le pendant utilisateur d’air-keysign, même motif oracle-safe. Décision BDFL : les clés entrent À LA DEMANDE (air-agent add <fichier>), pas au démarrage : un agent fraîchement lancé ne détient rien.

  • Protocole natif sans-IO (proto, motif [ADR-091]) sur Unix SOCK_SEQPACKET (un datagramme = un message, aucun framing applicatif) : 5 requêtes (AddKey/RemoveKey/RemoveAll/ListKeys/Sign), 5 réponses (Added/Removed/Keys/Signature/Failure) ; bornes explicites MAX_FIELD_LEN=16 KiB, MAX_MESSAGE_LEN=256 KiB, MAX_KEYS=256 ; refus muet (code figé, aucun détail) et fail-closed (un code d’échec inconnu est rejeté, jamais replié sur un défaut). Aucune réponse ne porte de matériel privé : le seul secret du fil est l’ENTRÉE d’AddKey. Fuzz fuzz_air_agent_proto — 12,1 M runs, 0 crash.
  • Oracle-safe : SO_PEERCRED. À chaque connexion, l’agent demande au noyau qui est le pair et refuse en silence quiconque n’est pas son propriétaire (uid réel, égalité stricte — pas d’exception root). Le socket 0600 dans $XDG_RUNTIME_DIR (repli /run/user/<uid>) est de la défense en profondeur ; c’est ce contrôle-ci qui fait foi. Éprouvé sans second compte : un agent lié à un uid qui n’est pas le nôtre nous refuse — même if, mêmes credentials noyau.
  • Zeroize ([ADR-034]) : clés sous AirSshKeyPair (ZeroizeOnDrop) — retrait et arrêt effacent ; le tampon de réception, qui voit passer la clé privée d’AddKey, est effacé après chaque message ; le Drop du service retire le socket même sur chemin d’erreur. Un client hostile ne tue pas l’agent : une connexion fautive est abandonnée, les clés restent.
  • CLI air-agent daemon|add|list|remove|remove-all (parsing manuel, run rend un CliResult ⇒ testable en process contre un vrai agent). list ne montre qu’empreintes, clés publiques et commentaires. Packaging : paquet air-agent (depends: air-keystore) — le regroupement client air-ssh+air-agent en un seul .deb (directive BDFL 2026-07-27) reste un refactor séparé, hors inc.1.
  • Additif couche 1 (minimal, requis par l’ADR) : AirUnixSeqpacket::peer_credentials (SO_PEERCRED) dans air-socket — la couche 1 ne l’exposait pas, et le contrôle d’appelant d’ADR-116 §1 est inatteignable sans lui (la couche 0 est hors de portée d’une crate couche 2). Deux tests dédiés ; le module l’annonçait déjà comme additif « au besoin, typé ». Spec docs/specs/layer-1/air-socket.md mise à jour.
  • HORS PÉRIMÈTRE inc.1 (inchangé) : intégration air-ssh = inc.2 ; codec du protocole ssh-agent / SSH_AUTH_SOCK = inc.2b ; agent forwarding = inc.3 ; unité systemd user et mode JSON = différés.

✅ FAIT — ADR-135 (descellement ADDITIF couche-1-v3.5 : le duplicateur fermait, lui aussi, le descripteur neuf). Corrige le défaut ci-dessus, jumeau d’ADR-131 §3 dont il achève la correction : le registre air-handle a deux portes d’entrée (register et duplicate), et ADR-131 n’en avait corrigé qu’une. Correctif d’une ligne — if let Some(stale) = table.insert(...) { core::mem::forget(stale); } — posé sur l’insertion commune aux deux voies de duplicate (la voie dup3 évinçait déjà sa victime en amont, ADR-069), ce qui ferme la question pour toute voie future. Aucun symbole ajouté, aucun retrait, aucune signature modifiée : c’est une correction du comportement d’une primitive scellée (une fermeture qui avait lieu n’a plus lieu), remontée à ce titre au superviseur. Preuves, les deux vérifiées ROUGES sans le correctif — ① unitaire, duplicating_onto_a_stale_number_does_not_close_the_fresh_fd (air-handle) : une entrée périmée est fabriquée pour un numéro fermé hors du registre, duplicate sans cible le réattribue, et le descripteur rendu doit survivre — EBADF sans le forget ; ② bout-en-bout root sur le vrai binaire, migrate_privsep_root : l’assertion de sortie de la commande distante, laissée en eprintln! par ADR-134 en attente du correctif, est désormais active — sans le correctif, stdout du client est vide (mesuré) ; avec, la commande rend son marqueur sous l’uid du compte de session (donc non-root). C’est le dernier blocage fonctionnel du déploiement 0.3.0. ⏳ Tag de re-sceau couche-1-v3.5 à poser par le superviseur (pas par cet ADR).

✅ FAIT — air-account : prompt de mot de passe + écho coupé (défaut UX/sécurité signalé par le BDFL sur machine réelle, 2026-07-29). Le CLI lisait le mot de passe administrateur (ADR-101) sur stdin sans imprimer de prompt : en interactif la commande semblait se figer ; et stdin restant en mode canonique avec ECHO, le mot de passe s’affichait à la frappe. Deux défauts, un seul correctif. Le lecteur injecté devient Fn(&str) -> Result<Vec<u8>, String> (le &str = libellé du prompt), donc contextuel — indispensable puisque passwd lit deux mots de passe : « Mot de passe administrateur : » puis « Nouveau mot de passe pour ‘’ : ». Nouveau module air-account-cli/src/password.rs (read_password_from), deux régimes : ① stdin est un terminal ⇒ libellé sur stderr (jamais stdout, qui porte --json/list/show et peut être redirigé) + ECHO retiré des drapeaux locaux via air-terminal (couche 1, ADR-060 — tcgetattr/tcsetattr, arête 2→1) et restauré par Drop y compris sur erreur ou panique, TCSAFLUSH purgeant l’entrée pré-frappée (sémantique getpass(3)) ; ② stdin non-tty (pipe, script, test) ⇒ comportement d’origine strictement inchangé : une ligne lue, aucun prompt, aucun ioctl. Preuves : le régime terminal est exercé en process sur un PTY réel (air_terminal::openpty) — une source de lecture espionne interroge tcgetattr au momentread_until lui demande des octets, prouvant sans course que l’écho était bien coupé pendant la saisie, puis restauré après ; non-régression du piping prouvée (puits d’écriture vide) ; libellés vérifiés côté cli.rs (deux saisies : admin puis nouveau) ; et le vrai binaire validé sous PTY (deux prompts visibles, aucun des deux secrets ré-affiché, stderr vide en piping). Couche 2 pure : aucun descellement, aucun ADR, aucun tag. main.rs reste la plomberie exclue de couverture (câblage fd 0 / stderr) ; toute la logique est dans password.rs, mesuré.

✅ FAIT — air-sshd : le monitor journalise la RAISON d’un refus d’autorisation (diagnostic StrictModes, défaut signalé par le BDFL en production 2026-07-29). Sous privsep, le monitor calculait déjà une raison précise (AuthorizeRefusal) à chaque refus, et la jetait : le fil recevait un AuthorizeDeny muet (correct, anti-énumération RFC 4252/U.4) et le journal serveur ne montrait qu’« échec d’authentification ». Diagnostiquer un Store(LaxPermissions) — répertoire parent d’authorized_keys inscriptible par le groupe, écarté comme le fait OpenSSH — a coûté ~10 étapes, alors qu’OpenSSH écrit « Authentication refused: bad ownership or modes for directory … ». Correctif en trois pièces : ① Display actionnable par variante — StoreRefusal en a désormais un (7 variantes : cause + geste, chmod 700/chown…) et AuthorizeRefusal l’embarque (« magasin authorized_keys écarté — … ») au lieu d’imprimer le Debug du code ; ② MonitorAuthorizer::authorize_with_reason — l’unique porte par laquelle la raison sort de la décision, sans toucher la réponse du fil (authorize y délègue, comptage inchangé) ; ③ SessionPolicy::authorize (le seul étage qui détient et la raison et le puits de log) émet AirOperationalLog::auth_refused — nouvelle méthode du trait ADR-111 d — avant l’auth_failed existant, à la manière d’OpenSSH. La raison est dans le MESSAGE (autorisation refusée pour <user> depuis <ip> : <raison>, journalctl n’affiche que lui) et en champ AIR_REASON (filtrable). Rien ne change sur le fil : AuthorizeRequest/MonitorResponse intouchés — AuthorizeDeny n’a aucun champ où loger une raison, l’anti-énumération tient donc par le type, pas par la discipline de l’appelant. Émission best-effort (le trait ne rend rien : un journal muet n’ouvre ni ne ferme de session). Preuves, rouges sans le correctif : refus journalisé avec compte + pair pour UnknownUser et trois causes de magasin (UnsafePath, Unreachable, NotARegularFile) via le magasin système (lu en root, sans abaissement) ; test root a_store_kept_out_by_strict_modes_is_explained_in_the_journal rejouant le cas de production (magasin conforme sous un parent 0770) ; anti-fuite : deux refus de causes différentes produisent le même datagramme encodé, et aucun fragment de la raison ne s’y trouve ; table des libellés par variante (les deux enums). Couche 2 pure : aucun descellement, aucun ADR, aucun tag.

✅ FAIT — ADR-114 (vague 2 #2) : extraction d’air-json, le codec JSON possédé, en crate couche 1 partagée — descellement ADDITIF ADR-136. Le codec JSON maison né dans air-sshd/src/json.rs (E.6, ADR-099) était enfermé dans un démon SSH alors que la CLI air-keystore (ADR-115, vague 2 #3) a besoin du même codec pour son mode JSON agent-safe (entrée sur stdin, sortie structurée : zéro piège de quoting shell pour un outil ou un agent). Nouvelle crate air-json (couche 1, no_std+alloc, pur codec zéro I/O, zéro dépendance externe, unique arête 1→1 vers air-base-core) : AirJsonValue (variantes RFC 8259, Number = i64), accesseurs, sérialiseur compact, parse(&[u8]), et la borne publique MAX_DEPTH. Erreurs → AirError (ADR-019, kind InvalidData, message qui localise l’octet fautif) au lieu de l’enum maison JsonError : l’appelant couche 2, qui mêle déjà I/O et parsing dans le même Result, n’a plus aucune conversion à écrire. air-sshd cesse d’héberger le module (src/json.rs supprimé) et dépend d’air-json ; air-sshd/air-ssh config export|import recâblés, non-régression E.6 verte sans toucher aux tests (config_json.rs, config_client_json.rs inchangés). Barre couche 1 tenue : couverture 100 % lignes ET branches per-crate — obtenue en retirant les bras inatteignables de la forme d’origine (un itoa maison dont l’unwrap_or(0) ne pouvait jamais servir, un get(..n) dont le None était impossible : de la prudence d’apparence, pas d’effet) au profit de formes dont chaque chemin d’erreur est atteint par un test ; fuzz fuzz_air_json_parse (renommée depuis fuzz_air_sshd_json) ; proptest parse ∘ serialize = identité + point fixe + « octets arbitraires ⇒ toujours un Result ». Périmètre INCHANGÉ (entiers, pas de flottants — Principe 5 ; BMP ; zéros de tête et clés dupliquées tolérés et documentés, les durcir serait un changement de comportement). Pas de serde_json en production (ADR-024/088/073) ; serde reste permis dev/test-only (ADR-030) et n’est tiré nulle part. ⏳ Tag de re-sceau couche-1-v3.6 à poser par le superviseur (pas par cet ADR). ⚠️ Remonté, non embarqué : air-account-cli garde son propre module JSON — un écrivain d’octets non-UTF-8 (noms de compte, chemins), contrat qu’air-json ne sait pas rendre aujourd’hui (AirJsonValue::String porte un String UTF-8). Une consolidation exigerait une extension de périmètre d’air-json, donc une décision — à instruire quand la CLI air-keystore aura montré le besoin réel.

📦 Packaging (directive BDFL 2026-07-27) : air-agent sera livré avec air-ssh dans un seul .deb versionné (client) ; air-sshd = son propre .deb indépendant (serveur). cargo xtask deb devra produire deux paquets, jamais fusionnés.

✅ CHANTIER CLOS — COMPLÉTION DU PAL : plus aucun unsupported (DIRECTIVE BDFL 2026-07-25). Clos par la migration std linux-air (tag migration-std-linux-air). Tout ce qui restait en repli unsupported dans la std linux-air a été supporté/implémenté. Origine : question BDFL sur getcwd/chdir, dont la réponse a révélé un motif — la capacité existe en couche 1 et dans la libc C-ABI, seul le binding vers std manque. Ce n’est donc pas un manque de fonctionnalité d’Air, c’est du câblage non écrit : le PAL a été branché au fil des besoins réels (les 5 correctifs révélés par le déploiement de juillet — sys/args, sys/time… — sont le même motif), et ce que personne n’avait encore appelé n’a jamais été relié. Point de départ VÉRIFIÉ (audit du patch rt/rust-src-air/air-pal.patch) : familles déjà pourvues d’un bras air réel = alloc, args, env, fd, fs, io/error, net/connection, paths, pipe, process, random, stdio, thread, time. Repli unsupported identifié : dans sys/paths, seul current_exe est réelchdir, getcwd, home_dir, temp_dir, split_paths, join_paths (+ SplitPaths / JoinPathsError) retombent tous sur unsupported. thread_local/mod.rs est touché par le patch sans bras air : à auditer. Conséquence concrète aujourd’hui : std::env::current_dir() rend Unsupported sur *-linux-air alors que AirFileSystem::current_directory() fonctionne dans le même binaire. Impact actuel : NUL — vérifié, aucun code livré n’appelle ces fonctions (les seules occurrences de std::env::temp_dir() sont dans du code de test, qui tourne sur l’hôte gnu). Le gap est réel mais dormant ; le chantier le referme avant qu’il ne morde. Méthode : (1) audit exhaustif du patch → liste complète des unsupported famille par famille (ne pas se fier à la seule famille paths trouvée ici) ; (2) pour chaque entrée, binder la couche 1 (jamais la couche 0 — règle dure, précédent #417) ; (3) un manque en couche 1 = additif couche 1 d’abord ; (4) preuve on-target par famille. temp_dir/home_dir sont triviaux (lecture de TMPDIR/HOME) ; chdir/getcwd sont un binding direct sur AirFileSystem::change_directory / current_directory, qui existent déjà. ⏱ ÉLARGISSEMENT (directive BDFL 2026-07-25, même jour) — le critère de réussite devient : LA SUITE DE TESTS TOURNE SUR NOTRE std. Règle posée : aucun code Rust d’Air, tests compris, ne s’exécute contre la glibc — donc contre la std de la toolchain. ADR-103 §2 amendé en conséquence : l’autorisation « banc de test hôte » est retirée ; la std gnu reste non-livrable, mais son usage comme harnais devient un état transitoire à résorber, plus une position tenable. Inventaire factuel : 3 417 tests unitaires + 268 tests d’intégration sur 69 crates tournent aujourd’hui sur gnu, via trois invocations CI sans --target (cargo test --workspace dans test-coverage et aarch64-native, cargo llvm-cov --workspace) ; seul le harnais rt/ (selftest + démonstrateurs hello-*) s’exécute sur notre std, et ce sont des exécutables de preuve, pas une suite de tests. Ce que l’écart a DÉJÀ coûté : le test air-process::drop_to_user_resolves_and_drops_under_root s’interbloque sous couverture — il clone3 puis alloue dans l’enfant ; sur notre cible air_alloc::reset_after_fork (ADR-056 D8) remet le futex de l’arène à UNLOCKED et le cas est résolu, mais sur l’hôte gnu l’allocateur est celui de la glibc, clone3 en syscall brut court-circuite ses gestionnaires pthread_atfork, et rien ne réinitialise ses verrous. Le défaut n’existe que parce que le test tourne contre la glibc — il disparaît sur la cible. Portée supplémentaire : faire fonctionner libtest sur notre std (std::process, std::thread, std::io, affichage, codes de sortie) et rendre cargo test --target *-unknown-linux-air capable d’exécuter les binaires produits, puis basculer les invocations CI sur --target. Ordre suggéré : compléter les unsupported (dont paths) → prouver libtest sur cible → basculer la CI famille par famille, en gardant gnu en parallèle le temps de la transition (une bascule d’un bloc perdrait la capacité de diagnostiquer).

⏱ 2026-07-26 — CHANTIER COMPLÉTION PAL, INCRÉMENT 1a : paths COMPLÉTÉ + correctif is_absolute, PROUVÉ ON-TARGET (x86_64, exit 0 ; aarch64 cross-compile). Premier incrément du chantier « tous les crates sur NOTRE std ». (1) sys/paths complet : les 6 fonctions + 2 types qui retombaient sur unsupported sont réelles — getcwd/chdir bindent air-filesystem::current_directory/change_directory (couche 1, existants), home_dir/temp_dir lisent HOME/TMPDIR, split_paths/ join_paths sont de la logique pure (séparateur :). paths/mod.rs route désormais use air as imp (plus aucun repli). (2) Bug Path::is_absolute() trouvé et corrigé : sys/path/unix.rs::is_absolute faisait if cfg!(any(unix, hermit, wasi, motor)) — Air (non-unix, ADR-106) tombait dans la branche « préfixe Windows » → /foo non absolu. Correctif : target_os = "air" ajouté (Air = userland Linux, / ⇔ absolu). Classe de findings que la migration révèle : du code std gated sur cfg(unix) se comporte différemment sur Air. (3) Preuve : crate rt/crates/hello-paths exerce les 6 fonctions

  • is_absoluteexit 0 en natif sur speedy (static-pie, zéro glibc) ; cross-build aarch64 OK. Round-trip du patch (air-pal.patch régénéré au format d’origine) vérifié. Leçon opérationnelle : build-std cache std agressivement et ne re-détecte PAS les éditions du rust-src vendoré → cargo clean obligatoire pour forcer sa reconstruction (à intégrer au cache d’ADR-107). Reste paths-adjacent : les 5 verrous fs (flock, couche 0 déjà présente) — différés (peu exercés).

⏱ INCRÉMENT 1b — libtest PROUVÉ SUR CIBLE. cargo test --target x86_64-unknown-linux-air compile ET exécute le harnais de test std sur NOTRE std, nativement sur speedy (static-pie). Crate rt/crates/air-libtest-probe : 3 #[test] (dont un exerçant is_absolute/current_dir) → 3 passed. Recette : build-std=["std","panic_abort", "test"] + panic-abort-tests = true + dép air-std-entry gated cfg(target_os="air") (fournit _start ; libtest fournit main) + #![feature(restricted_std)]. Arche identique ⇒ exécution directe (pas de runner). Comportement panic=abort confirmé : panic-abort-tests permet de bâtir le harnais mais un test qui échoue avorte tout le binaire (SIGILL, core dump au plus près — choix assumé) ; pas d’isolation par sous-processus → diagnostiquer en relançant le test/crate seul.

⏱ INCRÉMENT 2 — HARNAIS cargo xtask test-air : la migration d’un crate = UNE commande, ZÉRO édition par crate. Découvertes qui simplifient tout : (1) restricted_std PAS requis (le patch a déjà ajouté air à la liste « std complet » de std/build.rs). (2) Le _start s’injecte au link final seulement via RUSTFLAGS/RUSTDOCFLAGS = -Clink-arg=<_start.o> (objet extrait d’air-std-entry compilé pour la cible) — ça n’affecte pas la compilation des rlibs build-std (core/std intacts) et ne touche AUCUN crate (les 69 fardeaux dev-dep/feature évités). cargo xtask test-air [-p <crate>] [--clean] encapsule : vendor + build _start.o + cargo +nightly test --target linux-air -Zbuild-std=std,panic_abort,test -Zpanic-abort-tests. Prouvé : test-air -p air-random3 unit + 1 doctest verts sur notre std, natif sur speedy.

⏱ INCRÉMENT 3 — BACKEND getrandom custom pour os=air (air-getrandom-shim). En chaînant test-air, 1er mur : proptest (dev-dep quasi universelle) → randgetrandom 0.3.4 refuse os=air (« target not supported »). Solution transverse (pas 69 corrections) : rt/crates/air-getrandom-shim fournit __getrandom_v03_custom (backend custom getrandom 0.3) via un syscall getrandom(2) inline (asm x86_64/aarch64, aucun symbole externe) ⇒ objet auto-contenu en release, injecté au link comme _start.o. test-air le bâtit+extrait+ injecte automatiquement + pose --cfg getrandom_backend="custom". getrandom débloqué (l’erreur disparaît). air-random reste vert avec le shim câblé. ⚠️ MUR SUIVANT (décision BDFL requise) — l’écosystème proptest présume POSIX. Après getrandom, proptest tire wait-timeout (et probablement rusty-fork) gated cfg(unix) → échec sur os=air (Air non-unix, ADR-106). Ce n’est pas notre code : c’est la pile de test tierce. Option recommandée : proptest en default-features = false (workspace) pour larguer fork/timeout (donc wait-timeout/rusty-fork) — le property testing cœur reste ; impact : perte de l’isolation fork/timeout aussi sur gnu (mineur, cohérent avec panic=abort sans isolation). Alternative : forker/shimmer chaque dep POSIX. À trancher avant de poursuivre le chaînage des crates qui utilisent proptest.

⏱ INCRÉMENT 4 — DIAGNOSTIC CORRIGÉ + FORK proptest (la bonne direction). Un aller-retour a clarifié où est vraiment la complexité : elle n’est PAS dans Air et la toolchain de test n’impose PAS POSIX (libtest est neutre — prouvé). Le mur venait de crates tiers qui ne connaissent pas la cible Air : proptest fait le raccourci faux not(windows) ⇒ unix → tire std::os::unix (OsStringExt::from_vec). J’avais d’abord exposé os::unix::ffi dans la std d’Air — mauvais réflexe (déformer Air pour une présomption fausse d’un tiers). REVERTÉ (commit b1d4da6, cfg(unix) reste false, std d’Air pure). Bonne approche (décision BDFL) : patcher la DEP, pas la std — cohérent avec la doctrine « Air possède/ patche ses deps » (ADR-024/030). LIVRÉ : fork proptest vendoré+patché (modèle bitflags) : rt/vendor-deps/proptest-air.patch (branche Air via l’API octets neutre OsStr::from_encoded_bytes_unchecked ; masques flottants fully-qualified — proptest vs nightly récent ; features par défaut allégées : fork/timeout largués → plus de wait-timeout/rusty-fork/tempfile) + apply-proptest-air.sh (regen depuis le registry, vendor gitignoré). xtask test-air l’active uniquement via --config patch.crates-iola barrière gnu garde le proptest d’origine. RÉSULTAT : test-air -p air-sys-types → 201 tests tournent sur Air, std pure, proptest patché auto-régénéré. Seul échec : le #[should_panic] (limite inhérente panic=abort — panique attendue avorte le process, pas d’unwinding pour la rattraper). DÉCISION EN ATTENTE : sort des #[should_panic] sur cible (gater cfg(not(target_os="air")) ? compteur à part ? adapter ?). Puis chaînage crate par crate (chaque dep tierce inconnue d’Air = un petit patch vendoré, modèle établi).

⏱ 2026-07-27 — INCRÉMENT 5 : air-sys-syscall VERT SUR NOTRE std (le crate le plus dur, couche 0) + un VRAI DÉFAUT DE TEST révélé par la migration. Résultat : test-air -p air-sys-syscall491 passed / 16 ignored / 0 failed (unit) + 78 (intégration) + 4 doctests, exit 0. Barrière gnu intacte (815 + 78 + 4, clippy propre). La couche 0 tourne donc entièrement sur la std d’Air (air-sys-types 201, air-sys-syscall 573).

(a) FINDING — le test clock_nanosleep_relative_is_interruptible_by_signal ne testait rien sur gnu. Symptôme sur cible : SIGSEGV (si_code = SI_KERNEL, si_addr = 0) — la signature exacte d’un force_sigsegv du noyau quand il n’arrive pas à monter la trame de signal. Cause : le helper de test TestSignalHandler refaisait un rt_sigaction brut à la main avec sa_flags = 0, donc sans SA_RESTORER — obligatoire sur x86_64 quand aucune libc ne fournit le trampoline de retour de handler. Pourquoi gnu ne le voyait pas : la std de la toolchain installe son propre handler SIGSEGV (détection de débordement de pile) qui absorbe la faute synthétique et laisse le programme continuer ; le test passait alors que le handler de test ne s’exécutait jamais (mesuré sur gnu par un probe dédié : 0 invocation sans SA_RESTORER, 1 avec). Sur Air, sys/stack_overflow est un stub assumé : rien n’absorbe, le défaut devient visible. Correctif (pas un gate) : le helper est rebâti sur l’API de production du crate mêmesignal::async_handler::install / restore, qui posent déjà SA_RESTORER + trampoline rt_sigreturn sur x86_64 et passent par le VDSO sur aarch64. Le test vérifie désormais réellement ce qu’il annonce, sur les deux cibles, et ~140 lignes de rt_sigaction asm dupliqué disparaissent du module de test. C’est le dividende attendu du chantier : la std d’Air, plus mince, ne masque pas les défauts que la glibc + la std gnu masquaient (2e cas après Path::is_absolute).

(b) PIÈGE D’OUTILLAGE CORRIGÉ — vendor rust-src périmé, silencieusement. xtask test-air ne régénérait le rust-src patché que s’il était absent : un vendor antérieur à l’incrément 1a faisait tourner toute la suite contre une std obsolète, où sys/paths retombait encore sur unsupported (un temp_dir() de test paniquait « no filesystem on this platform » — diagnostic trompeur, il accuse la std, pas le crate). ensure_vendor compare maintenant la date d’air-pal.patch à celle du vendor, régénère et purge le target/ d’office si le patch est plus récent.

(c) Politique appliquée sur les 16 ignored : #[should_panic] / catch_unwind / thread qui panique / abort() en enfant → gatés #[cfg(not(target_os = "air"))] avec motif explicite (panic=abort : panique non rattrapable). Oracles POSIX (UnixStream/ UnixListener, OpenOptionsExt::custom_flags) → gatés de même. Octets : .as_bytes() sur OsStr.as_encoded_bytes() (API stable os-neutre, aucun cfg). La barrière gnu n’est jamais touchée : tout gate est inerte hors cible.

⏱ 2026-07-27 — INCRÉMENT 6 : COUCHE 1 ENTIÈRE (28 crates) SUR NOTRE std. Balayage systématique test-air crate par crate. 20 des 28 crates étaient verts d’embléeair-alloc, air-env, air-handle, air-libm, air-memory, air-random, air-poll, air-shm, air-signal, air-socket, air-stdio, air-system, air-terminal, air-runtime, air-crypto, air-config-compile, air-config-schema, air-account, air-account-schema, air-base-lib : zéro édition. Ce chiffre est le vrai résultat de l’incrément : le PAL d’Air est assez complet pour que la couche 1 n’ait, en très large majorité, rien de spécifique à faire. Les 8 restants et leur traitement :

crateaccroctraitement
air-base-coreSIGSEGV sur sleep_until_retries_after_eintrmême défaut SA_RESTORER qu’en couche 0, copié-collé → rebâti sur async_handler
air-threadAirThreadBuilder configure out sur cible ; 3 tests de non-poisoningharnais coexistence.rs gaté sous le cfg de l’API qu’il exerce ; tests à panique délibérée gatés (panic=abort)
air-uringTcpListener: IntoRawFd absenthelper reborné sur AsRawFd + forgetos-neutre, le PAL n’expose que les impls emprunteuses (différé assumé, ADR-088)
air-processPermissionsExt::set_modefchmodat (couche 0)
air-filesystemsymlink/PermissionsExt/MetadataExt/UnixListener4 helpers os-neutres (symlinkat/fchmodat/statx/mknodat FIFO) en tête du fichier de test
air-deviceOsStrExt + 4 symlinkas_encoded_bytes + helper symlinkat
air-config6 OsStrExt/PermissionsExt (dont un exemple)UTF-8 explicite + fchmodat
air-std-entryduplicate symbol: _start au linkharnais : test-air n’injecte plus _start.o quand la crate testée est celle qui le définit

Ce que la migration a appris, au-delà des chiffres. (1) Le défaut SA_RESTORER était dupliqué en couche 0 et en couche 1 — deux copies du même helper de test artisanal, deux fois faux, deux fois masqués par gnu. Les deux sont maintenant bâtis sur l’API de production, qui, elle, était correcte depuis le début. (2) La plupart des « accrocs » ne sont pas des manques d’Air mais du code de test écrit en réflexe POSIX là où l’API d’Air existait déjà : on a donc récupéré la couverture au lieu de la gater, en réécrivant sur les syscalls d’Air (dogfooding). Les seuls vrais gates restants sont ceux que panic=abort impose (panique délibérée non rattrapable) et une API délibérément absente sur cible.

⏱ 2026-07-27 — INCRÉMENT 7 : COUCHE 2 ENTIÈRE (41 crates) SUR NOTRE std. Même méthode. 33 des 41 verts d’emblée, zéro édition (dont tous les air-libc-* sauf trois, air-com*, air-object/-c, air-value/-c, air-service, air-notifyd, air-ssh-proto, air-sftp-proto, air-base-c/-capi, air-sshd-schema). Les 8 restants :

crateaccroctraitement
air-account-cli, air-libc-fileio, air-sshdoracles PermissionsExt/MetadataExt/FileTypeExt, symlinkoracles relus par la couche 1 (AirFileManager::metadata/change_mode/symbolic_link) — os-neutre, et plus franc : l’oracle n’emprunte rien à l’implémentation testée
air-asyncUnixStream::pair, IntoRawFdair-socket::socket_pair en dev-dep (2→1 conforme) : rend directement des OwnedFd, le détour de conversion disparaît ; helper reborné sur AsRawFd + forget
air-libc-capiduplicate symbol: air_mainle bridge air_main (pour un programme C) est gaté not(test) : le binaire de test de la crate est un programme Rust, dont l’entrée est l’air_main de la std
air-libc-threadpthread_create/join/detach/equal absents ; feature(thread_local)tests gatés sous le cfg de l’API exercée (cycle de vie = hôte, le spawn Air-natif est un chantier distinct) ; le not(test) parasite du cfg_attr(feature(thread_local)) retiré
air-libc-cExitStatusExt::signalseule l’assertion sur le numéro de signal est gatée ; la mort par signal reste prouvée par code().is_none() (API neutre). Harnais C hôte, sauté sur cible de toute façon
air-object-macrostrybuild : « could not find specification for target »gaté hôte : trybuild relance rustc avec le triple par son nom, or la cible d’Air est un JSON. Limite de trybuild, pas un écart d’Air — et une proc-macro est toujours compilée pour l’hôte

⚠️ DÉFAUT RÉEL TROUVÉ ET CORRIGÉ — extraction d’OwnedFd d’un type réseau std. Le raccourci « évident » pour contourner l’absence d’IntoRawFd sur cible — as_raw_fd() + mem::forget — est unsound sur Air : le fd sort du TcpListener en laissant son entrée orpheline au registre air-handle ; quand un socket ultérieur réutilise ce numéro, air_handle::register écrase l’entrée périmée, dont le Drop ferme le fd fraîchement ouvert. Symptôme observé : un bind qui rend EBADF une dizaine de tests plus loin. C’est exactement la raison pour laquelle le PAL n’expose que les impls os::fd emprunteuses pour les types réseau (les propriétaires exigeraient de désenregistrer — différé, ADR-088). Correctif : les sockets de test sont désormais créés par air-socket (couche 2) ou la couche 0 (air-uring, couche 1), dont into_owned_fd désenregistre proprement. Sur gnu le raccourci est inoffensif : seul le passage sur notre std pouvait révéler ce défaut — 3ᵉ cas après Path::is_absolute et SA_RESTORER.

Deux points remontés (pas de décision prise ici). (1) Les impls os::fd propriétaires des types réseau (IntoRawFd/FromRawFd/From<_> for OwnedFd) restent différées — le contournement ci-dessus montre qu’il vaut mieux les implémenter proprement (register/ unregister) que laisser chacun improviser. (2) Le crochet de destruction TLS à la sortie de thread sur cible manque toujours (air-libc-thread le REMONTAIT déjà) : l’assertion correspondante est la seule du chantier gatée pour cause de fonctionnalité absente.

Bilan du chantier — la suite entière tourne sur notre std. Couche 0 : 2/2. Couche 1 : 28/28. Couche 2 : 41/41, plus xtask. 71 crates, soit 53 verts sans la moindre édition (75 %). Aucun descellement de couche, aucun ajout de os::unix ni de compat POSIX au PAL : la std d’Air reste pure et cfg(unix) reste false (ADR-106). Deux forks de deps tierces seulement (proptest, backend getrandom), tous deux confinés au --config de test-air. Les gates posés se rangent en trois familles, toutes documentées sur place : panic=abort (panique non rattrapable), API délibérément absente sur cible (sous le cfg de l’API elle-même), et harnais hôte par construction (toolchain C, trybuild).

⏱ 2026-07-26 — ADR-107 ÉCRIT (Accepté, décision BDFL) : le CADRE CI/CD de ce chantier. La discussion « écarts OpenSSH » (serveur + client) a été triée en ~11 ADR répartis en 2 vagues (V1 : air-keystore → certs+CA → KEX ML-KEM → air-log → air-sandbox ; V2 : le reste). Avant d’attaquer la vague 1, le BDFL a demandé de figer le pipeline pour que les nouveaux crates naissent conformes → ADR-107 (docs/adrs/ADR-107-...). Décisions normatives : (1) gates sur la std linux-air, plus gnu — c’est le cadre du critère « la suite tourne sur NOTRE std » ci-dessus (l’ordre suggéré y est intégré : bascule --target famille par famille) ; (2) raspi-srv-2 cesse de compilercross-build x86 (carbon/speedy) + exécution native sur le Pi via target.<triple>.runner (transfert+exécution SSH d’un binaire static-pie autonome ; trivial car C-free, dividende d’ADR-088) ; (3) deux niveaux de gate — PR (crates affectés, x86, rapide, Pi non sollicité) / merge-queue (full + natif aarch64 + couverture 100 % + repro) ; (4) caches build-std/sccache/target/ chaud (sûrs car reproductibles, ADR-025) ; (5) jobs dédiés dudect (constant-time, 2 arches), fuzz deux étages (smoke PR / profond nightly), privilégié isolé sur runner jetable. Répond directement à la préoccupation BDFL « qu’une PR ne reconstruise pas tout, surtout pas sur le Pi ». Non implémenté (ADR = décisions) : l’incrément CI (ci.yml + ci/run-on-target.sh) reste à produire. SUMMARY + registre (v3.42) à jour, mdbook build vert.

⏱ 2026-07-25 — OUVERTURE CHANTIER air-sshd phase 4 : SFTP v3 + authorized_keys administrables (ADR-104, PROPOSÉ — en attente de ratification BDFL). Directive BDFL : (1) SFTP servi in-process (pas d’exécutable air-sftp-server séparé), v3 strict (cible d’interop = client sftp OpenSSH, depuis un Mac) ; (2) air-sshd doit résoudre les clés comme un serveur OpenSSH — magasin de l’utilisateur et magasin système — mais dans l’arbre Air (jamais $HOME/.ssh/), avec import des clés dans l’artefact binaire plutôt que recopie de fichiers en clair. Constat d’inventaire : le volet (2) est à moitié bâti — ADR-096 a figé la cascade et FileAuthorizedKeys::with_system_file existe et est testé, mais il manque l’écrivain (le magasin ne se peuple que par un helper de test ⇒ fonctionnalité inutilisable en pratique) et le chemin du magasin système n’est pas dans le schéma sshd-config.capnp (⇒ with_system_file inatteignable depuis la config). Arbitrage central de l’ADR — le modèle de privilèges : « in-process » ne peut pas vouloir dire « dans le démon », sinon les open/read/unlink se feraient sous l’identité root et il faudrait réimplémenter le contrôle d’accès Unix (classe de vulnérabilité connue). Décision proposée : worker forké sans exec — le fils largue ses privilèges (drop_privileges_to, déjà utilisé par le chemin shell) et le père pompe canal ↔ socketpair (machinerie E.3 réutilisée) ⇒ un seul binaire, et c’est le noyau qui applique le contrôle d’accès. Le fork sans exec est sûr ici car air-sshd est mono-réacteur mono-thread (le deadlock io_uring+fork, #128/#161, supposait un autre thread) ; invariants imposés au fils : ne jamais toucher l’anneau hérité, fermer tout fd hors socketpair. Repli documenté si S.3 les infirme : ré-exécution de soi (/proc/self/exe --sftp-worker), toujours un seul binaire. Plan : K.1→K.3 (écrivain + parseur de ligne OpenSSH fuzzé ; CLI air-sshd authorized-keys list/add/remove/import gatée ADR-101 ; champ additif systemAuthorizedKeysPath @1 + cascade) puis S.1→S.5 (cœur air-sftp-proto sans-IO fuzzé ; pilote lecture seule ; worker privsep ; écriture ; interop scellée en CI + transfert ≥ 100 Mio depuis un Mac). Aucun descellement couche 1 prévu (à re-confirmer en S.3/S.4 ; un manque = additif couche 1, jamais de raccourci couche 0). Re-sceau couche-1-v2.4 après clôture d’air-sshd (décision BDFL). ⏱ ADR-104 RATIFIÉ (Accepté, 2026-07-25) — avec deux contraintes rappelées en §7 normatif : le code nouveau ne repose que sur la couche 1 (mécanisé check-layers ; seule exception structurelle air-sys-types, crate transverse de types sans syscall, ADR-052 D6), et les exécutables produits ne dépendent JAMAIS de la libc gnu (mécanisé deux fois : check-target + garde-fou readelf -d de cargo xtask deb, qui refuse tout NEEDED). K.1 ✅ — écrivain + import du texte OpenSSH. build_authorized_keys (entrées → capnp scellé, tri canonique algorithme puis blob + déduplication, ADR-025 : mêmes clés ⇒ mêmes octets quel que soit l’ordre d’ajout) + write_authorized_keys (swap atomique 0600 via write_atomic_with_mode, mode fixé avant le rename donc sans fenêtre ni dépendance à l’umask). Cœur d’import sans-IO (ADR-091) : parse_openssh_authorized_key / parse_openssh_authorized_keys — base64 standard strict (décodeur couche 1 air_base_core::encoding::base64, pas de réimplémentation), ssh-ed25519 seul, ligne bornée à 8 Kio, lignes vides et # non-erreurs, erreurs distinctes et actionnables (OptionsUnsupportedUnsupportedAlgorithm : la correction n’est pas la même) ; options refusées explicitement (les accepter en silence reviendrait à prétendre les appliquer). (Dépassé depuis ADR-128 : les options du périmètre v2 sont désormais lues ; seul from= reste refusé, par décision — cf. l’entrée « OPTIONS authorized_keys FAIT » plus haut.) Deux vérifications de fond : le nom d’algorithme à l’intérieur du blob doit correspondre à celui annoncé (sinon on enregistrerait une clé sous un type qui n’est pas le sien) et le blob doit être entièrement consommé (32 octets de clé, zéro octet excédentaire). Commentaire conservé en octets bruts (Principe 3 — rien ne garantit qu’il soit UTF-8) ; à l’écriture, un commentaire non-UTF-8 est écrit vide (le champ capnp est Text, et le commentaire est non signifiant), contrat documenté — la CLI (K.2) refusera en amont. Import tout-ou-rien avec numéro de ligne en erreur (un import partiel laisserait un magasin dont personne ne connaît le contenu). Preuves : 17 tests ajoutés (79 tests lib air-sshd verts), dont la chaîne de bout en bout ligne OpenSSH réelle → magasin binaire → lecteur d’autorisation et la vérification du mode 0600 réel sur disque ; fuzz fuzz_air_sshd_authorized_keys_text 20 M exec, 0 crash (invariant renforcé : toute entrée acceptée doit survivre à l’aller-retour vers l’artefact scellé — un import produisant un magasin illisible serait un bug silencieux). Zéro nouvelle dépendance ; arêtes de couche inchangées (2→1). K.2 ✅ — CLI air-sshd authorized-keys (list/add/remove/import), gatée ADR-101. K.1 avait l’écrivain ; K.2 en fait une commande utilisable, avec les deux choses qu’une commande d’administration doit avoir : une cible explicite et une autorisation. Cible = la cascade d’ADR-096 côté écriture : son propre magasin (défaut), --user NOM, ou --system. Gate : son propre magasin est libre (le noyau tranche, c’est son fichier) ; le magasin système ou celui d’un autre utilisateur exige le mot de passe admin (credential root, temps constant air-crypto), fenêtre de bootstrap ADR-101 respectée telle quelle. list est lecture seule, jamais gatée (cohérent air-account user list, A.7). Le gate est un trait injectable (AdminGate) — c’est ce qui permet de prouver par test que le refus précède toute écriture, sans monter une base de credentials. Duplication assumée et bornée avec air-account-cli (~25 lignes de politique) : les deux dérivent d’ADR-101 (source normative), et air-account-cli est un crate binaire sans bibliothèque à consommer — à revoir si un 3ᵉ consommateur apparaît. Détails qui comptent : magasin corrompu ⇒ refus d’écrire (l’écraser détruirait des clés qu’on ne sait plus lire) ; magasin absent ⇒ vide (on peut ajouter une 1ʳᵉ clé) ; import fusion par défaut, --replace explicite, tout-ou-rien avec numéro de ligne ; remove refuse un sélecteur ambigu (deux clés, même commentaire) — l’empreinte SHA256:… (format OpenSSH, via AirHash couche 1) désigne sans ambiguïté ; clé déjà présente signalée (l’écrivain déduplique, mais l’administrateur doit le savoir) ; mot de passe lu ligne par ligne sur stdin, jamais dans argv (visible dans ps). Preuves : 22 tests (18 in-process + 4 sur le vrai binaire), 97 tests lib air-sshd verts. Les tests binaire sont volontairement en lecture seule : les chemins d’écriture visent le magasin réel du testeur (~/.config/air/sshd/…) — les exercer polluerait sa machine ; ils sont prouvés in-process sur chemins temporaires. Toute la logique vit dans la bibliothèque (le binaire démon est exclu de la couverture) ; le binaire ne fait que collecter argv, brancher le gate réel et lire stdin. Zéro nouvelle dépendance ; couches inchangées (2→1). K.3 ✅ — la cascade câblée : magasin système + magasin par-utilisateur. C’était le trou identifié à l’inventaire : FileAuthorizedKeys::with_system_file existait et était testé, mais inatteignable depuis la configuration. Champ additif Authorization.systemAuthorizedKeysPath @1 :Text (id croissant, jamais renuméroté — ADR-012) ; code capnp régénéré (tool C++ 1.1.0 + capnpc-rust 0.26.0), et régénération vérifiée reproductible : seul le fichier du schéma modifié change, les trois autres ressortent byte-identiques. Câblé de bout en bout : lecteur, écrivain, démon (FileAuthorizedKeys::with_system_file quand un chemin est déclaré, sinon par-utilisateur seul), CLI (config get l’affiche, config set system-authorized-keys / …-clear l’éditent) et export/import JSON — ce dernier point n’est pas cosmétique : omettre le champ du JSON l’aurait perdu silencieusement à chaque aller-retour (ADR-032, zéro discard silencieux). Chemin relatif REFUSÉ (il serait résolu depuis le cwd du démon : imprévisible et silencieusement faux). Compatibilité ascendante PROUVÉE, pas supposée : un artefact écrit avant K.3 (fabriqué avec un builder ne posant que les champs d’avant) se relit en None — Cap’n Proto rend une chaîne vide sur un champ absent, qu’on distingue explicitement d’un chemin vide. Preuve de bout en bout : un utilisateur sans aucun magasin personnel se connecte grâce à une clé du magasin système seul (SYSTEM_STORE_OK), avec son contre-test — la même clé est refusée quand la config ne déclare pas de magasin système, sans quoi le test positif ne prouverait rien. Preuves : 6 tests ajoutés (3 config, 2 démon bout-en-bout, 1 aller-retour JSON étendu), suite air-sshd entièrement verte. Zéro nouvelle dépendance ; couches inchangées (2→1). Volet clés (K.1→K.3) TERMINÉ. S.1 ✅ — cœur sans-IO air-sftp-proto (nouvelle crate couche 2). Le volet SFTP démarre par son cœur pur (ADR-091 normatif) : il transforme des octets en intentions typées et des décisions en octets, sans jamais toucher au réseau, au système de fichiers ni à l’horloge — il ne sait même pas d’où viennent les octets. no_std + alloc, #![forbid(unsafe_code)] (vérifié par le compilateur, pas par la revue), zéro dépendance externe (seul air-base-core, couche 1, pour la monnaie d’erreurs). Cinq modules : codec (primitives de fil, lecture bornée), framer (découpage en paquets, plafond 1 Mio), attrs, message (les 20 messages v3), session (ordre du dialogue). Décisions de conception qui comptent : (a) crate séparée d’ air-ssh-proto — SFTP est un protocole distinct, seulement transporté par SSH ; le séparer le rend fuzzable sans monter la moindre session SSH ; (b) codec propre (~120 lignes) plutôt que couplage entre deux protocoles sans raison d’évoluer ensemble ; (c) attributs modélisés en Option<T> par champ, masque dérivé et non stocké — il ne peut donc pas mentir, et « tronquer à 0 » reste distinct de « ne rien dire de la taille » ; (d) une longueur annoncée n’est jamais crue : Reader ne fait que découper une tranche existante, aucune allocation n’est dimensionnée sur un uint32 venu du réseau ; (e) octets excédentaires = refus — agir sur un message compris à moitié, c’est agir sur une intention devinée ; (f) noms et chemins en octets bruts de bout en bout (Principe 3 : en v3 un nom de fichier n’a aucun encodage garanti). Deux verrues v3 assumées et documentées : SYMLINK a ses arguments inversés chez OpenSSH par rapport au brouillon — on lit cible d’abord, comme OpenSSH, parce que suivre le texte donnerait un serveur conforme sur le papier et inutilisable en pratique (liens créés à l’envers) ; et NAME porte un longname d’affichage, produit en octets sans présumer de la locale. EXTENDED est décodé juste assez pour répondre OP_UNSUPPORTED avec le bon identifiant — un silence bloquerait un client qui attend une réponse par requête. Preuves : 44 tests dont 6 property-based, avec un modèle de référence sur la machine d’état (quelle que soit la séquence, une requête n’est exécutée que si un INIT valide l’a précédée) ; fuzz fuzz_air_sftp_packet 17 442 886 exec, 0 crash, sur la chaîne entière (framer → décodage → session) — fuzzer les morceaux séparément laisserait passer les défauts de leur jointure. S.2 ✅ — pilote SFTP en lecture seule : le vrai client OpenSSH liste et rapatrie. Le sous-système sftp est accepté sur liste blanche (connection.rs refusait tout subsystem ; une liste noire laisserait passer ce qu’on n’a pas prévu), et le canal est servi par une boucle strictement réactive — un paquet entre, une réponse sort : rien à pomper en sens inverse, donc aucune concurrence à orchestrer, contrairement au shell interactif d’E.3. Le moteur (sftp.rs) est une unité autonome : requête entrée → réponse sortie, avec entre les deux uniquement des appels couche 1. Il ne connaît ni le canal, ni le transport, ni le processus qui l’héberge — c’est le point de conception qui compte : en S.3 il sera déplacé dans le worker privsep sans que sa logique ni ses tests changent. Décisions notables : (a) O_PATH pour les stat — ouvrir un FIFO en lecture bloquerait le serveur jusqu’à ce qu’un écrivain se présente (test dédié avec un vrai FIFO) ; (b) OPENDIR liste d’un coup au lieu de tenir un curseur : les tranches de READDIR sont stables (le client ne voit pas le répertoire changer sous lui) et aucun descripteur ne reste ouvert pendant la conversation ; (c) l’ouverture en écriture est refusée à l’OPEN, pas au premier WRITE — accepter l’ouverture ferait croire au client que le transfert a commencé ; (d) aucune vérification d’accès en espace utilisateur : le noyau tranche, on traduit son errno (NOT_FOUNDNO_SUCH_FILE, PERMISSION_DENIED→…) — la granularité compte, un FAILURE générique n’apprendrait rien au client ; (e) handles opaques et jamais réutilisés (un handle fermé ne peut pas redevenir valide), table bornée ; (f) READ borné à 256 Kio — le tampon est dimensionné sur la demande bornée, jamais sur le uint32 brut. Le longname est produit avec une conversion civile faite maison (~15 lignes, la couche 1 n’expose pas de calendrier et icu4x est hors fermeture, ADR-090), UTC assumé et affiché plutôt que de prétendre connaître le fuseau du client. PREUVE RÉELLE : le binaire sftp d’OpenSSH se connecte à air-sshd, ls -l rend nos longname correctement (-rw-rw-r-- 1 1000 1000 4096 Jul 25 08:48 charge.bin) et get rapatrie 4096 octets octet-pour-octet identiques. 20 tests sur le moteur (cycle open/read/close, EOF, FIFO non bloquant, LSTAT ≠ STAT, refus explicite des 7 opérations d’écriture, borne de la table, dates civiles dont le 29 février et les dates négatives). (Piège de test consigné : le ~/.ssh/config de la machine injecte 8 clés d’agent AVANT celle de -i et épuise la limite d’essais U.4 — il faut isoler le client avec -F /dev/null + IdentityAgent=none. Ce n’est pas un défaut serveur : U.4 faisait exactement son travail.) Reste S.3→S.5. S.3 (en cours) — ADDITIF COUCHE 1 fork_process : la primitive n’existait pas. L’ADR-104 §4 supposait un fork sans exec disponible. Vérification à l’ouverture de S.3 : il ne l’était pas — le clone3 d’air-process est enfermé dans AirCommand::spawn/spawn_process, dont l’enfant va toujours jusqu’à execve (run_child). Aucune API couche 1 ne rendait la main dans l’enfant. Arbitrage porté au BDFL entre créer la primitive (descellement additif couche 1 : on fait entrer dans une couche scellée une primitive dont la sûreté ne peut pas être vérifiée par le compilateur) et ré-exécuter le binaire (/proc/self/exe --sftp-worker : zéro surface nouvelle, chemin spawn déjà audité, image mémoire neuve — ni anneau io_uring, ni clés d’hôte héritées — au prix d’un exec par session). DÉCISION BDFL 2026-07-25 : créer la primitive. air_process::fork_process ajoutée — unsafe fn (le contrat « ce que l’enfant a le droit de faire » est reporté sur l’appelant et doit être lu ; précédents couche 1 : air_thread::create_thread, air_memory::unmap), rendant AirForkResult::Parent(AirProcess) / Child. Le parent obtient un pidfd, jamais un PID nu (doctrine du projet) ; aucun tube n’est monté (l’appelant prépare ses socketpair avant, et ferme dans l’enfant ce qu’il ne doit pas hériter). La doc distingue les deux régimes : mono-fil (cas d’air-sshd, mono-réacteur — le danger classique du verrou d’allocateur ne s’applique pas) et multi-fils (restriction async-signal-safe jusqu’à execve/exit_group). 3 tests qui forkent réellement : l’enfant revient dans le code déjà chargé (ce qu’un spawn n’atteindrait jamais) et son code de sortie remonte ; le parent tient un pidfd valide ; les espaces d’adressage sont effectivement séparés (mémoire héritée vue par l’enfant, écriture de l’enfant invisible du parent, PID distinct). ADR-104 §4 amendé (amendement daté, texte d’origine conservé). Descellement additif couche-1-v2.x à acter au re-sceau. Reste S.3 (suite) : câbler le worker privsep sur cette primitive, puis S.4→S.5 (écriture, interop scellée en CI + transfert ≥ 100 Mio).

⏱ 2026-07-26 — S.4 ✅ : écriture SFTP, prouvée par le vrai client OpenSSH (put, mkdir, rename, rm). Le moteur (sftp.rs) n’est plus en lecture seule : OPEN accepte les drapeaux SSH_FXF_* d’écriture/création (traduits en OpenFlags, mode de création pris des attributs), et WRITE/MKDIR/RMDIR/REMOVE/RENAME/ SETSTAT/FSETSTAT/SYMLINK/READLINK sont implémentés. Aucun descellement de couche 1 — comme l’ADR-104 §Conséquences le prévoyait, air-filesystem exposait déjà tout : AirFile (path-based, write_at/set_length) pour OPEN+WRITE, et le manager AirFileManager (DirFd::Cwd + CStr) pour mkdir/rmdir/unlink/rename/symlink/ readlink/chmod/chown/utimens. Zéro contrôle d’accès en espace utilisateur (comme en lecture) : le noyau décide sur l’identité du processus, on traduit l’errno. WRITE est tout-ou-erreur (boucle jusqu’à épuisement) ; les chemins à NUL interne sont refusés (jamais tronqués) ; SETSTAT/FSETSTAT n’appliquent que les champs présents (taille via ftruncate, mode, propriétaire, dates). Preuve d’interop réelle : tests/sftp_interop.rs — le binaire sftp d’OpenSSH put un fichier de 8 Kio, mkdir un dossier, rename dans ce dossier, rm un fichier ; contenu vérifié octet pour octet sur le disque servi. Couverture moteur sftp.rs 97 % lignes (42 tests in-process : put, offsets, EXCL sur existant, handle inconnu, mkdir/rmdir, remove, rename, symlink+readlink, setstat/fsetstat taille+mode+propriétaire+dates, NUL refusé, écriture d’un handle lecture-seule refusée côté noyau). Reste S.5 : interop scellée en CI (sftp depuis un Mac) + gros transfert ≥ 100 Mio (fenêtres de canal, checksum).

⏱ 2026-07-26 — CAMPAGNE DE SÉCURITÉ ADR-105 CLOSE : air-sshd confronté à toutes les attaques SSH connues, tous critères atteints. CHANTIER air-sshd CLOS. Attaquant carbon (carbon.net:52222) → cible linux-air (0 glibc) sur speedy:2222. 6 axes (journal docs/notes/campagne-securite-air-sshd-fr.md) : (1) audit — Terrapin non vulnérable, crypto moderne-only ; (2) DoS pré-auth — finding DHEat corrigé (throttling par source : concurrence #438 + rate-limit token bucket #439), validé (300 KEX → 27/273 refusées ; handshake légitime distinct inchangé ~0.05 s pendant l’attaque) ; (3) constant-time — dudect sur la comparaison de tag |t|≈1–2 (seuil 10) ; (4) anti-énumération — dudect in-memory |t|<3 + réseau indistinguable ; (5) brute-force — publickey-only (zéro surface password) + plafond 6 essais/connexion (U.4) → ≤60 essais/s/source ; (6) SYN flood — SYN cookies + joignable pendant + récupéré après. Seul finding (axe 1) corrigé et validé (axe 2) ; limitation non exploitable consignée (FileAuthorizedKeys non constant-time par construction, écart ~µs noyé sous ~24 ms réseau — durcissement différé, décision ouverte). Harnais dudect in-tree gatés AIR_DUDECT=1 (hors gate CI).

⏱ 2026-07-26 — RE-SCEAU couche-1-v2.4 (tag signé GPG, main 9491018). Sceau additif depuis couche-1-v2.3 (E.3), couvrant deux campagnes — aucune API scellée modifiée ni cassée. ADR-100 base de comptes binaire : nouvelle crate air-account-schema (schémas Cap’n Proto passwd/group/shadow) + surface binaire d’air-account (build/write/parse/import_*_to_binary + projection /etc texte 0644 / shadow 0600), l’API texte historique restant intacte. ADR-104 SFTP : air-process::fork_process (unsafe fn, fork sans exec, AirForkResult — support du worker privsep) + air-filesystem::write_atomic_with_mode. linux-air : nouvelle crate air-std-entry (_start) + air-base-core sys/time bridgé couche 1 (#417). Barrière verte au sceau (cargo xtask barrier : fmt + clippy -D warnings + test workspace ; WARN advisory) ; couverture verte au merge de #436 (= HEAD). Reste pour clore le chantier air-sshd : la campagne de sécurité ADR-105 (carbon attaque air-sshd linux-air sur speedy:2222).

⏱ 2026-07-26 — S.5 ✅ : interop SCELLÉE en CI + gros transfert ≥ 100 Mio, fenêtre de canal respectée par construction. C’EST LA CLÔTURE FONCTIONNELLE D’air-sshd. (1) Contrôle de flux correct. Le TODO S.5 du pilote (connection/sftp_serve.rs) est fermé : send_sftp_data n’émet plus jamais au-delà de la fenêtre annoncée par le pair. Quand le crédit manque, il attend un WINDOW_ADJUST (au lieu de saturer à 0 et dépasser) ; les requêtes que le client pipeline pendant cette attente sont mises dans une file bornée (MAX_PENDING_REQUESTS, anti-DoS : un client qui inonde sans rendre de fenêtre → ENOBUFS) et rejouées ensuite, sans réordonnancement ni perte. Mesure préalable : le transfert de 100 Mio passait déjà avec le modèle séquentiel (OpenSSH recrédite assez vite), mais laisser un serveur réseau capable de dépasser la fenêtre d’un pair contredit l’éthos « sur-sécuriser puis dégraisser » — d’où la correction par construction. (2) Preuve : tests/sftp_interop.rs — le vrai client sftp d’OpenSSH get un fichier de 100 Mio, vérifié octet pour octet par tranches (mémoire bornée, correct même sur Pi 4). (3) Scellage CI : AIR_SFTP_INTEROP_REQUIRED=1 (même motif que le gate ABI) — l’absence de sftp/ssh-keygen devient un échec (fini le skip silencieux) et active le gros transfert ; posé dans le step cargo test --workspace (carbon a openssh-client, prouvé par les runs S.2/S.4). sftp_serve.rs reste exclu de la couverture (le démon est SIGKILLé en fin de test → .profraw jamais écrit) : la preuve valide le code sans pouvoir le mesurer ; levée = drain SIGTERM. ⏱ 2026-07-26 — VAGUE 1 #1 : ADR-108 air-keystore ÉCRIT (Accepté, décision BDFL). Premier ADR de la vague 1 (keystore → certs+CA → ML-KEM → air-log → air-sandbox), après le cadre CI/CD (ADR-107). Crée le manager de domaine couche 1 ([ADR-077]) qui centralise le cycle de vie des clés/secrets aujourd’hui éparpillé (clé d’hôte via air-crypto, authorized_keys via air-filesystem) : génération (délègue air-crypto, primitives pures), import/export, lookup, péremption, révocation (RevokedKeys), magasin d’hôtes connus (known_hosts, TOFU). Stockage binaire scellé (AIRCFGv1+capnp [ADR-040], motif [ADR-096], 0600). Interop OpenSSH requise aux frontières formats (clés pub/priv OpenSSH + PEM + certs — fuzz obligatoire) ; cœur interne binaire. Deux faces : bibliothèque non privilégiée (gros de l’usage) + service air-keysign (couche 2, socket-activé, jamais setuid, oracle-safe) pour hostbased/clé d’hôte. Frontières actées : format wire des certs = couche 2 (air-ssh-proto), CA = orchestration (pas monolithe) ; SecurityManager [ADR-089] distinct (politique, consomme). Reportés vague 2 : CLI air-keystore (ssh-keygen élargi + JSON)

  • extraction air-json. Non implémenté (ADR = décisions) : la crate reste à produire. SUMMARY + registre (v3.43) + suivi à jour, mdbook build à re-vérifier.

⏱ 2026-07-26 — VAGUE 1 #2 : ADR-109 Certificats SSH + CA ÉCRIT (Accepté, décision BDFL). Deuxième ADR de la vague 1 (après keystore ADR-108). Le format wire du certificat + sa vérification vivent dans air-ssh-proto (couche 2), motif sans-IO fuzzable ([ADR-091]), appelant air-crypto (Ed25519 verify pur). Format OpenSSH ssh-ed25519-cert-v01@openssh.com (interop) + moderne-only ; options critiques inconnues = rejet (fail-closed). CA = orchestration (pas monolithe), layering strict : air-keystore (couche 1) détient clé CA + magasin de CA de confiance + révocation (KRL, interop OpenSSH) ; l’émission est couche 2 (keystore/air-keysign fournit la clé → proto sérialise le to-be-signed → air-crypto signe → proto assemble) ; la vérification (signature + expiry + principal + révocation + options) consommée par air-sshd/air-ssh, politique au SecurityManager [ADR-089]. Deux types : cert utilisateur (remplace authorized_keys N×M) et cert hôte (remplace TOFU known_hosts, débloque le provisioning air-desktop-host — ADR séparé). CLI cert issue/inspect/verify/revoke = vague 2. Non implémenté (ADR = décisions). Vague 1 : reste ML-KEM → air-log → air-sandbox. SUMMARY + registre (v3.44) + suivi à jour.

⏱ 2026-07-26 — VAGUE 1 #3 : ADR-110 KEX post-quantique ML-KEM ÉCRIT (Accepté, décision BDFL). Ferme le seul vrai retard de sécurité (défaut OpenSSH ≥9.9 ; « harvest now, decrypt later »). Doctrine crypto : PQ = risque très élevé → crate pur-Rust d’abord, pas de DIY treillis. Adopte ml-kem (RustCrypto, pin =0.3.2) : pur Rust no_std sans heap, zéro unsafe, constant-time, deps C-free confirmées (aucun cpufeatures/cc/libc — déterminant sur *-linux-air), MIT/Apache, règle 80 % OK. Re-validé à notre barre (upstream non audité) : vendoré+piné, KATs FIPS 203, notre dudect encaps/décaps, fuzz décaps (surface réseau), Miri, proptest. Écrit par nous (faible risque) : combineur hybride mlkem768x25519 dans air-crypto (X25519 dalek ⊕ ML-KEM-768, concat+hash IETF/OpenSSH). KEX dans air-ssh-proto (couche 2, sans-IO), méthode préférée, repli curve25519-sha256 ; sntrup761 non implémenté. Non implémenté (ADR = décisions). Vague 1 : reste air-log → air-sandbox. SUMMARY + registre (v3.45) + suivi à jour.

⏱ 2026-07-26 — VAGUE 1 #4 : ADR-111 air-log ÉCRIT (Accepté, décision BDFL). OpenSSH journalise vers syslog/journald ; Air ne publie que sur AirCom ([ADR-001], bus programmatique) → surface admin/journalctl manquante. AirLogManager ([ADR-077]) = sink opérationnel distinct d’AirCom (pont opt-in), au-dessus d’air-socket, no_std+alloc. journald natif écrit par nous (socket datagram, champs CLÉ=valeur longueur-préfixés, pas de libsystemd C, [ADR-088]) + fallback stderr/fichier. Anti-injection OBLIGATOIRE (données attaquant : user/comment/IP → neutraliser \n/contrôles ; cadrage binaire journald protège par construction = argument vs syslog texte ; fuzz encodeur). API typée (niveaux + champs structurés). Envoi datagram fire-and-forget drop-safe (jamais bloquer le réacteur sous DoS). Abstraction de sink (journald/fichier/futur journal Air natif). Non implémenté (ADR = décisions). Vague 1 : reste air-sandbox (dernier). SUMMARY + registre (v3.46) + suivi à jour.

⏱ 2026-07-26 — VAGUE 1 #5 (DERNIER) : ADR-112 air-sandbox ÉCRIT → VAGUE 1 CLOSE. Crée le mécanisme de confinement seccomp/Landlock (distinct du SecurityManager [ADR-089] = politique) : builders d’allow-list haut niveau → Landlock ruleset + seccomp-BPF sans fuite couche 0, profils prédéfinis (pre-auth/sftp-worker), compilateur seccomp allow-list→BPF MAISON (décision BDFL : pas de libseccomp C, [ADR-088], arch-aware x86_64/aarch64, no_new_privs, fuzzé + tests d’efficacité on-target), fail-closed (opt-out explicite journalisé via air-log [ADR-111]), et répare la fuite couche 0 d’air-process (with_seccomp_filter/with_landlock prennent désormais les types haut niveau). Non implémenté (ADR = décisions).

✅ VAGUE 1 D’ADR CLOSE (triage des écarts OpenSSH) : ADR-108 keystore → 109 certs+CA → 110 ML-KEM → 111 air-log → 112 air-sandbox, précédés du cadre ADR-107 (pipeline CI/CD linux-air) et du correctif CI docs-only skip (#451, plus de recompilation inutile sur speedy/Pi pour un ajout de doc). Vague 2 restante (déjà tranchée, à rédiger) : air-agent, forwarding & multiplexing, config binaire capacités, extensions SFTP+statvfs, hostbased+provisioning, outillage air-json + CLI air-keystore, doctrine sourcing crypto. Différés : FIDO/HID, ControlMaster, -D SOCKS, AuthorizedKeysCommand, AuthenticationMethods. SUMMARY + registre (v3.47) + suivi à jour.

⏱ 2026-07-26 — VAGUE 2 OUVERTE, #1 : ADR-113 doctrine de sourcing crypto ÉCRIT (Accepté, décision BDFL). Formalise (raffine [ADR-034]/[ADR-024]) la doctrine déjà appliquée en vague 1 : arbre de sourcing (pur-Rust C-free/os-free → ne pas porter le C → DIY sérieux), asm OK / C non (invariant os-free [ADR-088] ; piège cpufeatures/libc aarch64), standard DIY (KATs/dudect/fuzz/Miri/proptest/zeroize), gradient de risque (formats=DIY possédés, symétrique=RustCrypto, asym-corps=dalek sauf C, PQ=crate auditée), fiable > performant. Plan vague 2 (9 ADR, 113→121) : 113 doctrine crypto · 114 air-json · 115 CLI air-keystore · 116 air-agent · 117 redirections & multiplexing · 118 config binaire capacités · 119 extensions SFTP+statvfs · 120 hostbased · 121 provisioning air-desktop-host. PROGRESSION vague 2 : ✅ 113→121 COMPLÈTE. Registre → v3.56. Les deux vagues du triage des écarts OpenSSH (108→121, + cadre CI ADR-107) sont rédigées et mergées. Reste = IMPLÉMENTATION (ADR = décisions) + différés (FIDO/HID, ControlMaster, -D SOCKS, AuthorizedKeysCommand, AuthenticationMethods).

Volet SFTP d’ADR-104 COMPLET (S.1→S.5). Reste pour clore le chantier air-sshd : le re-sceau couche-1-v2.4 (décision BDFL : après clôture), puis la campagne de tests de sécurité (ADR-105).

⏱ 2026-07-25 — CORRECTIF ABI C : un défaut réel, et surtout SIX gates qui se sautaient en silence. Découvert en passant la suite complète du workspace pendant le chantier SFTP. Le défaut : air_c.h déclarait int pipe(int *fds) alors que son propre commentaire trois lignes plus haut écrivait « POSIX. int pipe(int[2]) » ; glibc déclare int pipe(int __pipedes[2]), et GCC refuse la contradiction (-Werror=array-parameter). Un consommateur C incluant <unistd.h> et notre en-tête — le cas normal — ne compilait pas. Même divergence latente sur pipe2 et socketpair (leurs doc-comments écrivaient déjà int[2]). Corrigé dans le GÉNÉRATEUR (postprocess_air_c), pas à la main, puis en-tête régénéré par cbindgen 0.28.0 pinné. La cause racine, plus grave que le défaut : SIX gates de stabilité ABI se sautaient silencieusement en rendant « ok »c_abi_conformance d’ air-libc-capi (dès que libair_c.so n’était pas à côté du binaire de test, ce qui est le cas en CI) et les quatre header_committed (dès que cbindgen est absent — et la CI ne l’installait pas). Prouvé, pas supposé : le log CI du dernier run sur main montre le test « ok » sans avoir rien compilé, et le retrait local de la .so le fait repasser au vert instantanément. C’est ainsi qu’air_object.h avait dérivé de ses sources sans que rien ne le signale (dérive corrigée par la régénération). Correctif systémique : AIR_ABI_CONFORMANCE_REQUIRED=1 (déjà posée par la CI, déjà honorée par air-value-c/air-object-c) couvre désormais les six — outil ou artefact manquant = ÉCHEC, jamais un skip muet ; et la CI installe cbindgen (version épinglée, ADR-025 : une version flottante ferait diverger le diff). Vérifié dans les deux sens : sans la .so et conformité exigée ⇒ échec explicite ; sans elle et non exigée ⇒ skip signalé. Piste écartée après mesure : élargir la source C à <sys/socket.h> + _GNU_SOURCE pour couvrir socketpair/pipe2 — impossible, air_c.h est un en-tête libc de remplacement (il définit lui-même AF_INET, struct sockaddr, struct msghdr…) et ne peut pas coexister avec ceux de glibc ; ~20 redéfinitions. Consigné plutôt que forcé. (Au passage : le piège EACCES d’ADR-031 mordait aussi le dépôt local — des artefacts root du 2026-07-23 dans target/tests/trybuild faisaient échouer compile_fail ; propriété rétablie. C’est exactement ce que le filet pré-checkout de #419 empêche désormais côté CI.) SUITE — le durcissement a immédiatement mordu, et c’était juste. Première CI avec la variable posée : AIR_ABI_CONFORMANCE_REQUIRED=1 mais libair_c.so introuvable. Cause réelle, pas un faux positif : air-libc-c est déclaré crate-type = ["cdylib"] seul — aucune crate ne le lie, donc cargo test --workspace n’a jamais eu de raison d’émettre la .so. C’est exactement pourquoi le gate se sautait depuis toujours : l’artefact qu’il devait confronter au consommateur C n’était pas produit. Correctif : étape cargo build -p air-libc-c avant les tests, qui fait réellement tourner le gate au lieu de le faire semblant. Séquence prouvée dans les deux sens en local : sans la .so ⇒ échec explicite nommant la cause ; après cargo build -p air-libc-c ⇒ test vert. (Rappel : sur le poste local, la .so existait parce qu’un cargo build -p air-libc-c antérieur l’avait produite — d’où un test qui passait en local et échouait en CI.) PUIS l’étape de couverture a expiré (40 min) — sur un défaut SANS RAPPORT, révélé parce que #426 est la PREMIÈRE PR à dépasser l’étape Tests. Blocage dans air-process::drop_to_user_resolves_and_drops_under_root : clone3 sans exec puis, dans l’enfant, du code qui alloue (drop_to_userair-account : lecture + décodage capnp). Sur notre cible c’est résolu (air_alloc::reset_after_fork, ADR-056 D8, remet le futex de l’arène à UNLOCKED) ; sur l’hôte gnu l’allocateur est celui de la glibc, clone3 en syscall brut court-circuite ses pthread_atfork, rien ne réinitialise ses verrous — et si un autre fil du harnais le détient à l’instant du fork, l’enfant s’y bloque pour toujours. Latent depuis toujours, déclenché par la charge de la machine (builds locaux concurrents de la CI). Le défaut n’existe que parce que le test tourne contre la glibc. CONTOURNEMENT TEMPORAIRE POSÉ, MARQUÉ COMME DETTE DU CHANTIER PAL : les 5 tests de privilege_root.rs prennent un verrou de sérialisation commun, de sorte qu’aucun autre fil du processus ne puisse être dans malloc pendant le fork. Bloc de commentaire explicite en tête de fichier : pourquoi, pourquoi c’est temporaire, et « À RETIRER » quand la suite tournera sur *-unknown-linux-air — le reset d’arène rendra la sérialisation inutile. Le verrou est pris par chacun des 5 tests (un seul oubli rouvrirait la course) et récupère le poisoning (un test paniqué ne doit pas cascader en échecs sans rapport). Vérifié SOUS ROOT (le régime où le fork s’exécute réellement, les tests se sautant en non-root) : 3 exécutions consécutives vertes. Ce n’est pas un correctif — c’est une dette assumée, tracée, et dont la levée est le critère de réussite du chantier PAL.

⏱ 2026-07-25 — check-target étendu : couverture de LIVRAISON (couplage gate ↔ deb). Le gate [#415] prouvait qu’un exécutable déclare la bonne cible (linux-air / host-tool allowlisté) — mais pas qu’il est livré : la table des paquets de cargo xtask deb était une liste codée en dur dans deb.rs, qu’un nouvel exécutable pouvait manquer silencieusement (compilé linux-air, jamais packagé, donc jamais sur la machine). Correctif : la table devient une source unique partagée (xtask/src/packaging.rs), consommée par deb (production) et par check-target (gate) ⇒ le couplage est tenu par le compilateur, pas par un parseur de source. Deux règles ajoutées, dans les deux sens : (e) tout crate produisant un exécutable linux-air est packagé — ou justifié dans UNPACKAGED_LINUX_AIR (vide aujourd’hui : le prochain exécutable devra faire un choix conscient) — et chacun de ses binaires est installé par le paquet ; (f) réciproquement, tout paquet désigne un crate qui existe, produit un exécutable, est linux-air, et produit bien chacun des binaires déclarés. (f) attrape la dérive inverse (crate renommé, binaire supprimé) que deb ne signalerait qu’au build, et seulement sur un runner Linux-air. Gate vu ROUGE sur les 3 modes d’échec (binaire fantôme déclaré / livrable retiré de la table / binaire produit mais non installé), messages actionnables, puis vert : 3 exécutables, 2 livrables packagés, 0 non packagé. Extraction des binaires produits fidèle à cargo ([[bin]] explicites, auto-découverte src/bin/*.rs, src/main.rs sauf s’il est renommé par un [[bin]] — cas réel air-account-cliair-account). ADR-103 non modifié ; doctrine consignée dans docs/outillage-xtask.md (section check-target créée, section deb rafraîchie : elle décrivait encore un paquet unique air-account).

⏱ 2026-07-25 — HYGIÈNE CI : artefacts root post-couverture (piège EACCES). Effet de bord de la mesure en root (ADR-031) : les artefacts écrits sous target/llvm-cov-target appartiennent à root, alors que le git clean -ffdx du checkout tourne en non-root ⇒ un artefact root oublié bloque tous les runs suivants en EACCES (le workspace _work/<dépôt>/<dépôt> est partagé par tous les jobs du dépôt sur un runner), jusqu’à un rm -rf manuel sur speedy — incident survenu pendant le chantier linux-air. L’étape de restauration existante (if: always(), chown -R target) ne suffit pas : always() ne survit ni à un runner tué ni à une coupure, et un run annulé (cancel-in-progress, déclenché par un simple re-push de PR) laisse une fenêtre. Correctif — deux filets : (1) l’étape de fin de job est élargie au workspace entier et à CARGO_HOME (les étapes root tournent en sudo -E ⇒ un cargo root qui télécharge une dépendance écrit dans le registre de l’utilisateur : trou latent fermé au passage) ; (2) nouveau filet pré-checkout sur les 6 jobs — reprise de propriété du workspace avant le checkout, idempotent, no-op quand rien ne traîne, tolérant si sudo -n manque. C’est le filet (2) qui rend la CI auto-réparante quelle que soit la façon dont le run précédent est mort. Étape run: inline (une action composite locale exigerait un checkout préalable — précisément l’étape qui échoue). ADR-031 non modifié (décision inchangée, immuable) ; doctrine consignée dans docs/CI.md.

⏱ 2026-07-25 — CHANTIER conformité linux-air des exécutables (ADR-103, autonome). Directive BDFL : tout exécutable Air (air-account, air-ssh, air-sshd, suivants) doit être compilé pour *-unknown-linux-air — lié à NOTRE std linux-air (PAL couche 1, sans libc C, static-pie), jamais à la std gnu/glibc. ADR-103 ratifié (Accepté) : livraison linux-air only ; la std gnu reste un banc de test hôte (couverture/fuzz), jamais un livrable ; marquage par crate + gate check-target. Le déploiement a servi de révélateur : produire un vrai programme (pas juste hello-std-air, qui ne fait qu’un println!) a exigé 5 correctifs de la std linux-air, tous mergés — (1) restricted_std : os=air ajouté à l’allowlist de std/build.rs (débloque toute crate tierce utilisant std : RustCrypto/subtle, capnp) [#410] ; (2) .weak __air_rt_sigreturn_trampoline : dédup du symbole global_asm (air-sys-syscall compilé 2× — closure std ET app) [#408] ; (3) crate air-std-entry : point d’entrée ELF _start (std exporte __air_start mais pas _start) [#409] ; (4) sys/args/air.rs : std::env::args() ne remontait AUCUN argument (patch câblait sys/env mais pas sys/args) → binaires ignoraient leur ligne de commande [#412] ; (5) sys/time/air.rs : std::time non câblé (air-sshd panique « time not implemented ») → Instant/SystemTime via air_sys_syscall::time::clock_gettime [#414]. Packaging : cargo xtask deb basculé sur linux-air (-Z build-std + rust-src patché + cible JSON ; garde-fou readelf = 0 NEEDED ; 2 paquets air-account + air-sshd) [#413]. Enforcement : gate cargo xtask check-target [#415] — chaque exécutable déclare [package.metadata.air] target = "linux-air" (ou "host-tool" allowlisté = xtask seul) ; un exécutable gnu ou un crate non identifié = échec de barrière ; branché en CI. ✅ DÉPLOYÉ sur les 3 machines (2026-07-25) : air-account + air-ssh + air-sshd en *-unknown-linux-air natif, static-pie, 0 glibc sur speedy (x86_64), carbon (x86_64) et raspi-srv-2 (aarch64) ; air-sshd active sur :2222 (parallèle OpenSSH), air-account lit la base binaire (52/54/32 comptes). ADR-103 réalisé pour ces 3 exécutables. CORRECTION #417 (2026-07-25) : le fix #5 (temps) violait la règle dure de layeringsys/time/air.rs bindait air_sys_syscall::time::clock_gettime = couche 0 EN DIRECT (le PAL de la std binde la couche 1, JAMAIS la couche 0). Recâblé sur air-base-core::time (couche 1) via son API publique déjà existante (AirDuration::to_std_saturating, AirSystemTime::as_unix_seconds/subsec_nanos) — aucun additif couche 1, le raccourci c0 n’était même pas justifié ; air-sys-syscall retiré des deps de std. Les 3 machines RE-DÉPLOYÉES depuis la source corrigée (speedy/carbon/raspi, air-sshd active :2222, 0 glibc). Reste : rien de bloquant ; étendre check-target (paquetage des livrables) si besoin. Journal détaillé : rt/DECISIONS.md (CLA-1→CLA-4). 10 PRs #408→#417.

⏱ 2026-07-24 — CHANTIER base comptes/credentials BINAIRE (ADR-100, autonome). ADR-100 ratifié Accepté (décision BDFL) — companion ADR-067/073/041. Direction BDFL : la gestion de compte Unix d’Air (comptes utilisateurs + groupes + credentials) passe en binaire sous /etc/air/ ; un CLI unique air-account crée/modifie/supprime comptes et groupes ; les deux toits (libc C-ABI libair_c + std portée) doivent rester compatibles. Architecture : air-account reste le médiateur unique à API gelée — seule la source lue change (binaire /etc/air/*.aircfg canonique prioritaire, texte /etc en fallback transitoire) ⇒ libair_c (purs binders air-libc-pwd/grp/shadow) et std (build-std lie air_account) compatibles par construction ; /etc/passwd/group/shadow texte deviennent des projections générées pour le monde C (ADR-041). Descellement additif couche 1 (couche-1-v2.3v2.4, zéro rupture ABI). Plan A.0→A.6 : A.0 ADR ✅ ; A.1 crate air-account-schema ✅ — schémas Cap’n Proto bespoke passwd/group/shadow (fidélité Unix : champs textuels en Data octets ; shadow : hash Argon2id PHC secret + vieillissement OptI64 = Option<i64>, -1 ≠ absent), enveloppe AIRCFGv1 mutualisée, code généré committé (capnp 1.1.0 + capnpc-rust 0.26.0, reproductible byte-identique), 3 tests round-trip verts, fmt/clippy/machete/check-layers OK, générés exclus couverture via _capnp\.rs$. A.2 lecteur binaire dans air-account ✅ (descellement additif, feature store-binary) — module store (cœur pur sans-IO : parse_{passwd,group,shadow}_artifact, enveloppe AIRCFGv1+FNV réimplémentée no_std octet-compatible + décode capnp, panic-free) ; lookup_*/shadow_by_name/groups_of_user recâblés binaire /etc/air/*.aircfg prioritaire + fallback texte (résolveurs à chemins injectables, fail-closed sur binaire corrompu) ; API publique inchangée → libair_c + std compatibles par construction. Feature store-binary NON-default et jamais tirée par rustc-dep-of-std (capnp interdit dans la fermeture std, ADR-090 ; la std ne lit aucun compte) ; activée explicitement par les binders air-libc-pwd/grp/shadow (hors fermeture std) → le toit C-ABI lit le binaire. Fuzz fuzz_air_account_store 3,85 M runs, 0 crash (magie découverte → décode capnp exercé). 35 tests (round-trips, login non-UTF-8, OptI64, corruption enveloppe/message, KAT FNV canonique verrouillant l’interop, deux branches résolveur, membre supplémentaire) ; store.rs 100 % lignes couvrables. A.1 (#398) et A.2 (#399) MERGÉS sur main (CI verte, test-coverage incluse). A.3 écrivain binaire + projection /etc + import inverse ✅ — écrivain : store::seal_artifact (promu) + build_{passwd,group,shadow}_artifact (entrées → capnp scellé, tri canonique uid/gid/name, ADR-025) + write_*_binary (swap atomique air-filesystem::write_atomic) ; projection texte /etc (ADR-041) : shadow::encode_line (ajouté, symétrique de decode, gère -1) + codec::push_i64 + project_{passwd,group,shadow}_text/_to (tri canonique, seul écrivain) ; import inverse bootstrap (/etc texte → binaire, ADR-041 §3) : import_{passwd,group,shadow}_to_binary, fuzzé fuzz_air_account_import 410k/0. 48 tests (round-trips build→parse, encode→decode, import→parse, projection triée, -1 projeté, écritures atomiques fichier). fmt/clippy verts (avec ET sans feature). A.3 (#400) MERGÉ sur main (CI verte). A.4 CLI air-account (couche 2, nouvelle crate air-account-cli) ✅ — DIRECTIVE BDFL déploiement : le CLI doit importer /etc/passwd+/etc/shadow+/etc/group pour initialiser un système (cible : déployer sur speedy/carbon/raspi-srv-2). Exécutable air-account : import [--from /etc] [--to /etc/air] (bootstrap : lit les 3 fichiers /etc texte → écrit /etc/air/*.aircfg binaire par swap atomique 0600prouvé sur vrai binaire) ; user add/mod/del (mutation base binaire) ; passwd <name> (hachage Argon2id OWASP via air-crypto, PHC → shadow ; mot de passe lu sur stdin, jamais argv). Parsing hand-rolled (pas de clap). Logique testable in-process dans cli.rs/commands.rs (répertoire de base injectable, mot de passe injecté) — 15 tests (import bout-en-bout, add/mod/del, passwd+verify Argon2id, doublon/absent/corrompu refusés, parsing) ; main.rs (entrypoint I/O) exclu couverture (regex CI + barrier + COVERAGE-EXCEPTIONS prose) ; cli/commands ≥ 94 %. Additifs air-account (v2.4 à venir) : store::exists/ShadowHash::from_bytespub. check-layers conforme (2→1). A.4 (#401) MERGÉ sur main. A.5a CLI groups ✅ — air-account group add/mod/del <name> [--gid] [--members a,b,c] (mutation group.aircfg binaire, doublon/absent refusés, membres CSV filtrés) ; 17 tests, couverture 96,7 % (main exclu), cycle groupe prouvé sur vrai binaire. NOUVELLE DIRECTIVE BDFL déploiement : après A.5, packaging .deb Debian/Ubuntu deux arches (amd64 speedy/carbon + arm64 raspi-srv-2, build natif par runner, dpkg-deb via cargo xtask deb, /usr/sbin/air-account) puis install exécutée par l’assistant (feu vert BDFL) sur les 3 machines. A.5a (#402) MERGÉ. A.5b gate mot de passe admin ✅ — ADR-101 ratifié (mécanisme, companion ADR-073/100) : admin = root (uid 0), mot de passe admin = credential root dans shadow.aircfg vérifié en temps constant (air-crypto) ; toutes les mutations gatées (user/group add/mod/del, passwd <autre>) redemandent le mot de passe admin AVANT écriture (mismatch → refus, zéro écriture) ; gate actif ⇔ root a un hash $argon2id$ (un $6$ importé ou root absent/verrouillé = fenêtre de bootstrap, gate sauté → flux : import puis passwd root active le gate) ; passwd = 2 secrets (admin + nouveau), lus ligne par ligne stdin (jamais argv). commands::admin_gate_active/verify_admin + cli::authorize. 21 tests (gate actif bloque/autorise, flux stdin 2 lignes, fenêtre bootstrap) ; gate prouvé sur vrai binaire (mauvais admin refusé, bon admin autorisé). Couverture 96,6 % (main exclu). PACKAGING DEBIAN — outil cargo xtask deb ✅ : build release natif → DEBIAN/control + /usr/sbin/air-account (0755) → dpkg-deb (natif, pas de dep cargo) ; SOURCE_DATE_EPOCH figé → .deb reproductible (vérifié byte-identique) ; build natif par arch (amd64 speedy/carbon, arm64 raspi-srv-2 — pas de cross). .deb amd64 produit sur speedy. Déploiement (install par l’assistant, feu vert BDFL) : speedy (sudo OK) + carbon (SSH OK) = amd64 ; raspi-srv-2 = SSH refusé (port 22) → build/deploy arm64 BLOQUÉ en attente que raspi soit joignable. Reste : A.6 preuve deux toits (libair_c+std) + re-sceau couche-1-v2.4 (rigueur de clôture) ; finir le déploiement (raspi arm64 quand joignable). (Leçon infra consignée : une PR sans AUCUN check = conflit de merge bloquant le dispatch CI, PAS les runners — rebaser sur main.) ⏱ 2026-07-24 (suite déploiement). ADR-102 ratifié (C-ABI air-value/air-object/air-base-capirlib pur + crates sœurs -c host-only, débloque le build release de la pile air-com/air-sshd, #405) + cargo xtask deb (#404, dpkg-deb, SOURCE_DATE_EPOCH déterministe). Paquets .deb produits et installés nativement sur les 3 machines (amd64 speedy/carbon, arm64 raspi-srv-2 ; /usr/sbin/air-account), tables /etc/passwd+shadow+group importées en bases binaires /etc/air/*.aircfg (passwd/group 0644, shadow 0600). air-sshd déployé sur les 3 machines (systemd Type=simple, --config /etc/air/sshd.aircfg, écoute 0.0.0.0:2222 en parallèle d’OpenSSH, host-key-seed aléatoire par machine, per-user+privsep on, cfg 0600). A.7 lecture + JSON ✅ — air-account user list|show <name> et group list|show <name> (lecture seule non gatée), mode --json global (succès/lecture/erreur en JSON valide, émetteur hand-rolled sans serde, octets non-UTF-8 → {"invalid_utf8_hex":…} — zéro présomption UTF-8, Principe 3), statut de mot de passe set/none/locked (jamais le hachage, ADR-101). A.8 fix permissions ✅ — nouveau AirFileSystem::write_atomic_with_mode (couche 1, fchmod du temporaire avant le rename → mode final sans fenêtre, indépendant de l’umask) ; air-account store + import écrivent désormais passwd/group en 0644 (monde-lisible, non secrets, comme /etc/passwd) et shadow en 0600 (secret) — plus de chmod manuel au déploiement. Reste : A.6 preuve deux toits (libair_c+std) + re-sceau couche-1-v2.4.

⏱ 2026-07-23 — CHANTIER ergonomie + configuration air-ssh/air-sshd (ADR-099, autonome). ADR-099 ratifié Accepté (#382) : plan E.0→E.6 — ergonomie client (user@host, DNS, clé fichier, shell interactif) + sous-système de configuration binaire (/etc/air & XDG, édition CLI, export/import JSON). Précision BDFL normative : le JSON est exclusivement un format d’import/export pour mettre à jour les fichiers de config binaires ; à l’exécution, client et serveur lisent uniquement leur binaire. Progression : E.1 user@host + DNS ✅ (#383) — air-ssh connect [user@]host [-p PORT] [--user NAME], split_user_host, resolve_targets (IP littérale via AirIpAddress::parse, sinon AirNameResolver::best_effort async io_uring, IPv6 d’abord puis itération connect), $USER par défaut ; preuve air-ssh connect user@localhost (nom résolu) → exec. E.2 clé fichier ✅ — cœur sans-IO air_ssh_proto::parse_ed25519_private_key (format openssh-key-v1 Ed25519 non chiffré, décodage 100 % borné, refus des clés chiffrées) fuzzé (fuzz_air_ssh_openssh_key, 1,17M exec 0 crash) ; flag -i/--identity KEYFILE dans air-ssh (précédence -i > --key-seed > défaut ~/.ssh/id_ed25519) ; preuve interop réelle : ssh-keygen -t ed25519air-ssh -i <clé réelle> s’authentifie auprès d’OpenSSH sshd (.pub en authorized_keys), 3 tests unitaires + interop verts. E.3 shell interactif ✅ — décision BDFL : descellement additif couche 1 v2.3 : AirCommand::login_terminal(slave) (un seul OwnedFd esclave PTY = terminal de contrôle setsid+TIOCSCTTY et stdin/out/err ; se compose avec drop_privileges_to) — équivalent unifié de SpawnAttributes setsid+ctty (C.1) sur le chemin exec. Serveur (interactive.rs) : pump PTY bidirectionnel joint (join, [ADR-098]) maître↔canal — A (canal→maître, window-changeTIOCSWINSZ, SIGHUP par PID au teardown) ‖ B (maître→canal, exit-status+EOF+CLOSE à la sortie du shell) ; maître PTY lu via air_async::File (IORING_OP_READ/WRITE, universels — pas RECV/SEND sockets : bug trouvé et corrigé). Client (client_shell) : pty-req+shell, mode raw termios local (RAII RawTerminal, restauré à la sortie), stdin/stdout dupliqués (air-handle), pump via Unordered (B « premier terminé » annule A au teardown — anodin côté client) ; nouveau Signal::SIGHUP (couche 0). Preuves réelles : pump serveur vs vrai ssh -tt OpenSSH (prompt bash + echo→sortie + exit + fermeture propre) ; client+serveur de bout en bout (two_executables : air-ssh sans --command → shell interactif → marqueur). Suites vertes (air-sshd, air-process +login_terminal, air-sys-types). Correctif couverture (#386) : E.3 avait fait passer la couverture lignes sous 96 % (code interactif/démon exercé seulement par sous-processus non instrumentés) → tests in-process ajoutés (client_shell_against_air_server, serve_forever_serves_a_client_in_process, exec robuste par euid, tests unitaires binaire RawTerminal/local_terminal_io) + exclusion du binaire démon (SIGKILL’é → profil non flushable, comme air-libc-capi) ; test-coverage re-vert (96 %). 🔒 RE-SCEAU COUCHE 1 couche-1-v2.3 (tag GPG-signé posé sur main, clé C99E…) : fole l’additif AirCommand::login_terminal (+ constante couche 0 Signal::SIGHUP) ; barrière air-process re-vérifiée verte. Couche 1 reprend son régime scellé (descellement additif v2.x, RFC ADR-015/062). 🔓 BLOCAGE capnp LEVÉ (2026-07-23) : outil installé sur speedycapnp C++ 1.1.0 (build source → ~/.local/bin, apt=1.0.1/brew=1.5.0 ≠ pin ADR-025) + plugin capnpc-rust 0.26.0 ; chaîne vérifiée reproductible (régén sshd.capnp = byte-identique). CI/build ne consomme que le runtime capnp pur-Rust (tool C++ = régén mainteneur locale, code committé). ⇒ E.4 + U.3b débloqués. E.4 config binaire serveur ✅ : (a) schéma bespoke air-sshd-config.capnp (SshdConfig : listen/hostKeys/privilegeSeparation/authorization, dans air-sshd-schema — décision B ADR-096, #388) ; (b) lecteur read_sshd_config (enveloppe AIRCFGv1+FNV-1a-64 réutilisée d’air-config-compile, parse+valide, panic-free, fuzzé 500k/0, #389) ; (c) câblage démon : air-sshd --config <artefact> lit SshdConfig, sélectionne l’autorisateur (enum DaemonAuthorizer : Memory/File/Deny), multi-écoute (join_all), mode legacy conservé. U.3b magasin authorized_keys binaire ✅ (#390) : schéma sshd-authorized-keys.capnp + parse_authorized_keys (fuzzé 500k/0) + FileAuthorizedKeys (résout home via air-account → absent=false anti-énumération, lit <home>/.config/air/sshd/authorized_keys via air-filesystem, fail-closed). Preuve de convergence bout-en-bout (config_daemon.rs) : air-sshd --config (perUserAuthorizedKeys) + magasin binaire dans le home réel → air-ssh autorisé → exec. air-ssh-config (config client) ✅ (#392) : schéma bespoke ssh-config.capnp (SshConfig : defaultIdentity + hosts HostBlock façon Host OpenSSH) + parse_ssh_config/resolve_host (fuzzé 400k/0) ; air-ssh lit sa config XDG ~/.config/air/ssh.aircfg et résout hostName/user/port/identity par-hôte (CLI prioritaire) ; preuve air-ssh connect airtest (tout résolu depuis la config). E.5 CLI config get/set ✅ (serveur #393, client #394) : writer d’artefact wrap_artifact en couche 2 (enveloppe AIRCFGv1+FNV-1a-64, byte-compat verrouillée par test vs verify_checksumaucun descellement) ; write_sshd_config/write_ssh_config (round-trip write→read) ; read_sshd_config_editable (tolérant pour édition) ; air-sshd config get|set (host-key-seed/listen/privsep/authorization) + air-ssh config get|set (default-identity/host … trouve-ou-crée/host-remove). E.6 export/import JSON ✅ (serveur #395, client #396) : module JSON fait main air_sshd::json (parseur récursif borné MAX_DEPTH=64 panic-free + sérialiseur, PAS de serde, FUZZÉ 1M/0 + round-trip stable) ; config export [--secrets] (serveur élide les graines par défaut) / config import (parse JSON → valide → ré-encode binaire, jamais lu au runtime). Preuves round-trip set→export→import→binaire IDENTIQUE (config_json.rs/config_client_json.rs). 🎯 CHANTIER ADR-099 QUASI-COMPLET : E.1→E.6 + U.3b livrés ; serveur air-sshd ET client air-ssh entièrement pilotés par config binaire, éditables en CLI, échangeables en JSON. RESTE : mot de passe administrateur serveur (ADR-073 : air-sshd config set devrait l’exiger) — infra à bâtir (stockage haché type shadow + vérif), sous-chantier à part en attente de décision de périmètre BDFL.

⏱ 2026-07-23 — 🔒 RE-SCEAU COUCHE 1 couche-1-v2.2 (clôture d’air-ssh, autorisation BDFL). Tag git GPG-signé posé (clé C99E…, superviseur) sur main après le merge de C.6 (#380). Fole les additifs couche 1 depuis v2.1 — tous additifs, aucune API scellée cassée : air-socket accept SEQPACKET io_uring + into_owned_fd Listener (#338) ; air-crypto constructeurs seedés X25519/Ed25519 (« F2 contributory », #356) + AEAD nonce-explicite SSH byte-exact OpenSSH (#358) ; air-process SpawnAttributes setsid+ctty (ADR-097 C.1, #369). Barrière couche 1 re-vérifiée verte. La couche 1 reprend son régime scellé (évolution par descellement additif v2.x, RFC ADR-015/062). air-uring/air-shm restent non scellées (évolutives). air-ssh (client+serveur SSH natif Rust, sans C) est terminé et interopérable OpenSSH dans les deux sens.

⏱ 2026-07-23 — PHASE 3 air-sshd ssh-connection LANCÉE (autonome, ADR-097 Accepté). Arbitrages BDFL : additifs couche 1 minimaux autorisés (folés dans v2.2), shell de login /etc/passwd, périmètre serveur air-sshd complet + client air-ssh connect (sftp/verbes config-keygen différés), multiplexing complet (sessions + port-forwarding). Plan : C.0 (ADR-097) ✅ → C.1 additif air-process (setsid+ctty) ✅ (#369, couverture 96.44 % vérifiée) → C.2 cœur canaux multiplexés ✅ (codec 14 messages RFC 4254 §9 + fenêtres défensives + ChannelTable mux + fuzz 4,3M exec) → C.3 session shell/exec ✅ (pilote serveur serve_connection : open session→pty-req/env/window-change/shell/exec ; lancement du shell de login via air-process avec stdin/out/err sur socketpair, pump sortie→CHANNEL_DATA, exit-status+EOF+CLOSE, fermeture gracieuse RFC 4254 §5.3 drain-avant-FIN ; résolution compte via air-account ; PTY alloué sur pty-req, pump PTY interactif reporté C.5 ; preuve interop OpenSSH réelle ssh user@host 'echo …', 14/14 runs verts) → C.4 port-forwarding (🔨 cœur fait : module forwardTcpipChannel direct/forwarded-tcpip + TcpipForward tcpip-forward/cancel + ForwardEndpoint::port_u16 ; fuzz fuzz_air_ssh_forward 4,8M exec 0 crash. C.4a direct-tcpip/ssh -L FAIT : split transport into_forwardingRecvHalf/SendHalf, pump bidirectionnel concurrent par join2 (sans spawn/Mutex, recv jamais annulé), connect cible air-async, teardown par shutdown ; preuve interop OpenSSH réelle ssh -L→echo relayé bout-en-bout, 6/6 runs. Rekey suspendu pendant forward (limitation assumée). ADR-098 (concurrence structurée) ratifié + IMPLÉMENTÉ : combinateurs join/join_all/try_join + Unordered (ensemble dynamique) + Notify dans air-async (8 tests, sans unsafe) ; pump C.4a refondu sur air_async::join (fin du poll_fn bricolé). C.4b tcpip-forward/ssh -R FAIT : premier moteur multiplexé d’air-sshd (tcpip_forward.rs) — écrivain sérialisé Outbox+Notify, reader_loop/writer_loop/pumps_loop joints, ensemble dynamique Unordered + astuce sentinelle, accept-loop → canaux forwarded-tcpip concurrents, teardown propre (compteur pompes-canal, accept-loop droppé/annulé) ; preuve interop OpenSSH réelle ssh -R→echo relayé, 6/6 runs, teardown 0,14 s. C.4 (port-forwarding) COMPLET. C.5 démon FAIT : binaire réel air-sshd (src/bin/air-sshd.rs + module daemon.rs) — socket-activation systemd (air-service) ou --listen, accept-loop concurrent (serve_forever : join(accept, driver) + Unordered+sentinelle), clé d’hôte + politique par args ; handler de bibliothèque serve_ssh_connection (transport→auth→session) réutilisé par démon+tests ; serve_userauth rend le nom authentifié (AuthOutcome) ; privsep (--privsep) via AirCommand::drop_privileges_to/AirStdio::Fd (drop audité couche 1) — exec unifié sur AirCommand. Preuves : tests/daemon.rs lance le vrai binaire — exec réel, 2 connexions simultanées (concurrence), privsep fail-closed non-root. Différés : multi-cœur spawn_on, drain SIGTERM, clé d’hôte fichier, U.3b. C.6 client air-ssh FAIT : rôle client complet (client.rs) — establish_client (KEX curve25519 + vérif signature d’hôte verify_server_kex + cipher miroir + strict-KEX), authenticate_client (userauth publickey signée), client_exec (session + exec + collecte sortie) ; binaire air-ssh (src/bin/air-ssh.rs, verbes connect/pubkey) ; cœur client_kexinit. Interop double scellée en CI : air-ssh ↔ air-sshd (two_executables.rs — les DEUX exécutables Air discutent) ET air-ssh ↔ serveur OpenSSH sshd (openssh_server.rs — wire-compat client fermée). Différés : shell interactif client (PTY raw), clé OpenSSH fichier. ✅ PHASE 3 (ssh-connection) TERMINÉE — air-ssh (client+serveur) fonctionnellement complet et interopérable OpenSSH dans les deux sens.) → C.5 démon systemd → C.6 client air-ssh connect. Couche 1 reste scellée v2.1 ; re-sceau v2.2 unique à la FIN d’air-ssh (avec F2 contributory X25519). ⏱ 2026-07-23 — CYCLE air-sshd phase 2 (autonome). ADR-094 (ssh-userauth) et ADR-095 (packaging exécutables air-ssh) ratifiés Accepté + note d’orientation sauvegarde/restauration (/etc/air + homes, XDG) — PR #361 mergée. U.0 (#362) + U.1 (#363) + U.2 (publickey Ed25519, #364) mergés. U.3a (autorisation + events AirCom) FAIT : trait AuthorizedKeys (backends InMemoryAuthorizedKeys/DenyAll) consulté par serve_userauth (SUCCESS exige signature valide ET clé autorisée) + events AuthOk/AuthFailed publiés sur AirCom (schéma déjà figé) ; preuves : clé non autorisée→FAILURE, publication AuthFailed→AuthOk via subscriber in-process, interop OpenSSH. U.3b (magasin authorized_keys binaire fichier) : archi TRANCHÉE (option B, ADR-096 Proposé) = schéma bespoke air-sshd-schema + lecteur dédié couche-clean (read_to_bytes+verify_checksum, PAS de mmap couche-0) implémentant le trait AuthorizedKeys de U.3a ; résolution home via air-account. Implémentation encore BLOQUÉE : outil capnp ABSENT (génération du code du schéma = op mainteneur). Couche 1 reste SCELLÉE (v2.1) jusqu’à la fin d’air-ssh (décision BDFL 2026-07-23) ; re-sceau couche-1-v2.2 unique alors (F2 contributory X25519 + additifs). U.4 (durcissements + preuve CI succès/échec) FAIT : essais bornés (max_failures, défaut 6 comme OpenSSH), anti-énumération (réponse d’échec uniforme, testée byte-exact), grace-time documentée (enveloppe runtime.timeout côté pilote) ; preuve d’interop scellée : un vrai ssh -i (clé ssh-keygen autorisée) → OpenSSH « Authenticated to … using publickey » + les cas d’échec (DenyAll) conservés. U.2 (publickey Ed25519) FAIT : cœur verify_publickey_signature (reconstruction donnée signée RFC 4252 §7 + vérif constant-time air-crypto sur session_id) + pilote (serve_userauth : sonde→PK_OK, signature valide→USERAUTH_SUCCESS) ; preuves cœur (signature valide/altérée/rejeu/malformée) + e2e in-process (SUCCESS/PK_OK/rejeu) + interop OpenSSH + fuzz 3,8M exec 0 crash. U.1 (codec + StateMachine ssh-userauth) FAIT : cœur air-ssh-proto::userauth (codec UserauthRequest decode/encode borné anti-hostile, réponses FAILURE/SUCCESS/BANNER/PK_OK, bootstrap SERVICE_REQUEST/ACCEPT, UserauthStateMachine) + pilote serve_userauth (câblage SERVICE_REQUEST→userauth, none→FAILURE publickey) ; preuves : cœur ~100 % (unit + proptest model + round-trip), e2e in-process (4 cas : none/erreurs/disconnect), interop OpenSSH réel via serve_userauth, fuzz fuzz_air_ssh_userauth 6,4M exec 0 crash. Prochain : U.2 (publickey Ed25519 : sonde PK_OK + vérif de signature sur session_id). Dernière mise à jour : 2026-08-30 (campagne d’assainissement des 29–30 aoûttest-air vert sur la cible pour la première fois après trois obstacles distincts ; les deux cliquets de couverture relevés, workspace 76,14 → 80,87 et layer2 74,79 → 76,56 ; le défaut de cadence de la mesure, décrit trois fois et jamais corrigé, fermé par l’audit hebdomadaire ; ADR-106 amendé — Air refuse le C, pas Unix ; la dette os::unix ramenée à deux sites légitimes ; air-launchd dégelé, 3b reprend à son incrément 2. Détail par PR dans JOURNAL.md.)


0. Actualité — CIBLE DE PRODUCTION (2026-07-10)

🎯 OBJECTIF PREMIER (pivot 2026-07-10, ADR-088) : une toolchain Rust dont la std repose uniquement sur Air, par un PAL custom safe bindant la couche 1, SANS libc C. Sans cet outil fondamental, on ne peut pas produire sereinement le reste. Le jalon M5 est ATTEINT mais par le chemin de l’option A (ADR-076) : std liait la libc C libair_c — or la frontière C réintroduit de l’unsafe, incohérent avec la doctrine (« unsafe en couche 0 seulement »). ADR-088 amende ADR-076 : PAL custom std::sys::pal::air bindant les Managers couche 1 Rust safe (premier backend std safe de bout en bout), cible target_os="air", libair_c survit mais découplée de std, AirTaskManager remplace AirProcessManager, résorbe raw_syscall. M5 = marchepied de faisabilité. Étude : etude-std-pal-air-safe-fr.md.

Cible produit ensuite : air-sshd — le serveur SSH d’Air, full Rust, async io_uring, wire-compat OpenSSH (ADR-074) — la toolchain safe est son prérequis. (Il n’y a pas de jalon « compiler OpenSSH réel » : OpenSSH n’était qu’une fonction de forçage, rôle rempli.)

Acquis récents (couche 1 scellée → toits) :

  • 🎉 M5 ATTEINT (2026-07-10) — std tourne sur Air, ZÉRO glibc, 2 arches. Le lien de hello-std contre libair passe de 12 → 0 symboles indéfinis (5 PR : __xpg_strerror_r #308, open/fcntl #309, introspection thread couche 1 [ADR-086] #310, getauxval/ pthread_self/détection-pile #311, TLS keys #[thread_local] #312, syscall générique [ADR-087] #313). Run on-target : hello from std on air + exit 0 sur x86_64-air (carbon) ET aarch64-air (raspi). Un programme Rust std static-PIE lié intégralement sans glibc. Pari ADR-076 tenu de bout en bout. Re-sceaux couche-0-v1.12 (ADR-087 raw_syscall, escape hatch temporaire à retirer avec l’équipe Rust) + couche-1-v1.9 (ADR-086 introspection thread).
  • Campagne réseau ADR-069 close : registre de handles air-handle, moteur socket air-socket, surface socket libc complète (30 symboles, libair_c.so) + sendmsg/ recvmsg/SCM_RIGHTS. Re-sceaux couche-0-v1.10 (ADR-070 sockaddr + ADR-071 sockopt) et couche-1-v1.6 (ADR-069). CI basculée x86 (ADR-072).
  • Doctrines gravées : config binaire (ADR-073), vision air-sshd (ADR-074) — la vraie cible (OpenSSH n’était qu’une fonction de forçage), fourniture du PAL (ADR-075).
  • Chantier A du PAL COMPLET : 6 briques additives couche 1 (AirCondvar, AirOnce, os_str, mutation d’env, yield_now/set_thread_name, destructeurs thread_local).
  • Bug de fondation aarch64 CORRIGÉ (gdb) : le TCB (64 o) recouvrait la zone TLS (tpidr_el0 base commune TCB+TLS à +16) → tout 2ᵉ #[thread_local] cassait. Fix validé 2 arches (selftest rt x86-air = 42 ET aarch64-air = 42).
  • Chantier B lancé, option A prouvée : build-std=std compile entièrement pour Air (env=musl, zéro patch libc/std) ; reste le lien (-lc = libair, -lunwind = stub).

⚠️ 2026-07-08 — RÈGLE + ARCHITECTURE (ADR-077) : la libc bind EXCLUSIVEMENT la couche 1 (jamais la couche 0), via des Managers de domaine objet (AirFileManager/AirTaskManager/AirNetworkManager/AirSystemManager/AirSignalManager/AirEnvironmentManager/AirMemoryManager). Remédiation livrée (re-sceau couche-1-v1.7, ADR-077) : les Managers sont la surface médiatrice objet consommée par les toits (libc et PAL), jamais la couche 0 en direct. Les symboles air-libc-* bindent les Managers / briques couche 1.

Prochaines tâches (pivot ADR-088 — chantier PAL safe). Le jalon M5 (option A : std sur la libc C libair_c) est atteint et conservé comme marchepied de faisabilité, mais il réintroduit de l’unsafe par la frontière C — incohérent avec la doctrine. ADR-088 pivote : std reposera sur un PAL custom safe (std::sys::pal::air + sys/<domaine>/air.rs) bindant les Managers couche 1 Rust safe, zéro FFI, cible target_os="air" (Air n’est plus déguisé en unix), libair_c découplée de std, escape hatch raw_syscall résorbé. Stratégie de sceau : desceller la couche 1 une fois pour tout le chantier, resceller une fois au jalon (comme couche-1-v1.0).

  • Étapes 1–2 faites : note d’étude (etude-std-pal-air-safe-fr.md)
    • ADR-088 acté (tranche §6.a–e). ADR-089 réserve le SecurityManager (#324).
  • Lot 1 mergé (#322/#323)seul changement cassant : renommage AirProcessManager → AirTaskManager (médie air-process/air-thread/air-runtime + resource_limit) + surface futex/gettid ; propagation doc (macro-archi, ADR-077/085/086, INDEX, registre, libc-conformance).
  • Étape 3 faite (incrément 0+1, branche feat/pal-safe-air-target, en revue) — reprofilage cible : cible canonique *-linux-air reprofilée target_os="air" (retrait env=musl/ target-family), option-A renommée marchepied *-linux-air-musl (M5 préservé, baseline rejouée : hello from std on air/exit 0/zéro glibc). Fork rust-src localisé réversible (rt/rust-src-air/ : patch + apply-air-pal.sh + copie vendor/ gitignorée, override __CARGO_TESTS_ONLY_SRC_ROOT). std COMPILE pour *-linux-air, 2 arches (x86_64 + aarch64), lien d’un binaire std minimal sans symbole libc indéfini (patch minimal : 5 fichiers std::sys, murs A1–A4). os=air inconnue ⇒ restricted_std (voie standard des ports custom).
  • Incrément 2 — PROTOTYPE rustc-dep-of-std sur air-alloc : PROUVÉ + dé-risqué (commit 5f12621, branche). std compile 2 arches avec le vrai air-alloc (arène mmap/futex couche 0) comme allocateur global — sonde air-alloc-probe : ELF static-pie, zéro glibc, zéro symbole indéfini, 5 Kio (arène réelle liée, plus le placeholder). Mesure décisive : la couche 0/1 ne change QU’EN build-config (3 Cargo.toml, +38 l., feature rustc-dep-of-std + façades rustc-std-workspace-*) — ZÉRO .rs source, zéro sémantique, zéro gate/scellé touché. Vérifié superviseur : tests hôte des 3 crates scellées verts (23+807+199, 0 échec). ⇒ Le descellement formel de la couche 0 N’EST PAS nécessaire (crates Air déjà no_std-propres).
  • Incrément 3 — AUDIT deps externes (commit cfe216d, branche) : fardeau brut de binding naïf = ~71 forks (33 icu4x via air-base-lib + 37 RustCrypto via air-crypto + 1 bitflags), intenable — MAIS presque entièrement évitable par DÉCOUPLAGE, pas par fork. Aucune externe n’a rustc-dep-of-std amont (toutes no_std) ; les 11 proc-macros compilent pour l’hôte → zéro fork. Trouvaille : les 2 grappes 🔴 n’entrent dans std que par couplage accidentel — (1) AirError/AirResult/AirPath sont définis dans air-base-core (icu-free) ; air-base-lib ne fait que les ré-exporter + ajouter icu ⇒ rediriger les imports Managers fs/net/process/runtime vers air-base-core sort icu4x du graphe std (chemin d’import, zéro sémantique) ; (2) AirRandom::fill n’appelle que air-sys-syscall::getrandom (couche 0) ⇒ isoler le random sort RustCrypto du graphe. Vérifié source : air-base-core définit bien les types, Vague 0 dépend de air-base-core (pas -lib).
  • ADR-090 ratifié (2026-07-11, sur main) — grave la discipline : binding par rustc-dep-of-std (build-config, pas de descellement) ; imports air-base-core jamais air-base-lib ; exceptions 80 % (icu4x/RustCrypto) hors fermeture std ; AirRandom hors crypto ; frontière no_std = relation à std ; industrialisation par vagues.
  • Vague 0 — binding réel du noyau (commit a9769c4, branche) : std compile ET lie pour x86_64 ET aarch64 sur 5 vrais Managersio/errorair-base-core, pal/air::futex+sync/* (Mutex/Condvar/RwLock/Once/Parker réels)→air-thread::raw_futex, stdioair-stdio, envair-env (+ allocair-alloc). Fermeture vérifiée : uniquement bitflags (ni icu4x, ni RustCrypto, ni air-base-lib) — fork count = 1. Zéro .rs couche 0/1 touché, hôte scellé vert. Lien : static-pie zéro glibc ; x86_64 = __tls_get_addr seul indéfini (hook TLS runtime), aarch64 = zéro indéfini. Placeholders restants : random, thread_local.
  • Vague 1a — découplage air-base-libair-base-core (commits b9b4edc/89ba1d4/9d80380, branche) : seuls air-filesystem + air-socket dépendaient directement d’air-base-lib (les 3 autres déjà sur air-base-core). Redirection ABI-neutre, aucun cas d’i18n réel (le seul from_utf8_lossy = wrapper stdlib zéro-icu). Vérifié : air-filesystem/air-socket/air-process ont icu4x=0, air-base-lib=0 dans leur fermeture (fork=1). exit réel bindé sur air-process::exit_process (prouvé au désassemblage : __NR_exit_group, pas abort()). Hôte scellé vert (aucune régression).
  • 🎯 Vague 1b — JALON ATTEINT (commits 396944c/636eda7/168dd4b, branche) : hello-std s’exécute sur le PAL safe. Résidus Vague 0 levés : (1) __tls_get_addr supprimé via "tls-model": "local-exec" dans les specs cibles (TLS statique, accès %fs:OFFSET direct) ; (2) _start abouté au bootstrap d’air-runtime (crt0 Air : self-reloc/TLS/TCB/canari/argv → __air_startbootstrapmainprintln!exit_process), _start hors de std (côté binaire, ne casse pas les #![no_main]). air-runtime+air-memory en rustc-dep-of-std (build-config, zéro source couche 1 touchée), fermeture sans icu4x/crypto, fork=1. VÉRIFIÉ SUPERVISEUR sur 2 arches : hello from std on air+exit 0 sur x86_64/carbon ET aarch64/raspi-srv-2 (sha256 identique, static-pie, 0 glibc, 0 UND). Hôte scellé vert (127 tests feature OFF), zéro FFI hors _start asm/extern "C" (SAFETY documenté), aucun descellement.
  • Bug spawn aarch64 : NON REPRODUCTIBLE (2026-07-11, raspi)air_runtime::thread::spawn (clone3+CLONE_SETTLS Variant I) prouvé sound (~16 000 spawns : canonique .tbss + instrumenté .tdata + isolation ; selftest rt=42, 5/5 re-vérif superviseur). Les 2 bugs historiques sont corrigés (TCB/TLS overlap 2026-07-07 ; asm x19→x21 #150). Le TODO « fix bug spawn aarch64 » était stalesys/thread (PAL) est débloqué côté runtime.
  • Vague 1c — sys/thread réel (commits 2e953ef/2ccb15e, branche) : std::thread::spawn+join s’exécutent sur le PAL safe, 2 arches VÉRIFIÉES superviseur (x86_64/carbon + aarch64/raspi : worker sum=5050, join, exit 0 ; stress 800 threads OK). Binding Thread::newair_runtime::thread::spawn (trampoline extern "C" fn(usize), Box<ThreadInit> pointeur fin, expose_provenance), join→futex CHILD_CLEARTID, set_name/yield_nowair-thread, sleepair-base-core, id→current_tid ; available_parallelism/garde de pile = stubs documentés. Zéro source couche 0/1 touchée, fermeture toujours 1 fork, air-runtime déjà bindé (rien à ajouter).
  • air-alloc alignement fort — MERGÉ sur main (#325, 384c80b, 2026-07-11) : implémenté l’alignement fort (align > 16, memalign dlmalloc) dans l’arène, lève ADR-056 D5 (amendé). Découvert via std::sync::mpsc (align 64 → l’ancien refus align>16→null faisait abort SIGILL). 30 tests (matrice 32/64/128/256/page + realloc/dedicated/alloc_zeroed/OOM) + property + fuzz (44 600 exécutions). CI verte (test-coverage root ✓, supply-chain ✓, cross-aarch64 ✓). ⇒ mpsc/ crossbeam/types sur-alignés débloqués (la branche PAL rebasera sur ce main pour en bénéficier).
  • std::sync::mpsc VALIDÉ sur le PAL, 2 arches (branche, merge 927e624 + sonde d85405e) : après intégration du fix air-alloc (merge main, conflit nul), la sonde hello-mpsc (8 producteurs × 1000, somme 32004000) s’exécute — x86_64/carbon (120 runs 0 échec) ET aarch64/raspi (vrai ARM), exit 0, static-pie 0 glibc. Le cas SIGILL (V1c-6) est clos, fix align-fort prouvé en contexte. air-alloc = dépendance path de std (rustc-dep-of-std) ⇒ patch rust-src inchangé.
  • sys/fd + minimal sys/fs réels (commits d32fb79/f05907c, branche) : FileDesc(OwnedFd) → AirFileManager (read/write/read_at/write_at/seek/fsync/ftruncate/set_nonblocking) + duplicateair-handle ; File::open/create/read/write/seek minimal. Aller-retour fichier std prouvé 2 arches (sonde hello-fs : 16 Ko écrits+relus identiques + seek, exit 0 ; x86_64/carbon 10/10 + aarch64/raspi). Zéro additif couche 1, zéro unsafe dans le bras, zéro source couche 0/1, fermeture 1 fork. Frontière assumée (Vfd-3) : std::os::fd non exposé (éviterait un portage net/pipe/stdio-fd complet) — unification air-handle (ADR-069) = chantier distinct.
  • sys/fs COMPLÉTÉ (commits c2879d5/9aa5290, branche) : métadonnées (statxFileAttr/ FileType/FilePermissions, temps), ReadDir/DirEntry (getdents64), unlink/rename/mkdir/ rmdir/DirBuilder/remove_dir_all, liens (symlink/link/readlink/canonicalize/copy), set_permissions/set_times, exists ; current_exe (readlinkat /proc/self/exe, bras sys/paths/air.rs). Zéro additif couche 1, fermeture propre (fork=1, icu/crypto/regex=0). Prouvé 2 arches (sonde hello-fs2 : read_dir trié + métadonnées + liens + rename + suppression + current_exe, exit 0 ; x86_64/carbon + aarch64/raspi — 1ʳᵉ panique aarch64 = artefact de renommage du binaire par le superviseur, re-run au bon nom = OK). std::fs largement fonctionnel.
  • sys/process réel (commits 76a8dc1/f372a9c, branche) : std::process::Command spawn/exec/wait/status/kill — bâti sur AirProcess (pidfd : clone3+CLONE_PIDFD/execve/ waitid/pidfd_send_signal, wait+kill en couche 1, pas spawn_process nu qui exigerait un waitid couche 0). Stdio Inherit/Null/Fd ; Piped/output différés (avec std::os::fd/ pipe). Prouvé 2 arches (hello-proc : /bin/true→0, /bin/false→1, introuvable→127, exit 0 ; x86_64/carbon 20/20 + aarch64/raspi). Zéro additif couche 1, zéro FFI, zéro unsafe, fermeture 1 fork (air-accountair-base-core, découplage tenu).
  • sys/net réel (commits 54a53f6/ce6e8d7, branche) : std::net TCP + UDP — bâti sur air_socket::engine::AirSocket (couche 1 &self, pas les façades &mut self). TcpStream connect/read/write/peek/shutdown/nodelay/keepalive/nonblocking/addrs ; TcpListener bind/accept (SO_REUSEADDR, backlog 128) ; UdpSocket send_to/recv_from/peek_from + connect/send/recv ; adresses numériques (DNS différé). Prouvé 2 arches (hello-net : TCP + UDP loopback roundtrip, exit 0 ; x86_64/carbon 10/10 + aarch64/raspi). Zéro additif fonctionnel couche 1, fermeture 1 fork.
  • std FONCTIONNELLEMENT COMPLET pour fichiers / threads / mpsc / process / réseau — le réseau était le dernier gros sous-système. Reste pour « std bouclé » : sys/random (dernier mur dur), + différés soft.
  • 🎉 sys/random réel — std FONCTIONNELLEMENT BOUCLÉ (commits 82517f1/12b676e/6e336c0, branche) : isolation par micro-crate air-random (couche 1, no_std, sur getrandom couche 0) — air-crypto la ré-exporte (surface fill/generate_key inchangée, régression hôte 0 : air-crypto 42/42, air-random 3/3). Gate ADR-090 vérifié superviseur : fermeture air-random = {air-sys-syscall, air-sys-types, bitflags}, RustCrypto = 0 (build-std 33 crates : RustCrypto/icu4x/air-base-lib = 0, fork = 1). sys/random/air.rs (zéro unsafe) → HashMap/RandomState amorcés sur CSPRNG kernel. Prouvé 2 arches (hello-random : entropie réelle distincte + HashMap 1000 entrées, exit 0 ; x86_64/carbon 5/5 + aarch64/raspi). ⇒ tous les murs durs de std franchis.
  • Portage std MERGÉ sur main (#326, ce46a0b, 2026-07-11) : squash, CI verte (test-coverage root + supply-chain + build + loom + cross-aarch64), Verified GitHub. Couche 1 évoluée (découplage air-filesystem/air-socketair-base-core, micro-crate air-random, features rustc-dep-of-std) mais NON re-scellée — le re-sceau viendra après les différés soft (décision BDFL : ne desceller qu’une fois). Fork rust-src localisé (rt/rust-src-air, vendor/ gitignoré).
  • ⏭️ Séquence en cours (BDFL, branche feat/std-osfd-pipe, 1 PR à la fin) : différés soft PUIS re-sceau. (a) ✅ std::os::fd + sys/pipe (commit 3689ebb)AsRawFd/OwnedFd, io::pipe, Stdio::Piped/ Command::output débloqués ; zéro additif couche 1 (via sys/pipe+AirStdio::Fd), fermeture 1 fork ; prouvé 2 arches (hello-pipe : io::pipe + capture "hello-air\n" + os::fd, exit 0). (b) ✅ options socket (commit 4105188) — additif couche 1 air-socket (méthodes typées &self, +5 tests, zéro unsafe) : ttl/linger/nodelay+keepalive getters/broadcast/multicast loop+ttl/only_v6/connect_timeout (ppoll+SO_ERROR)/duplicate ; prouvé 2 arches (hello-sockopt set==get, exit 0), fermeture 1 fork, régression hôte 0. Options à STRUCTURE — additif COUCHE 0 v1.13 FAIT (commits 27195dd/1f89c98) : air-sys-types (ip_mreq/ipv6_mreq #[repr(C)], const assert! ABI) + air-sys-syscall (6 wrappers dédiés typés) + couche 1 air-socket + PAL ⇒ read_timeout/write_timeout (+ read vide expiré) + multicast join/leave v4/v6, prouvés 2 arches. Plus aucun unsupported sur les options socket. ⚠️ Barrière : plancher lignes rouge (93.29%<94) = artefact llvm-cov d’hôte (net.rs 64.53% au baseline = impossible pour couche 0 scellée ; branches +12/0-manque) → arbitre = CI test-coverage root ; tag couche-0-v1.13 contingent à CI verte. (c) ✅ DNS FAIT (commits 0ec7032/e18117c) — câblage lookup_host sur AirNameResolver existant (best_effort [hosts, DNS RFC 1035, localhost], IPv6-first) + additif air-socket (AirLocalhostSource, ponts core::net, zéro unsafe) ; prouvé 2 arches (hello-dns : localhost + numérique + connect-par-nom, exit 0), fermeture 1 fork (résolveur maison, getaddrinfo 0). ⇒ TOUS les différés soft traités. hostname() laissé unsupported (tirerait air-system — chantier distinct). Reste : PR unique + CI (arbitre couverture couche 0) + re-sceaux (couche-0-v1.13 + couche-1).
  • 📌 DIRECTIVE BDFL (2026-07-11, après consolidation) : rendre normatif le motif réseau sans-IO (docs/notes/reseau-architecture-crates-fr.md) via un ADRtoute impl réseau doit s’y conformer (seul motif testant la pile indépendamment). Précurseur : AirDnsSource. À faire avant air-async/ couche 2. Voir mémoire projet.
  • 🗺️ PHASE SUIVANTE = couche 2 (séquence BDFL) : runloop air-async (réacteur io_uring↔Waker/ exécuteur/timers) → IPC Airframework réseau Airair-sshd (démonstration couche 2). En parallèle, à traiter : hygiène pré-merge branche PAL (Cargo.lock stubs façades → deny/audit/ machete + repro), merge de la branche PAL, re-sceau couche 1 (ADR-088 « resceller une fois »). Différés soft std (non bloquants pour un programme courant) : std::os::fd (AsRawFd/FromRawFd) + sys/pipe (io::pipe, chantier libc-fd, débloque Stdio::Piped/Command::output) ; options socket fines (timeouts/ttl/linger/broadcast/multicast) ; DNS (brancher AirNameResolver sur lookup_host) ; available_parallelism/park-unpark/TLS dynamique. RFC amont (etude-rfc-trait-backend-std-fr.md).
  • 🗺️ Roadmap au-delà du portage std (séquence BDFL 2026-07-11) : (1) boucler std (fs/process/net) ; (2) couche 2 — la runloop (= air-async, réacteur io_uring↔Waker/exécuteur/timers, pièce maîtresse identifiée dans architecture-prerequis-air-sshd-fr.md), puis l’IPC Air, puis le framework réseau Air ; (3) air-sshd (ADR-074) — composant réseau primordial et démonstration du fonctionnement de la couche 2. Puis hygiène pré-merge (Cargo.lock stubs façades → deny/audit/machete + repro ADR-025), merge de la branche, re-sceau couche 1 (ADR-088 « desceller une fois, resceller une fois »). RFC amont Tier-3 + backend std::sys safe (etude-rfc-trait-backend-std-fr.md) : la preuve downstream est acquise.
  • ⏭️ Étape 4 (conditionnée à la décision) — implémentation par sous-système (facile→dur, chacun bindant son Manager) : exit/random/time/os_str/args/envfd/stdiofutex+sync (les 3 wrappers débloquent Mutex/Condvar/RwLock/Once/Parker) → fsthread+thread_localprocessnet. Manques couche 1 (§5 étude : current_exe, DirEntry typé, park/unpark, options socket) comblés en additifs v1.x au fil de l’eau.
  • 🎯 Jalon de succès : hello-std re-tourne sur le PAL safe, 2 arches, sans libair_c liée — preuve d’un userland Rust safe de bout en bout (unsafe confiné couche 0). Puis re-sceau couche 1 unique + RFC amont (Tier-3 *-linux-air, backend std::sys safe).

Dettes/leçons associées : raw_syscall/GlobalAlloc-safe traités dans ce chantier (§5) ; lane CI nightly hello-std/rt à monter ; leçon gravée jamais différer les runs raspi (un cross-build aarch64 vert ne prouve rien au runtime). Cible produit après la toolchain safe : air-sshd (ADR-074).


1. Où en est-on (vue d’ensemble)

CoucheÉtatDétail
Couche 0 (air-sys-types + air-sys-syscall)🔒 SCELLÉE couche-0-v1.11 (ADR-066 v1.9 signaux async + ADR-070/071 v1.10 sockaddr/sockopt libc, campagne réseau ; ADR-085 v1.11 le 2026-07-09 — additifs cumulés libc/std : chroot, kill_process_groupkillpg, sigaltstack+AltStack, sendfile, fchmod/fchown/futimens, munmap_raw ; pause sans additif via ppoll ; ADR-087 v1.12 le 2026-07-10 — raw_syscall, escape hatch syscall générique nécessaire au pal std, EXCEPTION assumée temporaire à retirer avec l’équipe Rust, jalon M5)Code complet (11 familles + io_uring 12 Temps + termios/tty). N’évolue plus que par descellement additif (RFC, ADR-015/ADR-051). Additif v1.9 (ADR-066) : sous-module async_handler (rt_sigaction non-faute = délivrance async réelle, install/restore) + trampoline rt_sigreturn x86_64 (global_asm) + rt_sigpending ; les 4 fautes restent inertes (ADR-064). Tag posé par le superviseur après barrière x86 + CI aarch64. Couverture 100 % hors exceptions documentées, 2 arches.
Couche 1 (briques Rust : libc + PAL)🔒 SCELLÉE couche-1-v1.0 → re-scellée jusqu’à couche-1-v1.9 (v1.1 errno_location ADR-065 ; v1.2 signaux/futex ADR-066 ; v1.3 air-account ADR-067 ; v1.4/v1.5 credentials+drop_to_user ADR-068 ; v1.6 campagne réseau air-handle+moteur socket ADR-069 ; v1.7 le 2026-07-09 — Managers de domaine (ADR-077) : AirFileManager/AirProcessManager/AirEnvironmentManager/AirMemoryManager/AirSystemManager/AirNetworkManager + nouveau AirSignalManager (set_alt_stack/pause), surface médiatrice objet consommée par la libc, chantier B ; v1.8 le 2026-07-09 — air-process::spawn_process (fork+file-actions+exec async-signal-safe) + types SpawnFileAction/SpawnAttributes, socle de la face libc posix_spawn, chantier B ; v1.9 le 2026-07-10 — introspection thread cible (ADR-086) : air-runtime::process_context [auxv + bornes de pile] + AirProcessManager::resource_limit, socle de getauxval/pthread_getattr_np de la libc, jalon M5 ; earmark ADR-078 crypto air-tlscouche-1-v1.10)API Rust publique figée (17 crates, voir ADR-062). Évolue par descellement additif v1.x (RFC ; v1.1 = AirRuntime::errno_location, ADR-065 ; v1.2 = air-signal::install_handler/restore_disposition/pending — brique d’install de handler non-faute dual-face — + air-thread::raw_futex — primitives futex(2) typées sur mémoire externe, ADR-066). v1.3 = crate air-account (codecs comptes /etc/passwd+/etc/group, brique bindée par la libc, ADR-067). Tag superviseur après validation couche 0. Les 4 P0 + remodel env/args pré-sceau.
Toit libc C-ABI (scopée OpenSSH)🔨 En production (air-libc-capi, roadmap roadmap-libc-openssh-fr.md)M0/M1-a/M1-b/M1-c/M1-d faits (crt+errno ; str*/mem* ; malloc+moteur format fuzzé ; FILE*+exit/atexit/getenv ; frontière variadique printf). M2 implémenté (à valider superviseur) : I/O fichier fd brut + métadonnées (struct stat ABI Air) + répertoires (DIR opaque) + mkstemp/realpath/getcwd — crate air-libc-fileio (logique mesurée) + shims air-libc-capi::fileio + shim C variadique fileio_shim.c (open/openat/fcntl). Archi libc : logique mesurée / shims C-ABI exclus — 6 crates de logique (air-libc-{fmt,alloc,stdio,stdlib,printf,fileio}) + shims air-libc-capi (cf. docs/libc-conformance.md). M3 Phase A faite (fondations couches 0/1, ADR-066 : install async non-faute + raw_futex). M3 Phase B VALIDÉE x86 (barrière verte : lignes 96.4 %, branches 82.9 %) : familles signaux (sigaction/signal/kill/raise/sigprocmask/sigsuspend/sigset* — crate air-libc-signal, 4 fautes INERTES ADR-064) et pthread (mutex/cond/rwlock/once/key/create/join/… sur air-thread::raw_futex — crate air-libc-thread, cycle de vie/TLS hôte). Types opaques ABI Air (mutex 40/cond 48/rwlock 56, sigset_t 128) + trailer cbindgen hand-written. Preuve d’exécution C (air-libc-c/tests/m3/) : mutex end-to-end + sigaction/raise handler async + déréf NULL → SIGSEGV coredump inerte. pthread_create NON testable via C -lair_c sur l’hôte (récursion d’interposition std::threadpthread_create ; sans glibc/on-target = OK) — couvert par tests unitaires air-libc-thread. Couverture : air-libc-c (artefact cdylib pur) EXCLU des runs llvm-cov (barrier + CI) — son test --lib force-lierait les shims pthread/TLS surchargeant la glibc (SIGSEGV) ; air-libc-capi modules signal/thread gatés #[cfg(not(test))]. Déviations D-M3.1..D-M3.9 ; additifs remontés (pthread_exit propre, dtor TLS cible, rt_sigsuspend atomique). M4.2 Temps VALIDÉE x86 : clock_gettime (REALTIME+MONOTONIC), gettimeofday, time — crate air-libc-time (logique mesurée, sur air-base-core couche 1, ZÉRO additif : monotone brut reconstruit via AirInstant::duration_since(zéro)). Types timeval/time_t/clockid_t ; preuve d’exécution C (tests/m4/time.c). Additifs remontés M4 : nanosleep (sommeil non-retry couche 1 — air-base-core::sleep retry-EINTR ≠ POSIX), clock_getres, strftime/localtime_r (tz icu4x). M4 Comptes VALIDÉE x86 : getpwnam_r/getpwuid_r (réentrants) — crate air-libc-pwd (parseur /etc/passwd mesuré + empaquetage borné anti-overflow, lecture via air-filesystem::read_to_bytes couche 1) ; struct passwd/uid_t/gid_t ; convention *_r (errno rendu direct, ERANGE/absent) ; preuve C (tests/m4/pwd.c contre le vrai /etc/passwd). Refactor ADR-067 : air-libc-pwd re-câblé en pur binder d’air-account (parseur retiré, ABI inchangée) ; groupes getgrnam_r/getgrgid_r (crate air-libc-grp, packing gr_mem aligné/borné). Non-réentrants + shadow = tranches suivantes. shadow (getspnam_r, codec durci ShadowHash zeroïsé) et identifiants (ADR-068 : air-process::credentials + binders getuid/setuid/setresuid/setgroups/initgroups, sémantique POSIX setuid ratifiée) faits. A.6 air-process::drop_to_user VALIDÉE x86+aarch64 (boucle A close 6/6) : résolveur AirPrivilegeDrop::for_user (résout un nom via air-account → descripteur de largage enrichissable : uid/gid primaires + groupes supplémentaires réels, sémantique initgroups) + convenance drop_to_user (résout puis drop_privileges) ; ADR-068 ; PAS de binder libc (OpenSSH largue via les syscalls individuels déjà exposés) ; re-sceau additif couche-1-v1.5.
Couches 2+⏳ Non commencéesToits (libc C-ABI, PAL std::sys) construits au-dessus de la couche 1 scellée.

Couche 1 scellée le 2026-07-03 (tag couche-1-v1.0, ADR-062). Voir §4 pour les jalons.


2. Sceau couche-1 : FAIT — et file des tâches à venir

Décision de gouvernance BDFL : combler les P0 des audits, remodeler ce qui l’exigeait, PUIS sceller. Tout est fait :

#TâcheStatut
P0.1AirFile — handle I/O fichier générique (air-filesystem)✅ Mergé (#214)
P0.2air-poll — multiplexeur ppoll générique (crate dédiée)✅ Mergé (#215)
P0.3termios/PTY/job-control re-exposé (air-terminal)✅ Mergé (#216)
P0.4air-signal — face signal générique, 100 % safe (ADR-064)✅ Mergé (#217)
Remodelos_strAirOsStr/AirOsString, env/args figés (sceau-readiness)✅ Mergé (#218)
§5.4Sceau couche-1-v1.0 — ADR-062 + tag gitSCELLÉ 2026-07-03

Prochaine étape (voir §0 Actualité en tête) : air-sshd — le serveur SSH d’Air full Rust (ADR-074). Le chantier B du PAL est clos : std lie et tourne sur la libc d’Air (M5 atteint, hello-std 2 arches). Toits libc C-ABI et PAL prouvés ; la toolchain Rust full-Air est acquise et sert désormais de socle à la production air-sshd.

Additifs v1.x (descellement additif, non bloquants) : spawn ergonomique §2.B.2, Condvar/Once, set_var/mutation d’env, destructeurs TLS, getsockopt/setsockopt, mman fichier-adossé, stat/fstat public, mkstemp, barrière de poison + fault_state() (ADR-064 §7), gap aarch64 crypto (#14), instrumentation IO debug (ADR-063), regex (§4-I), AirRuntime::errno_location() -> *mut i32 (socle de __errno_location() de la libc — premier additif révélé par le toit libc M0, roadmap §6 ; RFC de descellement additif requis), M3 Phase A — signaux+pthread (ADR-066) : install de handler async réel pour les signaux non-faute (air-signal::install_handler/restore_disposition, unsafe localisé) + pending(), et primitives futex(2) typées sur mémoire externe (air-thread::raw_futex) pour bâtir pthread_mutex_t/cond_t de la libc et std::sys du PAL ; fondation posée en couches 0 (couche-0-v1.9) et 1 (couche-1-v1.2), rien en couche 2. (La face sigaction-de-faute n’est PAS un additif — les 4 fautes restent inertes, ADR-064 §2 ; seul l’install de handler non-faute est descellé, ADR-066.) Cette liste se complète au fil des points identifiés.


3. Méthode de production (rappel pour agents)

  • Doctrine de couche : seule la couche 1 consomme la couche 0 ; les tiers couche 1 sont fidèles (un appel = un syscall), EINTR remonté (jamais de retry auto), zéro unsafe exposé, erreurs AirError/AirResult, arithmétique défensive (jamais as lossy), nommage ADR-029 (zéro abréviation).
  • Qualité, avant tout merge : cargo fmt/clippy -D/test, cargo xtask barrier (WARN = vert ; 5 advisory unsafe pré-existants security.rs/inotify.rs ignorés), couverture 100 % lignes (exceptions STRUCTURAL = marqueur in-code // COVERAGE (STRUCTURAL, ADR-035), comme air-process/air-poll), fuzz sur toute surface de parsing d’octets externes (sinon « Note fuzz » justifiée dans la spec).
  • Git : trunk-based, tout par PR sauf doc pure → commit direct sur main (pas de CI pour de la doc). DCO -s + signature GPG (clé BDFL), jamais de trailer Co-Authored-By. Production déléguée à claude -p headless (carbon/speedy) ; le superviseur pilote validation + push + PR + merge (squash) + re-vérif main/trailer.
  • Nouvelle crate ⇒ committer Cargo.lock (reproductibilité ADR-025).

4. Jalons réalisés (résumé — détail dans JOURNAL.md)

  • Couche 0 — scellée couche-0-v1 (2026-06-14) puis re-scellée jusqu’à couche-0-v1.8 (2026-07-02, famille terminal/termios-tty, ADR-060), puis les descellements additifs de la campagne libc/std : v1.9 (signaux async, ADR-066), v1.10 (sockaddr/sockopt libc, ADR-070/071), v1.11 (additifs cumulés libc/std — chroot/killpg/sigaltstack/sendfile/fchmod/fchown/futimens/munmap_raw, ADR-085) et v1.12 le 2026-07-10 (raw_syscall, escape hatch syscall générique, exception temporaire à retirer, ADR-087, jalon M5). Code complet, plus d’évolution hors RFC (descellement additif).
  • Couche 1 — cœur livré : air-base-lib (cœur + services), air-crypto, air-socket, air-filesystem, air-memory, air-device, air-thread, air-process, air-config, air-stdio, air-runtime, temps, i18n no_std (icu4x, ADR-059), futex maison, loom (ADR-058).
  • Couche 1 — SCELLÉE couche-1-v1.0 (2026-07-03, ADR-062) : campagne close. Audits pré-sceau face-libc (#212) / face-PAL (#213) ; les 4 P0 — AirFile (#214), air-poll (#215), termios/PTY (#216), air-signal (#217) ; remodel os_str env/args pré-sceau (#218) ; doctrines gravées ADR-063 (instrumentation IO) + ADR-064 (signaux, dont §7 barrière de poison). Re-scellée additivement jusqu’à couche-1-v1.9 (v1.1 errno_location ADR-065 ; v1.2 signaux/futex ADR-066 ; v1.3 air-account ADR-067 ; v1.4/v1.5 credentials + drop_to_user ADR-068 ; v1.6 air-handle + moteur socket ADR-069 ; v1.7 Managers de domaine ADR-077 ; v1.8 spawn_process socle posix_spawn ; v1.9 introspection thread cible ADR-086, jalon M5). Earmark ADR-078 crypto air-tls → v1.10.
  • 🎉 Jalon M5 — std tourne sur Air, zéro glibc (2026-07-10). Un programme Rust std (hello-std, static-PIE) lié intégralement contre libair (la libc d’Air, sans glibc) s’exécute on-target sur les 2 arches (hello from std on air + exit 0 sur x86_64-air/carbon ET aarch64-air/raspi). Le lien passe de 12 → 0 symboles indéfinis (5 PR #308–#314 : __xpg_strerror_r, open/fcntl, introspection thread [ADR-086], getauxval/pthread_self/détection-pile, TLS keys, syscall générique [ADR-087]). Le pari ADR-076 (std sur la libc d’Air) est tenu de bout en bout : toolchain Rust full-Air prouvée. Re-sceaux couche-0-v1.12 + couche-1-v1.9.

5. Dettes connues (à planifier, non bloquantes pour le sceau)

  • 🔴 La couverture « couche 2 » n’est PAS reproductible hors CI — sa référence dépend d’un répertoire de build. Instruit le 2026-09-05, sur demande du BDFL.

    Machinecouche 2, CARGO_TARGET_DIR neuf
    speedy66,91 %
    carbon44,88 %
    hydrogen35,16 %
    référence du gate (couverture-reference.toml)77,11

    Aucune machine du parc n’atteint 77,11 en mesure isolée — pas même carbon, qui l’a établie. La référence n’est atteinte qu’en CI, où le job enchaîne deux mesures (lignes puis branches) dans des répertoires persistants : au 2026-09-05, couverture-lignes-couche2 contenait 2 825 .profraw et couverture-branches-couche2 55. Le gate mesure donc l’état cumulé d’un répertoire, non la couverture d’un run.

    Ce qui est écarté, chacun vérifié : sccache, les comptes système, io_uring bridé par sysctl, la toolchain (même pin, même hash sur les trois), les outils externes (tous présents), la charge machine, et l’exécution des tests — 1177 passent identiquement partout, et une crate isolée mesure pareil sur les trois (68,2–68,3 % pour air-sshd).

    Comment le défaut s’est révélé : le label air-couverture-reference a été déplacé de carbon vers hydrogen, dont le répertoire était vierge. Le job y a rendu 35,09 % au lieu de 77 — non parce que la machine était défaillante, mais parce qu’elle était neuve. Le label est revenu sur speedy depuis, mais cela ne corrige rien : la première machine dont le répertoire sera purgé reproduira le symptôme.

    ⚠️ Une mesure prise dans un répertoire déjà utilisé est FAUSSE, et silencieusement. Un relevé sur speedy avait rendu 77,18 % avec « 0 ligne non couverte sur 53 crates » — invraisemblable, et non relevé sur le moment. Dans un répertoire neuf : 66,91 %.

    À trancher : soit le gate purge son répertoire avant de mesurer (et la référence est réétalonnée à ~67), soit il assume de mesurer un cumul et le dit. En l’état il affirme mesurer une couverture et mesure autre chose.

  • 🔧 Escape hatch syscall/raw_syscall (ADR-087) — à RETIRER avec l’équipe Rust. Le pal std de *-linux-air appelle quelques syscalls (gettid, futex…) via la fonction C générique syscall, ce qui a imposé un raw_syscall en couche 0 (entorse assumée à ADR-021 conv. 3 + à la règle « libc bind couche 1 »). Dette explicite : travailler avec l’équipe Rust pour que le pal std sur Air se passe du syscall générique (chaque op via son wrapper typé), puis retirer raw_syscall de la couche 0.

  • 🔧 GlobalAlloc safe (air-alloc) — à RENDRE safe avec l’équipe libs Rust. Doctrine fondatrice : le unsafe doit vivre en couche 0 uniquement (barrière kernel) ; l’impl de GlobalAlloc par air-alloc (couche 1) reste unsafe par le trait. But = supprimer ce unsafe dans le cas d’Air (action item amont teams libs Rust), même registre que raw_syscall. Voir la doctrine unsafe (macro-architecture, Section 1/2/8).

  • Lane CI nightly hello-std/rt à monter : le harnais M5 (rt/, hors-arbre) n’est pas encore construit/exécuté en CI. Leçon gravée : jamais différer les runs raspi.

  • Gates de couverture couche 1 : cargo xtask couvrable-vide/audit-exceptions ne réconcilient mécaniquement que crates/air-sys-* (couche 0). Les crates couche 1 documentent leurs exceptions STRUCTURAL in-code. Étendre les gates à la couche 1 bute sur une collision de basename (time.rs couche 0 ET couche 1) → décision de design de gate (RFC/tâche séparée).

  • mdBook non pinné — à figer pour la reproductibilité (ADR-025).

  • air-doc-publish.sh vit hors dépôt (speedy) — à éventuellement versionner sous tools/.

  • Instrumentation IO (ADR-063) : décidée, à implémenter en additif v1.x.

  • 🔬 remote_forward — l’hypothèse retenue depuis un mois est ÉCARTÉE (2026-09-04). Quatrième occurrence recensée, et la première qui apprend quelque chose — grâce à l’instrumentation de #916.

    Ce que le message dit désormais : « lecture de l’écho ÉCHOUÉE après 9,3 µs : Connection reset by peer (errno 104) — 0/39 octet(s) reçu(s) ». Et ce qu’il ne dit pas compte autant : aucune cause serveur n’a été restituée. Le hook s’armant à la première consignation, son silence signifie que serve_ssh_connection n’a pas échoué.

    Cela écarte la piste suivie jusqu’ici — un bind du serveur qui échouait en silence, port éphémère volé par un test étranger (corrigé le 2026-08-16 en tirant sous 32768). Le serveur va bien.

    Nouvelle hypothèse, à confirmer : connect_with_retry réessaie jusqu’à cent fois et aboutit, donc le port distant écoute. C’est l’écho qui est réinitialisé, immédiatement. La course ne serait donc pas sur le bind mais sur l’ouverture du canal — le serveur accepte sur le port distant et ouvre un forwarded-tcpip que le client n’est pas encore prêt à accueillir.

    Ce qui la confirmerait : faire dire au test l’état du canal côté client au moment du reset. Ne pas relancer en espérant : quatre occurrences ont déjà été relancées.

  • 🔬 mediation.is_closed() d’air-sshd — rouge intermittent, JAMAIS reproduit. Consigné pour ne pas être redécouvert par accident : il n’a pas de cause établie, et aucune tentative de reproduction n’a abouti. Ce qui le traitera n’est pas une relance : il faut que le test dise ce qu’il a vu au moment où il échoue — l’état des deux côtés de la médiation —, faute de quoi chaque occurrence ne rend qu’un booléen faux.

  • ✅ SOLDÉE le 2026-09-02 — le bus n’encodait pas quatre rapports. DependencyMissing, DependencyFailed, ReadyTimeout et DevenuPret ne sortaient que par le rapport texte — visibles à l’opérateur, invisibles à un abonné — alors que ADR-161 D6 exige que le lanceur les dise « nommément, sur son bus d’événements ». Ce sont les ordinaux 12 à 15 du schéma launch_event, ajoutés ensemble comme le traitement le prévoyait : un schéma qu’on rallonge quatre fois brûle quatre fois des ordinaux pour un seul besoin. Ce qui reste muet — Signalled, Killed, StopRequestedn’est pas une dette : ces trois-là disent ce que le lanceur fait, non ce que le service est. C’est cette distinction qui avait été perdue : les quatre soldés ici étaient rangés avec eux par ressemblance (« ça ne s’encode pas »), et une dette dormait derrière une catégorie.

  • ⚠️ NOUVEAU (2026-09-02) — rien ne vérifie que le code capnp COMMITTÉ suit son schéma. ADR-040 grave la politique « code généré committé » : « un mainteneur régénère le code Rust uniquement quand un schéma change, et commite le .rs généré ». C’est une obligation posée sur l’humain, qu’aucun contrôle ne relit — ni workflow, ni xtask, ni script. Elle a été manquée : #894 a renommé ready en readyRetire dans le .capnp le 2026-09-01 sans régénérer, et le .rs committé est resté celui du 2026-08-10 — quatre jours durant, le code généré exposait set_ready, un nom que le schéma ne contenait plus. Rien n’a rougi, et rien n’aurait rougi. Les cinq autres schémas du dépôt sont à jour, ce qui rend la lacune d’autant plus facile à ne pas voir. ✅ SOLDÉE le jour même par cargo xtask check-schemas, qui ne régénère pas — ce serait exiger en CI le tool C++ qu’ADR-040 en écarte délibérément. Il constate : le .rs porte en en-tête l’empreinte du schéma normalisé dont il est issu, et le gate la recalcule. Rien que std, aucun octet de C++. La normalisation retire les commentaires, et il le faut : un commentaire ne change pas le code généré — vérifié en régénérant après ajout d’une ligne, fichier identique au bit près. Sans elle, chaque reformulation rougirait sur des schémas très commentés, et le gate serait désarmé en pratique. Un # dans une chaîne fait refuser plutôt que tronquer. Ce qu’il ne voit pas, et qui est écrit dans sa doc : que le .rs soit correct (cela demanderait de régénérer), et qu’il ait été édité à la main. Il attrape le défaut observé — un schéma modifié sans régénération — et rien de plus.

  • 🔬 DEFAULT_MEMORY_HIGH_BYTES est mesurée mais ne dit ni OÙ ni QUAND. Son commentaire cite ses chiffres — « ~819 Kio par connexion, 1,5 Mio au repos » — mais ADR-174 D5 exige la machine et la date : une mesure sans machine n’est pas reproductible, une mesure sans date ne dit pas si elle a survécu au code. C’est la seule borne mesurée du lanceur, et il lui manque sa provenance. Relevé par #903, non corrigé — tant qu’elle n’est pas soldée, ADR-174 n’est pas intégralement réalisé.

  • Flake préexistant air-libc-stdio (révélé sous cargo test --workspace en forte charge, taux faible) : des tests inspectant errno après échec (fputs_null_and_write_on_readonly, close_reports_backend_error) échouent par intermittence — course probable sur l’état global (registre FILE) entre tests parallèles, bien qu’errno soit thread_local. Hors périmètre M3 (crate non modifiée). À corriger séparément (sérialiser ces tests).

Un motif qui revient : le compteur global partagé entre tests parallèles.

Trois crates l’ont montré, et les trois de la même façon — un test lit un état global, agit, puis vérifie un delta, pendant qu’un autre test le modifie :

  • air-libc-stdio (registre FILE) — ci-dessus, ouvert ;
  • air-value (OBJ_COUNT) — six tests sur sept prenaient le verrou, le septième l’oubliait. Corrigé (#899) ;
  • air-com (FINALIZED) — aucun des deux tests ne prenait de verrou, faute de verrou. Corrigé (#914).

Ces flakes ne se voient qu’en forte charge, donc surtout sur carbon sous llvm-cov, et ils font échouer des PR qui ne touchent pas la crate en cause. Devant un rouge intermittent dans un test qui compte quelque chose, chercher l’état global avant de relancer.


Ce document se met à jour à la main au fil des merges. Pour l’historique fin (session par session, PR par PR), voir JOURNAL.md.