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

Air — Journal de bord

Archive chronologique append-only (détail session par session, PR par PR). Pour l’état vivant « où en est-on / prochaine tâche / tâches à venir », le document de suivi est etat-avancement.md — à lire en premier. Ce journal reste la trace historique fine.

2026-09-07 — le JALON B est franchi, et onze défauts avec lui

Une session SSH complète, servie par air-sshd sous air-launchd, sur carbon. air-sshd.service désactivée et supprimée ; air-launchd.service active et enabled ; le démon sur le port 22, cagé par seccomp et Landlock dérivés de son octroi. Le critère d’ADR-168 D2 est littéralement vrai — c’est un état de MACHINE, et il a été annoncé deux fois à tort avant de l’être.

  • #958 — le premier lancement RÉEL a trouvé onze défauts, chacun par un refus du noyau, aucun par un test existant. Le harnais du jalon B construit sa ServiceSpec en mémoire : il prouvait le chemin d’exécution, jamais celui de l’installation.
    • Landlock refusait un droit de répertoire sur un fichier (EINVAL) — premier octroi de l’histoire à nommer un fichier ; le NOYAU décide du type, pas le manifeste ;
    • le manifeste ne passait pas --config, et le démon n’a aucun défaut ;
    • /etc/passwd puis /etc/group refusés — la prémisse de D20.5 était fausse : le démon passe bien par air-account, mais c’est un manager per-process, donc la lecture a lieu sous sa propre cage ;
    • SIGSYS sur pipe2 ⇒ catégorie Privsep (arbitrage BDFL), ouverte par la famille session ; 17 appels entrés au vocabulaire seccomp ;
    • le démon ne pouvait pas lire son propre manifeste — l’octroi ne peut pas gouverner sa propre lecture sans devenir circulaire ;
    • SIGSYS sur getppid : il ne vivait que dans le bloc transitoire, donc promis à disparaître en v1.0, alors qu’ADR-129 en dépend. Passé au socle ;
    • capset portait un mauvais numéro aarch64 — un chiffre faux dans un filtre seccomp n’échoue pas : il ouvre ou ferme le mauvais appel, en silence, sur une seule arche ;
    • seccomp/landlock_* manquants : un filtre seccomp est hérité et additif ;
    • les programmes de session n’étaient pas exécutables — la famille donnait la permission, Landlock ne la rendait pas exécutable ;
    • la session mourait sur access puis uname : le bloc transitoire visait le démarrage d’un démon, pas un shell interactif. Mesuré par strace -f -c /bin/bash — sur les 42 appels employés, deux manquaient.
  • ADR-180 — le gabarit {home} : un chemin par utilisateur qu’aucun manifeste statique ne peut énumérer, développé par le LANCEUR à la pose de la cage. Accorder /home aurait donné toutes les données personnelles pour un fichier de 1 Kio.
  • ADR-181 (#959)la doctrine du bon citoyen, arbitrage BDFL : ce qu’on déclare, on l’obtient ; ce qu’on tait, on ne l’a pas. Le manifeste est le lieu unique de la déclaration, rien n’est deviné, et le refus doit être lisible.
  • ADR-168 D2.1/D2.2 — l’hébergement du lanceur (feuille cgroup, Delegate=yes) et la bascule par cohabitation. Le mécanisme existait déjà ; seule la ligne d’unité manquait.
  • ADR-169 (#957)startup, septième famille d’octroi ; requires @4 brûlé.
  • Correctifs du jour : #951 (clés acceptées, D20.3), #952 (suivi 3b), #953 et #956 (manifeste), #954 (ordinal d’ADR-169), #955 (ADR-168 D2.1/D2.2).

Leçon de méthode, chiffrée : 64 runs de CI sur deux PR en six heures, dont neuf défauts sur dix étaient trouvables par une seule commande locale. Directive BDFL : barrière en local avant chaque push.

2026-09-07 — le plafond de service, la sixième famille, et la provenance enfin décidable

Campagne ADR-150 D19/D20, ouverte par le blocage du jalon B : air-sshd mourait de SIGSYS avant d’écouter, parce que le vocabulaire seccomp ignorait io_uring.

  • #942 → #944 — jalon B franchi pour de bon. Le harnais rendait ok par un return silencieux, sans que le démon ait jamais démarré ; il échoue désormais en nommant la cause. Deux défauts trouvés dans la foulée : poll manquait au vocabulaire seccomp, et air-sshd n’avait aucun mécanisme d’arrêt — il mourait tué (5008 ms → 2 ms).
  • #943 — deux workflows syntaxiquement invalides sur main, que rien ne voyait. Gate check-workflows écrit dans la foulée.
  • #945 / #946 — le gate de couverture mesurait un répertoire, pas un run (--no-clean). Retiré, trois références réétalonnées en répertoire neuf.
  • #947 / #948 — le témoin instrumenté mourait de SIGSYS sous la couverture (sched_getaffinity, appelé par le runtime de profilage de LLVM) ; le code 125 d’une cage refusée devient reconnaissable plutôt qu’un nombre nu.
  • #949 / #950 — ADR-150 D19 (io_uring, sixième famille d’octroi) et D20 (le plafond d’un service est explicite — pas de default(), sinon le défaut deviendrait la politique). D20.4 corrige D19 dans la foulée : la famille avait été créée sans plafond, donc hors arbitrage.
  • #951D17 tenu, D20.3 branché. ceiling_for était écrite, testée et inerte depuis un jour, sur deux objections dont une seule était fondée : l’image ne connaissait aucune clé de projet. La liste que D17 décrivait depuis le 2026-09-04 n’existait dans aucun fichier — verifies() rendait la clé du signataire sans la comparer à rien. ACCEPTED_PROJECT_KEYS l’écrit, vérifiée contre la signature d’un manifeste réellement produit.
  • Le jalon B n’est PAS franchi, contrairement à ce que le suivi affirmait depuis le 2026-09-05. Son critère de sortie (ADR-168 D2) est un état de MACHINE — « l’unité air-sshd.service retirée, le service tournant sous le lanceur » — et air-sshd.service est toujours enabled/active sur carbon. Ce qui est acquis est la preuve en test, et ses deux conditions (ADR-172 D4, ADR-174 D4). Restent : l’unité air-launchd.service, que aucun ADR ne spécifie (et dont la place dans la hiérarchie cgroup est un arbitrage), et la procédure de bascule.
  • Suivi remis d’aplomb, et 3b close : etat-avancement.md décrivait 3b « au tiers (au 2026-08-30) » alors que cinq PR l’avaient dépassée. Les quatre tâches sont faites — 2.1 (#889), 2.2 (#909), 2.4, et 2.3 mesurée le 2026-09-05 (ADR-174 D4).
  • La mesure ADR-174 D4 reproduite le 2026-09-07 sur raspi-srv-2 : READY 16 ms au pire, STOP 2 ms, 20 tours, 0 écarté — les mêmes chiffres que le relevé du 05, au même noyau. Les bornes restent à 30 000 / 10 000 ms (arbitrage BDFL, et démarrage tiède).

2026-09-05 — le gate de couverture mesurait un répertoire, pas un run

Instruction demandée par le BDFL après un déplacement de label qui a fait chuter la couverture « couche 2 » de 77 % à 35 %. Dix hypothèses écartées, une cause établie.

  • Ce n’est pas la machine. Les mêmes 1177 tests passent sur speedy, hydrogen et carbon ; une crate isolée (air-sshd --lib) y mesure 68,34 / 68,19 / 68,34 %.
  • C’est l’agrégat couche 2, et il dépend du CARGO_TARGET_DIR. Dans un répertoire neuf : speedy 66,91 %, carbon 44,88 %, hydrogen 35,16 %. La référence du gate est 77,11aucune machine ne l’atteint, pas même carbon qui l’a établie. Elle n’est atteinte qu’en CI, sur des répertoires persistants (2 825 .profraw accumulés dans couverture-lignes-couche2).
  • Hydrogen n’était pas défaillant : il était NEUF. Le job y a démarré avec un répertoire vierge. Le symptôme reviendra sur la première machine dont le répertoire sera purgé.
  • Une mesure dans un répertoire déjà utilisé est fausse, et silencieusement : un relevé sur speedy annonçait 77,18 % avec « 0 ligne non couverte sur 53 crates », ce qui n’a pas été relevé sur le moment. Répertoire neuf : 66,91 %.
  • Écartés, chacun vérifié : sccache, comptes système, io_uring bridé, registre cargo en root, toolchain (même pin, même hash), outils externes, charge machine, profils résiduels, exécution des tests.

Trouvé au passage : hydrogen compilait partiellement depuis le 15 août — 48 125 fichiers de ~/.cargo/registry appartenaient à root, fnv ne compilait plus. Réparé, ses 231 tests io_uring repassent. Son défaut rustup pointe aussi sur 1.96.0 au lieu de stable.

Dette et arbitrage consignés dans etat-avancement.md §5 : soit le gate purge avant de mesurer et sa 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.

2026-09-05 — la mesure trouve un démon qui ne savait pas s’arrêter

Suite directe de l’entrée ci-dessous. ADR-174 D4 — chronométrer air-sshd, et non le témoin — a enfin eu lieu : le jalon B lui a fourni son sujet, et deux workflows réparés lui ont fourni le moyen.

  • Deux workflows étaient INVALIDES sur main (#943), mergés vingt minutes plus tôt : un script Python inline en colonne 0 fermait son bloc littéral. mesure-bornes était passé de « ne mesure rien » à « ne démarre pas » ; jalon-b n’avait jamais pu s’exécuter. Rien ne le voyait — un workflow invalide n’échoue nulle part. D’où cargo xtask check-workflows, bloquant, vérifié contre le défaut réel, et le script sorti du YAML.
  • La mesure a trouvé ce qu’elle ne cherchait pas, pour la quatrième fois de la campagne : DEFAULT_STOP_DEADLINE_MS = 5008 ms, médiane égale au pire sur vingt tours. Un décompte, pas un délai. air-sshd n’avait AUCUN mécanisme d’arrêt — sa boucle était un accept infini, et il mourait par SIGKILL. Deux textes affirmaient le contraire, dont son unité .service et le corps de la PR du jalon B.
  • L’arrêt est implémenté : ShutdownSignal::into_owned_fd() en couche 2, join_all remplacé par Unordered — les écoutes et l’arrêt courent ensemble, le premier gagne, sans créer de fil (ADR-129 §D6). Le STOP passe de 5008 ms à 2 ms.
  • Le harnais asserte désormais le code de sortie, et non plus la seule publication : un service tué par SIGKILL ne peut plus passer pour un service bien élevé.
  • Relevé inscrit dans ADR-174 D4 — Pi 4, 20 tours : READY 13/16 ms, STOP 2/2 ms. Les bornes ne bougent PAS : arbitrage BDFL du 2026-09-03, et la mesure porte sur un démarrage tiède — un premier boot générant son keystore n’a pas été mesuré.

Au passage : la mesure de couverture a quitté carbon (MacBook 2011, disque mécanique) pour hydrogen (SSD). Le même code y rendait 80,48 % contre 80,99 % — un demi-point que la machine décidait.

2026-09-05 — un test qui rendait « ok » sans rien prouver, et ce qu’il cachait

Campagne du jalon B d’ADR-168 : air-sshd sous air-launchd. Le jalon avait été annoncé franchi le 2026-09-04 (#942). Il ne l’était pas.

  • Le harnais sortait par un return quand le lancement échouait, et rendait alors ok : CI verte, démon jamais démarré. Un test qui rend ok sans avoir rien exécuté n’est pas un test faible, c’est un faux témoignage. Il échoue désormais, devient #[ignore], et le workflow jalon-b lui fabrique l’environnement de preuve.
  • Il inventait sa cause — « bornes non posables ici ? » — tout en jetant l’erreur qu’il tenait dans la main. Cinquième occurrence du motif dans ce dépôt.
  • La cause réelle, cherchée deux jours : systemd-run --scope … cargo test laisse deux processus dans le scope ; cargo y reste, le cgroup demeure peuplé, et no internal processes interdit d’y distribuer pids/memory. On compile d’abord, on ne place que le binaire dans le scope.
  • Le même défaut rendait mesure-bornes muet : ses quatre runs des 2026-09-02 et 03 sont tous rouges sur Failed to find executable cargo. Ce job n’avait jamais relevé une seule valeur — les chiffres cités ces jours-là comme mesurés sur raspi-srv-2 n’en venaient pas.

Trois défauts réels, tous découverts parce que le test s’est mis à échouer :

  • poll manquait à la catégorie transitoire. La glibc l’émet sur x86_64 ; il n’existe pas sur aarch64, si bien qu’un binaire GNU confiné mourait sur speedy et survivait sur le Pi 4. Une preuve d’une seule arche n’en est pas une (ADR-014 par l’exemple).
  • io_uring n’était dans aucune catégorie — donc aucun service Air confiné ne pouvait faire d’entrées-sorties. Arbitrage BDFL : il devient la sixième famille d’octroi (ADR-150 D19), dédiée et jamais ouverte par une autre — un anneau est une seconde porte vers les descripteurs, que seccomp ne peut pas suivre au-delà de l’ouvrir.
  • L’octroi du harnais n’ouvrait pas la famille réseau : un démon SSH sans droit d’écouter.

Le jalon B est franchi, cette fois avec un test qui échoue quand il ne prouve pas.

Audit du motif « sauté = vert » : 246 sites, dont 129 légitimes (outil externe absent) et 14 qui portent la cage et les bornes — ceux-là ne tournent nulle part, et c’est pourquoi six appels manquants au vocabulaire de confinement ont pu passer inaperçus.

2026-09-03 — mesurer les bornes a trouvé deux défauts que personne ne cherchait

Campagne ADR-174 : chronométrer les bornes du lanceur au lieu de les raisonner. La mesure a fini par aboutir — et ce qu’elle a trouvé en chemin vaut plus que ses chiffres. Deux défauts qui rendaient ADR-172 littéralement inapplicable, et cinq refus successifs avant que le premier relevé n’existe.

Les chiffres, enfin (#921, #922, #929)

Sur raspi-srv-2 (Raspberry Pi 4, Ubuntu 26.04, noyau 7.0.0), cinq tours, zéro écarté :

bornemédianepire
DEFAULT_READY_DEADLINE_MS3 ms4 ms
DEFAULT_STOP_DEADLINE_MS1 ms1 ms
DEFAULT_MEMORY_HIGH_BYTES268 Kio512 Kio

L’instrument échoue sur l’absence de mesure, jamais sur un chiffre : un gate déguisé déciderait la borne qu’il est censé éclairer. Il écarte plutôt qu’il n’invente, et il n’a rien inscrit.

⚠️ granted.aircom n’ouvrait AUCUN appel (#927)

syscall_categories_for ne regardait que filesystem et network. Un service confiné qui publiait son nom — ce qu’ADR-172 D2 lui impose — mourait par SIGSYS. Le droit était accordé côté octroi et refusé côté noyau : un octroi qui n’ouvre rien n’est pas restrictif, il ment.

Corrigé au minimum : socketpair et lui seul. Ni socket, ni connect, ni bind — une paire ne se lie à rien et n’écoute nulle part, et un test verrouille cette limite.

⚠️ SO_PEERCRED ne peut pas identifier un demandeur sur un socketpair (ADR-172 D7, #928)

Le registre lisait SO_PEERCRED sur le canal remis au service. Sur un socket issu de socketpair(), le noyau rend les credentials du créateur de la paire — le lanceur — et non de qui écrit dedans. Mesuré : le lanceur admettait le pid 768251, le registre en voyait 768248. Aucun service ne pouvait publier son nom.

Le raisonnement du code était juste, sa prémisse ne l’était pas : « l’identité vient du noyau, jamais du demandeur » reste vrai — mais le noyau constatait le créateur.

D5, par amendement d’ADR-172 : l’identité vient de la capability. Le canal est fabriqué par le lanceur et remis à un seul service ; aucun autre ne peut l’obtenir. L’addendum annonçait d’abord une table canal → identité ; la mise en œuvre l’a rendue inutile — le lanceur itère déjà sur ses services — et le texte a été corrigé plutôt que laissé décrire une structure absente.

Aucun test ne pouvait voir ce défaut : ils créaient la paire et publiaient dans le même processus, où le créateur est le demandeur. Les deux pids coïncidaient par accident de montage. Il a fallu deux processus réels, sur du matériel.

Six couches d’information jetée (#926, #929)

Chaque échelon connaissait la réponse et la jetait :

  1. « un jalon manque » ne disait pas lequel ;
  2. rien ne rapportait ce que le lanceur avait vu ;
  3. let Ok(_issue) jetait le sort du service ;
  4. le témoin enchaînait and_then(…).ok() ;
  5. le registre refusait sans dire pourquoi ;
  6. son motif ne distinguait pas « admis sans ce nom » de « jamais admis » — deux corrections opposées derrière un même refus.

La sixième a tranché. Au même moment, air-process a cessé de réduire dix causes d’échec de cage au seul code 125 (#926), avec les parties pures extraites et testées — l’enfant post-fork n’est jamais mesuré (CHILD-EXIT, ADR-035), et y laisser un match aurait ajouté dix bras invérifiables à une crate de couche 1 tenue à 100 %.

Cinq refus avant le premier relevé — et ce qu’ils ont appris (#924, #925)

refusce qu’il a révélé
« aucune racine de délégation »le job cherchait une session utilisateur ; le runner est un service systemd
la correction lisait cgroup.controllers : elle serait passée au vert avant d’échouer plus loin
--pty incompatible avec --scopeune option poussée sans être essayée, par-dessus un montage vérifié
cargo introuvablesystemd-run résout l’exécutable avec son propre PATH
bornes non posablescargo test reste dans le scope : la règle no internal processes mord encore

Le blocage de fond n’aurait pas été trouvé sans vérifier que la délégation était effective et pas seulement déclarée : le cgroup du runner contient trois processus à lui en permanence, et un cgroup peuplé ne distribue rien à ses enfants. Un Delegate= posé sur son unité — essayé sur raspi puis retiré — n’y change rien. La mesure tourne donc dans un scope systemd-run à elle, avec --property=User= et non --uid : sans quoi le cgroup reste à root.

check-schemas a servi dès sa première occasion (#920, #930)

ADR-040 grave « le mainteneur régénère et commite » comme une obligation sur l’humain, que rien ne relisait : #894 avait renommé un champ d’un .capnp sans régénérer, et le .rs committé exposait quatre jours durant un nom absent du schéma. Le gate écrit ce jour-là a attrapé le cas suivant le lendemain, quand readyDeadlineMillis @6 est entré au manifeste.

La borne de « prêt » se déclare (#930)

READY_DEADLINE valait 30 s pour tout le monde, sans option ni champ : un service qui démarre en 20 ms était borné comme celui qui met dix secondes. Trois chemins désormais — --ready-deadline-ms, readyDeadlineMillis @6, et le défaut pour qui se tait.

La valeur reste à 30 000, et c’est délibéré : les 4 ms mesurés sont ceux du témoin, le plus petit service possible. Ils chiffrent le mécanisme, pas la vie d’un service réel. Mesure due, nommée dans le code : chronométrer air-sshd sur raspi-srv-2 — décision du BDFL, au moins une fois. ADR-174 D4 reste ouvert.

2026-09-02 — les dettes du lanceur soldées, et quatre défauts trouvés en les soldant

Campagne de dette : consigner ce qui ne vivait que dans une conversation, puis solder ce qui pouvait l’être. Elle a surtout mis au jour ce qu’on ne cherchait pas — un motif de test qui a frappé quatre fois, un plancher de couverture qu’on allait poser trop haut, un relevé promis qui n’était écrit nulle part, et une règle gravée que rien ne relit.

Les dettes : trois consignées, une soldée (#915, #918)

La question posée était simple — la dette ouverte est-elle prise en compte dans les runs ? La réponse était partiellement : huit gates comptent une dette et refusent qu’elle empire, mais sur cinq dettes citées en séance, deux seulement figuraient dans un document. Les trois autres ne vivaient que dans la conversation, donc nulle part.

Soldée dans la foulée (#918) : le bus n’encodait pas quatre rapports — DependencyMissing, DependencyFailed, ReadyTimeout, DevenuPret —, alors qu’ADR-161 D6 exige que le lanceur les dise « nommément, sur son bus d’événements ». Ordinaux 12 à 15 du schéma launch_event, ajoutés ensemble : un schéma qu’on rallonge quatre fois brûle quatre fois des ordinaux pour un seul besoin. becameReady et non ready — l’ordinal 3 retiré portait une annonce du service, celui-ci porte un constat du lanceur.

Ce que ce solde apprend dépasse le solde : les quatre étaient rangés avec Signalled, Killed et StopRequested par ressemblance — « ça ne s’encode pas ». Or ces trois-là disent ce que le lanceur fait, les quatre autres ce que le service est. Une dette dormait derrière une catégorie.

Le motif qui a frappé quatre fois : l’état global entre tests parallèles

Trois occurrences le même jour, la quatrième le lendemain — et c’est la plus instructive :

  • air-value (OBJ_COUNT) : six tests sur sept prenaient le verrou, le septième l’oubliait (#899) ;
  • air-com (FINALIZED) : aucun des deux ne le prenait, faute de verrou (#914) ;
  • air-libc-stdio (registre FILE) : ouvert, connu de longue date ;
  • puis la mienne (#917) : le test ajouté par #916 comparait deux lectures successives du même journal partagé, et affirmait leur égalité. Elle n’était pas fragile — elle était fausse : vraie seulement à instant figé, et rien ne fige cet instant.

#915 avait écrit la règle — devant un rouge intermittent dans un test qui compte quelque chose, chercher l’état global avant de relancerquelques heures avant que je ne la manque. Le remède, cette fois, n’était pas un verrou de plus : le journal est alimenté par des tests réseau qu’aucun verrou local ne discipline. C’est de sortir l’état de la fonction.

Le cliquet : trois mesures du même code, deux règles pour les départager (#913)

Les cinq positions d’air-launchd dont le résultat dépendait du nombre de cœurs sont couvertes ; le cliquet passe de 76,56 à 77,11. Le chemin y menant vaut d’être noté : 77,14 → 77,17 → 77,11, sur exactement le même arbre.

  1. une mesure amputée ne compte pas — le 77,14 venait d’un run où privsep_root avait bloqué 900 s et s’était fait trancher ; on l’écarte non parce qu’il est bas, mais parce qu’il ne mesure pas ce qu’il prétend ;
  2. entre deux mesures valides, le plancher prend la plus basse — 77,17 et 77,11 sont tous deux complets ; leur écart est le battement (0,06 point), et un plancher se pose sous le battement. À 77,17 il restait 0,04 de marge sous la tolérance de 0,10 : la PR suivante aurait rougi sans aucune régression.

Le second point corrige le premier commit, qui écrivait « on retient la mesure complète, pas la plus basse » — juste contre un run amputé, faux entre deux runs valides.

Ce que le serveur savait, et que huit fichiers sur neuf jetaient (#916, #917)

Le mécanisme de consignation d’air-sshd posait le bon principe dans sa propre doc — conserver avant de jeter, et rendre le tout au message d’échec. Neuf fichiers consignaient, un seul restituait. Un hook de panic armé à la première consignation tient la promesse partout, sans toucher aux huit autres : il voit tous les échecs par construction, y compris ceux d’un fil que le harnais n’attend pas.

⚠️ Une règle gravée que rien ne relit — le code capnp committé

Trouvée en régénérant le schéma : #894 avait renommé ready en readyRetire dans le .capnp le 2026-09-01 sans régénérer. Le .rs committé datait du 2026-08-10 et exposait encore set_ready, un nom que le schéma ne contenait plus. Quatre jours, aucun rouge — et aucun rouge n’était possible.

ADR-040 pose pourtant « le mainteneur régénère et commite » comme une obligation sur l’humain, qu’aucun workflow, aucun xtask, aucun script ne relit. Les cinq autres schémas du dépôt étaient à jour, ce qui rendait la lacune facile à ne pas voir. Consignée dans etat-avancement.md §5, avec la question qu’elle pose : ADR-040 confine délibérément capnp hors CI.

Au passage : hydrogen est la seule machine du parc portant les versions pinnées d’ADR-025 (capnp 1.1.0, capnpc 0.26.0).

Le reste

  • #909, #911 — le lanceur dit le franchissement de « prêt » (prérequis ADR-174 D4) ; le témoin publie son nom, sans quoi il cesse de témoigner de ce qu’il témoigne.
  • #912 — le relevé complet des positions non couvertes n’était écrit nulle part en CI : chemin relatif au répertoire courant, et répertoire jamais créé. Le remède existait à dix lignes de là, corrigé le 2026-08-18 pour un défaut identique.
  • #914air-com : deux tests comptaient l’un pour l’autre sur le compteur FINALIZED.

2026-09-01 — dix ADR ratifiés, le temps 3b clos, et un sceau qui rattrapait cinq campagnes

Suite de la campagne d’infrastructure de la nuit (entrée précédente), qui s’est prolongée en gouvernance puis en produit.

Les trous de la comparaison à systemd sont comblés — dix ADR (#896)

La comparaison d’air-launchd à systemd avait nommé sept manques. Cinq closent les specs du lanceur, quatre instruisent le jalon C d’ADR-168 :

  • ADR-169 — « l’octroi arbitre » ne tenait qu’à moitié : ServiceStartup est le champ @7, hors de requested, donc hors de l’invariant granted ⊆ requested. Sixième famille d’entitlements ; requires migre et son champ est brûlé. L’argument qui tranche est factuel : ADR-153 avait ouvert Entitlements quatre jours plus tôt pour un motif identique.
  • ADR-170 — la dépendance facultative, un axe et deux valeurs. Le défaut de systemd n’est pas d’avoir six mots, c’est de croiser deux axes ; prefers en ajoute un sur un seul, l’ordre restant toujours impliqué.
  • ADR-171 — la chute d’une base remet en attente. Décision centrale : le crédit de relance appartient à la faute.
  • ADR-172 — un service système publie son nom. Un NO GO relevé en cours de rédaction et consigné plutôt que corrigé en silence : la valeur listening proposée était fausse — le lanceur ne peut pas observer l’accept d’un enfant, et une sonde par connect() ne prouverait rien puisque la poignée de main aboutit par le backlog du noyau.
  • ADR-174 — toute borne porte son régime. L’inventaire dément une formule facile : sur neuf bornes, une est mesurée, quatre raisonnées, deux conventionnelles, deux non fondées.
  • ADR-175 — descellement couche-0-v1.17. mount par une fonction par usage : un mount générique ferait transiter par la couche la plus sensible un texte que le noyau analyserait.
  • ADR-176air-mount. L’idempotence se décide par statx, jamais par /proc/mounts — qui est circulaire : savoir si /proc est monté demanderait de lire un fichier situé dans /proc.
  • ADR-177 — la ligne de commande du noyau : ce qui est borné n’est pas la tolérance du parseur, c’est le domaine.
  • ADR-178 — la table d’alias de modules est compilée. Le glob n’est pas éliminé, il est déplacé — et l’ADR dit en quoi ce glob-ci diffère des deux que le dépôt a refusés : il désigne un pilote, pas un droit.
  • ADR-179 — PID 1 devient minimal, le superviseur relançable. ADR-168 D3 et D4 n’étaient pas tenables ensemble : la propriété « aucune panique atteignable » est atteignable sur quelques centaines de lignes, pas sur une pile de 19 crates.

Le sceau couche-0-v1.16 rattrapait CINQ campagnes, pas une

Demandé pour régulariser fchmodat2, entré dans la couche scellée le 2026-07-31 (#568) sans re-sceau. La vérification préalable a trouvé onze symboles sur cinq PR — #568, #659, #725, #726, #814 — dont quatre bitflags! et une constante de signal.

La première mesure était fausse : un balayage des déclarations en début de ligne n’en voyait que trois. Un inventaire de surface publique ne se fait pas au motif ; il se fait sur le diff complet de la couche, lu en entier. Le tag consigne la méthode.

air-launchd : le temps 3b est complet (#902, #905)

  • D5 — l’arrêt suit l’ordre topologique inverse, par passes symétriques du démarrage. La décision vit dans la fonction pure arretables, éprouvée sans processus.
  • D4 — la mort d’une dépendance remet en attente. rendre_a_l_attente ne consulte jamais le tracker : c’est ainsi que le crédit reste à la faute. Et le retour ne s’écrit pas — un dépendant en attente est indiscernable d’un service pas encore lancé, donc demarrer_ce_qui_peut le relance de lui-même, transitivité comprise.

Le filet de D5 semblait inatteignable — il faut un cycle, que D7 refuse au chargement. L’obstacle n’était pas l’environnement mais un invariant du chemin d’appel : aucune machine, fût-elle virtuelle, ne l’aurait levé. Ce qui le lève est d’appeler stop_everyone sur un état construit à la main, sans aucun processus.

air-sshd publie son nom (#908) — première condition du jalon B

Tout existait sauf le câblage : le lanceur héberge le registre et remet une capability, AirLaunchdHost la reçoit, air-com a un client. air-sshd ne demandait rien.

Trois pièces posées, dont deux qui dépassent le besoin : le trait gagne take_aircom_capability (un match sur la variante aurait trahi ce que le trait promet), et air-com réexporte ServiceName — sa façade exposait une fonction sans exposer de quoi l’appeler.

Ce que la CI a coûté et appris (#899, #900, #901, #904, #907)

  • un test d’air-value prenait un état global qu’il ne garantissait pas : six tests sur sept prenaient le verrou, il était le septième. C’est lui qui tenait couverture-audit rouge depuis le 31/08 ;
  • RUSTSEC-2026-0190 analysé et écrit plutôt qu’ignoré : anyhow n’entre dans aucun binaire Air (chaîne WASI/UEFI de getrandom). ignore reste vide — un warning visible et expliqué vaut mieux qu’un silence configuré ;
  • poser un tag périme docs/INDEX.md dans la seconde, et casse le build de toutes les PR : l’index lit le sceau depuis les tags git ;
  • couvrable-vide ne rendait pas ses propres répertoires de mesure. #895 n’a pas créé le défaut, elle l’a révélé — le chown global le masquait depuis toujours ;
  • un gate refuse désormais tout runs-on non auto-hébergé. Le dépôt est privé : un seul job hébergé a déjà consommé 3002 minutes sur 3000 et coupé la CI entière, exécuteurs auto-hébergés compris.

Une étude, et deux corrections qu’elle a values (#906)

Migration de la CI vers Buildbot, dépôt de code restant sur GitHub. Le relevé change la difficulté : sur 2 581 lignes de workflow, deux actions, un secret, zéro cache.

Deux prémisses corrigées après vérification : l’étude affirmait « aucune économie de minutes » — vrai au présent, mensonger par omission ; et le noyau Linux n’utilise pas Buildbot (KernelCI, 0-day/LKP, syzbot, LKFT, CKI). Les précédents qui comptent sont Yocto — qui construit déjà ce qu’Air construirait — et LLVM, qui est notre toolchain.

2026-09-01 — la CI cessait de mesurer ce qu’elle croyait garder

Campagne d’infrastructure, née d’un diagnostic de rouge et qui a fini par en trouver trois autres. Aucune ligne de production n’a bougé.

Le chown de reprise ne reprend plus que le répertoire du job (#895)

Mesuré sur carbon : ~/air-build pesait 285 Go et 1 035 095 inodes, et chaque job de chaque PR faisait un chown -R dessus avant son checkout. Le job loom de #891 y a passé plus de deux heures — pour un répertoire de build de 252 Mo. La file de CI n’était pas bloquée : elle chownait.

Le filet ne reprend désormais que air-build/${{ github.job }}. C’est sûr parce que chaque job a son propre CARGO_TARGET_DIR nommé d’après lui : un job root ne salit que le sien.

Le filet nocturne ne tombe plus (#897)

ADR-143 §4 a retiré la couverture complète du merge sur main et institué le nightly comme « le filet qui attrape la dérive lente ». Sur les huit derniers, il a réussi une fois : cinq annulés, deux rouges.

La cause est mécanique, et le run du 30/08 la donne à la seconde près — le schedule de 09:48:45 est annulé à 10:14:07, soit 26 s après un push sur main. Un schedule et un push sur main portent le même github.ref : le groupe ci-${{ github.ref }} les confondait, et cancel-in-progress: true faisait que tout merge annulait le filet en cours.

C’est ce qui explique qu’un écart de couverture de 4,73 points ait vécu six jours sans que rien ne rougisse. Le suivi le consignait comme défaut ouvert et concluait qu’il fallait « une mesure planifiée de main » : elle existait, elle était annulée presque chaque jour. Un dispositif qui se croit outillé et ne l’est pas — le défaut du préambule, sous une forme de plus, et pire qu’une absence puisqu’on compte dessus.

Correctif : l’événement entre dans la clé de regroupement. schedule et workflow_dispatch ont désormais le leur ; l’annulation reste ce qu’elle devait être — une PR repoussée annule son run précédent, un merge annule le merge précédent.

hydrogen reprend du service

Le parc x86_64 était tombé à un seul exécuteur : carbon portait air-x86_64 et air-couverture-reference, speedy n’ayant que air-docs. Or ci.yml suppose deux exécuteurs — son propre commentaire dit que les tests et le lint « continuent d’occuper l’autre exécuteur en parallèle ». Ce n’était donc pas une optimisation à imaginer, mais une régression du parc sous une conception intacte.

hydrogen était déjà installé et enregistré ; GitHub avait purgé son enregistrement pour inactivité. Re-enrôlé avec le label air-x86_64 seul — jamais air-couverture-reference.

La raison de cette exclusion est désormais expliquée, et non plus seulement constatée : les deux machines ont le même OS et le même noyau (Ubuntu 26.04, 7.0.0-30), et diffèrent par le nombre de cœurs — 8 contre 4. L’écart mesuré le 2026-08-20 (77,54 % sur carbon contre 76,00 % sur hydrogen, reproductible) vient donc du parallélisme des tests, pas de l’environnement. C’est le même mécanisme que les positions de couverture instables d’air-launchd : des branches dont le côté manquant n’est atteint qu’au gré de la vitesse de la machine.

Deux fautes de la nuit, consignées parce qu’elles ont coûté

  • Une purge de ~/air-build a été lancée pendant qu’un job de couverture tournait, et l’a fait échouer : le binaire air-sshd a disparu sous les pieds d’un test (spawn: NotFound), et cargo a perdu ses .fingerprint en cours de compilation. Les répertoires purgés étaient de plus déclarés « orphelins » à tort — couverture-lignes-* et couverture-branches-* sont les répertoires de mesure de couvrable-vide (xtask/src/couvrable_vide.rs:652), produits par xtask et non par un workflow. Leur doc-comment dit qu’ils vivent hors de target/ pour survivre au git clean -ffdx : les supprimer reprend le coût que ce choix avait payé.
  • Un script de veille de CI a annoncé une PR « terminée » alors qu’elle venait de repartir. Tester « plus aucun check en attente » est vrai à tort dans la fenêtre qui suit un push, avant que GitHub n’ait créé les checks du nouveau run. Corrigé : on exige que le dernier run du SHA de tête soit completed.

2026-08-31 — air-launchd 3b : le « prêt » devient OBSERVÉ, et l’outil de couverture cesse de mentir

Campagne autour d’ADR-161 (dépendances, ordre, « prêt ») et de l’outillage qui la mesure.

Le démarrage devient échelonné, et « prêt » n’a plus qu’un seul sens (#886, #889, #894)

  • #886 — les deux dernières dérogations poll d’air-launchd tombent ; le gel posé par check-poll-results est levé.
  • #889 — incrément 2 de 3b : demarrer_ce_qui_peut échelonne le démarrage selon les dépendances déclarées, echoir_les_non_prets fait d’un service qui ne devient jamais prêt un échec nommé (D6) et non une attente. Un jalon fut_pret remplace un état vivant, ce qui a fermé une course que speedy gagnait et que la CI perdait — le Readiness::Exec est posé au lancement, pas à la boucle suivante.
  • #894EXPECT_READY et Ready quittent le canal de lancement, comme D2 l’avait décidé et sous la condition de séquencement qu’il posait (published d’abord). Il n’y a plus qu’un sens de « prêt » : celui que le lanceur observe. Le numéro de genre 2 reste brûlé des deux côtés — canal et bus d’événements —, et un READY reçu est refusé nommément, jamais rangé parmi les genres inconnus.

Restent dus sur 3b : D4 (mort d’une dépendance), D5 (arrêt en ordre inverse) et la mesure du délai de D6 sur le Pi.

L’outil qui gardait la dette la détruisait (#890, #891)

cargo xtask couverture-inscrire réécrivait son fichier de référence au lieu de l’éditer : 133 lignes de commentaires écrits à la main ont disparu au premier usage. Rattrapé avant le push, puis corrigé : l’outil édite désormais en place, et relit ce qu’il vient d’écrire ou refuse. #891 lui apprend à dire est une régression confirmée, et plus seulement qu’il y en a une.

Ce que la mesure de couverture ne savait pas voir

Un écart reproductible a été isolé : air-launchd mesure 48 branches non couvertes dans un relevé léger et 50 dans un balayage complet, sur la même machine et le même code. La confirmation à trois tours du cliquet ne peut pas le voir : elle fait varier la charge entre les tours, jamais le périmètre du relevé. Le constat est écrit ; sa résolution est due.

Le RFC qui remet ADR-005 en face du code (#893)

ADR-168 — proposé — établit la couverture fonctionnelle de systemd (4 obligations de PID 1, 11 étages d’amorçage, contraintes de sécurité), mesure celle d’Air (aucun appel d’amorçage n’est enveloppé en couche 0), et nomme les huit reproches qu’air-launchd refuse de reproduire. Il consigne surtout que six des huit dépendances qu’ADR-005 déclare sont déjà remplacées — sa Conséquence sur libudev est fausse depuis qu’air-device existe.

2026-08-29 — test-air devient vrai, et quatre textes qui mentaient sont rectifiés

Campagne dominée par une question du BDFL qui a défait une analyse : « je ne peux pas te laisser dire “sur une cible qui refuse d’être unix” : c’est faux ! Ce que l’on refuse, c’est le C. » Elle a mis au jour que le motif écrit d’un ADR accepté divergeait de la raison réelle — et, de proche en proche, trois autres textes affirmant des propriétés que le code n’avait pas. La rectification de ces textes est le vrai contenu de la journée.

test-air n’avait JAMAIS été vert — et le disait pourtant (#876, #881, #882)

Le job qu’#858 a fait entrer dans le chemin des PR était rouge sur main elle-même. Son commentaire affirmait depuis le 2026-08-21 : « Il naît à ZÉRO — les deux crates en écart ont été corrigées par #801 ». Six défauts restaient, et il est resté rouge huit jours — parce qu’un consultatif ne se regarde pas. La faute n’était pas d’avoir corrigé #801 : c’était d’avoir écrit « zéro » en ayant vérifié deux crates, quand ce gate en compile tout un workspace.

Trois obstacles, apparus l’un après l’autre, chacun masqué par le précédent :

  • compilationair-keystore-cli, air-cert-issue puis air-filesystem lisaient mode et uid par std::os::unix, absent de la cible. Remplacés par AirFileManager::metadata (statx) : pour air-filesystem, sa propre porte — un test de couche 1 court-circuitait la surface qu’il est censé éprouver ;
  • exécution — quatre catch_unwind sous panic=abort : la panique n’y déroule pas, elle abandonne, et abort() s’émet en ud2, d’où un SIGILL. L’idiome de garde existait déjà dans air-memory et air-sys-types ;
  • sélectiontest-air exécutait xtask sur la cible, où xtask refuse lui-même de tourner (« ne tourne que sur la machine de build »). Deux causes cumulées : le balayage ignorait la racine du dépôt, et l’exclusion n’avait qu’un motif. Elle en porte deux désormais — « la cible ne SAIT PAS bâtir ceci » et « ceci n’a RIEN À FAIRE sur la cible ».

Vert depuis le 2026-08-29, run complet jusqu’aux doc-tests. Le régime consultatif proposé par #877 est devenu sans objet : la PR a été fermée sans fusion, le gate naissant réellement à zéro — le seul régime que CLAUDE.md tient pour légitime.

Consigné comme non expliqué : un SIGILL unique d’air-sys-syscall, non reproduit en quatre vérifications. L’hypothèse (contention io_uring) est donnée comme non démontrée.

ADR-106 amendé — Air refuse le C, pas Unix (#879, #880)

L’ADR justifiait cfg(unix) == false par un refus du contrat POSIX. Le motif réel est le refus de la libc C, dont la frontière est unsafe par construction depuis Rust — ADR-088 a abandonné l’option « std sur libair_c » alors qu’elle fonctionnait (jalon M5).

Le nœud : cfg(unix) confond trois choses — libc, errno in-band, sémantique POSIX. Air refuse les deux premières et honore la troisième ; rustc n’offre aucun moyen de le dire. cfg(unix) == false est donc un contournement d’une limite amont, pas une déclaration sur la nature d’Air. L’argument /etc/passwd est retiré : Android est unix et n’en a pas de conventionnel.

Deux conséquences actées : l’absence de std::os::unix devient un dommage collatéral, et l’horizon du trait de backend OS-neutre cesse d’être une reddition.

Les deux cliquets de couverture, et un test qui avait raison (#878)

Relever layer2 seul violait l’invariant layer2 < workspace que défend couvrable_vide::tests::reference_file_in_repo_is_valid_and_plausible. Le test a posé la bonne question — lequel des deux chiffres est faux ? workspace, resté à sa valeur du 2026-08-23 alors qu’il avait monté de 4,73 points sans que rien ne le confronte.

workspace : 76,14 → 80,87        layer2 : 74,79 → 76,56

L’assertion n’a pas été touchée : modifier un test pour faire passer un chiffre aurait été la faute que CLAUDE.md range parmi les onze fondatrices, en miroir.

Le lanceur observe enfin la publication (#873)

Premier temps de 3b : AirRegistry::names_published_by, RegistryHost::has_published, et ServiceSpec::readiness porté depuis le manifeste. Le fait est constaté sur la table, jamais annoncé par le service. expect_ready n’est pas retiré — ADR-161 D2 subordonne son retrait au démarrage échelonné, qui n’existe pas encore : supervise_many ignore toujours l’ordre topologique que discovery calcule.

La pile ADR-167 vidée (#856, #857, #858, #859, #875)

Le sommeil quitte air-base-core pour AirThreadManager ; le PAL de la std passe par la porte ; air-sandbox n’a plus qu’un harnais de fork. Deux en-têtes d’air-base-core décrivaient encore le sommeil comme vivant là — rectifiés, le cliquet rustdoc les ayant signalés (126 → 131 → 126).

Ce qui reste OUVERT, et qu’on n’écrit pas comme fait

  • Le défaut de cadence de la mesure de couverture — décrit le 2026-08-09, le 2026-08-19, et de nouveau ici. C’est lui qui a laissé workspace dériver de 4,73 points en six jours. Seule une mesure planifiée de main le fermera.
  • streamlocal_forward.rs reste hors cible : direct-streamlocal@openssh.com n’est vérifié que sur l’hôte. Le portage sur air-socket est du travail réel, pas un obstacle.
  • air-launchd 3b : « prêt » ne conditionne encore rien.

2026-08-28 — la CI cesse de refaire ce qui n’a pas bougé ; trois flakes instruits

Campagne de vidange et d’assainissement. Le fil directeur n’était pas la vitesse mais une question du BDFL : qu’est-ce que nous refaisons de PR en PR, à l’identique, sur des livrables dont nous savons qu’ils n’ont pas bougé ? Onze PR fusionnées, toutes issues de cette question ou de ce qu’elle a mis au jour.

Le travail répétitif, arrêté à sa source (#862, #871, #866)

  • Le périmètre de la CI cesse de basculer en mode complet pour rien (#862). Les quatre fichiers de référence des gates — abi-reference, controleurs-reference, couverture-exemptions, couverture-reference — sont inertes : les toucher ne change aucun livrable. Ils forçaient pourtant la reconstruction complète. Nommés un par un, jamais par un glob xtask/*.toml qui aurait attrapé xtask/Cargo.toml, lui bien structurant. Le script d’épreuve du périmètre passe de 32 à 41 cas, zéro échec.
  • Le job build de la doc passe sur un exécuteur à SSD (#871). Le facteur dominant du coût de CI n’était ni le compilateur ni le cache : c’était le disque mécanique de carbon. Mesuré : 16,8 min contre 1 à 2 min.
  • doxygen est épinglé (#866), avec une garde qui constate l’épinglage plutôt que de l’affirmer : la référence C dépendait jusque-là du socle de l’exécuteur.

Trois flakes, instruits un par un (#861, #869, #868)

  • #861 — le test du service sourd pariait sur la vitesse de démarrage. Remplacé par une synchronisation sur fichier témoin. L’hypothèse initiale — l’enfant sleep tué par le SIGTERM de groupe — était fausse : trap "" pose SIG_IGN, qui s’hérite à travers fork et exec. Réfutée sur carbon avant d’être écrite.
  • #869 — les tests du manager d’air-handle échappaient à la sérialisation du registre process-global. Le verrou était pub(crate), les neuf tests le prennent désormais.
  • #868 — la constatation d’écoute ne distinguait pas notre démon d’un squatteur. Elle croise maintenant les inodes en écoute avec /proc/<pid>/fd. Ce flake-ci n’a jamais été reproduit : le durcissement vaut pour ce qu’il est, pas comme une correction démontrée.

Ce que la vidange a révélé sur main (#867, #864, #870, #872)

  • main ne compilait plus ses doctests (#867) : trois d’entre eux appelaient as_object, un nom qui n’a jamais existé. Conséquence d’une fusion sur les seuls checks requis — test-coverage ne l’est pas. Règle adoptée : jamais de fusion d’une PR de renommage sans test-coverage vert.
  • Les verrous de ports d’air-sshd naissaient en 0644 (#864), et le message d’échec accusait le mauvais coupable.
  • Les cinq codecs d’air-base-core passent sur leurs types (#870) — sept opérations libres ramenées à deux.
  • Un octroi accordant execute n’était exercé par rien (#872) : la branche existait, aucun test ne la traversait.

Et le sommeil rejoint son domaine (#854)

sleep/sleep_until quittent les fonctions libres pour AirThreadManager — première tranche de la dette poll et sommeil. Le reste de la pile (#855 à #859) en dépend.

Ce qui reste ouvert, et qu’on n’écrit pas comme fait

  • La protection de branche : enforce_admins, required_signatures et les revues requises restent désactivés. Décision du BDFL, hors du pouvoir d’un agent.
  • La réparation du mode de /tmp/air-test-ports ne couvre que les ports que le tirage revisite.
  • Le cache sccache reste inertemultiple input files et incremental, la clé portant le chemin. Diagnostiqué, non corrigé.

2026-08-25 — la pile se vide, et trois pannes d’infrastructure sortent du bois

Journée de vidange : dix-sept PR ouvertes au matin, douze fusionnées. Le travail de fond — managers, codecs, gates — était écrit la veille ; ce qui a occupé la journée est ce qui l’empêchait d’atterrir. Trois pannes, toutes dans l’outillage, aucune dans le code.

Ce qui a été embarqué (#837, #840 à #845, #848, #851 à #853, #860)

  • AirHandleManager (#842) — la porte garde les deux desserrages du dup : dupliquer sur un numéro imposé, et la borne plancher. La duplication sûre, elle, ne desserre rien et n’est pas soumise.
  • AirPollManager (#853) — une porte sans garde, avec l’analyse qui le prouve. Chaque paramètre est examiné : les descripteurs sont déjà détenus, l’échéance est auto-infligée, le masque de signaux relèverait du domaine signal. Rien à arbitrer.
  • Les codecs portent leurs opérationsair_json::parse devient AirJsonValue::decode (#851), air-config-schema déplace lookup_domain et init_domain_payload sur DomainSchema (#852). Deux écarts vocabulaire-impur soldés au registre.
  • modules-orphelins (#837) — un .rs sous src/ qu’aucune chaîne de mod n’atteint ne compile pas, et le gate le refuse. Né bloquant, à zéro.
  • Le gate de conformité libc couvre enfin ce que son en-tête affirmait (#844) : il ne lisait que air-libc-capi/src/*.rs, sans récursion — 258 symboles sur 282. Sept étiquettes manquaient réellement. Un texte affirmant une propriété que le code n’avait pas : le NO GO n°2 commis par un gate.
  • L’aléa par la porte (#840), ADR-166 D7 — l’inventaire dit sept fonctions, pas deux cent quatre-vingt-huit (#845), ADR-165 rectifié sur /sys (#848), et dix exemptions de couverture vidées par la migration d’ADR-163 (#843).

Panne 1 — le job facturé, et le plafond à zéro

docs.yml était le seul job hébergé chez GitHub. À lui seul : 3002 minutes sur les 3000 du forfait, puis un budget Actions à zéro avec « arrêt d’usage » — service coupé. La bascule self-hosted (#849, veille) l’a réglé structurellement, sans changer de facturation.

Conséquence en cascade toute la journée : un job utilise le workflow de sa propre branche. Les PR antérieures à la bascule rejouaient l’ancien docs.yml et son apt-get install doxygen sur des runners GitHub devenus inaccessibles. Le remède est le rebase, pas une correction.

Panne 2 — les verrous de ports posés par root

air-sshd réserve ses ports de test par des fichiers-verrous sous /tmp/air-test-ports. Les étapes de couverture tournent sous sudo et y déposaient des verrous 0644 appartenant à root ; l’étape de tests, non-root, ne pouvait plus les ouvrir. Deux cents essais, deux cents refus de permission — et un message qui parle de ports libres.

11 779 verrous remis en 0666 sur carbon. Le code repose le mode du répertoire à chaque appel, jamais celui des fichiers déjà créés : la panne reviendra à la prochaine couverture sous root. Dette nommée, non corrigée.

Panne 3 — deux runners, un seul journal

Pour doubler le débit x86 après la mort d’hydrogen, un second runner a été posé sur carbon. Il a fallu le retirer : les deux tournaient sous le même utilisateur, donc le même $HOME, et les workflows dérivent leur répertoire de build du seul nom du job. Deux jobs build concurrents écrivaient et relisaient le même rustdoc.log — 05:07:24→05:09:55 sur l’un, 05:07:40→05:09:56 sur l’autre.

Le symptôme : le cliquet comptait 127 liens non résolus quand sa propre sortie console en montrait 126. Il lisait un journal que l’autre job réécrivait. Le gain de débit était réel — près de 7 heures de CPU en 4 heures — mais il produisait des verdicts faux.

Une fois la course supprimée, chaque PR a montré son défaut, distinct : une ancre Zola cassée sur #845, un lien [air_random] non résolvable sur #840 — air-random n’est pas une dépendance d’air-sshd. Le premier cliquet check-rustdoc (#850, veille) a ainsi attrapé ses deux premières vraies régressions.

Ce que la journée a appris sur la méthode

  • Un comptage par position de colonne ne lit pas un job matriciel. test-coverage (x86_64, air-x86_64) contient un espace : la colonne d’état lisait (x86_64,. Douze heures de veille à vide, et un automate qui aurait pu fusionner du rouge. Corrigé en lisant gh pr checks --json state.
  • Fusionner sur les seuls contrôles requisbuild et changes sont les seuls que la protection de main exige — passe pour de la documentation. Pour un renommage d’API, non : la protection a strict: false, et le build d’une branche non rebasée ne voit pas ce que la fusion produira. C’est ainsi que #852 est passée verte puis a bloqué toute la pile, jusqu’à #860.
  • Une PR empilée se rebase avec --onto $(git merge-base origin/main HEAD), jamais par un rebase nu : les commits du parent sont déjà sur main en squash, les rejouer casse sur du code.

Le parc

hydrogen est définitivement morte — la machine ne s’allume plus. Le parc x86 se réduit à carbon, avec raspi-srv-2 pour l’ARM. Purges : carbon 20 → 207 Go, speedy 0,9 → 65 Go (dont 63 Go d’air-build sur une machine qui n’héberge plus aucun runner).

2026-08-24 — cinq managers de plus, deux gates neufs, et les codecs sans I/O

Entrée écrite pendant la campagne, pas après. Les PR #833 à #840 étaient ouvertes et non fusionnées au moment de l’écriture — la CI de couverture était saturée. Ce qui est décrit est ce qui est écrit et poussé, pas ce qui est mergé ; le registre des ADR et etat-avancement.md font foi sur l’avancement réel.

Nuit de travail continue. Deux fils : fermer les routes parallèles que le gate check-controleurs inventorie, et geler les formats que le noyau expose en fichiers.

Les managers et les routes parallèles (#823, #825, #828, #835, #836, #838, #840)

  • AirConfigManager + la table des paramètres noyau (#823). Trois espèces de paramètre — échelle, seuil, masque de bits — parce que « monter durcit » est faux de net.ipv4.ip_forward et vrai de kernel.unprivileged_bpf_disabled. Mesuré sur les deux arches : kernel.sysrq = 176 partout, vm.mmap_min_addr = 65536 (x86_64) contre 32768 (aarch64), perf_event_paranoid descend à −1 (champ signé).
  • AirConfigCompileManager (#825) et AirCryptoManager (#828), chacun gardant exactement un desserrage — l’élargissement des bornes, l’entropie fournie par l’appelant.
  • air-sshd raccordé à AirTerminalManager (#835) et à l’identité (#836). Le second n’était pas mécanique : le manager exige un AirLoginName là où la fonction libre acceptait des octets bruts, et grep AirLoginName crates/air-sshd/ ne rendait rien avant — le nom venu du réseau n’était jamais validé. Aucune vulnérabilité n’est démontrée pour autant : un nom portant : ne correspondait déjà à aucun enregistrement. Ce qui change est le régime. Les 57 signatures qui portent encore user: &[u8] restent une dette nommée : les convertir touche la voie d’authentification et se fait sous revue.
  • AirStdioManager (#838) — la première porte qui ne garde rien, et qui le dit. Le domaine n’offre aucun desserrage : refuser à un programme d’écrire sur sa propre sortie n’arbitre rien, il casse tout. L’objection « une application confinée ne devrait pas obtenir un flux non octroyé » ne tient pas — le descripteur est alors fermé, et le kernel rend EBADF. La porte existe pour D9 seul.
  • L’aléa par la porte (#840). La bonne réponse n’était pas un manager de plus : air-crypto::AirRandom est déjà la porte, et air-random n’existe que pour que le PAL de std atteigne getrandom sans traîner RustCrypto dans la fermeture de std (ADR-090). Contrainte du toit, pas surface de couche 2.

Écarts du gate : 19 → 17.

Deux gates neufs (#834, #837)

  • routes-paralleles (#834) remplace trois inventaires manuels divergents par un compte régénérable : 147 appels, 55 paires — mon relevé à la main disait 351/63, il comptait des use. L’outil se documente lui-même comme une borne haute, D7 autorisant la consommation intra-couche.
  • modules-orphelins (#837) — ce qu’ADR-163 D1-bis c disait « restant à écrire ». Il refuse tout .rs sous src/ qu’aucune chaîne mod n’atteint. Trois défauts du gate lui-même, tous trouvés par son propre test sur le workspace réel, aucun par relecture : #[path] annoncé « honoré » et jamais câblé (le NO GO n°2 commis par l’outil qui l’applique) ; puis câblé depuis le mauvais répertoire, le test censé le couvrir partant de lib.rs où les deux coïncident ; puis le { d’un mod x { jamais compté, deux modules inline successifs s’empilant. Et une affirmation fausse en doc : « aucun include! sous src/ » — il y en a quatorze.

Les codecs sans I/O (#829 à #832, #831, #839)

ADR-165 gèle onze formats que le noyau expose ADR-165 gèle onze formats que le noyau expose en fichiers, ADR-166 pose la règle « une fonction qui peut échouer rend son échec » — int free(void*), pas void free(void*). Production déléguée à une session Claude Code indépendante sur speedy : tranche 1 (passwd, shadow, group) en #831, tranche 2 (fstab/mounts, hosts, resolv.conf) en #839 — un seul codec pour fstab et /proc/mounts, D2 l’exigeant. La session s’est arrêtée pour signaler le membre vide de group(5) plutôt que de trancher seule : D3-bis (#832) l’a réglé.

Ce qui reste ouvert, et qu’on ne masque pas

air-base-core = ["vocabulaire-impur"] n’est pas soldable sans décision : sleep et sleep_until n’ont aucun consommateur dans tout le dépôt, et la couche 1 est scellée — les retirer serait une rupture (ADR-062). base64/hex ont 54 appelants. À trancher.

2026-08-24 — le onzième format n’en est pas un : l’attribut sysfs, et pourquoi on ne gèle rien

Quatrième et dernière tranche d’ADR-165. Les onze formats gelés sont livrés. Le dernier est le seul qui ne soit pas un format — et c’était le sujet.

  • sysfs(5) ne définit AUCUNE grammaire, et ça a été établi plutôt que supposé. Les deux références ont été lues, et /sys mesuré sur speedy : 27 848 attributs lus sur 46 288 parcourus. Quatre croyances tombent, chiffres à l’appui — « c’est de l’ASCII » (487 attributs binaires, 482 avec des octets NUL) ; « ça tient dans une page » (74 au-delà de 4 096 octets, dont /sys/kernel/btf/vmlinux à 7 114 264) ; « ça finit par \n » (66 attributs texte sans) ; « une valeur par fichier » (2 298 multi-lignes). La page de manuel dit elle-même être « incomplete, possibly inaccurate », et le noyau ne formule qu’un souhait : « Attributes should be ASCII text files ».
  • La preuve que deviner est impossible tient sur deux fichiers voisins. …/cpu0/topology/core_cpus vaut 5\n et …/core_cpus_list vaut 0,2\nle même ensemble de processeurs, {0, 2}, écrit en masque et en liste. Et 5\n est l’entier cinq dans des milliers d’autres attributs. De même, \n est la liste vide dans …/cpu/offline et la chaîne vide dans /sys/kernel/uevent_helper. Le sens vit dans Documentation/ABI/, jamais dans les octets — un codec ne peut pas l’y chercher.
  • Le booléen a treize écritures mesurées, et deux d’entre elles ne sont pas des booléens : /sys/kernel/sched_ext/state vaut disabled comme valeur d’énumération, et …/power/control vaut on — dont le contraire est auto, pas off. Un décodeur tolérant y aurait écrit une valeur que l’attribut refuse.
  • Le codec est donc une famille de décodeurs TYPÉS que l’appelant choisit — jamais un decode qui devine. Onze décodeurs, chacun calqué sur la fonction du noyau qui a produit la valeur : as_digit_boolean sur sysfs_emit("%d\n") et as_letter_boolean sur param_get_bool() sont deux décodeurs, parce que ce sont deux producteurs. Un test dédié, aucun_decodeur_ne_retombe_sur_un_autre, interdit tout repli silencieux.
  • Ni decode, ni encodeur, ni aller-retour — le seul module de la crate dans ce cas, et c’est une décision : ce qu’on écrit dans /sys est le vocabulaire propre à l’attribut visé, que seul l’appelant connaît. Un encode n’y ajouterait qu’un \n, en faisant croire à une garantie de format qu’il n’a pas.
  • 100 % lignes ET branches sur la crate entière : 1 706 régions, 130 fonctions, 1 078 lignes, 144 branches — atteint au premier passage. 184 tests unitaires et 33 de documentation, aucun ne faisant d’I/O.
  • La onzième cible de fuzz a mordu à la première campagne, et sur elle-même : sur b"-00", elle affirmait qu’un décimal signé positif se relit à l’identique en non signé. C’est faux pour le zéro négatif — -0 vaut zéro, mais ses octets portent un - qui n’est pas un chiffre non signé. C’était l’invariant qui était faux, pas le codec : le refus est le bon comportement. Corrigé, puis 4,7 millions d’exécutions sans un plantage.
  • La divergence de la tranche 3 est soldée. air-system::parameters::decoder a été corrigé (da6f0607) pour refuser +1, +0 et +1\n ; la table de confrontation passe de seize à dix-huit lignes, dix-huit accords, zéro divergence. Elle n’avait pas été trouvée en relisant l’un des décodeurs, mais en écrivant le second et en les confrontant.

Reste de l’ADR : la migration D5 des lectures dispersées — au moins sept crates portent encore leur propre lecture de /etc/passwd.

2026-08-24 — les codecs de formats système : les trois formats de /proc, et le piège du champ comm

Troisième tranche d’ADR-165, même campagne. Les trois formats de /proc rejoignent air-format-codecs : le scalaire sysctl (/proc/sys/**), /proc/meminfo, et /proc/[pid]/stat. Dix formats sur onze sont livrés ; reste l’attribut sysfs.

  • /proc/[pid]/stat est écrit à partir de la DERNIÈRE parenthèse fermante, jamais de la première. C’est le piège qu’ADR-165 signale nommément, et il n’a pas été traité de mémoire : les vecteurs hostiles ont été produits sur speedy en posant le nom par prctl(PR_SET_NAME) puis en relisant /proc/self/stat. Quatre cas mesurés, dont a)b(c — qui s’écrit (a)b(c) et fait lire l’état b à un analyseur naïf — et un nom vide, que le noyau écrit () et qu’il faut donc accepter. Le fuzz éprouve l’invariant lui-même : après la fermante, la queue ne porte plus aucune parenthèse.
  • Les types sont ceux de proc(5), pas des u64 de confort : %di32, %uu32, %ldi64, %lu/%lluu64. C’est le typage qui fait le refus — un tpgid de 4294967295 n’est pas un -1 mal écrit.
  • 100 % lignes ET branches sur la crate entière : 1 481 régions, 103 fonctions, 923 lignes, 120 branches. 158 tests unitaires et 19 de documentation, aucun ne faisant d’I/O. Un test balaye les cinquante champs de queue un par un et vérifie que chacun refuse ce qui n’est pas de son type, puis un second qu’aucun ne tronque un débordement.
  • Trois cibles de fuzz de plus, une par codec — et la campagne a mordu dès la première minute sur /proc/[pid]/stat : l’entrée b"310224 () R 112) ( 11 …" s’y décodait avec un état (, la dernière fermante étant celle qui suit 112. Le codec refuse désormais la parenthèse ouvrante comme état — non pour énumérer les états, mais parce qu’un champ ne porte pas le délimiteur de sa propre grammaire, et que c’est de là que la règle de la dernière fermante tire sa justification. Même faute que le EmbeddedLineTerminator trouvé à la tranche 1. Campagnes finales : 73 139 182 exécutions sur le scalaire sysctl, 6 841 919 sur meminfo, 42 081 546 sur /proc/[pid]/stat après correctif — zéro plantage.

⚠️ Une divergence signalée, et non tranchée : le + du scalaire sysctl

air-system::parameters::decoder (PR #823, non mergée) est un second décodeur du même format. Les deux s’accordent sur seize cas éprouvés et divergent sur un : b"+1", que le premier accepte — str::parse::<i64> admet « an optional + or - sign », il l’a donc hérité — et que le nouveau refuse, ADR-165 n’accordant au format 8 aucune dérogation et le noyau n’écrivant ni n’acceptant de + (proc_get_long() ne reconnaît que le -). Rien n’a été migré ni modifié chez l’autre : la divergence est écrite, en tête de module et dans un test qui la compte, et son arbitrage appartient au BDFL.

Deux lectures signalées, qu’ADR-165 ne tranche pas explicitement

La dérogation « clés inconnues ignorées » du format 9 est appliquée de la seule façon qui n’invente rien : le codec ne porte aucune liste de clés, et n’en refuse donc aucune — figer la liste demanderait de la décider, et elle serait fausse à la version suivante du noyau. Et la clé de /proc/meminfo est refusée si elle porte un blanc, parce que Node 0 MemFree: est la grammaire de /sys/devices/system/node/*/meminfo, qui n’est pas dans la liste gelée.

2026-08-24 — les codecs de formats système : deux tranches, sept formats sur onze

Suite immédiate de l’entrée ci-dessous, même campagne. Les quatre formats à champs séparés par des blancs rejoignent air-format-codecs : fstab(5)et /proc/mounts, qui est le même codec —, hosts(5), resolv.conf(5).

  • Un seul codec pour les formats 4 et 5, comme ADR-165 D2 l’exige : MountEntry est un alias de FstabEntry, pas un second type. En écrire deux aurait refait exactement le défaut que l’ADR corrige.
  • La charpente est partagée, pas dupliquée : le module fields porte la découpe sur les blancs des trois formats, et la liste à virgules de group(5) et de fs_mntopsGroupMembers en est désormais un alias. RecordIterator admet la ligne sans enregistrement (Ok(None)), ce qui donne aux six formats la même charpente de fichier, avec un numéro de ligne qui compte les lignes ignorées.
  • 100 % lignes ET branches sur la crate entière : 1 060 régions, 87 fonctions, 666 lignes, 86 branches. 125 tests unitaires, 14 de documentation, aucun ne faisant d’I/O — les vecteurs réels (un /etc/fstab de curtin, un /proc/mounts, un /etc/hosts, le resolv.conf de systemd-resolved) sont des littéraux d’octets.
  • Trois cibles de fuzz de plus, dont une seule pour les formats 4 et 5.
  • D3-bis appliqué : l’élément vide d’une liste group(5) est accepté et ignoré. La règle vaut du même coup pour fs_mntops, puisque la découpe est la même.

Trois lectures signalées, qu’ADR-165 ne tranche pas explicitement

Elles sont documentées en tête de leur module, et rapportées dans la PR plutôt que décidées en silence : la fourchette de 4 à 6 champs de fstab (la dérogation nomme le plancher, pas le cas des cinq champs) ; le commentaire indenté de fstab, accepté comme le fait libmount alors que la page de manuel se lit à la colonne zéro ; et l’absence de validation de l’adresse IP dans hosts et resolv.conf, dont la grammaire est celle d’inet_pton(3) et non celle de ces formats.

Les séquences d’échappement ne sont pas appliquées au décodage

fstab(5) documente \040 et \011 ; /proc/mounts y ajoute \012 et \134. Les appliquer au décodage allouerait, et le décodeur perdrait la propriété qui fait la crate. La paire fstab::unescape / fstab::escape est donc explicite et opt-in — la seule allocation de décodage de la crate, et l’appelant la demande.

2026-08-24 — les codecs de formats système : première tranche, et un défaut trouvé par le fuzzing

Ouverture d’ADR-165 : les formats de fichiers système deviennent des codecs purs, sans aucune entrée/sortie. La crate air-format-codecs naît avec les trois premiers des onze formats geléspasswd(5), shadow(5), group(5), qui partagent leur nature de fichier à enregistrements.

  • Aucune dépendance, ni externe ni interne — pas même air-base-lib, dont l’AirError aurait fait entrer icu4x dans la fermeture std par la porte d’un décodeur de /etc/passwd. Le type d’erreur est écrit ici, Copy, sans allocation : refuser ne doit pas allouer, sinon le refus devient une porte de déni de service.
  • 100 % lignes ET branches (532 régions, 43 fonctions, 317 lignes, 36 branches), 65 tests unitaires et 6 tests de documentation. Aucun test ne lit de fichier : les vecteurs réels — extraits authentiques de /etc/passwd, /etc/shadow et /etc/group — sont des littéraux d’octets.
  • Trois cibles de fuzz, une par codec, éprouvant deux propriétés : le décodeur ne panique sur aucun octet, et l’aller-retour decode ∘ encode est une identité pour tout enregistrement accepté.

Ce que le fuzzing a trouvé, à la première minute

Les trois cibles sont tombées d’un coup, sur la même cause : decode acceptait un \n au milieu de ce qu’il présentait comme un seul enregistrement. Les mêmes octets voulaient donc dire deux choses selon la porte — un champ portant un saut de ligne par decode, deux enregistrements par decode_file — et l’encodeur, lui, refusait d’écrire ce que le décodeur venait d’accepter. C’est exactement la divergence entre deux lecteurs qu’ADR-165 existe pour empêcher, et elle était dans le codec censé la supprimer.

Corrigé par un refus explicite (EmbeddedLineTerminator), inatteignable depuis le décodage de fichier. Campagne de vérification ensuite : 9,67 millions d’exécutions sur les trois cibles, zéro défaut.

Un gate qui ne peut pas conclure — à traiter

cargo xtask couvrable-vide --no-root compose cargo cargo llvm-cov … : en mode sans root, le lanceur vaut déjà cargo et "cargo" est poussé une seconde fois en tête des arguments (xtask/src/couvrable_vide.rs l. 1186 et 1237). Le gate rend alors « mesure des LIGNES IMPOSSIBLE », pour une raison qui n’a rien à voir avec le code mesuré. Défaut préexistant, reproductible sur main (cargo cargo llvm-cov --versionerror: no such command: cargo) ; le mode root, lui, fonctionne. Signalé, non corrigé ici — c’est un autre chantier.

2026-08-23 — la métrique de couverture cesse de mentir, et trois managers de plus

Campagne en deux temps : les managers dus, puis un défaut d’outillage découvert en route qui a occupé le reste — et qui valait le détour.

Les managers et les ADR (#810 à #817)

  • AirThreadManager (#815). Le registre chargeait air-thread de « 14 opérations libres » ; dix n’en sont pasruntime_primitives est la surface qu’ADR-052 impose à air-runtime, raw_futex la couture du PAL de la std (neuf occurrences dans air-pal.patch). Le manager ne garde que thread.spawn (la fork-bomb sans privilège) et thread.name.set (/proc/…/comm est ce que lit ps : un thread qui se nomme kworker/0:1 prend un déguisement).
  • Enveloppe de session air-sandbox (#812, #816), incréments 2 et 3. Deux constats Landlock : handled_access étant l’union des droits accordés, un droit qu’aucune règle ne mentionne sort de l’arbitrage — d’où un $HOME où tout binaire s’exécutait, et un EXDEV sur tout déplacement de fichier entre dossiers, mesuré sur cible. ADR-150 D13 est rectifié : EXECUTE sur /usr et /lib seulement, REFER sur $HOME.
  • SIGPIPE (#814) : tout binaire Air mourait sur le signal 13 là où un programme Rust reçoit Err(EPIPE). Invisible sur la std gnu ; seul test-air pouvait le voir.
  • ADR-033 amendé (#817) : les paramètres du noyau entrent dans le modèle de configuration. Mesuré sur les deux arches — net.ipv4.ip_forward durcit en descendant, kernel.sysrq est un masque de bits, vm.mmap_min_addr un seuil dont le plancher diffère par architecture. « Plus grand = plus sûr » est faux.
  • ADR-149 D11→D15, ADR-010, ADR-007 (#810, #811) : anneau et couche deviennent deux axes distincts ; les types traversent les couches, les opérations non.

ADR-163 : ce que la métrique mesurait vraiment (#818, #819, #820)

En instruisant une prétendue régression de couverture, on a découvert que le chiffre du cliquet ne mesurait pas ce qu’on croyait. cargo-llvm-cov écarte de lui-même tests.rs et les répertoires tests/règle qui ne figurait nulle part dans le dépôt — mais rien contre un #[cfg(test)] mod tests { … } inline. Le périmètre dépendait donc de l’endroit où un auteur avait mis une accolade : 234 fichiers, 57 798 lignes de test comptées comme de la production.

234 fichiers migrés, 6 668 #[test] avant et après, conservation vérifiée crate par crate. Gate check-tests-isoles livré, devenu bloquant à zéro. Nouvelle référence de branches : 76,14 %, et surtout — lignes non couvertes résidant dans du code de test : 529 → 20, dette de production inchangée à 744. La métrique a cessé de mélanger.

Trois angles morts découverts en migrant, tous par une mesure rouge et jamais par une relecture : un module de test nommé autrement que tests retombe dans la mesure ; un #[cfg(test)] mod peut être un substitut de production qu’il ne faut pas déplacer ; et un fichier de test qu’aucun mod ne déclare est muet. Ce dernier n’était pas théorique : ceiling/tests.rs, 428 lignes et vingt-deux tests, ne s’exécutait plus depuis #816 — ce qui a fait passer la dette apparente d’air-sandbox de 3 à 17 et m’a fait inscrire ce 17 au registre comme structurel.

Deux dettes ouvertes, signalées et non traitées

  • Quatre tests d’air-sshd tombent sous charge en exécution workspace — identiques sur main, donc préexistants.
  • La cadence de mesure : main ne se mesure jamais (couverture-couches y est skippé, couverture-pr n’y sélectionne rien). C’est la troisième occurrence du défaut que couverture-reference.toml décrit lui-même aux 2026-08-09 et 2026-08-19.

2026-08-22 — air-device et air-sandbox soldés, et la dernière question bloquante tranchée

Deux managers de plus, et surtout une décision : la « largeur des enveloppes par défaut », que l’amendement d’ADR-112 qualifiait de dernière dépendance de décision d’AirSandboxManager, est tombée.

  • La question ne portait pas sur ce qu’on croyait (#806). Ce n’était pas « la cage par défaut d’une application » — une application empaquetée n’en a jamais, la sienne vient de son octroi. C’était la racine d’une chaîne de lancement, et deux racines sur trois étaient déjà closes : PID 1 hors arbitrage (ADR-150 D5), binaire étranger refusé (BDFL, 2026-08-21). Restait la session de l’utilisateur.
  • La contradiction à lever : le confinement étant monotone, la cage du shell de session plafonnerait tout ce que l’utilisateur lance — y compris une application dont l’octroi, signé par l’administrateur, dit davantage. Session étroite ⇒ octrois légitimes inapplicables ; session large ⇒ enveloppe décorative. On ne pouvait pas choisir la largeur avant le modèle.
  • ADR-150 D12 — le lanceur médie, la session ne plafonne pas. Motif déjà implémenté par ADR-147 pour air-sshd. D12 écrit aussi ce que la médiation oblige, sous peine d’escalade : le lanceur ne fait confiance à aucun demandeur, et vérifie que cet utilisateur peut invoquer cet octroi. D13 fixe la largeur : C (65 appels sur 92), Landlock $HOME en lecture-écriture et /usr,/lib en lecture, sans réseau — sans D12 il aurait fallu ouvrir E, soit 14 appels de plus pour tout ce que l’utilisateur lance.
  • ADR-010 rectifié : j’y avais écrit la veille que (A) « doit rester le chemin ordinaire ». C’est l’inverse. Rectification en bloc daté, dans les deux langues.
  • ADR-010, amendement scripts et interprètes (#804) — le sujet de l’octroi est le programme, jamais l’interprète ; l’octroi propre d’un interprète est minimal ; on ne filtre jamais sur argv (-c, -m, l’entrée standard, le tube : un filtre qui échoue ouvert) ; air-run demande, il ne négocie pas.
  • ADR-150, amendement forme de l’arbitre (#803) — l’appelant ne nomme jamais la cage ; trois verbes ; on exécute le descripteur vérifié, pas le nom vérifié (execveat) ; le lien symbolique n’accorde rien. Et D11 amende D4 : le refus devient gradué — trois motifs sur quatre deviennent explicites, seul l’octroi reste opaque, avec un jeton de corrélation qui ne confère rien.

AirDeviceManager (#807) — le moins cher, et le plus sensible

air-device avait sa surface objet complète ; seul le point d’entrée manquait. Mais le domaine porte les primitives de deux attaques nommées : grab prend la capture exclusive d’un périphérique d’entrée — sur un clavier, un enregistreur de frappe, et pire qu’une écoute puisqu’il prive les autres lecteurs —, et write_attribute ne lit pas le matériel : il lui donne un ordre.

Deux choix imposés par le domaine, non par la conception : AirSubsystem n’est pas Copy, donc les opérations le portent par référence ; et AirInputDevice n’est qu’un descripteur, donc le contrôle a lieu à la porte — la politique décide qui peut obtenir un clavier et quelle classe d’action il en fait, pas « ce clavier-ci, non ». C’est plus faible, et c’est écrit dans la doc pour que personne ne croie l’inverse.

AirSandboxManager (#808) — le manager qui ne garde qu’une opération

Thèse inhabituelle, et assumée : poser une cage ne peut pas affaiblir le système. Le confinement est monotone ; une politique qui refuserait confine rendrait la machine moins sûre. Guarder tous les verbes « par symétrie » avec les managers voisins ferait de la garde le vecteur qu’elle prétend fermer.

Une seule opération peut affaiblir : l’opt-out (AirSandboxMode::Disabled). Une porte de sortie que personne ne peut fermer n’est plus une porte de sortie, c’est un défaut. Double sabotage à l’appui — garde retirée (2 rouges), garde ajoutée sur prepare (1 rouge) : la thèse se démontre dans les deux sens, et le second sens est celui qu’on oublie.

Ce que la campagne a appris sur l’outillage

  • Le gate de couverture lit du texte, pas un arbre syntaxique. Un #[cfg(test)] séparé de son mod par dix lignes de documentation : regions_test abandonne au bout de trois, et 184 lignes de harnais passent en code de production. Même limite que check-safety-comments, et même remède : ne pas égarer le lecteur.
  • Sous fork, la couverture ne remonte pas — l’enfant sort sans écrire son profil. Ce constat a forcé à séparer la décision du mécanisme dans air-sandbox (autoriser_le_mode), et le code en sort meilleur.
  • Le Cargo.lock est un registre à part : il a mordu cinq fois en deux jours, dont une après un simple rebase — un manifeste inchangé, une base déplacée, et tout job de build tombe sur un message qui ne nomme jamais ce qui manque.
  • Ajouter une dépendance à une crate de la fermeture std y fait entrer la dépendance. Ce n’est pas un registre qu’on oublie de remplir, c’est une propriété transitive : cargo bâtit alors la crate avant std (-Z force-unstable-if-unmarked) et rustc rend can't find crate for core — un message qui accuse la cible au lieu de nommer la cause. Réparé par les façades rustc-std-workspace-core/-alloc.

Dette

26 entrées sur 24 crates → 22 sur 20 ; 8 managers absents → 4 ; 11 portés → 15. Restent air-thread (14 opérations libres), air-config-compile (10), air-crypto (7), air-config (4).

2026-08-21 — la campagne des managers s’ouvre : air-terminal et air-account soldés, et les tests qui ne franchissaient pas la cible

Réorientation BDFL : le produit est libair — les managers de domaine —, pas air-sshd, qui est un démonstrateur. La roadmap ordonnait pourtant les chantiers par ce qui le débloque, reléguant les 8 managers absents au point 5. Symptôme chiffré : air-sshd portait 553 des 1078 branches non couvertes de la couche 2, la moitié.

  • Carte des chantiers par surface développeur (#796) — et son compte était faux : « 24 écarts » alors que le registre en porte 26 sur 24 crates, avec un motif entier oublié, service-consomme (ADR-149 D7), qui désigne deux bibliothèques à extraire d’air-agent et air-launchd.

  • air-terminal est SOLDÉ : AirTerminalManager coordonne les 21 opérations libres, avec la couture SecurityManager (ADR-089) posée dès maintenant — l’ajouter après coup exigerait de rouvrir une surface déjà contractuelle. 0 branche non couverte. Dette : 26 → 25 entrées, 11 → 12 managers portés.

  • test-air révèle un trou que rien ne gardait : la suite jouée contre notre std linux-air (critère ADR-103 §2) ne compilait pas pour air-bundle et air-sshd — leurs tests appellent std::os::unix, qui n’existe pas sur la cible. 110 crates sur 112 passent ; la production, elle, était propre (check-target, cargo xtask deb). Le défaut avait déjà été payé une fois en production — le commentaire de bin/air-ssh.rs le raconte — sans que les tests soient jamais alignés. Une leçon qu’aucun gate ne garde se re-paie.

  • air-security : le vocabulaire commun (#800). Écrits en parallèle, les deux managers avaient produit deux vocabulaires ET deux formes pour dire la même chose — AirSecurityDecision/AirAccountSecurity d’un côté, Decision/SecurityManager de l’autre ; un générique ici, un Box<dyn> là. Une crate sans dépendance porte désormais le verdict, l’interface et le défaut permissif ; chaque domaine ne garde que la liste de ses opérations sensibles. Les deux raccordements ont trouvé ce que l’écriture seule n’avait pas vu : une politique observable vit derrière un compteur de références, d’où les implémentations pour Box, Rc et Arc.

  • Un piège de toolchain, révélé par le raccordement : std → air-stdio → air-terminal → air-security. Y raccorder un manager fait entrer la crate dans la fermeture std, mécaniquement. Cargo la bâtit alors avant std (-Z force-unstable-if-unmarked) et rustc rend error[E0463]: can't find crate for core — un message qui conseille d’installer la cible et d’employer -Zbuild-std, deux choses déjà faites. Réparé par les façades rustc-std-workspace-core/-alloc, motif déjà porté par air-random. Ce n’est pas un registre qu’on oublie : c’est une propriété transitive qu’il faut connaître.

AirAccountManager — la plus grosse dette du registre, et trois défauts latents

air-account déclarait role = "manager" et n’en portait aucun : 38 opérations libres.

  • Manager ADDITIF, patron d’air-uring et d’air-keystore : les fonctions libres restent publiques en route parallèle jusqu’au descellement unique de fin de campagne. Aucun appelant rompu. xtask/controleurs-reference.toml : 26 écarts → 24, avec air-terminal.
  • La surface est celle du DOMAINE, pas celle d’air-sshd, qui n’en consommait qu’une poignée. Ce qui n’existait pas et existe maintenant : l’énumération distinguée du lookup (une politique doit pouvoir l’interdire sans interdire les sessions), les membres d’un groupe (None ≠ groupe vide), l’état du secret sans livrer le hachage, le vieillissement transcrit d’isexpired(3) — bornes <= comprises, parce que c’est là que les réimplémentations divergent —, l’attribution du prochain identifiant libre, et l’audit d’intégrité (doublons, groupe primaire introuvable, membre sans compte, enregistrement non réécrivable).
  • Trois défauts fermés, tous LATENTS — les projections n’ont, à ce jour, aucun appelant. Ce n’était donc pas une faille vivante, c’était une API qui l’autorisait.
    1. Les encodeurs texte n’échappent rien. Un champ portant un \n fabrique une ligne dans la projection /etc/passwd (ADR-041) — donc un compte que personne n’a créé, éventuellement uid 0. Un : décale les champs. Rien ne l’interdisait, et air-account-cli::add_user accepte un &str quelconque.
    2. project_passwd_to/project_group_to écrivaient en 0600 : elles passent par write_atomic, dont le défaut est privé. Une projection /etc/passwd illisible par le monde est la négation de sa raison d’être — plus aucun processus non privilégié ne résout uid → nom.
    3. project_shadow_to documente une garantie que l’appelant ne peut pas donner : « l’appelant garantit 0600 sur le fichier cible ». Avec un remplacement atomique, le fichier final est le temporaire renommé ; un mode posé d’avance sur la cible n’y survit pas.
  • Couture SecurityManager posée (ADR-089, décision BDFL du 2026-08-21) : chaque opération sensible consulte un oracle Allow/Deny(motif), AirPermissiveSecurity par défaut. Le moteur reste hors sujet ; c’est la place qui ne peut pas s’ajouter après, une fois la surface contractuelle. ADR-089 passe a-faireen-cours.
  • Deux affirmations fausses corrigées dans le code : lib.rs et store.rs disaient store-binary « activé par défaut ». Le manifeste dit l’inverse — et il a raison, la fermeture std ne peut pas tirer capnp (ADR-090). Conséquence pratique : cargo llvm-cov -p air-account ne mesure pas le lecteur binaire.
  • 22 sabotages, 22 rouges — dont deux qui ont d’abord rendu VERT. Les tests d’injection refusaient le : du nom hostile au lieu du \n qu’ils prétendaient éprouver : verts pour la mauvaise raison. Corrigés en ne laissant dans le nom que l’octet en cause, et en exigeant que le refus nomme le rôle de cet octet.
  • Fuzz : fuzz_air_account_identity (validation, audit, arithmétique de vieillissement sur valeurs négatives et extrêmes que le codec texte ne sait pas produire) — 1 005 076 exécutions en 121 s, zéro plantage.

Écart de documentation constaté, NON corrigé ici

ADR-067 affirme, en Décision et en Conséquences, qu’air-base-lib « ré-exporte l’API d’air-account » — et son statut est fait. Vérifié contre le code le 2026-08-21 : crates/air-base-lib/Cargo.toml ne dépend pas d’air-account, et aucun air_account n’apparaît dans ses sources. La surface Rust de première classe promise aux développeurs n’existe donc pas. Laissé au BDFL : soit la ré-exportation se fait, soit l’ADR est réconcilié par RFC — ce n’est pas un arbitrage d’agent.

2026-08-21 — la queue de defauts rares que l’instrumentation revele

L’entree precedente disait vrai sur ce qu’elle mesurait, et il faut la completer : la suite privsep_session_root garde, sous instrumentation, une queue de defauts rares que le correctif du canal de session ne touche pas.

  • A/B sous instrumentation (suite complete, root, -C instrument-coverage — la suite passe de ~2 s a 45,8 s, et c’est cet elargissement de fenetre que la CI voit) :

    3e39484a (avant #794), 75 tours : 10 x test du tunnel + 1 x no_pty
    e4cd4078 (apres #794), 25 tours :                        1 x no_pty
    

    #794 est prouve dans les conditions de la CI : le test du tunnel passe de 13,3 % a zero. Le rapport de la session d’oxygene (Mac, PR #793) est confirme de bout en bout — le defaut etait bien sur main seul, sa PR n’y etait pour rien.

  • no_pty_makes_the_server_refuse_the_terminal PREEXISTE a #794 : meme ligne 843, meme Some(41) = MONITOR_SERVICE_FAILED, sur les deux commits. Ce n’est pas une regression.

  • Un defaut reel corrige, qui n’est PAS celui-la. push_session_exit transformait un EPIPE — la disparition normale du worker — en echec de service : is_ready_to_push interroge POLLOUT, qui dit « le noyau accepte l’ecriture », pas « le worker est la » ; un socket dont le pair a ferme est writable, et c’est l’ecriture qui rend EPIPE. La documentation de reap_session decrivait deja ce cas comme une fin normale, et le code le propageait comme une faute. Prouve par un test deterministe (0,00 s), tolerance bornee a BrokenResource (EPIPE/ECONNRESET) — tout autre echec d’emission remonte.

  • Mais il ne solde pas le no_pty : 75 tours instrumentes apres correctif rendent encore 1 x no_pty. Un mecanisme demontre n’est pas pour autant LE mecanisme observe — la lecon de l’entree precedente, repayee le lendemain.

  • DEUX pieges de mesure, payes la meme nuit, et corriges dans cette entree.

    1. Deux instances de la suite ne peuvent PAS tourner en meme temps : elles partagent les comptes de test (air-t-session, son magasin de cles). Une seconde serie lancee pendant qu’une premiere finissait a rendu 5 rouges sur 10, sur des tests sans rapport, tous en Permission denied (publickey). Un echec de la 75e serie (a_real_ssh_client_displays_the_pre_auth_banner) tombe dans cette fenetre de chevauchement : il est retire du constat — ce n’etait pas une troisieme signature, c’etait ma propre mesure qui se marchait dessus.
    2. Tuer le processus de test ne tue pas sa descendance : douze monitors et workers des chasses au blocage tournaient encore cinq heures apres, en ppoll. Ils etaient presents des deux cotes de l’A/B, qui reste donc comparable — mais un nettoyage qui ne nomme que le parent ne nettoie rien.
  • Le harnais dit desormais quelle erreur : run_monitor reduisait toute erreur de serve() a 41 sans la nommer, le meme trou d’observabilite que #792 avait bouche sur le test du tunnel.

2026-08-20 — fin : l’interblocage des pompes n’était pas dans les pompes

Le blocage intermittent du tunnel privsep (a_real_ssh_client_gets_a_shell_and_a_tunnel_on_the_same_connection, environ 30 % de rouge) est soldé. La cause était deux étages au-dessus de l’endroit où elle a été cherchée pendant trois campagnes.

  • Ce que ssh(1) fait vraiment : il enchaîne ses deux CHANNEL_OPEN — la session, puis le direct-tcpipsans attendre la confirmation du premier, dès que l’entrée d’un -L est franchie tôt. Comparé sur le fil, c’est la SEULE différence entre un vert et un rouge : dans le vert, l’ouverture du tunnel arrive après l’exec.
  • La cause : connection::serve_connection_with rendait la main en mode différé (ConnectionOutcome::Forward, et de même RemoteForward / RemoteForwardUnix) sans regarder le canal de session qu’elle tenait déjà — ouvert, et confirmé au client. Le canal partait à la poubelle : le démultiplexeur naissait sans lui, le client attendait pour toujours la réponse à un exec adressé à un canal que plus personne ne connaissait, et la connexion entière restait suspendue.
  • Le correctif : les trois issues différées transportent le canal, et le démultiplexeur le replante à l’âge exact où il en est — SessionSlot::Opening, un état qui existait déjà et que sa propre documentation décrivait comme « celui que tenait la boucle de connexion avant de rendre la main ». Aucune pièce nouvelle : la pièce manquait.
  • Le test mord : a_session_opened_before_a_deferred_forward_travels_with_it envoie les deux ouvertures d’affilée et vérifie l’identité du canal rendu. Correctif retiré, il tombe ; remis, il passe.
  • Une piste abandonnée, et c’est le point de méthode. Un correctif intermédiaire — remplacer le prédicat de fin de connexion par « trois fins nommées » — faisait tomber le taux de 30 % à 4 %. Il n’a pas été retenu : le résidu de 4 % n’était plus un échec mais un blocage, et la suite complète en root a montré qu’il en créait un second sur a_forward_only_worker_runs_under_a_seccomp_filter (un -L pur ne pose aucune écoute côté serveur : aucune des trois fins ne se déclenchait). Il aura servi de révélateur, pas de remède — en supprimant une sortie prématurée, il a transformé une réponse fausse en attente franche, et c’est cette attente qui a nommé le vrai coupable.
  • Constaté : 25/25 sur le test qui pendait, 1 168 tests de bibliothèque, et les 16 suites privsep et forwarding en root (196 tests) toutes vertes.
  • Deux pièges d’outillage consignés : -E de ssh(1) ajoute au journal au lieu de le tronquer (deux exécutions dans le même fichier — un vert lu pour un rouge), et un tail -1 sur une sortie libtest rend la ligne vide finale, pas le verdict — seize suites ont été rapportées « sans verdict » avant que le harnais soit corrigé.

2026-08-20 — suite : la VRAIE cause du chiffre qui dérive, et un parc entier réparé

L’entrée précédente attribuait la dérive de la couverture au cgroup ambiant. C’était faux, comme les deux explications qui l’ont précédée. Ce qui l’a tranchée : mesurer au lieu de raisonner.

  • Trois explications successives, toutes fausses. « C’est la machine » — non : deux machines, même commande, répertoire propre, rendent 2 165 côtés manquants chacune, à l’unité près. « C’est un battement » — non : trois relevés d’un même job rendent 76,04 trois fois. « C’est le cgroup ambiant » — non : le scope neuf ne déplace pas le chiffre en CI, et le redémarrage de carbon (5 semaines d’uptime, noyau neuf) non plus.
  • La cause : le RÉPERTOIRE DE MESURE PARTAGÉ (#790). llvm-cov construit son rapport à partir des objets présents dans le répertoire de compilation ; --ignore-filename-regex filtre des chemins source, pas des objets. Avec --no-clean sur un répertoire partagé entre périmètres, tout ce qu’une exécution antérieure y a laissé entre dans le rapport suivant. Mesuré sur carbon, même commit, même commande : répertoire neuf 9 884 branches → 78,10 % ; répertoire du gate 18 908 branches → 44,42 %, avec 129 binaires de test là où le périmètre en produit 89. Le chiffre était fonction de l’HISTORIQUE du répertoire.
  • Corrigé : un répertoire par périmètre, comme le cliquet a déjà un répertoire par crate — ce qui explique après coup pourquoi le cliquet, lui, n’a jamais dérivé. En CI, le workspace repasse à 77,83 % contre une référence de 77,52 : la référence n’avait jamais menti, et n’a pas eu à être abaissée.
  • La remesure sur soupçon corrigée (#790) : ses arguments étaient figés une fois, donc chaque reprise rejouait le même nom d’unité systemd et mourait — le gate a « CONFIRMÉ » une régression sur deux relevés qui n’avaient jamais eu lieu. Les arguments sont reconstruits par tentative, et le constat DIT sur combien de mesures il repose.
  • air-sshd était mort ou condamné sur tout le parc. Découvert en cherchant des cgroups résiduels (il n’y en avait aucun) : échec sur speedy depuis le 18 août, sur carbon dès son redémarrage. Un bundle de service posé sous /usr/lib/air/bundles, qui est une racine d’applicationKindMismatch, refus correct (ADR-153) mais invisible tant que personne ne relance. Le correctif était dans main depuis le 19 août (#749) ; le binaire déployé datait du 24 juillet. Installer le nouveau paquet ne suffit pas : rien ne retire l’ancienne copie, qui empoisonne l’activation — il faut la supprimer à la main. Dette de migration à traiter dans le postinst.
  • raspi-srv-2 ne tournait que par survie : démon démarré le 27 juillet, jamais relancé. Corollaire à retenir : dpkg --configure ne relance pas le service — une machine peut afficher active avec une configuration que son démon n’a jamais lue.
  • Parc mis à jour et redémarré : carbon (7.0.0-27 → 7.0.0-30, listes apt vieilles de deux mois, 43 paquets), hydrogen (-29 → -30). speedy et les deux Pi étaient déjà à jour et n’avaient aucun redémarrage à faire. Espace récupéré : +130 G sur speedy, +100 G sur carbon, +115 G sur hydrogen, en supprimant les caches devenus morts.

2026-08-20 — suite : air-launchd temps 3b, et un ADR qui affirmait deux choses fausses

Reprise du chantier interrompu le 2026-08-14. PR #793 est OUVERTE et BLOQUÉE au moment où ces lignes sont écrites — neuf vérifications vertes, couverture-couches rouge sur un test qui ne lui appartient pas. Rien de ce qui suit n’est mergé.

  • ADR-161 était Proposé, et le relire contre le code a trouvé deux affirmations fausses. Elles sont rectifiées dans l’ADR, en français et en anglais, plutôt que contournées. D1 disait que la dépendance s’arbitre « exactement comme pour un accès fichier » : faux — ServiceStartup est hors de requested, donc hors de l’invariant mécanique d’ADR-150 D1. L’arbitrage retenu passe par Grant.manifestSha256, il est tout-ou-rien, et « l’octroi arbitre » veut dire l’administrateur a vu et signé. D2 rejetait « un canal de readiness dédié » comme s’il n’existait pas : il existait — HelloFlags::EXPECT_READY, ToLauncher::Ready, AirLaunchdHost::expects_ready(), et la spec l’annonçait « équivalent Air de Type=notify ». Il était inerte (imprimé, publié sur le bus, rien ne s’y adossait) et inatteignable depuis un manifeste. Décision : il sera retiré, au moment où published sera mis en œuvre et pas avant. La phrase de la spec qui sur-vendait est corrigée dès #793.
  • D7 est précisé : le refus est par service et transitif, jamais global. Lu à la lettre, la rédaction initiale faisait qu’un seul manifeste fautif déposé dans /var/lib/air/services/ empêchait la machine de démarrer air-sshd — et contredisait discovery::Skipped, qui pratique déjà le démarrage partiel.
  • Ce que le code gagne (#793) : requires @4 et readiness @5 au schéma du manifeste, l’enum Readiness, cinq refus constatables sur le seul manifeste, puis le graphe — tri topologique déterministe, cycle nommé, fermeture transitive des Skipped. Le tri est une fonction pure : le graphe s’éprouve sans installer un bundle.
  • Un cycle n’était nommé que pour un de ses membres. ecarter_les_cycles mutait la liste pendant qu’il l’inspectait : après le retrait de b, le service a ne voyait plus le cycle dont il fait partie et retombait au motif « dépendance indisponible ». Trouvé par le test qui exige que chaque membre porte le motif InCycle.
  • Le cliquet a refusé la PR (48 → 49 côtés, confirmé sur trois mesures) et couverture-production a nommé la position : discovery.rs:475:16. Le côté était inatteignable par construction. Plutôt que de le justifier par un COVERAGE-UNREACHABLE:, la branche a été suppriméetri_topologique réordonne désormais par permutation d’indices.
  • Quatre échecs de CI, trois causes différentes, aucune dans le diff de #793 : blocage fork de cinq tests privsep_root ; CHILD_CONFINE_FAILURE sur un test d’air-launchd ; puis deux fois privsep_session_root.rs:2895 (« cible d’écho DÉJÀ MORTE »), test arrivé avec #792. Mesuré sur carbon, à conditions identiques : main seul échoue 1 fois sur 25 au MÊME endroit — le défaut est dans main, pas dans la PR. Compte rendu déposé pour la session qui reprend le sujet ; la mesure sous instrumentation, qui manque, lui revient.
  • Leçon de méthode, payée une heure : la CI teste refs/pull/N/merge, donc la branche fusionnée avec main courant — pas la branche. Instruire un échec sans rebaser d’abord, c’est comparer deux arbres différents ; le message d’erreur venait d’une PR absente du mien.

2026-08-20 — la mesure de couverture bat plus que le code, et deux paquets cassés

Nuit de travail sur la prévisibilité de la chaîne. Le fil conducteur, découvert par accident puis instruit : les gates de couverture mesuraient un chiffre plus instable que ce qu’ils prétendaient surveiller.

  • Un apt upgrade cassé sur raspi-srv-2 (#787) : le postinst d’air-sshd échouait faute de /etc/air/grants, laissant le paquet en iF. write_grant écrit, il ne crée pas — et le bac de test créait ce répertoire lui-même, si bien qu’aucun test n’a jamais éprouvé la machine neuve. Le provisionnement le crée désormais, en 0700 : deux machines du parc (carbon, speedy) le portaient en 0755, donc énumérable, contre une doctrine qu’ADR-150 D4 énonçait déjà. Même panne le 2026-08-15, où la correction n’avait fait qu’améliorer le message.
  • Le plancher de couche 2 interdisait main (#785) : calibré à 77,16 % sur 44 crates le 31 juillet, retenu à 75 ; la couche en compte 52 et mesure 74,79 %. Personne ne l’avait vu parce que la mesure par couche ne tourne que si une PR touche cette couche. Remplacé par une non-régression sur clé propre (layer2), les deux clés du fichier de référence étant désormais exigées ensemble.
  • La mesure tournait sur n’importe quel exécuteur (#785) alors que la référence vient de carbon. Trois machines, même code : 77,95 (speedy), 77,54 (carbon), 76,00 (hydrogen). Le job est épinglé sur la machine de la référence, comme l’audit et le cliquet l’étaient déjà.
  • …mais l’épinglage n’a pas suffi, et la cause a été trouvée (#790) : carbon rend 77,58 puis 76,00 d’un job à l’autre, lignes non couvertes IDENTIQUES (692) — donc même code exécuté, issues différentes. Dans un MÊME job, trois relevés rendent 76,04 trois fois : la mesure est déterministe à environnement figé. Ce n’était donc pas du bruit, mais une dépendance au cgroup ambiant — celle-là même instruite le 2026-08-18 pour le cliquet par crate (la délégation cgroup d’air-launchd aboutit depuis un scope vide, échoue depuis un cgroup peuplé ; les tests passent pareil, la MESURE bouge). Le cliquet avait reçu le scope transitoire, couvrable-vide jamais. Corrigé : chaque mesure part d’un cgroup neuf. Vérifié en local — 78,78 puis 78,79 sous scope, contre 77,95 puis 78,58 sans.
  • La traîne d’amplitude (#784, #785, #789) : air-sys-syscall est la première amplitude ÉTEINTE et constatée éteinte (85..85 sur trois tours) — le côté manquant était le SUCCÈS d’io_uring_enter, tenu par un test qui impose une file vide et exige d’avoir franchi le kernel. air-sshd passe de 3 à 1, sa position enfin nommée grâce à la colonne entrée dans l’identité d’une position.
  • Des refus que rien n’exerçait (#788) : bornes protocolaires du canal d’agent, matrice complète des options authorized_keys, contrat C d’air-libc-thread (26 entrées balayées au pointeur nul), court-circuit de try_join. Mesuré : air-sshd 553 → 530 positions. Le POURCENTAGE, lui, ne bouge pas (76,81 → 76,78 en local) — la variance de la mesure dépasse l’effet d’une nuit de tests, ce qui plaide pour piloter la couche 2 au cliquet de positions plutôt qu’au pourcentage.

2026-08-19 — suite : la dette dit enfin ce que la politique mesure, et deux affirmations sont rectifiées

Fin de la campagne « chaîne de CI prévisible ». Deux phrases écrites plus haut dans ce journal étaient fausses ; elles sont corrigées ici plutôt que réécrites là.

  • RECTIFICATION — « UNE seule crate qui bat » était faux. L’entrée précédente le déduisait des seules mesures locales d’air-launchd, la crate qui venait d’être soldée. air-sshd n’a jamais cessé de battre (550..553), et air-sys-syscall bat aussi (85..86) — invisible tant que la référence retenait un tour. Le constat ne valait que pour la crate mesurée : l’étendre aux autres était une extrapolation, pas une mesure.
  • Le cliquet appliquait sa propre liste d’exclusion (#780) : couvrable-vide et check-couverture écartaient les main.rs de CLI, les sources générées et les façades capi ; le cliquet, seul, les comptait. Les trois régimes partagent désormais une liste. Dette : 1448 → 1363, sans qu’une seule branche ne soit couverte.
  • Le cœur de l’instrument ne s’invalidait pas lui-même (#782) : modifier le cliquet ou le gate de couvrable-vide ne déclenchait aucune remesure. Un instrument qui se modifie sans se remesurer publie des chiffres qu’il n’a plus produits.
  • Une étape réseau sans borne a bloqué deux PR pendant six heures (#778, #781) : l’installation de Doxygen, sans timeout-minutes, a tenu un exécuteur 5 h 42 pendant que les checks affichaient « pending ». Bornée, puis reprise trois fois — borner ne répare rien, cela ne fait qu’échouer plus tôt.
  • Le cliquet redevient bloquant (#777), chaque rouge confirmé par deux remesures : un battement s’évanouit, une régression se reproduit. Une tolérance chiffrée aurait laissé passer une vraie régression de sa propre taille, et pour toujours.
  • Le diagnostic comparait des lignes là où le cliquet compte des côtés (#775), puis ignorait la colonne (#784) — deux conditions d’une même ligne portaient le même nom, et la déduplication en écrasait une. Le total bougeait, la liste ne bougeait pas, et l’outil concluait « rien ne change de camp ». C’est ce qu’il a répondu sur air-sshd deux audits durant, en disant vrai et en n’apprenant rien.
  • wait_completion : le côté SUCCÈS de sa garde était tenu par personne (#784). Il exige une file de complétions vide à l’entrée ; les tests qui attendent après un submit_and_wait ne l’atteignent jamais, les autres au hasard de l’ordonnanceur. Tenu par un test dont la précondition est prouvable (poll sur un tuyau vide) et vérifiable — la couture du simulateur compte les io_uring_enter franchis, et le test l’exige.
  • show --granted affichait un octroi sans le vérifier (#776) : la lecture passe désormais par l’arbitrage.
  • Référence inscrite : 1363 branches, 91 crates, 2 amplitudes (#783) — air-sshd 550..553 et air-sys-syscall 85..86, écrites à côté du chiffre. Le maximum de trois tours borne le battement ; il ne l’explique pas, et ce journal ne prétendra pas le contraire.

2026-08-19 — la chaîne de CI devient prévisible : une seule crate bat encore

Suite du 2026-08-18. Ce qui restait d’aléatoire dans la mesure a été traité par des tests, pas par des tolérances.

  • Les cinq unités d’amplitude sont attaquées, quatre sont éteintes (#771). Le bras EINTR d’une lecture, tenu par une doublure d’entrées/sorties dont chaque réponse est écrite d’avance ; l’échec d’écriture vers un client disparu, rendu certain en fermant son bout du tuyau ; deux fenêtres de concurrence d’air-thread — un compare_exchange qui échoue, un send qui s’intercale — provoquées par huit fils en contention et un producteur que le receveur rattrape. Mesuré avant/après : air-thread 10, 11, 10, 11 → 10, 10, 10, 10 ; air-sshd 593..596 → 593, 593, 593, confirmé sur carbon.
  • Un jugement descend dans la bibliothèque. Les deux refus de la séparation de privilèges vivaient dans bin/air-sshd.rs, qui ne porte aucun test sur ses 3518 lignes : devenus config::privsep_impossible, ils s’éprouvent en trois assertions. Quand une branche n’est atteinte que par accident, le remède n’est pas toujours un test — parfois c’est de déplacer la décision là où elle est testable.
  • Le balayage NOMME désormais ce qui bat. Une amplitude sans nom oblige à rapatrier des exports de 20 Mio par tour et à les diffé à la main. Le balayage publie la carte de présence des positions instables, et la décision est séparée de son affichage — donc éprouvée par trois tests sans lancer aucune mesure.
  • La référence cesse de crier au loup : elle déclarait « INSTABLE : de 4 à 4 » sur 88 crates de 91, noyant les trois qui battent vraiment. Trois cas, trois textes.
  • La couverture devient un job de CI à part : deux étapes qui ne dépendent pas l’une de l’autre s’enchaînaient sur le même exécuteur, faisant du chemin critique leur somme (13,7 min sur 16,9 de temps mur). En parallèle sur les deux exécuteurs x86, ~10 min attendus.
  • État : 1448 branches, 91 crates, et UNE seule qui batair-launchd, 49..50. Le cliquet reste consultatif tant que cette dernière amplitude n’est pas éteinte.

2026-08-18 — la mesure de couverture devient reproductible, et le harnais cesse de mentir

Suite directe du 2026-08-17 : le cliquet, rendu bloquant la veille, avait rougi sur une mesure qui bougeait. Deux jours pour comprendre que le problème n’était pas la lenteur mais des instruments qui affirmaient plus qu’ils ne mesuraient.

  • La non-reproductibilité est instruite, puis supprimée (#770). Huit mesures identiques d’air-launchd rendaient 49, 52, 52, 52, 52, 51, 49, 49. Vingt et un côtés de branche instables, tous dans supervise.rs, dont vingt dépendaient de la délégation cgroup — qui aboutit ou non selon le cgroup ambiant du processus de test, une propriété de qui lance et non du code mesuré. Chaque mesure part désormais d’un cgroup neuf (systemd-run --scope) : trois exécutions rendent des ensembles de côtés non couverts identiques, pas seulement des totaux égaux. La référence des 91 crates a été refaite dans ces conditions sur carbon — quatre crates bougent, dont trois qui baissent d’un côté chacune (air-config, air-launchd, air-thread) : la dépendance au cgroup était réelle mais étroite.
  • Trois défauts du harnais air-sshd, dont deux rendaient vert sans mesurer (#769). Un démon mort passait pour un port volé — 200 reprises de 10 s, soit 2000 s puis un diagnostic faux ; le répertoire de verrous de port, créé par root, devenait inutilisable par l’utilisateur du job suivant ; et treize suites *_root se sautaient hors root en rendant okprivsep_session_root annonçait 27 tests passés en 0,0 s là où elle prend 43,9 s. AIR_ROOT_SUITES_REQUIRED=1, déclaré sur les étapes de couverture, en fait un échec.
  • air-sshd n’était pas degradee : mon lancement sous nohup mettait SIGHUP en SIG_IGN, disposition héritée par les processus de session que quatre tests raccrochent. Le code documentait déjà le piège depuis le 2026-08-16 ; j’y suis retombé. Mesurée avec setsid, la crate rend 60 suites, 0 échec — et la référence porte 88 crates mesurées, 0 dégradée.
  • Les quatre branches laissées par #768 sont soldées : trois tests, chacun vérifié rouge sans le code qu’il garde, et une branche supprimée plutôt que justifiée — retain rend structurel le silence sur un identifiant inconnu. Dette d’air-sshd : 600 → 596.
  • Le cliquet reste CONSULTATIF tant que la CI n’a pas confronté le gate à la nouvelle référence en conditions réelles. Une mesure sous scope comparée à une référence établie sans lui fabriquerait le faux rouge qu’on vient de supprimer.

2026-08-17 — la mesure devient vraie, et une attente circulaire est coupée

Trois chantiers, liés par un même fil : un instrument qui affirmait plus qu’il ne savait.

  • La couverture entre en CI, et sa référence est refaite (#765). Le gate mesurait dans un répertoire partagé, où llvm-cov rapporte tout ce qu’il trouve. La correction — un répertoire par crate — a révélé que la première référence trompait dans les deux sens : air-libc-strmem passait de 17 branches non couvertes à 0 (des artefacts d’autres crates comptés comme dette), air-config de 33 à 36 (des profils fusionnés qui faisaient paraître couvert du code exercé par une AUTRE crate). Le second cas est le pire : il ne gonflait pas la dette, il la cachait. Nouvelle référence : 91 crates, 1455 branches, mesurées en isolation sur carbon, où l’étiquette air-couverture-reference a suivi. Trois crates de façade se révèlent non mesurables — le partagé les donnait « mesurées, zéro dette ». Le gate a été rendu bloquant, puis ramené à consultatif le lendemain : son premier verdict a montré que la mesure elle-même n’est pas reproductible. Huit exécutions identiques d’air-launchd, mêmes 103 tests : 49, 52, 52, 52, 52, 51, 49, 49. Les 21 côtés instables sont tous dans supervise.rs, et tiennent à deux choses — la délégation cgroup, qui aboutit ou non selon le cgroup ambiant du processus de test (20 côtés ; dans un scope transitoire vide, la mesure rend 49 trois fois de suite), et une course de lecture (1 côté). Le rouge reproché à la PR — 52 contre 51 — est cette course, pas une régression. Le cliquet redeviendra dur quand les conditions de mesure seront fixées.
  • Les suites de tests sont bornées dans le temps (#767). Sur carbon, privsep_session_root et cinq descendants vivaient depuis 8 h 50, orphelins d’un job de CI terminé. La borne (15 min, AIR_TEST_TIMEOUT_SECS) emporte la descendance avant de sortir : PDEATHSIG ne couvrait pas ce cas — le parent était vivant, c’est le job qui était mort. Trois défauts de la borne elle-même ont été trouvés en l’exerçant : elle tuait ses voisins, elle tuait en silence (capture de libtest), elle nommait des numéros sans les décrire.
  • L’interblocage des pompes est instruit et corrigé (ADR-147 inc.3). Reproduit une fois sur dix, état capturé en lisant les ppoll dans la mémoire des processus : attente circulaire, toutes les files TCP vides. shutdown(2) ayant été retiré du profil seccomp du worker à l’incrément 2, un CHANNEL_EOF ne devenait rien côté cible — et le canal ne se ferme qu’aux deux sens finis. Le geste change de processus : le monitor garde une copie de chaque cible, la nomme, et exécute demi-fermeture puis relâchement. 0 blocage en 100 tours ensuite. Reste le déclencheur, une course du harnais sur le port du tunnel, qui se manifeste maintenant en échec net.

2026-08-16 — l’instrument de mesure était cassé, et il accusait le code

Une PR s’est vu reprocher une régression de 1,83 point de couverture de branches. Elle n’avait rien régressé. Six expériences ont été nécessaires pour l’établir, et deux hypothèses fausses ont été brûlées en chemin (la toolchain ; la contamination des profils) avant que la bonne apparaisse. Ce qui suit est écrit pour que personne ne les refasse.

  • Ce que mesurait le gate. Sur le même arbre, la même machine, la même toolchain et le même filtre : 9830 branches dans un répertoire neuf, 5208 dans la séquence du gate. Près de la moitié du workspace sortait de la mesure, et le pourcentage était calculé sur ce qu’il en restait — 75,84 % au lieu de 77,89 %.
  • Pourquoi. cargo-llvm-cov n’ajoute pas -Zcoverage-options=branch à RUSTFLAGS mais à __CARGO_LLVM_COV_RUSTC_WRAPPER_RUSTFLAGS, lue par son wrapper de rustc. L’empreinte de cargo ne connaît ni cette variable ni ce que le wrapper en fait : avec --no-clean, la mesure des BRANCHES réutilise les objets compilés par celle des LIGNES, qui ne portent aucune donnée de branche. C’est aussi pourquoi cargo-llvm-cov nettoie par défaut — le --no-clean ajouté la veille pour le cache avait désarmé sa protection en silence.
  • Correctif (#754) : un CARGO_TARGET_DIR par mesure, sous target/ pour rester couvert par le filet d’hygiène root d’ADR-031. Chaque mesure garde son cache chaud, aucune ne lit les objets de l’autre. Au passage : CARGO_TARGET_DIR ne traversait sudo pour aucune des deux mesures — c’est ce qui les faisait se rejoindre dans target/.
  • Résultat : CI 77,59 % contre une référence de 77,52 %. Aucune régression n’avait eu lieu ; xtask/couverture-reference.toml n’a pas bougé d’un centième.

Le gate déclarait ✅ ce qu’il n’avait pas mesuré (#754). Sur main, un test tombé pendant la mesure des lignes interrompait llvm-cov, et le gate publiait « lignes non couvertes = 0 » puis « couvrable VIDE ✅ » avant de rendre le job VERT — zéro parce qu’il n’avait rien vu. Le critère lignes était donc désarmé par tout test en échec, c’est-à-dire exactement quand il sert. Les branches traitaient déjà ce cas en violation ; la dissymétrie est levée, et la décision est extraite dans interpret_line_run pour être éprouvable.

Une borne demandée est EXIGÉE (#755, ADR-157). air-launchd pose pids.max et memory.high, demandées par défaut. Quand elles ne pouvaient pas l’être, le service démarrait quand même, sans plafond, et la seule trace était un événement que rien n’obligeait à lire. Le cas n’était pas d’école : sous root en session SSH, delegate() réussit mais les contrôleurs ne sont pas distribués — le lanceur se croyait outillé. Désormais le service n’est pas lancé, et le refus dit comment lancer sans la borne (--max-processes unlimited). Ce défaut a interrompu trois mesures de couverture, dont une en CI : une frontière absente et un gate aveugle se nourrissaient l’un l’autre.

Trois affirmations fausses tombent avec : le doc-comment de delegation() promettait « l’échec n’est pas silencieux, l’appelant le rapporte » alors qu’un let _ = le jetait ; un test de verrou inter-processus se sautait hors root en jurant le contraire ; et son témoin — relâcher puis reprendre — était inéprouvable dans un processus qui forke, flock portant sur la description ouverte dont l’enfant hérite.

2026-08-15 — le compilateur qui ment est remplacé, et la CI cesse de tout refaire

Premier point de la roadmap, et il ne relevait pas de l’hygiène : le nightly qui produisait tout ce qu’Air livre en x86_64 segfautait sur du Rust sûr.

  • Pin monté à nightly-2026-07-11, le plus ancien nightly corrigé — dichotomie faite sur le reproducteur amont, 2026-07-09 segfaute encore, 2026-07-11 passe. La version de LLVM ne suffisait pas à trancher (22.1.8 des deux côtés) : seul le test réel le disait.
  • Patch PAL porté (4175 lignes) : deux morceaux de contexte dans os/fd/owned.rs, et surtout sys/sync/once/mod.rs, où l’amont a introduit une sélection cfg_select! dont air était absent — le module retombait sur no_threads, qui refuse de compiler sur une cible AVEC fils. Trois lignes, trouvées en une compilation.
  • Rust 1.99 exige -Z json-target-spec pour une cible décrite en JSON : le genre d’exigence qu’une montée révèle sans prévenir.
  • Preuve de bout en bout : les six paquets Debian se produisent en 4 min, et le reproducteur rend 0 sur le nouveau pin contre 139 (SIGSEGV) sur l’ancien.

Et la CI cesse de tout recompiler (#742). Le checkout portait clean: true — donc git clean -ffdx, donc target/ détruit à chaque job : 342 crates refaites pour la seule couverture, et autant au job suivant. Le cache vit désormais hors du workspace, un répertoire par job (les RUSTFLAGS diffèrent entre loom et la couverture ; un répertoire commun se serait détruit lui-même). Vérifié en production : ~/air-build/{fuzz,loom, cross-check-aarch64} survivent pendant que le target/ interne disparaît. Mesuré : 32 s à froid contre 0,198 s à chaud ; couverture 22 s contre 0,637 s.

Deux couplages traités plutôt que découverts en panne : la reprise de propriété suit le cache (sinon EACCES au job suivant), et les gates cherchaient leurs artefacts dans repo/target EN DUR — check-abi --exiger-symboles aurait échoué pour une raison sans rapport avec l’ABI.

--no-clean sur llvm-cov : sans lui, le cache persistant ne servait qu’à moitié, cargo llvm-cov nettoyant par défaut les crates du workspace. 0,716 s contre 4,854 s sur trois crates.

Et check-abi gardait 0/4 en CI : le job ne construisait pas les cdylib, donc le niveau symboles — celui qui avait trouvé 27 écarts — n’y vérifiait rien. Corrigé, et rendu irréversible par --exiger-symboles, qui fait d’une bibliothèque absente une violation.

Infrastructure : raspi-srv-2 était hors ligne depuis le 13 août. Il tournait hors de son service systemd — le service était mort le 27 juillet, GitHub ayant supprimé son enregistrement — et le redémarrage de la machine l’a emporté sans que rien ne prenne le relais. Réenregistré et installé sous systemd, cette fois. La CI cesse d’être sérialisée sur une seule machine.

Signalé, non corrigé : cargo xtask test-air échoue sur air-libc-c (crate host-only à crate-type = cdylib, que la cible linux-air ne supporte pas). Indépendant de la montée — ni la cible JSON ni aucun manifeste n’ont bougé. Personne ne l’avait vu : test-air n’est lancé nulle part en CI, comme les 90 cibles de fuzz avant hier.

2026-08-15 — l’ABI outillée jusqu’aux symboles, le fuzzing réveillé, et un compilateur qui ment

Suite directe de la règle NO GO : chaque outil annoncé et inexistant a été écrit, et chacun a trouvé quelque chose de réel à son premier passage. Sept PR (#734 → #740).

  • check-abi (#734, bloquant) : la rupture d’ABI C ne passe plus. Ni gen-capi-header --check ni les tests de conformité ne la voyaient — les deux lisent des textes, alors qu’un consommateur C lie un objet. 392 déclarations figées dans xtask/abi-reference.toml.
  • ADR-158 accepté (#735) : voie A — le versionnement de symboles — mise en œuvre différée sous condition nommée. La mesure exigée par l’ADR a rendu autre chose que l’attendu : Air ne livre aucune bibliothèque partagée, et rustc possède le script de version, si bien qu’un script ajouté ne peut ni cacher un symbole ni le versionner. La technique .symver de la glibc, elle, fonctionne : coexistence prouvée de bout en bout — une .so, deux consommateurs C, l’ancien épinglé rend 1, le neuf rend 2.
  • Le zonage ABI (#736) : abi_zone par crate, et la règle qui l’empêche d’être une échappatoire — une zone ne se déclasse pas. Sans elle, il suffirait de reclasser une surface en experimental la veille de la rompre.
  • check-symver (#737, bloquant) : aucune .so livrée sans versions de symboles. Gate volontairement vide aujourd’hui, et qui l’écrit — sa raison d’être est le jour d’après, puisque versionner après coup est soi-même une rupture de liaison.
  • check-doc-links (#739, bloquant) : 98 liens morts corrigés, 3975 résolus. Écrit après que j’ai fabriqué huit liens d’ADR en devinant leur nom de fichier — que rien n’avait vus, mdbook ne suivant que le sommaire. Un seul écart n’était pas un renommage : dans ADR-079, ../../notes/ depuis docs/adrs/ désigne la racine du dépôt.
  • Le fuzzing réveillé (#740) : le dépôt portait 90 cibles que rien ne construisait, dont sept ne compilaient plus. Sa sentinelle d’exhaustivité avait bien détecté une variante nouvelle — mais personne ne compilait. Job CI : cargo fuzz build + passe de fumée bornée, 0 échec en 5 min 30.
  • check-abi au niveau des symboles (#738) : 27 écarts réels, dont 7 découverts dans libair_c.so (air_fmt_*, air_libc_*). Ces 7 corrigent un chiffre faux que j’avais écrit dans ADR-158 (« 18 _Unwind_* » — il y en a 11). Avec eux, ADR-159 (air-elf, et le rôle codec qui n’existait pas) et ADR-160 (la frontière variadique).

Trois découvertes hors programme.

  1. Le nightly épinglé miscompile. Reproduit ici : nightly-2026-06-13 (LLVM 22.1.6) rend SIGSEGV sur du Rust sûr ; 2026-07-11 corrige. Le signalement amont date du 10 juillet — notre pin du 12 juin ne pouvait pas contenir un correctif qui n’existait pas. x86 uniquement. La montée est bloquée par une dérive sémantique du patch PAL (4115 lignes).
  2. c_variadic est stabilisé en Rust 1.99 (1ᵉʳ octobre) — ce qui supprime le coût central d’ADR-160.
  3. Le fuzzing ne tournait nulle part, et personne ne pouvait le savoir puisque rien ne le construisait.

Et quatre fautes de ma main, toutes attrapées par les gates et non par moi : huit slugs d’ADR inventés, un chiffre faux masquant 7 écarts, un --json tronqué que ma propre doc annonçait complet, et des modules de test hors-ligne comptés comme du code livré (400 blocs). C’est l’argument le plus net en faveur de ces gates : ils ont corrigé leur auteur.

2026-08-14 — la règle NO GO, et les gates qu’elle a forcés à exister

Le BDFL a posé en tête de CLAUDE.md et d’AGENTS.md une règle qui prime sur tout le reste : un outil ou un référentiel affirmé existant et qui ne l’est pas arrête tout ; et un agent qui écrit qu’un sujet est traité doit avoir tenu cette promesse au moment où il l’écrit. Onze occurrences en trois jours l’ont payée. Le reste de la journée en est l’application immédiate au dépôt lui-même.

  • Les trois gates annoncés et inexistants, écrits (#733). check-dco (bloquant) : Signed-off-by sur chaque commit de la branche, refus de tout trailer Co-Authored-By: Claude — règle de paternité qui ne reposait que sur un réglage du client qu’elle contraint. check-dependencies (consultatif) : 40 crates en écart sur 91, dont 23 sans aucun DEPENDENCIES.md. check-safety-comments (consultatif) : 46 blocs unsafe de production sans justification sur 1909, et 845 sur 2042 en code de test.
  • Une erreur en sens inverse, corrigée : CLAUDE.md affirmait depuis le 2026-08-03 que check-safety-comments « n’avait jamais existé ». Un fragment vivait dans barrier.rs — deux crates socle, consultatif, et la barrière ne tourne pas en CI. Les deux contrôles de la barrière ont été promus en gates plutôt que redoublés ; elle les appelle désormais.
  • check-abi (bloquant) : la rupture d’ABI C, qu’aucun contrôle ne voyait. 392 déclarations figées dans xtask/abi-reference.toml ; une disparition ou un changement de signature est refusé. gen-capi-header --check prouvait header ≡ sources, jamais qu’aucun contrat n’était rompu — retirer un symbole et régénérer donne un header cohérent.
  • Les affirmations d’outillage ABI, réconciliées : air-abi-check, air-symver, air-deprecation-tracker, annoncés « intégrés dès la phase 0 », n’ont jamais existé. CLAUDE.md, macro-architecture-fr.md et la roadmap libc disent maintenant ce qui est réel. docs/principes-ingenierie/ porte la même affirmation mais est IMMUABLE : signalé au BDFL, à réconcilier par RFC (ADR-015).
  • Quatre défauts trouvés dans les gates eux-mêmes, tous du motif traqué : modules de test hors-ligne comptés comme code livré (400 blocs), --json tronqué alors que la doc y renvoyait, blocs en tête d’une unsafe fn documentée # Safety signalés à tort (22 sur 68), et check-dco accusant le commit de fusion que GitHub fabrique pour une PR — un commit que personne n’a écrit.
  • xtask porte désormais #![forbid(unsafe_code)] : c’est le compilateur, et non un grep, qui le met hors du périmètre du balayage unsafe.

Dette ouverte et nommée : 40 DEPENDENCIES.md à écrire · 46 justifications // SAFETY: de production · required_signatures non activée sur main (décision BDFL) · air-symver (versionnement de symboles ELF) et la disposition des structures, hors de portée de check-abi.

2026-08-14 — les trois machines portent enfin ce qu’on construit

Le déploiement a eu lieu, et il a prouvé en réel ce que les tests promettaient.

  • Trois machines, trois clés de signature distinctes — speedy c6a36f1b…, carbon cb11040e…, raspi-srv-2 a1aabea4…. Chacune signe ses propres octrois ; aucune ne peut valider ceux d’une autre.
  • L’idempotence de la clé, éprouvée par une VRAIE mise à jour de paquet sur speedy (et non par un second appel de test) : « déjà présente — inchangée », empreinte identique, fichier non réécrit. C’est la propriété qui protège tous les octrois d’une machine.
  • air-launchd tourne sur aarch64, ce qui n’avait jamais été éprouvé. Sur les trois : « aucun service installé — rien à superviser », sortie 0.
  • ADR-149 D9 — le gate d’administration désignait l’admin par son NOM (#728). Ne trouvant pas d’entrée root, il concluait à la fenêtre de bootstrap et laissait passer toute mutation sans secret : renommer le compte d’UID 0 le désarmait. air-sshd avait déjà tranché dans le bon sens ; le gate ne suivait pas. Corrigé — et ses deux consommateurs (air-account-cli, air-keystore-cli) dans la même PR, après que la CI eut rendu 12 échecs que cargo check --workspace ne pouvait pas voir.
  • Rien ne pouvait installer un bundle de service (#729). air-bundle savait montrer, accorder, révoquer — pas poser. Le lanceur supervisait ce qu’il trouve, et rien ne pouvait rien y mettre. air-bundle install compile le manifeste texte, le signe, vérifie l’empreinte de chaque exécutable déclaré, puis pose. Substitution après coup ⇒ refus, et rien n’est écrit. Le compilateur de texte vit dans l’outil, jamais dans air-bundle que le lanceur lie (ADR-150 D2, Principe 11).
  • ADR-156 TSYNC (#726) : une cage qui se contourne en changeant de fil n’est pas une cage. Mesuré : TSYNC n’est pas monotone, il écrase le domaine d’un frère plus strict — d’où l’opt-in. Et seccomp a la garde de monotonie qui manque à Landlock.
  • Ménage sur carbon : deux processus de test ProxyJump tournaient depuis le 5 août, neuf jours. Exemplaire vivant de la dette « rien ne moissonne un processus de test bloqué ».

Ce qui reste, et qui 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. C’est exactement ce qu’il existe pour empêcher : un shell root ne suffit pas à accorder.

Le matin — livrer pour de vrai : trois trous entre le vert de la CI et une machine

Le chantier « déployer » a commencé par constater que rien n’était déployable. Trois défauts distincts, tous du même genre : le vert de la CI ne dit rien de ce qui tourne sur une machine, parce que la CI ne construit pas ce qu’on livre.

  • ⚠️ RIEN ne générait la clé de signature de la machine. set_machine_key n’était appelé que par des tests ; la doc disait qu’elle naissait « au premier démarrage » (ADR-121), et machine_key précisait même que « c’est à l’appelant de décider si l’absence est réparable » — aucun appelant ne la réparait. Conséquences : le postinst d’air-sshd appelle provision-bundle sous set -e, qui l’exige — l’installation du paquet échouait sur toute machine ; air-bundle grant ne pouvait rien signer ; air-launchd refusait tout bundle. Corrigé par air-keystore machine-key init (idempotent, non gaté) et un postinst sur le paquet air-keystore.
  • ⚠️ cargo xtask deb ne compilait plus — le seul chemin qui produit les livrables. air-ssh utilisait std::os::unix::ffi::OsStrExt, or std::os::unix n’existe pas sur la cible d’Air. Ça compilait sur l’hôte gnu, où tournent les tests, et cassait le build livrable en silence depuis #627. as_encoded_bytes est la forme portable.
  • air-launchd n’était packagé nulle part, sur une justification périmée : « pas encore livrable […] à déplacer dans PACKAGES quand le manifeste et la cage existent ». Les deux existent depuis #723. Six livrables au lieu de cinq, et le .deb du lanceur est produit pour la première fois.

Le motif, désormais nommé : un commentaire, une justification ou une doc affirme une propriété que le code n’implémente pas. Sept occurrences en trois jours, toutes trouvées par hasard en construisant autre chose. Une chasse systématique est ouverte.

Et l’angle mort qui les explique : la CI des PR ne lance pas cargo xtask deb. Elle aurait attrapé deux des trois.

2026-08-13 — campagne des managers, Landlock, chaîne kernel, et le multi-service

Journée longue, quatre sujets, et deux défauts trouvés en construisant autre chose.

  • Campagne des managers dus (ADR-149 D8). Étape 2 : la famille aircom d’un octroi devient la politique du registre (#714) et le canal de lancement gagne un sixième enregistrement pour remettre la capability (#715). Étape 4 : la famille devices est appliquée, avec l’ioctl borné par chemin (#719). Statut d’ADR-149 corrigé en en-cours (#713) — il annonçait « non commencé » alors que deux étapes étaient livrées.
  • ⚠️ Un octroi de fichiers n’ouvrait aucun fichier (#718). La traduction ne posait que les variantes _dir : dans Landlock, READ_DIR et READ_FILE sont disjoints. Un service accordé en lecture listait son répertoire sans pouvoir ouvrir un seul fichier. La famille la plus employée du vocabulaire était inutilisable. Trouvé en écrivant l’applicateur des périphériques, pas en le cherchant.
  • ADR-156air-sandbox cible l’ABI Landlock v7 (#720), sur la règle : on n’expose pas ce dont on a besoin, on expose ce que le noyau offre. Sa D3 est appliquée (#721) : deny_all() ferme désormais ce que le noyau sait fermer — le jeu figé en v1 laissait TRUNCATE, REFER et IOCTL_DEV hors de la cage sur des descripteurs hérités.
  • ADR-155 — le kernel d’Air se bâtit avec Clang/LLVM (#711, #712). Avec du Rust dans le kernel, la question cesse d’être « quelle chaîne » pour devenir « une chaîne, ou deux » : rustc est LLVM et bindgen exige libclang, donc GCC serait une chaîne en plus. Quatre décisions BDFL, plancher 6.12 = plancher d’exécution.
  • CI : actions/checkout passe en v5, fin des avertissements Node 20 (#722).
  • ADR-157 — le lanceur supervise ce qu’il trouve, temps 3a du multi-service. Le manifeste gagne un bloc service (point d’entrée parmi les exécutables empreintés), et le lanceur balaie la racine des services. Un seul ppoll sur N services.
  • ADR-156 — le scoping v6 est posé, et il MORD. L’étage pré-auth d’air-sshd — celui dont le système de fichiers a entièrement disparu — pouvait encore signaler n’importe quel processus de la machine et joindre n’importe quel socket UNIX abstrait : ni l’un ni l’autre ne passe par un chemin, donc aucune règle Landlock ne les voyait. Même mécanique que l’EXECUTE du 12 août. deny_all() ferme désormais les deux, et refuse de se poser sous ABI v6 — qui est le plancher d’Air. Quatre tests en paires : l’enfant sous portée obtient EPERM, le même enfant sans la portée passe.
    • Mesuré, pas recopié : un noyau 7.0 annonce l’ABI v8, pas v7 — et LANDLOCK_RESTRICT_SELF_TSYNC (v8) applique la restriction à tous les fils, sans quoi une cage posée sur un seul fil se contourne en changeant de fil. À instruire.
    • Piège : /usr/include/linux/landlock.h (libc) était périmé — deux champs là où l’en-tête noyau en porte trois. Prendre la disposition dans le mauvais fichier aurait fait écrire un scoped que le noyau n’aurait jamais lu.
  • ADR-156TSYNC : la cage sur TOUS les fils, et ce que la mesure a renversé. Sans lui, restrict_self et seccomp ne bornent que le fil appelant : dans un processus multifil qui se confine lui-même, la cage se contourne en changeant de fil.
    • Mesuré : TSYNC n’est PAS monotone — il RELÂCHE. Un fil frère confiné au point de ne rien pouvoir ouvrir retrouve l’accès quand un autre fil pose un domaine plus laxiste avec TSYNC. Le noyau l’annonçait (« irrespective of previously established Landlock domains ») ; c’est un remplacement, pas une formule prudente. D’où l’opt-in, et un test qui fige le comportement pour qu’un changement de noyau se voie.
    • Doc fausse, corrigée : LandlockRuleset affirmait « Landlock est monotone », sans réserve. Cinquième occurrence du motif en trois jours — et la première où la doc était fausse par anticipation d’une ABI qui n’existait pas quand elle a été écrite.
    • Piège seccomp désamorcé : sous TSYNC, seccomp rend le tid du fil fautif — un entier positif — en cas d’échec, que la couche 0 lit comme un succès. Toujours accompagné de TSYNC_ESRCH, qui existe pour cela.
    • Seccomp a la garde de monotonie qui manque à Landlock : SECCOMP_FILTER_FLAG_TSYNC refuse (ESRCH) quand un fil frère porte déjà un filtre divergent, là où Landlock écrase sans broncher. Découvert parce qu’un test a échoué ; Landlock étant posé en premier, la cage est alors partielle — le contrat « échec ⇒ fatal » s’applique.
    • Un piège que j’ai posé moi-même, puis retiré : la première rédaction créait le fil frère dans un enfant clone3. pthread_create alloue, l’allocateur a un verrou qu’un fil de libtest peut tenir au moment du fork, et qui n’est jamais relâché dans l’enfant. Blocage 1 fois sur 10, et un processus fantôme de 2 h 45 sur la machine. C’est la règle que le fichier de tests portait déjà depuis le 10 août. Corrigé en relançant le binaire de test lui-même en témoin : monofil au démarrage, aucun fork. 20 lancements consécutifs verts.
    • Seuil d’ABI fixé à 8, et conservateur assumé : l’en-tête ne nomme pas l’ABI d’introduction. Viser trop haut ne coûte qu’un durcissement manqué ; viser trop bas fait échouer restrict_self en EINVAL et, la pose étant fail-closed, tue le processus.
  • Étape 2b-ii — le lanceur HÉBERGE le registre de noms, et le dernier mur tombe. launch dépose la capability avant le fork ; la boucle guette le canal de chaque service et sert Register/Lookup ; admit pose la politique au lancement, forget la reprend à la mort constatée. Côté service, air-com::RegistryClient publie et joint dans le vocabulaire du manifeste (publish/connect). Plus aucune famille d’octroi n’est murée faute d’applicateur (ADR-150 option B).
    • Le registre répondait en b"oui"/b"non" — un doublon du RegistryResponse déjà défini dans air-com-proto. Retiré au profit du type existant, qui est aussi le meilleur : il ne confond NotFound et Denied que pour un demandeur non habilité, là où mon refus uniformément muet aurait condamné tout client à réessayer indéfiniment un refus définitif.

Le motif à retenir : trois fois en deux jours, un commentaire affirmait une propriété que le code n’appliquait pasEXECUTE (12 août), la famille filesystem, deny_all() figé en v1. Cela mérite une chasse systématique.

2026-08-13 — ADR-155 : la chaîne de construction du kernel

Question du BDFL en préparation de l’image installable : le kernel se bâtit-il avec GCC ou avec Clang ? L’instruction (note) a été promue en ADR-155 (#711 puis PR de promotion).

  • Le cadre bascule avec le Rust dans le kernel. La question cesse d’être « quelle chaîne » pour devenir « une chaîne, ou deux » : rustc est LLVM par construction et bindgen utilise libclang, donc LLVM est requis quoi qu’il arrive. Choisir GCC n’aurait pas retiré LLVM du lot — cela y aurait ajouté GCC.
  • Cinq décisions : Clang/LLVM (make LLVM=1) sans GCC par-dessus ; du Rust dans le kernel ; Air ne livre que son propre kernel ; une seule rustc pour userland et kernel ; parité LLVM entre clang et rustc, sans quoi kCFI ne couvre pas le Rust.
  • Une confusion dissipée : le plancher 6.12 (LTS) est un plancher d’exécution, pas une contrainte sur le kernel qu’Air bâtit. La précision est posée dans macro-architecture-fr.md.
  • 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, et leurs substituts existent des deux côtés.

Reste à outiller : un gate vérifiant que la rustc épinglée est celle que le noyau accepte.

2026-08-12 — ADR-153 : le champ 7 de /etc/passwd cesse d’être une porte d’exécution

Point de départ, une question du BDFL : « lors d’une connexion SSH via air-sshd, quel shell est exécuté sur l’hôte ? » La réponse — celui du champ 7 — décrivait une décision d’exécution que l’arbitrage d’ADR-150 ne regardait pas, et un binaire glibc que seul le profil transitoire faisait vivre. Le seuil d’exécution d’ADR-046 D6 tenait partout sauf sur le chemin le plus emprunté d’Air.

  • Incrément 1 (#702) — la cinquième famille d’entitlements, session, dans le schéma, le lecteur (chemin absolu, doublons refusés, borne par famille), l’invariant demande↔octroi, l’intersection par présence (une règle de session n’a rien à cocher) et l’affichage d’administration. Le rapprochement est l’égalité stricte du chemin : /usr/bin ne couvre pas /usr/bin/alize. Le plafond refuse la famille tant que rien ne l’applique — un octroi qui a l’air d’accorder et n’accorde rien serait une promesse écrite dans un fichier.

  • Incrément 2a (#703) — le vocabulaire du manifeste et de l’octroi quitte air-launchd pour la crate air-bundle. Le fait qui décide : 8 049 lignes sur 8 310, soit 97 %, dans une crate qui déclare « n’expose rien de réutilisable ». Un démon root n’a pas à lier le superviseur pour lire un artefact signé. Déplacement pur ; 236 tests répartis sans perte ; trois dépendances devenues fausses tombent, que la séparation a rendues visibles.

  • Incrément 2b (#704) — air-sshd devient consommateur d’octroi. Il lit le sien au démarrage sous org.air-desktop.sshd — premier identifiant de bundle réel d’Air. Le monitor oppose la liste admise au champ 7 avant la commande ; le refus est muet vers le client et tracé avec le compte, le programme et la liste admise. Le mur du plafond tombe, air-bundle grant --session-program accorde.

  • Provisionnement (#705) — le postinst pose le manifeste signé sous la racine d’image et, s’il n’y en a pas, un octroi VIDE. Sans octroi du tout le démon ne démarre pas ; avec un octroi vide il démarre, sert ses redirections, et n’ouvre aucune session tant que rien n’est admis. Un octroi vide n’accorde rien : il n’usurpe donc aucune décision. La clé qui signe les manifestes de service est distincte de la clé de machine (le développeur demande, la machine accorde) ; auto-signée localement, phase transitoire déclarée et datée vers une clé de release du projet.

Un défaut réel trouvé en écrivant le provisionnement : 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. ADR-153 n’aurait jamais fonctionné. Aucun test unitaire ne pouvait le voir : ils exerçaient l’émission et la lecture séparément. C’est la traversée qui prouve. Corrigé dans #704 avant merge, avec un aller-retour couvrant les cinq familles.

Questions ouvertes 1 et 2 tranchées : famille vide par défaut (option B d’ADR-150) ; aucun cas particulier pour nologin/false. Reste la 3 (Subsystem SFTP), avec ADR-119.

Conséquence d’exploitation : pas d’octroi, pas de service. Une machine mise à jour sans octroi ne démarre plus air-sshd — provisionner avant.

2026-08-12 — ADR-010 : la porte du bac à sable, et ce qu’on a trouvé en l’ouvrant

Le mécanisme d’ADR-150 était complet et prouvé depuis le 10 août — manifeste, octroi, signature, arbitrage, pose de la cage — mais rien ne reliait un .airapp sur disque à spec.granted. Cette campagne construit cette porte, et corrige tout ce qu’elle a révélé.

  • Deux traces mortes d’abord (#684). ADR-010 portait a-faire — « non commencé » — alors que la moitié en était implémentée depuis deux jours ; et la spec couche 2 annonçait des incréments « à faire » déjà mergés. Un état qu’on ne relit pas ment sans que personne mente.
  • La disposition tranchée (#685, BDFL) : <racine>/<bundleId>/ avec manifest.aircfg, ses executables/, trois racines, octroi en /etc/air/grants/<id>.aircfg 0600. Le répertoire ne porte pas le suffixe .airappkind vit dans l’artefact pour ne pas se déduire d’un chemin.
  • « Aucune exécution nulle part » n’était pas appliqué (#686). handled_access_fs étant l’union des droits des règles, et aucune enveloppe n’accordant execute, Landlock ne gérait jamais EXECUTE : une application confinée pouvait écrire un binaire dans son répertoire de données et l’exécuter. Le lanceur pose désormais une règle EXECUTE d’office sur ce qu’il lance — et l’effet de bord est la propriété : tout le reste cesse d’être exécutable. La mesure a corrigé la conception au passage : un binaire lié dynamiquement exige aussi son interprète ELF, ce que busybox statique a prouvé.
  • La porte, l’activation, l’émission (#687, #688, #692) : chargeur gardé (O_NOFOLLOW, métadonnées prises sur le descripteur), --bundle <identifiant> — jamais un chemin —, et une émission qui refuse ce que l’administrateur nomme sans que le manifeste l’ait demandé. issue intersecte déjà : le refus n’est pas une garde de sécurité de plus, c’est une garde contre une méprise.
  • La clé de la machine (#689) dans air-keystore, générée au premier démarrage : deux fichiers écrits d’un seul geste, la graine 0600 et sa contrepartie publique 0640 — le lanceur vérifie, il ne signe jamais.
  • L’outil d’administration (#693) : air-bundle show/grant/revoke, gate ADR-101 sur les seules mutations, secret jamais dans argv, un drapeau par droit et aucun « tout accorder » — celui que tout le monde finirait par taper, comme --filesystem=home chez Flatpak.
  • ADR-010 amendé (#697) : « déplaçable » ne veut pas dire exécutable en place.

Et quatre corrections nées de la campagne, sans rapport avec elle :

  • sudo -E ne préservait rien (#694). Politique env_reset sans env_keep : root démarrait avec HOME=/root, retéléchargeait le registre dans son propre home (/root/.cargo : 211 Mio), et l’étape « restaurer la propriété » nettoyait à côté. Première version refusée par le gate pour la bonne raison : transmettre HOME rendait les tests non hermétiques.
  • Deux lectures qui échouaient sans dire pourquoi (#696). remote_forward et monitor::forward rougissent par intermittence en CI depuis le 9 août ; quatre hypothèses posées, quatre réfutées par la mesure (root, interop SFTP, périmètre couche 2, purge de descripteurs). Aucune n’était vérifiable par le journal, faute qu’il dise la cause : les deux sites la nomment désormais.
  • aarch64-native rouge depuis 36 h sans que personne le voie (#698). rustc génère son propre version-script anonyme pour tout cdylib depuis Rust 1.54, et GNU ld refuse de cumuler ; ld 2.46 (Pi) applique la règle que 2.42 (x86) ignorait. Corrigé par rust-lld, déjà épinglé avec la toolchain. Le job ne tournant que sur push main, il échouait là où personne ne regarde — on croyait avoir une couverture ARM qu’on n’avait plus.
  • Ajouter une crate demande QUATRE registres (#695), découverts un par un à un cycle de CI chacun. Guide écrit, pointeur dans CLAUDE.md.

2026-08-12 — le shell d’Air, la quiescence, et un test qui parlait à un mur

Trois chantiers nés d’une seule question du BDFL — « lors d’une connexion SSH via air-sshd, quel shell est exécuté sur l’hôte ? » — et de ce qu’on a trouvé en y répondant.

  • ADR-153 : le programme de session est OCTROYÉ (#680). air-sshd lançait le shell du champ 7 de /etc/passwd : 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 le profil TransitionalGlibc fait vivre, si bien que le seuil ADR-046 D6 tenait partout sauf sur le chemin le plus emprunté. Décidé : cinquième famille d’entitlements session, chemin absolu et exact, 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 par une exception dans ce mécanisme.
  • ADR-154 : le shell d’Air s’appelle alize (#680). Pas POSIX, accepté par le BDFL (« trop flou, trop ambigu, laissant trop de portes ouvertes ») : 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 « 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. Nom propre choisi contre un nom en sh, qui promettrait un sh. -c reste une ligne interprétée, pour l’interopérabilité dont git, rsync et scp dépendent.
  • La quiescence soumettait après avoir annulé (#681). Le drop d’un Reactor coûtait 6,4 s et fuyait le fd d’anneau plus ses trois mappings, à chaque destruction. Le diagnostic d’abord publié — « l’eventfd n’est jamais annulé » — était faux, et la mesure comparative l’a démenti : un réacteur ayant tourné se détruisait déjà proprement. 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 donc invisible à sync_cancel et au drainage. quiesce() soumet désormais d’abord. Mesuré : 6,4 s → 40 µs, zéro fuite, suite air-uring 12,9 s → 0,11 s. Le correctif vit en couche 0 et vaut pour tout appelant d’IoUring, pas seulement le réacteur.
  • Un test qui se sautait en le disant à un mur (#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 d’eux ne disait rien du tout. Corrigé en trois temps : la branche du curseur de continuation ne dépend plus d’aucune capacité (fd invalide suffit) ; le saut devient exigible là où on le déclare (AIR_EBPF_BATCH_REQUIRED=1 ⇒ échec portant l’errno, scopé x86_64) ; 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.
  • Ce que la nuit a appris sur la méthode. Deux diagnostics faux en deux jours, tous deux plausibles et non mesurés — le drop du réacteur, puis la cause de l’échec de #681. Un troisième constat, le segfault d’air-libc-c, s’est révélé une fausse alerte : il était déclaré et expliqué depuis longtemps dans xtask/src/barrier.rs. La règle qui en sort : 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. Ce qui a payé, à l’inverse : le diff des lignes non couvertes, le sabotage qui prouve qu’une garde mord, et l’interrogation directe de l’exécuteur.

Dettes ouvertes : la cause du refus de lot eBPF par carbon reste inconnue (le prochain refus la nommera) ; 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.

2026-08-11 — les managers dus, et la porte du magasin de clés

Reprise de la campagne d’ADR-149 D6 (dix managers de domaine déclarés mais absents), puis de bout en bout sur air-keystore.

  • AirUringManager (#671) — le pilote, choisi parce qu’il n’avait aucune opération libre : rien à rendre privé, donc aucune rupture. Le motif qui vaut pour les neuf autres y est établi : ajouter le manager est additif, seul le retrait des routes directes est cassant. Surface conçue pour le développeur à venir et non pour l’appelant du jour (posture BDFL du 2026-08-11) : interroger la machine avant de s’engager, un vocabulaire qui répond à la question posée, des bornes avec un défaut, un dimensionnement tiré de l’affinité plutôt que du nombre de cœurs.
  • AirKeystoreManager (#672, puis #678, #674, #675, #676, #677, #679) — la porte du domaine « cycle de vie des clés et secrets », et la rupture de couche 1 autorisée par le BDFL puis consommée. Deux défauts trouvés en instruisant : les chemins canoniques des magasins vivaient dans air-sshd (couche 2), redérivés par neuf crates ; et la garde de lecture protégeait le matériel public (known_hosts) pendant que la clé privée d’hôte et la clé qui forge les certificats étaient lues sans aucun contrôle.
  • Une revue adverse a arrêté la première version de la garde : elle ne contrôlait que le droit d’écriture, donc déclarait digne de confiance un hostkeys.aircfg retrouvé en 0644 — clé privée lisible par tout compte local, sans que l’épinglage n’y voie rien. La règle héritée d’un fichier public avait été appliquée telle quelle à un secret. Corrigé par AirStoreTrust::ExposedSecret, plus quatre autres constats de la même revue (repli fail-open, trous de zéroïsation, inventory() inutilisable hors root, rooted() qui désarmait la garde du magasin système).
  • Trois arbitrages du BDFL : --store conservé par une dérogation nommée qui déroge sur et jamais sur ce qu’on vérifie ; 0640 du magasin personnel porté en couche 1 ; puis 0640 étendu à trusted_ca et revoked — seul le magasin d’hôtes connus administré reste 0644, sa diffusion étant sa fonction. Public devient l’exception, PrivateListing le défaut.
  • Ce que la campagne a appris sur la méthode. Trois tests écrivaient leurs artefacts par std::fs::write, donc sous umask : la garde les refusait avant qu’ils n’exercent ce qu’ils annonçaient. Une pile de PR doit être verte cran par cran, pas seulement au sommet — la CI l’a démontré. Et un grep par nom ne remplace pas un pub(crate) : le compilateur a trouvé deux appels que l’inventaire avait manqués.

2026-08-10 — le profil TRANSITOIRE : ouvrir juste ce qu’il faut, et le dater

La cage posée, plus rien de l’userland d’aujourd’hui ne démarre — c’est la propriété recherchée (décision BDFL du 2026-08-10) et c’est aussi, en l’état, un Air sans outils. D’où un profil permissif nommé, mesuré et daté.

  • Établi par la mesure, pas par prudence. strace -c sur sh, ls, sed, mkdir, cat, grep et un pipeline complet ; puis vérification qu’un shell tourne vraiment sous le profil obtenu. Rien n’y figure « au cas où » : une entrée sans cas d’usage serait une porte ouverte sans même la raison qui permettrait de la refermer.
  • Le coût est dit franchement. ioctl est le plus lourd — un multiplexeur qu’une allow-list ne distingue pas — et clone/vfork rouvrent la création de processus qu’ADR-021 avait restreinte à clone3. Un service transitoire est notablement moins confiné qu’un service natif.
  • Supprimable d’un bloc, et c’est VÉRIFIÉ. Un test garantit qu’aucune des 17 variantes du bloc transitoire n’a fui dans le socle, dans une autre catégorie ou dans un profil scellé — sans quoi la permissivité serait devenue permanente sans que personne l’ait décidé. Un second garantit l’inverse : la catégorie les accorde toutes, faute de quoi l’une serait déclarée mais jamais autorisée.
  • Jamais dérivé d’un octroi, jamais un défaut. Le drapeau est un constat sur l’état du portage, pas une permission — le mêler aux entitlements en aurait fait une permission qu’on ne retirerait jamais. Il se demande par --profil-transitoire-glibc, nom long à dessein pour qu’il se voie dans une unité systemd et dans un journal.
  • Sentinelle NUMBER_ABSENT : access, dup2, mkdir, vfork et arch_prctl n’existent pas sur aarch64. Leur donner le numéro de l’autre arche aurait autorisé un syscall SANS RAPPORT ; ils sont donc écartés du filtre, jamais traduits.
  • Un gate qui avait cessé de vérifier en silence. La sentinelle rendait la ligne illisible pour check-syscalls, qui abandonnait alors AUSSI le numéro x86 — sans le dire. Corrigé : 281 numéros vérifiés au lieu de 277, et un test fige le comportement. Même leçon que la mesure des branches : un gate qui s’arrête sans le dire est pire qu’un gate absent.

2026-08-10 — la cage est POSÉE : le lanceur cesse de décider sans appliquer

Temps 2 d’ADR-150. Le lanceur calculait le confinement de ce qu’il lance (syscall_categories_for) puis lançait sans l’appliquer — l’écart le plus large, à cette date, entre ce que le code décide et ce qu’il fait. Il est refermé.

  • Deux additifs couche 1, non cassants. air-sandbox ne savait fabriquer un profil que depuis ses deux constructeurs figés (&'static) : AirComposedProfile compose depuis un octroi, et AirSandbox::composed l’applique — les deux formes coexistent plutôt que la première soit élargie, pour que rien de figé ne devienne variable par accident. air-process ne savait confiner qu’attaché à un largage de privilèges : AirCommand::confine sépare les deux propriétés, qu’un lanceur peut vouloir séparément.
  • execve doit être permis, et c’est structurel. Une cage posée du dehors s’installe à l’instant qui précède l’execve du service — le dernier où le lanceur exécute du code dans l’enfant. D’où une catégorie nommée Exec plutôt qu’un ajout discret au socle : le prix reste visible. Ce qu’elle n’ouvre pas : la cage survit à l’execve (no_new_privs, seccomp et Landlock sont hérités) — un programme confiné qui en exécute un autre l’emmène dans sa cage.
  • La politique Landlock manquait entièrement. landlock_policy_for traduit chemins et ports. Piège refermé au passage : un octroi sans aucun chemin produit deny_all, pas une politique vide — Landlock ne borne que ce qu’on lui déclare géré, et une politique vide ne ferme donc rien, précisément dans le cas où l’on veut tout fermer.
  • Un binaire glibc ne démarre pas confiné — et c’est le RÉSULTAT RECHERCHÉ (décision BDFL du 2026-08-10). Mesuré, pas supposé : le chargeur dynamique appelle access("/etc/ld.so.preload"), syscall legacy qu’ADR-021 exclut. Un binaire téléchargé depuis un courriel, compilé pour la bonne architecture mais lié à la glibc, ne doit pas pouvoir s’exécuter. Ne tourne sur Air que ce qui est recompilé contre la libc d’Air — outils en ligne de commande et shells compris. La phase transitoire se traitera par des profils permissifs nommés comme transitoires, supprimables d’un bloc en v1.0, jamais en diluant le socle.
  • Cage posée seulement sur commande d’un octroi. Sans bundle, rien n’est posé : confiner par défaut ne serait pas prudent mais faux — on appliquerait une politique que personne n’a signée. Et fail-closed : une cage qui ne peut pas être posée fait sortir l’enfant en 125 (code dédié, distinct du 126 d’un largage raté et du 127 d’un binaire absent) sans rien exécuter.
  • Preuve, pas déclaration : le test de bout en bout tombe quand on débranche la pose et repasse quand on la rebranche — il vérifie que le noyau a reçu la politique, pas qu’on l’a calculée.

2026-08-10 — la course air-cgroup : le processus changeait de cgroup en cours de test

Le rouge intermittent de air-cgroup traînait depuis le 2026-08-09 sans nom de test : le gate ne rapportait que la crate. Le correctif diagnostic_excerpt() de la campagne précédente a rendu le nom au premier rouge suivant — creating_the_same_cgroup_twice_is_refused.

  • Cause réelle, et ce n’était pas un aléa. AirCgroupManager::create relit le cgroup du processus courant à chaque appel. Le cas qui délègue (delegation()) déplace le processus ; mené de front par le harnais, il pouvait s’intercaler entre les deux créations du test homonyme. Le second create visait alors un autre parent, réussissait, et l’assertion tombait.
  • Prouvé, pas supposé : 2 échecs sur 60 exécutions sans le correctif, 0 sur 120 avec — échantillon doublé exprès.
  • Le correctif nomme l’invariant plutôt que de sérialiser le fichier : un RwLock où le lecteur dit « le cgroup courant ne bouge pas pendant que je m’en sers » et où l’unique écrivain est la délégation. La portée du verrou couvre les deux créations — l’invariant est « même parent », pas « parent stable pendant un appel ».
  • Ce n’est PAS un défaut d’air-cgroup. Un superviseur réel délègue une fois, au démarrage, avant de lancer quoi que ce soit : la fenêtre n’existe pas chez lui. Elle n’existe que pour un binaire de test qui mène de front ce qu’aucun superviseur ne mènerait de front. Le verrou vit donc dans le harnais, pas dans la bibliothèque.

2026-08-10 — air-registry : le deuxième service du bus, en couche 2

La découverte nommée d’AirCom, incrément 1. Le premier service publie (air-launchd) ; celui-ci permet de le trouver.

  • Le cœur était déjà écrit, et ailleurs. air-com-proto porte la table des services et les verdicts purs (resolve_lookup, resolve_register, motif sans-IO d’ADR-091). Cette crate n’ajoute que ce qui touche au monde : l’identité réelle du demandeur, la politique qui la juge, et la mise en relation par descripteurs.
  • Le registre rend un fd, jamais une adresse. Une adresse obligerait le demandeur à se reconnecter lui-même, et rien ne lui dirait qui l’attend au bout — le premier à créer le chemin se ferait passer pour le service. Un descripteur ne se fabrique pas : on ne peut que le recevoir. Le registre ouvre la connexion, en donne un bout à chacun ; le lien existe avant que quiconque ait pu s’interposer.
  • L’identité se constate, elle ne se déclare pas : SO_PEERCRED, rempli par le noyau. Un champ « je suis untel » dans un message serait rempli par le demandeur — c’est-à-dire par l’attaquant.
  • Le refus ne dit pas si le service existe. Répondre « il existe, mais pas pour toi » ferait du registre un oracle d’énumération : il suffirait de demander tous les noms pour cartographier la machine. Même discipline pour register — « ce nom est pris » et « tu n’y as pas droit » se confondent. Prouvé mordant : simuler la fuite fait tomber le test.
  • Table vide ⇒ personne n’a droit à rien. Un défaut permissif ferait d’un registre mal initialisé une porte ouverte, et l’oubli d’initialisation est le défaut le plus banal.
  • Limite assumée et écrite : la politique se rapporte au pid, qui se réutilise après la mort du processus. forget existe pour cela, et devra être appelé à la mort du service — même fenêtre que celle qui a fait préférer pidfd au pid nu ailleurs.
  • Rôle service, pas manager : le gate check-controleurs a signalé l’écart immédiatement (« déclare manager mais ne porte aucun Air*Manager »). Il a raison — le registre ne médie l’accès à aucune ressource de couche N-1 ; il tient une table et met en relation, comme air-sshd et air-launchd.

12 tests, 0 échec. check-layers conforme (86 crates).

Reste : brancher la politique sur les octrois signés — ce qui demande la distribution de la capability initiale par le lanceur, encore à écrire. Le trait RegistryPolicy laisse la place ouverte plutôt que d’inventer une source d’autorité qu’il faudrait défaire.

2026-08-10 — air-launchd publie sur AirCom : le bus a son premier service

Le bus existait — protocole, anneau pubsub, capability par fd — mais personne n’y parlait. J’avais d’ailleurs conclu à tort qu’AirCom attendait un destinataire : c’est un émetteur qui manquait, et le lanceur est celui qui a le plus à dire.

  • Pourquoi le lanceur en premier : il sait, avant tout le monde, quel service démarre, s’arrête, plante, est relancé, ou vient d’être abandonné après trop d’échecs. Ces faits n’allaient jusqu’ici que sur sa sortie standard — nulle part où un programme puisse les lire.
  • Ce que le bus apporte face à un socket Unix : pas de rendez-vous. Un chemin de socket, les deux bouts doivent le connaître, n’importe qui peut le créer, et rien n’atteste son origine. Un anneau dont on reçoit le fd n’a ni chemin, ni rendez-vous, ni usurpation possible — et il est scellé : un abonné ne peut que lire, fût-il hostile.
  • La supervision passe avant l’observabilité, et c’est tenu par construction : publish ne rend rien et ne peut donc rien faire échouer ; l’anneau écrase au lieu de bloquer (spec §7). Sans cela, saturer l’anneau ou refuser de lire suffirait à aveugler puis bloquer le superviseur. Si l’anneau ne peut même pas être créé, le lanceur le dit et supervise quand même.
  • Un schéma, dans une crate de vocabulaire. air-launch-events-schema ne porte que des types et ne dépend d’aucune crate Air : un abonné de couche 3 ou 4 décode les événements sans rien lier du lanceur. Le canal d’activation (air-launch-proto) reste distinct — il est privé, de rôle controleur, donc fermé aux couches supérieures ; le bus est public. Les mêler aurait fermé le second.
  • Estampille monotone, pas calendaire : un abonné ordonne des faits et mesure des durées. Une horloge calendaire recule (NTP, fuseau, réglage manuel) et rendrait l’ordre des événements incohérent — ce qu’un flux d’événements ne doit jamais être.
  • Ce qui n’est pas publié : Signalled, Killed, StopRequested. Ils décrivent ce que le lanceur fait, non ce que le service est. Les ajouter demanderait des variantes neuves — une décision de schéma, pas une improvisation au fil de l’eau.
  • Le test qui compte fait voyager le fd par SCM_RIGHTS à travers un socketpair, comme le fera un vrai abonné. Dupliquer le descripteur en local n’aurait prouvé que la relecture de soi-même.

air-launchd : 167 tests, 0 échec. Vérifié de bout en bout : le binaire tourne et ouvre son anneau sans erreur.

Reste : remettre ce fd aux abonnés relève de l’octroi (ADR-150) et de la découverte (air-registry) — le deuxième service du bus.

2026-08-10 — un test qui dormait au lieu d’attendre, et le motif du gate qui a servi

a_refusal_during_the_final_drain_is_reported_without_killing_the_launcher échouait en CI et jamais en local — isolé comme en suite complète, 0/10 puis 0/8 même sous charge CPU.

  • La cause était dans le test : il dormait 200 ms en espérant que le service ait écrit ses deux enregistrements avant le SIGTERM. Sous llvm-cov — instrumentation lourde, machine chargée — ce délai ne suffit pas toujours.
  • Le correctif attend un fait, pas une durée : le service touche un fichier témoin après ses deux printf, et le test attend ce témoin (plafond de 10 s, qui ne borne plus rien de significatif). Effet de bord : le test passe de 0,35 s à 0,16 s, puisqu’il n’attend plus à vide.
  • Le motif du gate a fait son travail. Corrigé le matin même pour nommer le TEST et non seulement la crate, il a donné directement supervise::tests::a_refusal_during_the_final_drain…. Sans lui, il aurait fallu relancer le binaire sur le runner comme la veille pour air-cgroup — dont le nom, faute de ce message, reste inconnu.
  • Leçon déjà consignée et re-vérifiée : devant « rouge en CI, vert en local », chercher d’abord une échéance trop serrée dans le test ; la corriger là vaut mieux que d’accuser la machine.

2026-08-10 — air-registry n’est pas en couche 5 : la portée avait été prise pour un étage

Question du BDFL en préparant le premier service AirCom : d’où sort « air-registry, couche 5 » ? Vérifié — c’est faux, et amendé directement (sans RFC, décision BDFL).

  • La source était ADR-001 (fr + en), reprise cinq fois par la spec air-com.md. Héritage de la famille « cycle de vie applicatif » de la macro-architecture d’origine.
  • Pourquoi c’est impossible : ADR-149 D1 pose qu’une couche N n’invoque que N-1, sans exception — la section « saut de couche autorisé exceptionnellement » a été supprimée. Or air-registry lie air-com (couche 2) et air-socket (couche 1) : depuis la couche 5, des sauts de trois et quatre étages.
  • C’est exactement l’erreur déjà corrigée pour air-launchd le 2026-08-04, et pour la même raison. Le précédent rendait le diagnostic immédiat une fois la question posée.
  • Ce qui avait induit en erreur : le registre est universellement joignable — tout processus Air reçoit une capability vers lui. Cette portée a été prise pour un étage. Or « la couche déclare ce qu’un composant a le droit de lier, pas qui a le droit de lui parler » : les dépendances AirCom sont d’exécution, pas d’édition de liens. air-sshd est un démon de couche 2 que joignent des clients du monde entier.
  • Les arêtes intra-couche sont permises : air-launchd (couche 2) lie déjà air-launch-proto et air-service, tous deux couche 2.
  • Bénéfice de bord : l’arbre ne compte aucune crate en couche 3, 4 ou 5. La note de cadrage d’air-launchd prévient que la formulation d’ADR-149 D1 « reste fausse pour la couche 5 » et qu’il faudra l’amender « au premier vrai composant de couche 5 ». Ce placement évite d’ouvrir ce chantier sans nécessité.

Amendé : ADR-001 (2 langues) et les six mentions de docs/specs/layer-2/air-com.md. Portée : le placement seul — pas de broker central, découverte par remise de fd, p2p ensuite : tout le reste est inchangé.

2026-08-10 — Landlock v4 : le réseau se borne, et le gate des syscalls s’élargit

Dernier verrou de la part réseau d’AirSandboxManager. Fait sur les trois couches.

  • Couche 0LandlockAccessNet, landlock_create_ruleset_with_net, add_rule_net_port. La négociation d’ABI existait déjà (landlock_supported_abi) ; il manquait la forme d’attributs à deux champs et le type de règle NET_PORT. La forme v1 est conservée : le noyau lit exactement size octets, et lui en passer seize quand il n’en connaît que huit échoue E2BIG — ce qui, la pose de cage étant fail-closed, tuerait le processus sur un noyau parfaitement valide. Deux fonctions, donc, jamais une dégradation.
  • Couche 1AirPortAccess, AirPortRule, allow_port, handled_net, et la pose. Le refus sur ABI insuffisante (NetworkUnsupported) n’était testé par rien : mettre le seuil à 99 ne faisait tomber aucun test. Décision extraite en fonction pure (choose_ruleset), désormais verrouillée et prouvée mordante.
  • Couche 2 — le plafond parle en ports, comme Landlock. « N’importe quel port » n’est jamais couvert : une image ne peut pas plafonner ce qu’elle n’énumère pas, et accorder l’illimité reviendrait à ne plus avoir de plafond. L’enveloppe Air ouvre 80 et 443 ; rien en écoute, et pas le 53 — donner le port du résolveur laisserait l’application parler à n’importe quel résolveur, y compris celui d’un attaquant.
  • Un trou de vérification refermé au passage. La catégorie seccomp Network exigeait treize syscalls socket, absents du vocabulaire AirSyscall. En les ajoutant, constat : les 62 numéros d’air-sandbox n’étaient vérifiés par rien — le seul test contrôlait l’absence de doublons, ce qui laisse passer un numéro faux mais unique. Or sur une table qui compile des allow-lists, un numéro faux autorise un autre syscall que celui qu’on croit nommer. check-syscalls couvre désormais cette table : 265 numéros vérifiés au lieu de 203, zéro écart. Prouvé mordant sur les deux chemins (incohérence croisée, et écart aux headers pour un syscall que seul air-sandbox déclare).
  • Une leçon de méthode, encore la même : la première version de l’extracteur déduisait le nom Linux de la variante en minuscules. ExitGroup donnait exitgroup, inconnu de l’uapi — et cinq syscalls sortaient du champ sans que rien ne le signale comme une perte. Le gate rendait un avertissement, pas une erreur. Corrigé, et un test le verrouille.
  • Un test est devenu faux et a été remplacé : il affirmait que le réseau était refusé faute d’applicateur. C’est vrai jusqu’au jour où l’applicateur arrive.

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

2026-08-10 — le grain seccomp : nommer ce que le code pratiquait déjà

Question restée ouverte après la décision sur les enveloppes. Instruite (docs/notes/instruction-grain-seccomp-fr.md), tranchée : grain A (catégories nommées), avec égalité stricte pour les profils d’air-sshd.

  • L’instruction a mesuré avant de proposer. sftp_worker (58 syscalls) contient exactement pre_auth (34) et lui ajoute 24 syscalls formant un ensemble cohérent : les fichiers. La structure « socle + catégorie » existait déjà — le commentaire du code la nommait (« le contrat de ce profil est pre-auth plus les fichiers ») et un test la vérifiait. La question n’était donc pas « quel grain inventer » mais « faut-il nommer celui qu’on emploie ».
  • La dette n’était pas le grain, c’était la duplication. Les 34 entrées du socle étaient recopiées dans les deux profils : ajouter un syscall demandait de le faire à deux endroits, et rien ne le vérifiait. Un oubli ne se voit pas en relecture — il se voit quand un worker meurt d’un KILL_PROCESS sur un chemin rare.
  • recvmsg sort du socle. Il n’était dans pre_auth que pour le montage privsep d’air-sshd. Le laisser dans le socle donnerait à tout processus confiné le droit de recevoir des descripteurs — donc n’importe quel objet ouvert que son pair veut lui remettre. C’est une catégorie à part (FdPassing), minuscule à dessein.
  • Les profils figés ne sont PAS réécrits : air-sandbox est couche 1, sa surface est scellée. Un test vérifie qu’ils valent exactement la composition de leurs catégories — la duplication demeure dans le texte, elle cesse d’être silencieuse. Prouvé mordant : simuler l’oubli fait tomber le test, en nommant le syscall en trop.
  • La dérivation vit en couche 2 (syscall_categories_for), parce que les entitlements y vivent : air-sandbox pose la cage, il ne lit pas de manifeste (ADR-112).
  • Une seule famille produit une catégorie — le filesystem, la seule que la cage sache borner. Les trois autres n’ont pas d’applicateur ; leur catégorie naîtra avec lui, par cohérence avec la décision sur les enveloppes : on ne déclare pas ce qu’on ne sait pas appliquer.

Reste ouvert : le calendrier de Landlock v4 (réseau), dernier verrou de la part réseau d’AirSandboxManager.

2026-08-10 — la largeur des enveloppes par défaut, tranchée (ADR-150 q.1)

La question ouverte n° 1 d’ADR-150 bloquait AirSandboxManager et le temps 2 du lanceur. Instruite (docs/notes/instruction-largeur-enveloppes-defaut-fr.md), tranchée par le BDFL : option B.

  • L’énoncé induisait en erreur. « Trop étroites / trop larges » fait croire à un curseur ; c’est en réalité le contenu d’un plafond, famille par famille — et ADR-046 A2 en imposait déjà deux (étranger / signé Air).
  • Le fait qui bloquait vraiment : le vocabulaire déclare quatre familles, air-sandbox n’en applique qu’une (filesystem, Landlock ABI v1). Ni Landlock réseau, ni capabilities AirCom, ni traitement des périphériques. Fixer les quatre aurait été décider pour trois familles dont rien ne sait appliquer la décision.
  • Option B : enveloppes filesystem écrites ; familles sans applicateur vides par défaut, donc tout octroi qui les invoque est refusé — explicitement, pas par omission. Le mur est visible et daté ; il tombera quand l’applicateur existera.
  • Ce que l’instruction n’avait pas vu, et qui a failli tout bloquer : l’étape 3 comparait l’octroi à l’enveloppe par égalité stricte de chemins. Or une image est écrite une fois, pour des applications inconnues — il aurait fallu une image par application. Deux questions distinctes méritent deux rapprochements : égalité stricte pour demande ↔ octroi (inchangé, c’est ADR-150 §5.1), couverture par gabarit pour enveloppe ↔ octroi.
  • Le gabarit n’est pas un glob : Exact, Subtree, et un marqueur {bundleId} substitué avant comparaison. Aucun *, donc aucun moteur d’expansion et rien à normaliser.
  • Le piège classique, évité et testé : "/vart".starts_with("/var") est vrai. Le rapprochement se fait composant par composant ; deux tests le verrouillent, prouvés mordants (remplacer par starts_with les fait tomber).
  • Ce que les enveloppes refusent est la décision : l’exécution partout (y compris pour un bundle signé Air — A1 : la signature décide de la largeur, jamais de l’exemption), l’écriture sous /etc, et le $HOME en bloc — l’échec de Flatpak, où --filesystem=home est devenu ce que tout le monde demande.
  • Couverture ceiling.rs : 100 % lignes, 15 tests. air-launchd : 148 tests, 0 échec.

Restent ouverts : le grain du profil seccomp et le calendrier de Landlock v4. AirSandboxManager reste partiellement bloqué par eux.

2026-08-09 — ADR-150 inc. 4 : l’arbitrage, et le refus qui ne dit rien

Signature → octroi → enveloppe. L’ordre est une condition de validité, pas une optimisation : évaluer un octroi avant d’avoir authentifié le manifeste revient à évaluer un accord contre un inconnu, et c’est directement exploitable — remplacer le binaire, garder l’octroi.

  • L’enveloppe est une ENTRÉE, pas un choix de l’arbitre. La largeur des enveloppes par défaut reste la question ouverte n° 1 ; l’arbitre n’en a pas besoin pour exister. Ce qui manque est une valeur, pas un mécanisme — et le mécanisme est celui qui borne déjà un octroi par une demande. C’est ce qui a permis de faire cet incrément malgré le blocage.
  • L’étape 2 vérifie que l’octroi est signé PAR LA MACHINE, pas qu’il est « signé ». Sans cela, il suffirait d’émettre un octroi large et de le signer avec sa propre clé.
  • L’altération est vue par la signature, pas par le checksum, et un test le fixe : il « répare » le checksum pour montrer que cela n’avance à rien. FNV-1a-64 détecte la corruption, jamais l’adversaire.
  • Le refus est muet, et le type y veille. Refusal ne rend rien en s’affichant ; le motif s’obtient par for_journal(), dont le nom dit où il a le droit d’aller — un geste visible en relecture, là où un {refusal} distrait passerait. Un test vérifie que trois causes très différentes rendent le même texte.
  • L’enveloppe d’artefact est réimplémentée (30 lignes) plutôt que dépendue. air-config-compile est un compilateur : y faire entrer le lexeur et le parseur du format de source dans le binaire du LANCEUR — la porte d’exécution — contredirait la décision qui structure tout ce chantier. Un test confronte les deux implémentations octet pour octet, et le compilateur reste une dépendance de test.
  • Un test disait le contraire de ce qui se passe (l’altération attrapée par le checksum) ; il a été corrigé et rendu plus intéressant. Couverture : les quatre modules du bundle sont à 100 % de branches, 98–100 % de lignes.

ADR-150 est complet à l’exception de la pose de la cage, qui reste bloquée par la largeur des enveloppes par défaut.

2026-08-09 — ADR-150 inc. 3 : l’émission, et l’invariant qui cesse d’être une vérification

Grant::authorizes vérifiait qu’un octroi ne dépasse pas la demande — une vérification a posteriori, qui suppose qu’un octroi excessif puisse exister. issue ferme la porte plus tôt : elle ne prend pas « ce qu’on accorde » mais « ce qu’on propose », et rend l’intersection avec la demande. Aucun argument ne peut en faire sortir un octroi excessif.

C’est la différence entre « on vérifie que la règle est respectée » et « la règle ne peut pas être enfreinte ». Seule la seconde survit à un appelant distrait — et l’émetteur d’octroi sera un jour un outil d’administration écrit par quelqu’un d’autre.

  • Property-based testing, et il a servi. La propriété n’est pas « ces octrois-ci sont corrects » mais « aucune proposition n’en produit un excessif » : des cas choisis ne peuvent pas dire cela. proptest a trouvé un vrai défaut de conception — une demande portant deux règles pour le même chemin, dont l’intersection perdait des droits. Laquelle fait foi ? Toutes les réponses sont mauvaises. La réponse n’a pas été d’assouplir la propriété mais de refuser l’ambiguïté à la lecture : deux règles de même clé (chemin, service) rejettent l’artefact. Ce cas n’existait nulle part ailleurs dans les tests ; il fallait un générateur pour le produire.
  • Une propriété reformulée parce qu’elle était fausse. « Proposer la demande rend exactement la demande » ne tient pas : une règle demandée sans aucun droit disparaît de l’octroi. Elle n’est pas rognée, elle est vide. La formulation juste est que proposer la demande donne l’octroi maximal — aucune autre proposition ne peut accorder davantage.
  • L’octroi s’encode, en artefact AIRCFGv1 + ConfigEnvelope, déterministe (ADR-025) : sans cela son empreinte n’aurait aucun sens et deux émissions identiques sembleraient différer. C’est cet artefact entier que la clé de la machine signe.
  • La révocation n’a pas de mécanisme et n’en aura pas : elle est l’absence d’octroi (D3 conséquence 4). Retirer le fichier fait échouer l’étape 2 ; le binaire reste inerte.
  • Où vit l’octroi : ce qui le protège est la composition de trois décisions déjà prises — signé par la machine, écrit par la seule porte d’administration, plafonné par l’enveloppe de l’image à l’étape 3. Reste le chemin exact, qui vient avec la recherche de l’octroi, donc avec l’arbitrage.
  • Couverture issue.rs : 99,0 % lignes, 100 % branches et fonctions.

2026-08-09 — ADR-150 inc. 2 : la signature, et l’étiquette qui empêche de la rejouer

L’étape 1 de l’arbitrage — établir qui présente un artefact. Sans elle, les deux suivantes n’ont pas de sujet : chercher un octroi « pour cette identité » avant d’avoir établi l’identité, c’est évaluer un accord contre un inconnu, et c’est directement exploitable — remplacer le binaire, garder l’octroi.

  • La signature porte sur l’artefact COMPLET, enveloppe comprise. L’enveloppe porte le schemaDomain : signer le seul payload laisserait ré-envelopper les mêmes octets sous un autre domaine sans invalider la signature. Le checksum FNV ne s’y oppose pas — il détecte la corruption, pas l’adversaire.
  • L’étiquette de domaine dans le message signé, et la raison n’est pas celle qu’on croit. On pourrait penser que des clés distinctes suffisent (le développeur signe le manifeste, la machine l’octroi). Mais rien n’oblige ces deux clés à différer : sur un poste où le développeur est aussi l’administrateur, ce sont les mêmes mains. La distinction ne peut donc pas reposer sur la clé.
  • Deux gardes, et il ne faut pas les confondre. La garde déclarative (le champ subject) attrape l’artefact présenté au mauvais endroit ; la séparation cryptographique (l’étiquette) attrape l’attaquant qui corrige ce champ pour qu’il concorde. Le test que j’avais d’abord désigné comme « le test de cet incrément » ne prouvait que la première : il ne tombe PAS quand on rend les deux étiquettes identiques. Commentaires et documentation corrigés pour dire lequel prouve quoi — un test qu’on croit porteur d’une propriété qu’il ne teste pas est pire qu’une absence de test.
  • Refus au coût constant : les deux conditions sont évaluées sans court-circuit (&, pas &&) et la crypto est payée dans tous les cas — un refus moins cher qu’un autre dirait laquelle a échoué (ADR-150 D3 conséquence 1).
  • Couverture signature.rs : 97,5 % lignes, 100 % branches, 14 tests.

2026-08-09 — ADR-150 inc. 1 : les deux artefacts existent, et l’invariant est du code

Le manifeste et l’octroi n’existaient que dans le texte d’ADR-150. Ils existent désormais comme artefacts binaires lus, validés, et reliés par l’invariant qui fait tout le sens de la décision.

  • Le choix qui a le plus compté : ne PAS écrire de parseur. Les deux artefacts sont enregistrés comme domaines du registre (air.manifest, air.grant), ce qui leur donne le compilateur texte→binaire existant — borné, fuzzé, couvert à 100 % — au lieu d’un second parseur bespoke. Écrire ce parseur aurait ajouté une surface d’analyse syntaxique au voisinage d’une décision de privilèges, ce que le Principe 11 cherche précisément à éviter. 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, pour les deux domaines.
  • Le vocabulaire est PARTAGÉ entre demande et accordEntitlements est le même type des deux côtés. Ce n’est pas une économie : comparer une demande et un accord exprimés dans deux langages exigerait une traduction, et toute traduction arrondit — du mauvais côté une fois sur deux.
  • developerKey dans l’octroi, et pourquoi. bundleId + empreinte du manifeste disent quoi ; ils ne disent pas qui. Sans la clé, un tiers re-signant le même bundle présenterait le même identifiant et la même empreinte, et hériterait d’un accord rendu à quelqu’un d’autre.
  • La comparaison des chemins est une égalité, pas une inclusion. /var demandé ne couvre pas /var/lib/app accordé, même si Landlock l’appliquerait. La question posée n’est pas « l’accord est-il applicable » mais « la machine a-t-elle accordé quelque chose que le développeur n’avait pas écrit ». Raisonner par préfixe demanderait de normaliser deux chemins, et l’écart entre deux normaliseurs est la source classique des contournements.
  • Un test a été écrit faux, et l’a dit tout de suite. Il affirmait que la confusion octroi/manifeste passerait sans la garde de domaine ; en réalité les deux dispositions divergent aujourd’hui et la lecture échoue. Le test et la documentation qui s’appuyait dessus ont été corrigés : la garde reste nécessaire non parce que la confusion passerait, mais parce que rien ne garantit qu’elle continuera d’échouer — un champ ajouté suffirait. La garde est prouvée mordante (la désarmer fait tomber deux tests sur quatre).
  • Toutes les bornes portent une valeur, aucune n’est laissée ouverte (directive du BDFL du 2026-08-09) : version, longueurs exactes d’empreinte et de clé, textes, chemins, exécutables, règles par famille, ports par direction.
  • Statut ADR-150 : a-faireen-cours. Spec : docs/specs/layer-2/air-manifeste-octroi.md, qui dit aussi ce qui reste (signature, émission de l’octroi, arbitrage) et le seul point bloqué : la largeur des enveloppes par défaut.

2026-08-09 — le filet de couverture ne mesurait rien, et disait « ok »

En cherchant pourquoi la référence de branches n’avait pas été confrontée depuis cinq jours, la vraie cause est apparue — et ce n’était pas la cadence.

Le nightly tourne bien tous les jours en mode COMPLET (ADR-143 §4 en a fait le seul filet depuis que le mode complet a quitté le merge sur main). Mais son verdict était :

▌ couvrable-vide — WARN
  ⚠ mesure branches indisponible (root/nightly ?)
  ✓ couvrable VIDE ✅

La mesure de branches échouait, et le gate se contentait d’un avertissement. Le job « réussissait » sans jamais mesurer ce qu’il surveille. Huit nuits de suite, success.

  • La cause de l’échec est celle déjà rencontrée deux fois le même jour : un test qui tombe interrompt llvm-cov (exit status: 101), qui ne rend alors aucun total.
  • La correction est un fail-closed, cohérent avec le reste du gate — il rougit déjà quand la référence est illisible, exactement pour la même raison : ne pas pouvoir conclure n’est pas conclure. La mesure n’étant tentée que hors --no-root, son échec est désormais une violation, avec un message qui nomme la cause la plus fréquente et où la chercher.
  • La décision a été extraite dans une fonction pure (interpret_branch_run) pour être testable ; trois tests la verrouillent, prouvés mordants.
  • Une ligne perdue en réécrivant le bloc a été rétablie : le chown -R qui rend target/ à son propriétaire après une mesure sous sudo.

Ce que cela change : la dérive qui a fait rougir #651 n’aurait pas dû l’atteindre. Le filet la voyait passer chaque nuit sans la mesurer. Il mord désormais — au prix, assumé, de nightlies rouges tant qu’un test root reste instable.

2026-08-09 — la référence de couverture rencontrée par accident, et ce qu’elle a révélé

Le gate a rendu #651 rouge sur une « régression de branches » de 0,20 point. Instruit, le diagnostic est l’inverse de ce que le message laissait croire.

  • #651 AMÉLIORE la couverture. Mesurée à conditions identiques sur la seule crate qu’elle touche, elle fait passer air-launchd de 137 à 56 branches non couvertes (supervise.rs 122 → 54, cli.rs 15 → 1). Elle en couvre 81 de plus qu’elle n’en ajoute.
  • La référence n’avait plus été confrontée depuis le 4 août. Depuis le mode par couche (ADR-143), #649, #650 et #652 ont toutes mesuré une seule couche : aucune n’a comparé le workspace. #651 y revient par accident — le repli du détecteur de couches touchées — et hérite de toute la dérive accumulée.
  • La dette est réelle et reste due : elle vient très probablement de #649/#650, les cgroups, dont une large part du code n’est exerçable qu’en root avec cgroup délégué. Rafraîchir la référence (77,72 → 77,52, décision BDFL) corrige le chiffre, pas la règle ; le ratchet la fera remonter quand la dette sera rattrapée.
  • Le défaut de fond reste ouvert : une référence qui n’est confrontée qu’au hasard d’un repli n’est pas confrontée. C’est une question de cadence de mesure, pas de seuil.
  • Leçon de méthode : le message du gate désignait une régression et un coupable. Les deux étaient faux. Mesurer les deux côtés à conditions identiques est ce qui l’a montré — et c’est la deuxième fois de la journée qu’un message de couverture désigne mal sa cause (la première, c’était un llvm-cov interrompu par un test).

2026-08-09 — temps 3c (i) : le lanceur relance, et garde ses écoutes pour cela

Le lanceur ne relançait rien : Exited(status) => return Ok(status), et il sortait avec son service. Aucune politique de redémarrage n’était décidée nulle part.

  • Trois politiques, pas sept : no, on-failure (défaut), always. systemd en propose sept ; les autres distinguent des nuances de mort — signal, chien de garde — que le lanceur ne sait pas encore observer, et les exposer serait promettre plus qu’il ne tient.
  • Ce qui n’est jamais relancé : un arrêt demandé (fin voulue) et une violation du contrat. Relancer un service qui ment sur le fil, c’est relancer le mensonge.
  • Le recul double (100 ms → 30 s) et l’attente est interruptible : un ordre d’arrêt reçu pendant trente secondes de recul est honoré tout de suite, ce qui a coûté une dépendance à air-poll — un sleep aurait fait patienter le superviseur.
  • Le crédit se rend : cinq échecs consécutifs, remis à zéro par un démarrage qui tient dix secondes. Sans cela, un démon relancé cinq fois en cinq mois finirait par ne plus l’être — ce qui n’est pas ce qu’un opérateur attend de « cinq tentatives ».
  • Le changement qui rend tout le reste utile : le lanceur garde ses écoutes. Il les relâchait après le fork, au motif documenté que « celui qui écoute est le service, pas nous ». C’était vrai tant qu’un service ne vivait qu’une fois ; dès qu’il peut renaître, les relâcher rend le redémarrage impossible — le port se libère, un tiers peut le prendre, et le lanceur ne pose pas SO_REUSEADDR. En les gardant, les connexions qui arrivent pendant le redémarrage attendent dans la file du noyau au lieu d’être refusées.
  • Ce que 3c-ii attend encore : plusieurs services, leurs dépendances et leur ordre. Cela demande une description de service, c’est-à-dire le manifeste d’ADR-150 — un artefact binaire compilé qui n’existe pas. La ligne de commande porte un --exec, et la répéter n’est pas un modèle de dépendances.

2026-08-09 — les bornes reçoivent leurs valeurs : pids.max = 2048, memory.high = 512 Mio

Décision BDFL, prise sur la mesure de la veille au soir. Le bloquant de publication est levé : un service lancé par air-launchd n’est plus jamais sans bornes.

  • pids.max = 2048 : couvre les 256 connexions pré-auth qu’air-sshd admet (1 + 4×256 = 1025 tâches) plus environ 256 sessions établies — celles-ci n’étant bornées par rien, le crédit d’admission étant rendu au succès de l’authentification.
  • memory.high = 512 Mio : plus de 600 connexions à 819 Kio. Les démons système mesurés plafonnent à 35 Mio.
  • high, pas max : au-delà du seuil, le noyau freine le service et récupère de la mémoire — il ne le tue pas. Un service qui fuit devient lent et visible ; memory.max, elle, appelle l’OOM killer et le superviseur ne trouve qu’un cadavre. Pour une posture défensive, freiner vaut mieux qu’exécuter.
  • Retirer une borne se demande explicitement (--max-processes unlimited). 0 est refusé des deux côtés : pids.max = 0 interdirait au service d’exister, memory.high = 0 le freinerait dès son premier octet — le noyau accepte les deux, ce sont des fautes de frappe. Le mot, lui, ne s’écrit pas par accident.
  • L’unité est dans le nom de l’option (--memory-high <Mio>), jamais devinée dans la valeur : un suffixe M ferait diverger deux lecteurs (méga ou mébi ?), et un parseur de tailles tolérant est exactement ce que le Principe 11 borne. 64M est refusé.
  • Le test qui compte relit le NOYAU, pas la structure : une constante bien nommée que personne n’écrit dans pids.max ne borne rien. Le témoin est le contenu de pids.max/memory.high après lancement.

2026-08-09 — le coût d’une connexion air-sshd, mesuré ; et une directive de publication

Mesure demandée par le BDFL pour dimensionner les bornes du temps 3b, sur le service réel (privsep, systemd) par paliers de connexions.

connexionstâchesmémoirepar connexion
0 (repos)11 544 Kio
4174 820 Kio4,00 tâches / 819 Kio
166514 648 Kio4,00 tâches / 819 Kio
après11 544 Kioaucune fuite
  • 4,00 tâches par connexion, exactement : la topologie privsep (monitor, enfant pré-auth/proxy, worker, session). Le modèle est linéaire de 1 à 16 sans dérive.
  • Ce que la mesure a révélé, et qui décide du chiffre : le plafond de concurrence (throttle.rs, 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 donc bornées par rien. Pire cas garanti : 1 + 4×256 = 1025 tâches (~205 Mio) ; au-delà, ouvert. C’est ce qui rend pids.max utile plutôt que redondant.
  • Deux pièges de méthode, payés tous les deux : une première mesure plafonnait à 65 tâches et semblait révéler une borne globale de 16 — c’était MAX_PREAUTH_PER_SOURCE, toutes les connexions venant de 127.0.0.1. Et systemctl show -p A -p B --value rend les valeurs dans un ordre qui ne suit pas les options : lire une propriété à la fois.

Directive BDFL, consignée : « 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 publication, et plus personne ne la révise. C’est désormais un bloquant de publication porté par etat-avancement.md et le prompt de reprise — la largeur des enveloppes (question ouverte n° 1 d’ADR-150) doit être tranchée avant, pas indéfiniment ajournée.

2026-08-09 — temps 3b du lanceur : borner les processus, et déménager pour y arriver

Second incrément du temps 3 : pids.max (la bombe à fourche) et la comptabilité. Le mécanisme tient en peu de lignes ; ce qui a coûté, c’est il fallait le poser.

  • Une API qui cassait la session de l’appelant. Le premier jet écrivait +pids dans le cgroup.subtree_control du cgroup courant. Le noyau accepte cette écriture sur un cgroup peuplé — et c’est ensuite que tout casse : plus aucune migration vers ses enfants n’aboutit, pour personne. Lancé depuis un terminal, le code rendait le cgroup de la session entière inutilisable en croyant poser une borne. Constaté en direct : mes propres tests du temps 3a se sont mis à échouer, et l’état dégradé survivait aux exécutions. enable_for_children refuse désormais avant d’écrire si la racine n’est pas vide.
  • Le superviseur doit déménager. Poser une borne exige que le cgroup parent du service soit vide de tout processus (no internal processes). Le lanceur crée donc une racine, s’y déplace dans un sous-cgroup dédié, et ses services sont les frères de celui-ci — jamais ses enfants, sans quoi la même règle le rattraperait un cran plus bas. C’est ce que fait tout gestionnaire de cgroups ; ce n’est pas un contournement.
  • Un suicide armé, désarmé. Le Drop d’un AirCgroup tue tout ce qu’il contient. En garder un pour le cgroup où le superviseur vient d’entrer, c’était armer sa propre mort au premier relâchement. into_path consomme le cgroup sans rien détruire ; la délégation n’en garde qu’un chemin.
  • Ce que pids.max fait, et qu’il fallait mesurer sans se tromper de cible : il refuse de créer, il n’exécute personne. Le fautif reçoit EAGAIN ; la machine ne s’en aperçoit pas. Un test qui aurait cherché un processus mort aurait cherché ce que ce mécanisme ne fait pas.
  • La borne compte les TÂCHES, donc chaque fil : un service à cinq processus de huit fils en consomme quarante. Une borne choisie sur le nombre de processus attendus étranglerait un service sain.
  • Aucune borne par défaut, et c’est délibéré : quel plafond un service doit recevoir relève de la largeur des enveloppes (question ouverte n° 1 d’ADR-150), qui n’est pas tranchée. Inventer un chiffre étranglerait des services sans que personne ne l’ait décidé. L’option --max-processes existe ; sans elle, rien n’est borné, et c’est dit.
  • Les bornes SONT éprouvées, contrairement au confinement du 3a : sous systemd-run --user --scope, la racine ne contient que le binaire de test, la délégation aboutit, et aucun test ne saute. Dans un terminal ordinaire ils se sautent en le disant. Prouvé mordant : borne retirée, la bombe aboutit.

2026-08-09 — temps 3a du lanceur : le cgroup, la seule frontière que le noyau tienne

Premier incrément du temps 3 : AirCgroupManager (crate couche 1 nouvelle) et un lanceur qui confine son service dans un cgroup, puis n’y laisse rien vivre.

  • Ce que le cgroup apporte, et que le groupe de processus ne peut pas. Un groupe est une convention : un descendant qui appelle setsid en sort, et le signal ne l’atteint plus (limite assumée de l’A-2, le matin même). D’un cgroup on ne sort pas de sa propre initiative, et cgroup.kill prend l’arbre entier. Le cas hostile est fermé, pas seulement le cas courant.
  • Ce n’est pas un descellement de couche 0 : les cgroups se pilotent entièrement par le système de fichiers. air-cgroup ne consomme que air-filesystem — aucun syscall enveloppé.
  • L’invariant qui décide de tout : rejoindre avant de forker. Un processus mis dans un cgroup après avoir forké y laisse ses fils dehors — ce n’est pas une faiblesse de cgroup.kill, c’est un montage fautif, et l’essai l’a montré au premier jet. AirCommand::join_cgroup (14ᵉ additif c1) fait donc écrire l’enfant, juste avant son execve : séquentiel, sans course. Écarté : que le parent écrive après le clone3 — fenêtre courte mais réelle, et une course sur une frontière de sécurité n’est pas une frontière. Consigné comme évolution possible : CLONE_INTO_CGROUP (additif couche 0), qui ferait naître l’enfant dedans.
  • Le test a corrigé la conception, pas seulement le code. Le premier jet ne tuait l’arbre que si le service refusait de mourir — donc jamais dans le cas courant, où il cède au SIGTERM. Le descendant échappé survivait. Le cgroup n’est pas un « dernier marteau », c’est la clôture : il est ramassé à chaque arrêt, quelle que soit la voie empruntée. Aucune relecture ne l’avait vu.
  • Un défaut de nommage qui aurait mordu en production : le cgroup était nommé d’après le programme et le PID du lanceur. Un même processus lançant deux services leur donnait le même nom — le second voyait EEXIST et tournait sans frontière. Invisible en test isolé, visible en suite complète. C’est la troisième fois de la journée que le partage d’un nom fixe se paie ; un numéro d’instance clôt celle-ci.
  • Aucun contrôleur n’est activé (cpu, memory, pids) : cgroup.kill est une interface de base, et n’en activer aucun évite la règle no internal processes. Les bornes — dont pids.max contre la bombe à fourche — viendront avec leur propre arbitrage.
  • Les tests s’exécutent sans root : systemd délègue son sous-arbre à chaque session utilisateur. Un test de confinement qui n’aurait tourné qu’en root n’aurait, en pratique, jamais tourné.
  • Mais ils ne s’exécutent PAS en CI, et il faut le dire : le job tourne dans un cgroup dont l’écriture est refusée (EACCES, même en root — runner conteneurisé). Les tests de confinement y sautent, et la preuve vient de speedy. Une PR verte ne dit donc rien de cette partie-là ; consigné dans les pièges du prompt de reprise.

2026-08-09 — A-2 du lanceur : un service qui fork ne laisse plus de fils derrière lui

Treizième additif couche 1, autorisé par le BDFL. Il ferme la limite que la spec air-launchd §4.5 documentait sans euphémisme depuis le temps 1.

  • Le défaut. Le lanceur ne visait que son enfant direct : les signaux partaient par le pidfd, et PR_SET_PDEATHSIG ne s’hérite pas aux petits-enfants. Un service pré-forkant laissait des fils vivants avec la copie héritée de l’écoute : port occupé, redémarrage impossible (EADDRINUSE, le lanceur ne pose pas SO_REUSEADDR), et des processus que plus personne ne supervise.
  • L’additif est un découplage, pas une capacité nouvelle. setsid était déjà appelé par AirCommand::login_terminal — mais lié à l’acquisition d’un terminal de contrôle et à l’écrasement des trois flux standard, dont un service supervisé ne veut pas. new_session() ne fait que délier ce qui était couplé. Les deux bâtisseurs se composent, et setsid n’est appelé qu’une fois — un second échouerait (EPERM) et tuerait l’enfant.
  • L’arrêt vise le groupe, et sa sûreté tient à un fait précis : 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 jusque-là. Le groupe visé est donc le sien, ou plus rien (ESRCH) ; jamais celui d’un tiers, jamais le nôtre. Sur ESRCH, repli sur le pidfd.
  • La garde a été vérifiée par l’exécution, pas seulement raisonnée : le test lancé sans new_session montre le groupe inexistant, le repli sur le pidfd, et un binaire de test qui n’est jamais signalé.
  • Prouvé mordant : sans la session, le fils du service survit à l’arrêt (le test le constate après 2 s de sondage). Le témoin est le PID du petit-fils, et ce qu’on attend n’est pas sa mort mais sa disparition — un zombie répond encore présent.
  • Ce que cela ne ferme pas, et qui reste au temps 3 : un petit-fils qui appelle setsid à son tour quitte le groupe. Le groupe de processus est une convention que la descendance peut rompre ; la frontière que le noyau tient, c’est le cgroup.

2026-08-09 — make_directory_all : un mode de répertoire qui ne doit rien à l’umask

Douzième additif couche 1, autorisé par le BDFL. Il ferme le seul vrai downgrade qu’ait trouvé la revue adverse de l’épinglage des clés d’hôte.

  • Le constat était mal formulé, et le vérifier l’a corrigé. La note disait « air-filesystem n’expose aucune création de répertoire à mode explicite ». C’était faux : AirFileManager::make_directory existe et pose un mode. Ce qui manquait est la forme récursive — et le downgrade venait de std::fs::create_dir_all, qui demande 0777.
  • Le fait, mesuré. Sous umask 0, std::fs::create_dir_all faisait naître ~/.config/air/ en 0777 — inscriptible par tous. Or 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. Après : 0700, quel que soit l’umask.
  • AirFileSystem::make_directory_all(path, mode) rend le nombre de répertoires créés — c’est ce compte qui rend la propriété vérifiable plutôt que déclarée. Chaque répertoire créé reçoit son mode par un fchmodat2 explicite après le mkdirat : mkdir(2) n’applique que mode & ~umask. L’ordre compte — mkdir ne pouvant que retirer des bits, le répertoire n’est à aucun instant plus permissif que demandé. Le chmod est no-follow : un tiers qui substituerait un lien symbolique entre les deux appels ferait sinon poser le mode sur une cible qu’il choisit.
  • Ce que la fonction ne fait pas est aussi important : elle ne retouche pas le mode d’un répertoire qui existait déjà. Resserrer en silence un ~/.config qu’on n’a pas créé, c’est modifier ce qui appartient à quelqu’un d’autre.
  • Alternative écartée : umask(0) autour de la création. État process-global — un autre fil qui créerait un fichier dans cette fenêtre le verrait naître au plus large. On échangerait une dépendance à l’environnement contre une course.
  • Un défaut de sémantique trouvé par son propre test : EEXIST était compté « déjà là » sans regarder quoi. Un chemin dont le dernier composant est un fichier rendait donc Ok(0) — la fonction affirmait qu’un répertoire existait là où il n’y en a pas. L’ouverture en O_DIRECTORY tranche désormais, en un appel.
  • Prouvé mordant : sans le fchmodat2, le test d’exactitude rougit (0o775 au lieu de 0o777 sous l’umask 0002 de la machine). Et un test de bout en bout lance air-ssh à travers un shell posant umask 0 — la seule façon d’exercer la précondition depuis un test.

2026-08-09 — air-ssh known-hosts : chaque magasin derrière l’outil qui a l’identité qu’il faut

Décision BDFL, en réponse à une question simple : « avec mon compte non root, je ne peux pas ajouter un hôte dans mon magasin sans le mot de passe de root ? » — et la réponse était oui.

  • Le constat, vérifié plutôt que supposé. air-keystore known-hosts add|remove appelle authorize(context) avant de regarder --store : le gate d’ADR-101 s’appliquait donc au magasin personnel d’un compte, dans son propre home. Avec un compte ordinaire la commande échoue même avant le gate — elle ne peut pas lire /etc/air/shadow.aircfg (0600 root). Et la contourner par sudo détruit le magasin : le fichier renaît possédé par root, que la garde de lecture d’air-ssh refuse ensuite. Plus aucune connexion.
  • Aucun gate n’a été relâché — c’était l’autre option, et elle a été écartée. Le verbe air-ssh known-hosts add|remove écrit le magasin personnel sous l’identité de son propriétaire, exactement comme la première vue le fait déjà ; air-keystore garde le magasin administré derrière son gate. Chaque magasin est derrière l’outil qui a l’identité qu’il faut, et air-ssh n’a toujours aucune écriture qui puisse viser le magasin de la machine.
  • L’écriture du magasin est désormais à UN seul endroit (write_personal_known_hosts) : tri canonique, création du parent, 0640. Deux écritures de magasin dupliquées sont deux écritures qui divergent — l’une trierait, l’autre non.
  • Un message qui promettait l’impossible est corrigé. Le refus « LA CLÉ D’HÔTE A CHANGÉ » nommait air-keystore dans les deux cas ; appliqué au magasin personnel, le conseil était inexécutable, et suivi sous sudo il cassait ce qu’il prétendait réparer. Il nomme maintenant la commande que son destinataire peut réellement exécuter, et un test vérifie les deux sens — la bonne commande est là, et la mauvaise n’y est pas.
  • Ce que les tests établissent : que l’artefact écrit par le verbe est celui que la connexion lit (un add sans le moindre privilège, puis une connexion réussie sans terminal), et son pendant — épingler la mauvaise clé fait refuser la connexion, sans quoi « ça se connecte » resterait compatible avec un client qui accepte n’importe quoi. Plus les refus : motif déjà connu sans --force (magasin inchangé, octet pour octet), retrait qui ne retire rien, nom d’hôte porteur d’un métacaractère, drapeau inconnu jamais ignoré.

2026-08-08 — la demi-fermeture : un CHANNEL_CLOSE de trop faisait perdre des réponses

Le tunnel inverse concurrent perdait silencieusement des connexions — several_third_parties_reverse_tunnel_concurrently_through_the_privsep_worker échouait 3/3 sur speedy et passait sur carbon, donc invisible en CI puisque le runner est carbon. La cause n’était ni une course ni la machine : c’était une lecture fautive de RFC 4254 §5.3, répétée à trois endroits.

  • Le fait. SSH_MSG_CHANNEL_EOF clôt une direction ; SSH_MSG_CHANNEL_CLOSE clôt le canal. Les trois pompes serveur émettaient les deux dès que la lecture de leur pair rendait 0 — ce que produit une demi-fermeture, geste parfaitement normal de tout protocole requête/réponse (curl, nc -N, et le harnais de ce dépôt). Le canal disparaissait donc avant la réponse, et la perte était muette : aucune erreur, aucun journal, des octets qui n’arrivent jamais.
  • Le client, lui, avait raison depuis toujours : client_forward::finish_pump n’émet le CHANNEL_CLOSE que « quand les deux ont fini ». Ce sont les trois pompes serveur qui avaient divergé — forward_blocking (privsep, livré), forward_pump (-L monolithique) et tcpip_forward (-R monolithique).
  • Pourquoi c’était invisible. Tous les montages existants avaient une cible qui répond à mesure qu’elle lit : la réponse doublait la demi-fermeture dans la course et arrivait avant que le canal ne soit fermé à tort. Les nouveaux tests emploient une cible qui ne répond qu’après la fin de flux — et la fenêtre s’ouvre en grand.
  • Et pourquoi nos propres tests d’intégration ne pouvaient pas le voir : notre client continue de pomper malgré un CHANNEL_CLOSE prématuré. Il faut un client conforme pour l’observer — ssh(1) jette le canal sans hésiter (« will not send data after close »). Les deux tests du moteur monolithique passent donc par un vrai client OpenSSH.
  • Quatre tests existants encodaient le défaut : ils affirmaient qu’une demi-fermeture ferme le canal. Ils affirment désormais l’inverse, et que notre CHANNEL_CLOSE miroite celui du pair.
  • Les trois correctifs sont prouvés mordants, chacun par son propre témoin d’octets : sans eux, le test du pilote privsep bloque pour toujours sur la lecture de la réponse, le -R monolithique reçoit "", et le -L monolithique voit la charge d’après-bannière ne jamais atteindre sa destination.
  • Ce que cela coûte, et qui est assumé : un canal dont un sens s’est éteint vit jusqu’à ce que le pair conclue. Il ne tourne pas à vide (sa cible quitte le ppoll) et les tables restent bornées — un tiers qui les accumulerait n’obtient rien de plus qu’en ouvrant des connexions muettes.
  • Deux suites *_root se partageaient un chemin fixe (#644, découvert en chemin) : authorize_root et from_restriction_root écrivent toutes deux le magasin du compte de test, dont le chemin ne se redirige pas — l’un installait pendant que l’autre effaçait. Un verrou existait, mais il ne disait qu’une moitié de la vérité (« sérialisation des forks ») et un seul test par fichier le prenait. Il devient exigible par le type : install_entries réclame le verrou dans sa signature, donc on ne peut plus installer sans le tenir. Le défaut était invisible parce que ces suites sautaient en rapportant « ok » en 0,00 s sur toute machine dépourvue du compte air-t-authz — un flake latent, pas un trou de couverture.

2026-08-07 — épinglage des clés d’hôte : air-ssh cesse d’accepter n’importe quel serveur

Clôture du chantier ouvert par l’amendement [ADR-118] §A6. Le client vérifiait la signature de la clé d’hôte — donc que le serveur possède bien la clé qu’il présente — mais rien ne disait que cette clé était celle de la bonne machine. Sans magasin de CA d’hôtes configuré, c’est-à-dire dans le cas nominal, air-ssh acceptait n’importe quel serveur.

  • Le défaut n’était pas dans le code écrit, il était dans ce qui manquait. evaluate_host_key rendait HostTrust::PlainKey avec un commentaire renvoyant « au chemin known_hosts/TOFU historique » — un chemin qui n’existait pas. Les deux appelants de production le recevaient dans un _host_trust et le jetaient. Un test, plain_host_key_defers_to_tofu_path, certifiait ce renvoi et passait au vert depuis des mois : il constatait qu’on déléguait, sans jamais vérifier que quelqu’un recevait.
  • Le socle existait déjà en entier et personne ne s’en était servi : air-keystore::hosts (entrées, verdicts, encodage scellé, chargement), le schéma known_hosts.capnp, et le programme air-keystore known-hosts add|list|check|remove. Le chantier annoncé comme « le plus grave » était en réalité un câblage manquant, pas une brique manquante.
  • Deux magasins, l’administrateur l’emporte (décision BDFL) : le magasin administré de la machine (/etc/air/keystore/known_hosts.aircfg, chemin fixe qu’aucun réglage ne redirige — même précédent que la base du gate d’administration, [ADR-141] §D6) est consulté d’abord et son verdict est définitif ; le magasin personnel (knownHostsPath, ordinal @8) est le seul qu’air-ssh écrit. Une entrée personnelle ne peut donc jamais rattraper un refus administré.
  • Les deux magasins sont GARDÉS à la lecture — propriétaire attendu (root pour l’administré, l’utilisateur pour le personnel), pas de lien symbolique, fichier ordinaire, non inscriptible par autrui. Sans cette garde, épingler ne servirait à rien : qui peut écrire le magasin y inscrit la clé de son choix, ou retire celle dont le changement l’aurait trahi. La mécanique d’ouverture (O_NOFOLLOW + O_NONBLOCK, statx sur le descripteur) est factorisée avec celle de la config cliente — une ouverture de sécurité dupliquée est une ouverture qui diverge.
  • Magasin personnel en 0640 (décision BDFL) et non 0644 : le contenu est public, mais la liste des machines qu’un compte a jointes n’a pas à l’être. L’administré reste 0644, il sert tous les comptes.
  • Première vue : empreinte affichée, accord explicite exigé (oui/yes en toutes lettres — ni o, ni y, ni entrée), et refus s’il n’y a pas de terminal aux deux bouts du dialogue (entrée standard et sortie d’erreur — sinon la question tomberait dans un fichier et le client attendrait en silence). La réponse n’est lue que sur une entrée standard qui est un terminal : la lire d’un tuyau consommerait les données destinées à la commande distante, et un fichier commençant par oui accorderait la confiance tout seul.
  • Ce qui n’est pas garanti, et qui est écrit comme tel : la toute 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é.
  • Onze textes rectifiéshost_trust.rs, client.rs, proxy_jump.rs, jump_channel.rs, le schéma ssh-config.capnp, l’aide de -J, l’amendement §A6 (FR+EN). Chacun affirmait une vérification « à chaque saut » que le code ne tenait pas devant une clé nue.
  • Revue de sécurité adverse : verdict BLOQUANT, deux défauts que la campagne avait elle-même introduits ou laissés. Ils valent d’être nommés parce qu’ils sont du même genre que celui qu’on venait de corriger.
    • La question de première vue était impossible à honorer. air-ssh place le terminal en mode brut avant d’ouvrir la connexion (pour la session interactive) ; la question, elle, n’arrive qu’après la poignée de main. ECHO/ICANON/ICRNL/ISIG coupés ⇒ saisie à l’aveugle, touche Entrée émettant \r qu’une lecture de ligne n’accepte pas, Ctrl-C sans effet : figement, dans le cas le plus courant qui soit — ouvrir un shell sur une machine jamais jointe. Corrigé par une saisie qui compose la ligne elle-même (classement pur des touches, écho manuel, \r et \n valident, abandon possible). Aucun test ne l’avait vu parce qu’aucun test n’empruntait ce chemin : toutes les suites sèment le magasin d’avance, et aucune ne tournait sur un pseudo-terminal. D’où tests/first_view_pty.rs — qui, sans le correctif, rougit et passe de 0,8 s à 15,6 s, la signature du figement.
    • Empoisonnement d’épinglage par la virgule. verify_host découpe le motif stocké sur les virgules (un motif OpenSSH couvre plusieurs noms) ; host_pattern recopiait le nom d’hôte sans rien refuser. Accepter une première vue sur banque.example.com,x.evil.tld inscrivait donc une entrée que banque.example.com retrouve ensuite — la clé de l’attaquant devenait un Match valide pour la vraie banque, définitivement. Corrigé par une validation en amont (Principe 4) refusant , [ ] * ? |, blancs et contrôles ; : reste autorisé (IPv6). Un test démontre l’empoisonnement sur le magasin pour que le refus ne passe pas pour de la superstition.
    • Et la phrase de doc qui masquait le second : « le motif est comparé octet par octet » — faux, et c’est ce qui rendait le défaut invisible à la relecture.
  • Seconde revue adverse : verdict « mergeable avec réserves ». Les deux bloquants sont vérifiés réellement clos (scénarios reconstruits, aucun trou ouvert par les correctifs). Deux majeurs de plus, tous deux du même genre :
    • Le conseil imprimé rendait le magasin personnel illisible. Il disait d’inscrire la clé avec air-keystore known-hosts add --store <magasin PERSONNEL> ; cette mutation est gatée, donc s’exécute en root, et l’écriture atomique crée un nouvel inode possédé par uid 0 — que la garde (propriétaire = uid effectif) refuse ensuite, si bien que plus aucune connexion ne fonctionnait. Le message vise désormais le magasin administré (dont root EST le propriétaire attendu) et avertit de ne pas l’appliquer au personnel.
    • known_host_refusal() n’avait aucun appelant, alors que sa doc promettait le message détaillé. Câblé — en corrigeant au passage une inexactitude que la revue n’avait pas vue : le message ne peut pas nommer l’hôte fautif (un rebond en traverse plusieurs et le refus remonte sans son motif), il nomme donc le magasin et la commande.
    • La casse : le DNS y est insensible, mais l’épinglage ne l’était pas — une majuscule suffisait à transformer un « la clé a changé » en première vue. Minusculisation ASCII, comme OpenSSH ; la contrepartie (entrée administrée en majuscules = inerte) est documentée.
    • Deux faiblesses du test PTY, dont une sérieuse : assert_ne!(code, Some(0)) passait quand le client était tué à l’échéance — le figement même que le fichier surveille serait passé au vert. Et le saut silencieux sur $USER absent rendait la suite verte sans rien exercer.
    • Mineurs : EINTR (un redimensionnement de fenêtre) annulait la connexion ; deux messages en \n s’affichaient en escalier ; parent_directory n’avait aucun test.
    • Reportés et inscrits : umask sur create_dir_all (seul vrai downgrade de la chaîne, précondition anormale), double écho hors mode brut, écriture concurrente en dernier-écrivain-gagne, air-keystore known-hosts add qui ne normalise ni ne valide.
  • Autres corrections de la première ronde : le raccourci mémoire de la première vue ne savait qu’accepter (un Mismatch sur une clé apprise dans la même exécution reposait la question au lieu de refuser, et laissait inscrire deux clés pour un même hôte) ; with_known_hosts conservait le pilote permissif de without_any_host_verification — croire durcir, accepter tout ; aucun mkdir du répertoire parent, donc première connexion impossible sur un poste neuf ; un from_utf8_lossy sur le chemin du magasin désignait silencieusement un autre fichier ; un message recommandait une commande inexistante.
  • Trouvaille annexe, non traitée ici : connection::tests::a_peer_that_answers_the_probes_is_never_disconnected échoue en suite complète et passe en isolé, sur main comme sur la branche — reproduit sans aucune de ces modifications. Profil de défaut le plus trompeur (contention), à instruire à part.

2026-08-06 — air-launchd temps 1 : un service ne peut plus se soustraire à sa supervision

Durcissement du lanceur (#631) après revue adverse de la pile. Le contrat air-launch-proto n’est pas en cause — il refusait déjà correctement tout ce qu’il devait refuser. C’est le pilote qui tirait la mauvaise conclusion de ce refus.

  • Le défaut, en une phrase : un refus du cœur remontait par ? hors de supervise(), main l’imprimait et sortait — sans signaler ni attendre l’enfant. Huit octets (AIRL ‖ version ‖ un genre inconnu) suffisaient donc à un service compromis pour devenir orphelin, réparenté à init, et seul détenteur de l’écoute que le lanceur venait de lui remettre : SO_REUSEADDR n’étant pas posé, plus aucun redémarrage n’était possible. La négation exacte de ce que la spec revendique — « un superviseur qui ne saurait arrêter que ce qui veut bien s’arrêter ne superviserait rien ».
  • Trois correctifs, indépendants, parce qu’aucun ne couvre ce que couvrent les autres : un refus produit désormais un Report::ProtocolViolation et enchaîne sur la voie d’arrêt existante (les deux sites : lecture nominale et drainage du dernier mot) ; LaunchedService porte un Drop qui tue et récolte si la fin n’a pas été constatée — c’est ce qui rend l’invariant vrai par construction, y compris sur panique et sur un chemin d’erreur non prévu ; PR_SET_PDEATHSIG(SIGKILL) est armé sur l’enfant, pour le cas où le lanceur ne vit plus du tout. Chacun est prouvé mordant : correctif retiré ⇒ test rouge.
  • Une tolérance fermée : les tampons de réception valaient exactement MAX_RECORD_LEN, et le seqpacket tronque en silence. Un enregistrement de 300 octets dont les 248 premiers étaient valides se décodait sans broncher, là où la spec §2.5 promet « aucune tolérance ». Tampon à MAX_RECORD_LEN + 1 des deux côtés : ce qui dépasse arrive avec 249 octets et le cœur le refuse. Plus simple que consulter MSG_TRUNC, et sans drapeau qu’on puisse oublier de lire.
  • Une limite écrite au lieu d’être corrigée, parce que c’est celle qui compte pour un opérateur : le lanceur ne sait pas arrêter un service qui fork. Il ne vise que l’enfant direct, et 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. La demi-mesure (setsid puis kill(-pgid)) exigerait d’exposer setsid sans terminal de contrôle sur AirCommand : un additif couche 1 hors de celui qu’ADR-151 a autorisé. Consigné en spec §4.5 et en dette ; la vraie réponse est le cgroup du temps 3.

2026-08-05 — Amendement d’ADR-118 : ce que la campagne a refusé de faire, et pourquoi

La campagne « config client » est close (c1 #633, c2 #636, c3 #638). Elle n’a pas appliqué l’ADR intégralement, et cet amendement le consigne — un ADR appliqué à moitié en silence devient un document dont plus personne ne sait ce qu’il engage.

Trois refus, une remise à plus tard, deux corrections de fait.

  • A1 — IdentitiesOnly n’est pas implémenté parce qu’il est déjà vrai. Le §4 le justifiait par l’épuisement du plafond de 6 essais — constat qui porte sur le client OpenSSH (dans nos interops, -o IdentitiesOnly=yes est passé au vrai ssh). air-ssh n’offre jamais plus d’une identité, et refuse pour ambiguïté devant un agent à plusieurs clés sans sélecteur : plus strict que l’option. Ajouter le bouton aurait été mentir sur ce que le système fait.
  • A2 — CanonicalizeHostname écarté : mettrait une résolution DNS sur le chemin d’une décision de configuration (Principe 11), pour un gain nul tant qu’il n’y a pas de jokers.
  • A3 — Match client écarté : le modèle conditionnel client existe déjà (HostBlock) ; ce que le Match d’OpenSSH ajoute inclut le critère exec, porte d’exécution pilotée par un fichier de config, refusée par D6 d’ADR-046 et ADR-150.
  • A4 — ProxyCommand reporté pour la même raison qu’A3. ProxyJump, lui, est purement interne (aucun exec) — c’est ce qui le rend acceptable là où l’autre ne l’est pas.
  • A5 — le §5 disait vrai à moitié : les timers air-async valent côté client, pas côté serveur, où le keepalive vit dans le worker confiné et s’implémente en échéance de ppoll. La décision est partagée ; seul l’ordonnanceur diffère.
  • A6 — une limite à ne pas croire dépassée : la vérification de clé d’hôte par saut n’a d’effet qu’avec un certificat d’une CA de confiance. Devant une clé nue, air-ssh ne vérifie rien — HostTrust::PlainKey est produit puis jeté, l’épinglage known_hosts/TOFU n’existe pas. Manque global au client, antérieur à la campagne. Consigné ici parce que c3 place un intermédiaire semi-fiable sur le chemin, et qu’une revendication de sécurité non adossée au code est plus dangereuse que son absence. L’épinglage devient un chantier à part entière.

Au passage, les références [ADR-NNN] du document, jusqu’ici nues, sont définies : elles étaient rendues en texte littéral.

2026-08-05 — ProxyJump (-J) — ADR-118 volet client inc.c3, campagne close

Troisième et dernière tranche du volet client d’ADR-118, et mise en œuvre d’ADR-117 §2 côté rebond. air-ssh sait désormais atteindre une machine derrière un bastion, sans y déposer la moindre clé privée.

  • La couture existait déjà, et c’est le résultat qui compte : le client est générique sur son transport, ProxyJump est donc une quatrième implémentation de SshByteStream (JumpStream, crate::jump_channel), adossée à un canal direct-tcpip. Handshake, confiance d’hôte, authentification, 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, pas le parsing de -J. La connexion extérieure doit avancer pendant que l’intérieure fait son propre handshake : un constructeur séquentiel, plus un lecteur et un écrivain par étage, dans un seul ensemble concurrent (ADR-098), les pompes étant intégrées à chaud par le motif sentinelle Notify déjà employé par client_forward. Le premier étage est peeled hors de la boucle : il n’y a que deux instanciations de la mécanique (socket, puis JumpStream), donc aucune récursion générique à boxer.
  • ⚠️ Le contrôle de flux, la famille de défauts qui a déjà mordu ce dépôt. Le canal de rebond est un canal SSH ordinaire, avec sa fenêtre. Le crédit est rendu à mesure que le transport intérieur consomme réellement — le rendre à l’arrivée reviendrait à n’avoir aucune fenêtre — et le maximum_packet_size est opposé en réception comme en émission. Prouvé par un transfert de 3 Mio à travers le rebond, in-process et contre un sshd d’OpenSSH en bastion ; vérifié par mutation : sans le recrédit, le transfert cale à 2 068 992 octets contre un serveur Air, et se fige (chien de garde) 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 (ClientHostTrust, ADR-109/137, avec les magasins de la config) et s’authentifie séparément — le bloc du saut fournit son compte et sa clé. Deux tests le verrouillent : certificat non vérifiable au bastion ⇒ échec, à la cible ⇒ échec aussi, bastion sain ou non.
  • Aucun repli direct, et c’est testé sur un montage qui pourrait tricher : la cible est joignable en direct, le saut est mort, la commande ne s’exécute pas. Contourner le bastion reviendrait à annuler sa raison d’être, silencieusement, au pire moment.
  • La chaîne est bornée parce qu’un fichier la décrit : chaînage transitif comme OpenSSH, mais refus au-delà de 8 sauts et refus du cycle (motif fautif nommé), tous deux avant le moindre octet de réseau.
  • ⚠️ Faux positif de cycle, levé par le bout en bout. Une première règle refusait air-ssh connect 127.0.0.1 -p 2200 -J 127.0.0.1:2222 — deux démons sur la même machine. Le cycle traqué est celui du graphe de configuration : un motif qui ne porte aucun rebond ne peut pas en faire partie, et sort avant toute vérification. Les tests unitaires passaient tous ; ce sont les tests par les binaires livrés qui l’ont trouvé.
  • Modèle : HostBlock.proxyJump @12 :List(JumpHost) — une liste structurée, pas une chaîne à re-parser (Principe 11 : c’est la CLI qui compile user@host:port, et son échec n’est qu’un refus d’écriture). Aucune union nécessaire, à la différence de c1/c2 : ce réglage n’a 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 none annule un ProxyJump de config (parité OpenSSH), comme -a annule un ForwardAgent. Corollaire assumé : avec -J, la cible n’est plus résolue localement — c’est le dernier saut qui la résout, donc un nom qui n’existe que derrière le bastion se joint enfin.
  • ProxyCommand reste hors périmètre, et son absence est consignée dans le schéma, le module et le guide : c’est une porte d’exécution pilotée par un fichier de configuration (amendement D6 d’ADR-046, ADR-150). Elle attend son propre arbitrage BDFL. ProxyJump, lui, ne lance aucun processus — c’est ce qui le rend acceptable là où l’autre ne l’est pas.
  • Aucun additif couches 0/1 (l’inventaire gelé est intact). Un ECONNREFUSED aurait été le mot juste pour un canal refusé ; il n’existe pas dans la table Errno scellée — on rend ECONNRESET et le diagnostic utilisateur, lui, nomme le saut et la raison.

⚠️ Fast-follow c3 (même jour, revue adverse indépendante). La revue a rendu PASS avec réserves : elle 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, le refus des cycles, le cloisonnement des étages, l’additivité du schéma et l’absence d’additif couche 1. Ce qui a suivi :

  • Une revendication de sécurité rectifiée — et c’est le point qui compte. « 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 (l’épinglage known_hosts/TOFU n’est pas 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. Le manque est pré-existant et global ; il n’a pas été introduit par c3, mais c3 revendiquait une propriété que le code ne tient pas, ce qui est pire que de ne rien revendiquer : cela fait cesser la vigilance. Arbitrage BDFL : corriger les textes (module proxy_jump, schéma ssh-config.capnp, guide §7, statuts.toml, aide de -J), sans 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.
  • L’identité d’un 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 la clé dédiée à prod au bastion — pas une compromission (la signature est liée au session_id de l’étage), mais le démenti exact de l’invariant écrit partout. Comportement corrigé : Config::jump_identity, résolue à part et seulement quand un saut en a besoin.
  • Aucune échéance sur le montage de la chaîne. Un bastion qui accepte le TCP, va au bout du KEX et de l’authentification 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 borne désormais les quatre moments d’un étage à la fois (60 s), et son dépassement est un échec.
  • Quatre dégradations silencieuses fermées : compte de saut vide sur le fil (refus explicite, avant tout octet de réseau) ; zip qui tronquerait la chaîne — une chaîne tronquée ferait ouvrir la session sur un bastion en croyant être sur la cible (contrôle de longueur) ; CHANNEL_OPEN à charge vide sur échec d’encodage (l’erreur se propage) ; write_ssh_config qui pouvait produire un artefact que parse_ssh_config refuserait entièrement (il revalide et rend un Result). Le compte d’un saut est en outre borné (MAX_JUMP_USER_LEN) à la lecture comme à l’écriture : c’est un champ qui part sur le fil dans le USERAUTH_REQUEST.
  • Chien de garde de test par PID. La suite CLI polait /proc/{pid} puis lançait kill -9 {pid} : entre la moisson du processus et le sondage suivant, le PID peut être recyclé, et le signal viser un tiers du runner. Le Child est désormais conservé, et chaque cas arme un Watchdog de processus comme les deux autres suites de rebond.
  • Couverture : jump_channel.rs était à 40,5 % de branches — son moteur n’est exercé que par des tests d’intégration, invisibles à la mesure --lib du gate. Douze cas unitaires de plus, dont un lecteur d’étage piloté par un transport en clair (message par message, y compris ce qui ne nous est pas adressé) et le montage d’un étage éprouvé seul.
  • Dette consignée, non corrigée : l’Outbox d’un étage n’est pas bornée (un bastion hostile qui inonde 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 une rustine.

Doc : §7 du guide docs/guides/air-ssh-config-client-fr.md (pourquoi le bastion, la vérification par saut et ce qu’elle ne fait pas, l’échéance de montage, la borne, le refus de repli, ce que le rebond coûte, et pourquoi ProxyCommand n’est pas là). Volet client d’ADR-118 clos ; un amendement consignera séparément les trois refus argumentés.

2026-08-05 — Keepalive client ServerAliveInterval/ServerAliveCountMax — ADR-118 volet client inc.c2 (#636)

Deuxième des trois tranches du volet client d’ADR-118 (§5). Le client sait désormais détecter un serveur mort et rendre ses ressources, sur ses deux moteurs — shell/exec et moteur -N/-L/-R, celui-là même où une connexion oisive ne porte aucun trafic qui trahirait un silence.

  • La décision est partagée avec le serveur, pas recopiée : KeepalivePolicy et KeepaliveTracker::step (crate::keepalive, écrits pour l’inc.8 serveur) servent aux quatre points d’intégration. Il n’y a pas deux sémantiques de keepalive dans ce dépôt.
  • L’ADR-118 §5 dit vrai côté client, et faux côté serveur — et c’est écrit dans les deux modules et dans le guide, parce qu’un lecteur qui compare sans cette phrase conclura à une divergence de style. Le client construit un vrai air_async::Runtime : le keepalive y est une future de plus sur un Timeout io_uring. Le worker serveur, lui, n’a aucun réacteur (cage seccomp sans io_uring_*) et n’avait que l’échéance de son ppoll.
  • Deux portées : défaut du poste (SshConfig) et surcharge par hôte (HostBlock), composées champ par champ. Là où ForwardAgent (c1) n’a que la portée par-hôte — parce qu’un défaut global y exposerait l’agent partout —, un keepalive n’ouvre et ne ferme aucun accès : il ne fait que borner le temps qu’une connexion morte occupe des ressources.
  • Piège du zéro capnp, traité trois fois : l’intervalle du poste est un UInt32 nu (son 0 est le défaut, aucun piège), mais le compteur est une union absent/count (absent = le zéro ⇒ 3) et les deux surcharges de bloc sont des unions inherit/valeur — sans quoi le 0 d’un 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.
  • ⚠️ Le défaut que seule l’interop réelle a trouvé. air-sshd émet ses sondes en requête globale ; OpenSSH les émet en requête de canal dès qu’un canal est ouvert. 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, sur les deux moteurs et quel que soit le réglage (RFC 4254 §4 et §5.4 — répondre n’est pas une option).
  • Les deux tests d’interop sont séparés par sens, et il le faut : 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. Constaté par mutation, écrit dans le fichier.
  • Aussi : message de sortie dédié quand le keepalive tranche (ETIMEDOUT, jamais un EPROTO générique) ; guide guides/air-ssh-config-client-fr.md étendu (§6) ; graines de fuzz pour les trois nouvelles unions.
  • Deux revues adverses avant merge, et elles ont porté. La première a défait un ajout de l’incrément lui-même — la file des requêtes globales du moteur -N avait été marquée (PendingGlobal) pour y faire entrer les sondes, ce qui n’apportait rien à une FIFO qui apparie par position et coûtait une croissance non bornée de la file ; plus trois autres correctifs (diagnostic ETIMEDOUT distingué du connect(2) filtré par un type, réponse de canal restreinte aux canaux confirmés, cycle Rc vidé au dénouement à chaud). La seconde a trouvé le jumeau non corrigé de ce cycle côté session — avec des fd de socket d’agent à la clé —, deux tests dont la documentation mentait (l’un décrivait le design révoqué, l’autre annonçait une borne timeout qu’il n’appliquait pas), un sshd réel qui fuyait sur panique (jeton RAII), et a fait passer KeepalivePolicy d’un « validate » réparti sur trois lecteurs à un « parse » porté par le type (try_new, champs privés) — Principe 4.

Reste : c3 — ProxyJump (ADR-117 §2).

2026-08-05 — Le plancher de branches avait dérivé au-dessus de toute mesure réelle (#635)

Le gate couvrable-vide --strict bloquait toutes les PR en mode COMPLET, y compris celles qui ne touchent pas la métrique. La cause n’était pas une régression : c’est le plancher qui était devenu inatteignable.

Ce qui a été mesuré, une fois llvm-cov réparé (#634 a levé trois échecs root-seulement qui interrompaient la mesure — sans quoi tous les chiffres antérieurs étaient des artefacts) :

MesureBranches
main, local, worktree neuf77,42 %
main, CI (run 30959942675, root)77,72 %
branche de PR, CI77,74 %
FLOOR_BRANCHES78,00

Aucune mesure n’atteignait le plancher, dans aucun environnement — et l’écart entre environnements atteint ~0,3 point, soit trois fois la marge qui séparait main du seuil. Un nombre absolu ne pouvait donc que se faire démentir : calibré sur un environnement, il est faux dans l’autre. Personne ne l’avait vu parce qu’un agrégat sur ~70 crates ne nomme aucun coupable : il dit « 77,4 < 78 » et laisse chercher.

Ce qui remplace (décision BDFL) : une non-régression contre xtask/couverture-reference.toml, une référence versionnée avec son contexte de mesure — échec sous référence − 0.10 avec le delta nommé, invitation au relèvement au-delà de +0.25 (le ratchet, seule façon dont elle monte), statu quo entre les deux. La référence ne se relève qu’en CI : y poser une mesure locale ferait rougir la CI sans raison.

Le fichier de référence est lui-même soumis au Principe 11 : schéma figé (une section, une clé, un flottant borné), toute déviation refusée — et le refus fait échouer le gate au lieu de le désarmer. Un fichier qui arme une garde ne peut pas se replier en silence sur une valeur par défaut.

Ce qui n’a pas bougé : la réconciliation ligne à ligne (couvrable VIDE, la partie fine, celle qui nomme les crates) était verte et reste inchangée ; seul l’agrégat grossier est remplacé. Le plancher couche-2 (75, calibré à part, ADR-143 §3) et le plancher lignes (90) sont hors périmètre.

Dette ouverte, relevée au passage : d’autres planchers 78 absolus et non calibrés subsistent — la barrière locale (xtask/src/barrier.rs) et les modes --layer 0|1. Chacun demande sa propre référence, les périmètres n’étant pas comparables.

2026-08-05 — Le gate d’architecture : role déclaré, et ce qu’il a trouvé

ADR-149 disait que la dérive venait d’une règle jamais outillée. Elle l’est (ADR-152) : cargo xtask check-controleurs vérifie que chaque crate déclare son role, qu’un manager en porte effectivement un, qu’un vocabulaire n’expose aucune opération, et qu’un interne ne fuit pas vers la couche du dessus. role n’était déclaré dans aucune des 83 crates ; il l’est maintenant dans toutes.

Le vocabulaire de D9 a dû grandir, de quatre valeurs à sept : abi-c pour les 28 coquilles de traduction C-ABI de D5, syscall pour l’interface kernel, service pour les 7 exécutables. Aucune des quatre valeurs prévues ne les décrivait. Extension additive, signalée pour ratification plutôt que glissée dans l’outillage.

Le gate a trouvé quatre écarts que l’audit manuel n’avait pas vus. L’amendement d’ADR-077 regardait les managers et les fonctions libres ; il ne regardait pas le graphe de consommation. Quatre crates de couche 1 déclarées interne sont consommées depuis la couche 2 — air-handle, air-poll, air-random, air-stdio. Le cas d’air-stdio est le plus parlant : la coquille air-libc-stdio appelle des fonctions libres là où D5 lui impose un manager.

Et un trou dans le gate lui-même, trouvé en l’attaquant. Requalifier air-system — qui porte AirSystemManager — en vocabulaire le faisait passer sans un mot : la règle manager-absent ne s’applique qu’à qui se déclare manager. D’où une cinquième règle, manager-non-declare : une crate qui porte un manager le déclare. Sans elle, toute la doctrine s’esquivait par un seul mot dans un manifeste.

Le vocabulaire a été arrêté par le BDFL dans la foulée, et il est meilleur que ce que je proposais : interne devient controleur — le mot dit le rang de D4 (un manager est un manager de contrôleurs) au lieu de dire seulement « pas public » — et abi-c devient bridge, un nom qui porte sa règle : un bridge n’accède jamais à un contrôleur, toujours au manager de la couche qu’il expose. Plus une règle sur la couche 0, qui se lit en deux natures : les types traversent toutes les couches, les syscalls ne se consomment que depuis la couche 1.

Ces deux règles, rendues vérifiables, trouvent 9 écarts de plus : 7 bridges sur 28 atteignent un contrôleur — dont air-object-c et air-value-c, intra-couche 2, qu’aucune règle inter-couche ne pouvait voir — et 2 crates consomment les syscalls hors de la couche 1 (air-libc-capi sous cfg(target_vendor), l’advisory d’ADR-087 ; air-libc-spawn en dev-dependency seulement).

Une correction, aussi. Un premier relevé annonçait « huit coquilles dépendent de la couche 0 » : il comptait des commentaires, dont un qui disait exactement l’inverse (« REMÉDIATION ADR-077 : cette crate ne dépend plus de air-sys-syscall »). Le nettoyage avait déjà eu lieu ; c’est la lecture qui était fautive.

Deux rôles de plus, et une leçon. service désigne un démon système ; les outils en ligne de commande deviennent user-cli. Ni l’un ni l’autre n’expose d’API réutilisable — d’où une règle : aucune autre crate ne doit les lier. Elle a immédiatement montré son utilité : j’avais rangé air-notifyd en service sur la foi de son suffixe -d, alors qu’elle ne produit aucun binaire (une lib et deux exemples que personne ne consomme) — exactement l’inférence que D9 interdit. La règle l’aurait attrapée seule. Confirmation qu’une valeur du vocabulaire sans règle est un tiroir où l’on range sans regarder.

Elle trouve deux écarts réels : air-agent est liée par air-sshd en dépendance livrée (la crate mélange un outil utilisateur et une bibliothèque de protocole — c’est la seconde qui doit être extraite), et air-launchd par le harnais de preuve, en dev seulement.

Et une limite du mécanisme, à porter à la reprise : air-sshd produit deux binaires de natures opposées — le démon et le client air-ssh — plus une lib de 96 opérations libres. Or role se déclare par crate : aucune valeur ne peut y être vraie. ADR-095 avait déjà tranché « deux binaires séparés » pour le packaging ; la taxonomie des rôles le redit depuis l’angle de l’architecture.

Le gate est un cliquet, pas un interrupteur — 28 écarts inventoriés au registre, les nouveaux signalés, les orphelines dénoncées ; consultatif d’abord, bloquant quand le registre sera vide. Même discipline que la référence de couverture (#635). Le registre est la matière de l’ADR de reprise qui priorisera la remise en conformité. Détail à noter : xtask compile et se teste sur macOS en 4 secondes, donc ce gate se développe sans exécuteur Linux.

2026-08-04 — air-launchd temps 1 : le lanceur existe, et air-sshd tourne dessous

Le critère de sortie était binaire — air-sshd démarre sous air-launchd au lieu de systemd, sans qu’une ligne d’air-sshd change. Il est atteint. Une ligne d’air-sshd a changé : SystemdHost::from_env()air_service::detect(), le point de sélection, qui ne changera plus.

Le contrat d’activation est Air, pas une imitation de systemd (décision BDFL). Un socket AF_UNIX/SOCK_SEQPACKET sur le descripteur 3 ; cinq enregistrements à disposition fixe et bornée ; les écoutes remises par SCM_RIGHTS ; la disponibilité, l’état et l’ordre d’arrêt sur le même canal. Aucune variable d’environnement — là où systemd en fait parser trois sur le chemin d’entrée d’une décision (Principe 11). Le Framer, c’est le noyau : le seqpacket préserve les frontières, donc aucune longueur ne vient du pair. Et le nombre d’écoutes n’est pas annoncé dans le Hello : il est constaté — l’annoncer en plus le rendrait redondant, donc ouvrirait un désaccord possible entre deux sources.

Ce que l’exécution réelle a trouvé et qu’aucune revue n’avait vu. Le masque de signaux d’un superviseur est hérité à travers le fork et l’execve. air-launchd bloque SIGTERM pour le consommer sur son signalfd ; air-sshd démarrait donc SIGTERM bloqué, insensible à l’insistance, et ne mourait qu’au SIGKILLsans que rien ne le signale. D’où l’additif couche 1 (ADR-151, couche-1-v3.9) : AirCommand::clear_signal_mask(), plus AirCommand::inherit_fd() pour installer le canal sur le descripteur 3 sans renoncer au pidfd.

Preuves — et la distinction n’est pas cosmétique. Le test d’intégration (crates/air-launch-temoin/tests/lancement.rs) et le fuzz du cœur pur (11,1 M exécutions) tournent sur la pile hôte gnu : ils valident la logique, pas la cible. Sur un projet dont la raison d’être est une std sans libc C, ça ne démontre rien — leçon déjà consignée au 2026-07-11.

La preuve qui engage est on-target, en static-pie sans libc, sur les deux arches tier-1 : air-launchd a lancé le témoin et air-sshd sur x86_64 (carbon) et sur aarch64, vrai Raspberry Pi 4 (raspi-srv-1). Des deux côtés : écoute remise par SCM_RIGHTS et comptée par le service (ecoutes=1), READY reçu sur le canal (aucune variable d’environnement n’était posée — il n’existe pas d’autre chemin), bannière SSH-2.0-Air_0.7.0 servie sur le socket hérité, ordre d’arrêt redescendu et honoré. Un superviseur tué par SIGKILL — sans la moindre chance d’émettre un ordre — est constaté par le service, qui s’arrête de lui-même.

Une première version de ce journal annonçait une preuve « réelle » qui était un build hôte gnu. Corrigé après relecture du BDFL.

Nouvelles crates : air-launch-proto (cœur pur, no_std sans alloc, zéro unsafe) et air-launchd (couche 2). Spec : specs/layer-2/air-launchd.md.

2026-08-04 — ADR-118, le volet CLIENT s’ouvre : ForwardAgent par hôte, -a, et trois refus argumentés (inc. c1)

Premier des trois incréments du volet client d’ADR-118 — celui dont l’entrée d’intendance de la veille constatait qu’il n’existait nulle part. Suivront ServerAliveInterval/ServerAliveCountMax (c2) et ProxyJump (c3).

  • Schéma client, évolution additive. HostBlock gagne forwardAgent, une union à trois membres (unspecified @5 / yes @6 / no @7) sur le patron de MatchRule.forceCommand (inc.5 serveur) : unspecified est le premier membre, donc le zéro du discriminant, donc ce que relit un artefact d’hier. SUPPORTED_VERSION reste 1 — ajouter un champ n’est pas changer de format.
  • Pourquoi trois états et pas un Bool. Le défaut sûr (false) tiendrait dans un booléen ; ce qui n’y tient pas, c’est la distinction entre « ce bloc ne se prononce pas » et « ce bloc éteint » — sans elle, un bloc hôte ne pourrait jamais éteindre ce qu’un défaut global allumerait plus tard.
  • La réservation knownHosts @5 était intenable, et elle est retirée. Cap’n Proto refuse les trous d’ordinaux (Skipped ordinal @N) : une réservation par commentaire est une promesse que le format ne peut pas tenir. Rien ne casse — la règle de versioning protège les ids déjà diffusés, et aucun artefact n’a jamais porté ce champ. Le raisonnement est écrit dans le schéma, avec la remarque que la forme de knownHosts n’est pas acquise non plus (les précédents du struct désignent des artefacts par leur chemin, pas par une liste inline).
  • -a / --no-forward-agent, la contrepartie obligatoire. Précédence, écrite et testée : -a > -A > bloc hôte > défaut fermé, et -A+-a ensemble ⇒ erreur, pas un vainqueur arbitraire. Dès lors qu’un fichier peut exposer l’agent à un hôte distant, l’absence d’un coupe-circuit serait un défaut de sécurité. decide_forward_agent est une fonction pure ; decide_agent_forwarding (ce que la demande obtient) garde ses trois règles intactes — seuls trois messages d’avertissement cessent de dire « -A », qui n’est plus la seule façon de demander.
  • CLI et JSON, trois états partout : config set forward-agent yes|no|unset --host <motif> (--host obligatoire — il n’y a pas de défaut global), config get distingue - de no, et l’export n’émet le champ que si le bloc se prononce. Un forward_agent non booléen fait refuser l’import.
  • Trois refus argumentés, documentés plutôt qu’implémentés : IdentitiesOnly est déjà vrai par construction (le constat de l’ADR 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 — plus strict qu’IdentitiesOnly), verrouillé par le test air_ssh_n_offre_jamais_plus_d_une_identite contre un agent réel ; 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 est exacte ; blocs Match client — le modèle conditionnel existe déjà (le bloc par-hôte), et ce que le Match d’OpenSSH ajoute par-dessus inclut exec, une porte d’exécution pilotée par un fichier de config, que refusent l’amendement D6 d’ADR-046 et ADR-150.
  • Preuves. Rétro-compat prouvée (artefact pré-c1forward_agent absent partout, test explicite) ; discriminant d’union hors schéma ⇒ refus (artefact forgé) ; round-trip capnp et JSON des trois états ; e2e réel contre un vrai sshd OpenSSH : un ForwardAgent yes en config suffit à obtenir un SSH_AUTH_SOCK distant sans -A, et -a l’éteint. Le harnais d’interop épingle désormais XDG_CONFIG_HOME — sans quoi la config réelle de la machine de test entrerait dans le circuit.
  • Nouveau guide : guides/air-ssh-config-client-fr.md (la première page de config client de docs/guides/, prévue pour accueillir c2 et c3).
  • Constat sur le fuzz. La cible fuzz_air_ssh_config existait sans corpus : le checksum FNV-1a-64 de l’enveloppe arrête les octets aléatoires avant le décodeur, donc la campagne n’exerçait rien du schéma. Graines fabriquées avec la vraie CLI (une par état d’union), 3,4 M d’exécutions, zéro crash — et la marche à suivre est désormais écrite en tête de la cible. La campagne a aussi exhumé un slow-unit (~10 s) : une longueur de liste hosts mutée à 4 194 305 entrées. Antérieur à cet incrément et identique côté serveur (ReaderOptions par défaut comme unique borne anti-DoS) — signalé, pas corrigé ici : changer cette borne est une décision de portée projet, pas un effet de bord d’incrément.

Fast-follow, sur la même PR, après une revue adverse de sécurité rendue PASS avec réserves (les quatre invariants critiques tenaient — le refus -a gagne toujours, la rétro-compat est prouvée au niveau de la disposition capnp, un discriminant hors schéma est refusé, decide_agent_forwarding est intacte ; rien à défaire) :

  • Garde de propriété et de permissions sur ssh.aircfg. Le fichier était lu par std::fs::read et écrit par std::fs::write : aucun contrôle de propriétaire, aucun contrôle de permissions, le lien symbolique suivi. Désormais : ouverture en O_NOFOLLOW, statx sur le descripteur déjà ouvert (AT_EMPTY_PATH — pas de fenêtre entre le contrôle et la lecture), et refus — pas un avertissement — si le propriétaire n’est pas l’uid effectif ou si le mode porte 0o022. 0644 passe : on refuse l’inscriptible, pas le lisible, sans quoi un umask 022 casserait tout le monde et le durcissement ne serait pas déployé. Config absente ⇒ comportement inchangé. Chaque refus nomme son correctif (chmod 600, chown), comme le fait OpenSSH. Écriture (config set et config import) par AirFileSystem::write_atomic_with_mode en 0600 : privé par construction quel que soit l’umask, et plus de fichier à moitié écrit. Zéro additif couche 1 — toute la matière existait (AirFile, write_atomic_with_mode).
  • Pourquoi maintenant, et pourquoi ça vaut au-delà de ForwardAgent. OpenSSH refuse un ~/.ssh/config mal protégé précisément parce qu’il peut allumer ForwardAgent et ProxyCommand ; la config d’air-ssh vient de franchir ce seuil. Mais ce même fichier portait déjà trustedHostCaPath et revokedHostKeysPath (ADR-109/138) : quiconque pouvait l’écrire pouvait neutraliser la révocation de certificats d’hôte. La dette précédait l’incrément ; ForwardAgent l’a rendue exigible.
  • L’allumage par la config n’est plus muet. Une ligne sur stderr nommant le bloc responsable et rappelant -a. Rien quand -A est explicite (le consentement vient d’être tapé), rien quand aucun relais n’est finalement obtenu (pas d’agent ambiant, -N) — annoncer une exposition qui n’a pas lieu serait un mensonge de plus. La provenance voyage par le type : decide_forward_agent rend un ForwardAgentSource (None / CommandLine / Config { pattern }) au lieu d’un bool, et son paramètre de config est un Option<(&[u8], bool)> — un verdict de config sans le nom de son bloc est structurellement impossible à construire. Les quatre règles de précédence et les messages existants sont inchangés ; HostSettings gagne le motif du bloc retenu, ce qui restera juste quand les jokers arriveront.
  • Preuves (tests/config_client_guard.rs, par la vraie CLI) : 0644 accepté — le cas qui décide du déploiement ; 0664/0666/0620 refusés avec chmod 600 dans le message ; lien symbolique refusé alors que sa cible est irréprochable (O_NOFOLLOW prouvé, pas supposé) ; config absente sans effet ; artefact écrit en 0600 avec contenu intact au round-trip. Le propriétaire étranger ne s’éprouve pas sans root — d’où le prédicat pur inspect_config_trust, couvert en unitaire (c’est la raison pour laquelle il est factorisé).
  • Trois réserves consignées en dette, pas corrigées ici (détail dans etat-avancement.md) : air-json retient la première clé dupliquée quand jq/Python retiennent la dernière (⚠️ couche 1 — un changement de comportement y est une rupture, pas un additif : re-sceau + décision BDFL) ; aucun gate CI ne confronte ssh_config_capnp.rs à ssh-config.capnp (regenerate.sh est manuel, donc la propriété « unspecified = zéro capnp » ne vit que dans le .rs committé) ; le slow-unit de fuzz — réserve levée côté client (aucun chemin réseau n’atteint parse_ssh_config, l’artefact est le fichier de l’utilisateur, air-ssh n’est pas setuid), maintenue côté serveur.

Fast-follow n°2, toujours sur la même PR, après une seconde revue adverse (elle aussi PASS avec réserves : les dix points de sécurité revendiqués par la garde sont vérifiés dans le code — TOCTOU réellement fermée, O_NOFOLLOW prouvé sur les deux arches, uid effectif, écriture 0600 sans fenêtre, tests par le binaire livré avec témoin positif ; rien à défaire) :

  • Le défaut majeur n’était pas dans ce que la garde contrôle, mais dans ce qu’elle n’atteignait jamais. O_NOFOLLOW refuse un lien symbolique final ; il ne dit rien d’un FIFO. Or open(O_RDONLY) sur un tube nommé bloque indéfiniment jusqu’à ce qu’un écrivain se présente : le metadata() et le prédicat de confiance n’étaient jamais atteints, et le propriétaire étranger du FIFO jamais constaté. C’est exactement le scénario que nos propres tests invoquent — ~/.config/air sous un umask permissif, un tiers y pose quelque chose — sauf qu’ici le tiers fait mkfifo ssh.aircfg et chaque air-ssh de la victime se fige à jamais, sans message, sans délai, sans Ctrl-C utile dans un script. La voie préexistait (std::fs::read bloquait pareil) ; ce qui est neuf, c’est qu’une garde prétendait fermer cette classe. Une garde qui annonce plus qu’elle ne tient est pire qu’une garde absente : elle fait cesser la vigilance.
  • Le correctif est un duo, et l’ordre compte. O_NONBLOCK au masque d’ouverture rend la main tout de suite sur un FIFO sans écrivain — mais il ne décide rien, et laisserait lire un tube comme si c’était une config. C’est le refus du type qui décide : ConfigTrust::NotRegular s’ajoute à l’énum pure de verdict (donc éprouvable sans I/O, comme les autres) et se juge avant propriétaire et permissions — un FIFO à nous en 0600 n’a de problème ni de l’un ni de l’autre, ce qu’il faut dire c’est que ça n’est pas un fichier. O_NONBLOCK garantit seulement qu’on atteint ce verdict. Sur un fichier ordinaire, il ne change rien à la lecture ; le chemin « config absente » est strictement inchangé. Zéro additif couche 1.
  • Le test échoue par timeout borné, jamais par blocage. Sans cette borne, une régression future ne se lirait pas comme un bug mais comme une CI qui pend — précisément ce qu’on cherche à exclure. Contrôle négatif exécuté : O_NONBLOCK retiré ⇒ le test échoue en 20 s avec le message qui nomme la cause. Le FIFO est posé en 0600 exprès, pour que seul le type puisse le faire refuser — et le cas passe en root comme en utilisateur (le gate de couverture mesure en root). Corollaire signalé et non traité : sur un FIFO, read() peut rendre EINTR, que la boucle de lecture ne rattrape pas — le refus par le type fait qu’on n’y arrive plus.
  • Err(_) => Ok(None) discriminé. La docstring promettait « Err = config présente mais refusée, jamais ignorée en silence » ; le code avalait EACCES, EMFILE, ENOMEM, ENAMETOOLONG… Désormais : ENOENT/ENOTDIR ⇒ pas de config, tout le reste ⇒ refus. La direction restait fermée (magasins absents ⇒ listes vides ⇒ aucun cert d’hôte accepté), donc pas de dégradation exploitable — mais une promesse fausse devient un vrai défaut au premier refactor. Prouvé deux fois : EACCES (avec le verdict root dit plutôt que le test désactivé) et ENAMETOOLONG (uid-indépendant, il montre la règle plutôt qu’une de ses instances).
  • La garantie « impossible sans le nom du bloc » devient structurelle. Le couple (motif, état) passé à decide_forward_agent est maintenant dérivé du bloc par un zip ; l’unwrap_or_default() d’avant construisait exactement le verdict que le type Option<(&[u8], bool)> est censé rendre impossible (message « bloc « » »). C’était inatteignable — resolve_host pose le motif dès que forwardAgent est présent — mais l’invariant vivait dans une coïncidence entre deux fonctions, pas dans le type.
  • Lecture fail-closed : buffer.get(..lus).unwrap_or(&buffer) concaténait le tampon entier — donc les octets du tour précédent — si le noyau rendait un compte supérieur à la taille du tampon. Devenu un ok_or(…)?, via un helper pur couvert dans les deux sens.
  • Deux formulations rectifiées : le relais annoncé est demandé, pas « obtenu » (decide_agent_forwarding constate un $SSH_AUTH_SOCK non vide, pas un agent qui répond, et le message part avant tout réseau — la direction est conservatrice, mais le mot juste compte quand on revendique de ne pas éroder la confiance dans un avertissement) ; et le refus d’une config possédée par root — plus strict qu’OpenSSH — est désormais assumé par écrit dans le guide, avec son raisonnement et sa conséquence de déploiement (une config provisionnée par un admin doit être chown à l’utilisateur).
  • Quatre réserves consignées en dette, pas corrigées (détail dans etat-avancement.md) : composants de répertoire non contrôlés (le correctif serait RESOLVE_NO_SYMLINKS, qu’AirFile::open n’expose pas ⇒ additif couche 1, inventaire gelé — mais le guide le dit désormais, avec la propriété qui tient à la place : le contrôle de propriétaire, parité OpenSSH) ; lecture non bornée en taille ; octets de config rendus verbatim au terminal ; config get/export/set qui lisent sans la garde, donc un diagnostic plus permissif que l’exécution.

2026-08-04 — Intendance de clôture : ADR-118 passe à fait, et l’inventaire des additifs non scellés existe enfin

Passe d’intendance, sans code. Deux écritures, dont une découverte.

  • ADR-118 passe en-coursfait dans statuts.toml (registre régénéré). Les huit incréments mergés (#575, #614, #615, #616, #618, #619, #620, #623) closent le volet serveur : bornage du forwarding, contrôle d’accès, évaluateur Match et ses verbes CLI, ForceCommand, bannière pré-auth, MaxSessions, keepalive.
  • La découverte : le volet client de cet ADR n’est implémenté nulle part. Le §4 (IdentitiesOnly, ForwardAgent, CanonicalizeHostname, ServerAliveInterval) et le §2 côté client (blocs Match) n’ont aucune ligne de code — crates/air-sshd-schema/schema/ssh-config.capnp ne porte aucun de ces champs, et -A n’existe côté air-ssh que comme drapeau de ligne de commande (#627), jamais comme option de configuration. Ces options avaient été rattachées à ADR-116, qui a été clos sans elles : le résidu ne tenait donc plus nulle part. Il est désormais écrit trois fois — dans la note de statut de l’ADR, dans les dettes ouvertes du suivi, et ici — parce qu’un résidu qui ne vit que dans une mémoire de session est un résidu perdu. Il reste à trancher : incrément dédié, ou mise hors périmètre explicite.
  • Inventaire des additifs couches 0/1 non scellés : notes/inventaire-additifs-couches-avant-re-sceau-fr.md. La directive de ne re-sceller qu’une fois, à la complétion d’air-ssh, a pour effet mécanique que les additifs s’accumulent sans trace consolidée. La note relève 9 additifs couche 1 depuis couche-1-v3.8 (ct_eq_bytes, peer_credentials, change_mode_nofollow, is_connect_in_progress, assume_with_supplementary_groups, MAX_KEPT_FDS 16→48, signal_by_pidfd, parent_death_signal, default_sigpipe_on_exec) — tous strictement additifs, donc un mineur v3.9 et non le majeur d’ADR-123 — et 1 additif couche 0 (fchmodat2, #568) qui porte la mention « descellement » mais dont aucun tag n’a jamais été posé : la couche 0 est descellée depuis le 30 juillet. La méthode de relevé est donnée dans la note, pour être rejouée au moment du sceau plutôt que crue sur parole.

2026-08-04 — Agent forwarding : l’interop réelle trouve le défaut que trois suites de tests avaient laissé passer (ADR-116 §3-c inc.c-4)

Dernier incrément de la part c d’ADR-116 : prouver l’agent forwarding dans les deux sens contre le vrai OpenSSH, et corriger ce que l’interop révélerait. Elle a révélé quelque chose.

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 pas dans le démultiplexeur. Or cette boucle refusait la requête pour tout le monde, avec un commentaire qui ne parlait que du démon monolithique. Résultat : ssh -A vers air-sshd n’obtenait aucun SSH_AUTH_SOCK, et ssh-add -l distant répondait « Could not open a connection to your authentication agent ».

Pourquoi rien ne l’avait vu. c-2 éprouvait la médiation du monitor (le socket, ses permissions, le tunnel) ; c-3 éprouvait le client contre un pair scripté ; les unitaires éprouvaient la fonction de décision, qui est correcte. Aucun des trois ne faisait passer un auth-agent-req par le trajet réel d’un vrai client. C’est exactement la classe de défaut que l’interop existe pour attraper — deux moitiés écrites le même jour s’accordent toujours.

Le correctif, au plus près. La couche connexion retient la demande (ForwardAgent en mode différé) au lieu de la refuser, et l’issue ConnectionOutcome::Service la porte jusqu’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), sans quoi il jetterait les connexions que le monitor lui pousse. 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, et aucun client connu n’emprunte ce chemin (OpenSSH comme air-ssh envoient want_reply = 0).

Les deux directions, prouvées.

  • A — ssh -A d’OpenSSH → air-sshd (tests/agent_forward_interop_root.rs, privsep réel, root + _air-sshd) : le ssh-add -l qui tourne sous le compte de session liste l’empreinte d’une clé que seul l’ssh-agent du client détient. Plus les deux refus — sans -A, et sous AllowAgentForwarding no — vus depuis un vrai client.
  • B — air-ssh -Asshd d’OpenSSH (tests/agent_forward_openssh_server.rs, sans root, tourne en CI) : c’est OpenSSH qui honore notre requête, ouvre le canal auth-agent et laisse ssh-add -l parler à notre agent à travers notre relais.

Chaque cas exige en outre 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é ». Deux tests de non-régression in-process tiennent désormais le trajet corrigé.

ADR-116 passe à fait.

2026-08-04 — Deux exemples AirCom bout-en-bout : le coût réel, mesuré

Épreuve de vérité demandée par le BDFL : écrire deux applications console, l’une publiant une pseudo-température toutes les minutes, l’autre s’y abonnant et l’affichant — pour mesurer la complexité réelle d’écrire une application AirCom.

Le verdict, chiffré : meteo_station fait 34 lignes de code, meteo_console 23. Zéro unsafe, zéro extern "C", clippy et fmt propres. Publier tient en une ligne (publisher.publish(releve.as_bytes())?), recevoir aussi (abonnement.next_blocking(None)?).

Exécutés sur carbon, vraie machine Linux — deux processus séparés, la console affichant les relevés de la station. Le détail instructif : la console branchée deux minutes après le démarrage a affiché 59 relevé(s) manqué(s). L’anneau ne fait que 8 emplacements ; c’est de la diffusion à perte assumée, avec une comptabilité honnête — un abonné lent ne bloque jamais le publieur, et il sait qu’il a décroché.

Ce que la compilation locale ne prouvait pas. Le code compilait sur le Mac : cargo check type sans lier ni exécuter, alors qu’air-sys-syscall émet des numéros de syscalls Linux en assembleur inline. Sur un noyau Darwin il serait parti dans le décor. Seule l’exécution sur carbon a valu preuve.

Le constat qui compte : la complexité n’est pas dans le message, elle est dans le rendez-vous. La moitié du code sert à ce que l’abonné obtienne le descripteur de l’anneau — guichet, send_fd/recv_fd, protocole écrit à la main. Il n’existe pas de résolution par nom.

Et la vérification a montré que ce n’est pas la faute d’AirCom. Le Registry existe déjà, avec la bonne doctrine — « il ne remet jamais une adresse en clair : il remet un fd » — et lookup rend une Connection établie en une ligne. Mais il faut un canal vers le registre, que rien ne donne aujourd’hui : c’est air-launchd qui doit remettre cette capability initiale (ADR-010, POLA strict). La verbosité vient de l’absence du lanceur et du registre de couche 5, pas de l’API.

Deux simplifications identifiées, dont une qui renforce la sûreté. Le total_len transmis au premier message est redondant : Subscriber::attach s’en sert comme contre-vérification alors que les valeurs qui font foi (capacity, slot_payload_cap) sont déjà dans l’en-tête du segment. Le dériver du descripteur — le noyau connaît la taille du memfd scellé — supprime un message, des lignes des deux côtés, et une hypothèse de confiance : aujourd’hui l’abonné croit un u64 envoyé par son interlocuteur.

Exemples conservés (décision BDFL) et situés par un en-tête disant ce qu’ils montrent, ce qui disparaîtra quand la couche 2 sera complète, et qu’ils ne sont pas une application Air conforme — ni manifeste, ni octroi, ni cage.

2026-08-04 — air-launchd passe en couche 2, et la couture existait déjà

Décision BDFL : air-launchd est un composant de couche 2 et il est requis dès la v1.0. Cadrage et découpage : notes/air-launchd-couche2-roadmap-fr.md.

Pourquoi le placement change. Aux premières heures du projet — avant d’avoir mesuré la complexité de systemd — on espérait lui faire porter la mise en cage selon nos critères, et air-launchd était rangé en couche 5. L’hypothèse est tombée : seul un lanceur à nous peut appliquer l’arbitrage manifeste/octroi d’ADR-150, et s’appuyer sur systemd a trop d’implications structurelles.

Et le placement en couche 5 était devenu intenable — de mon fait. ADR-149 D1, écrit la veille, supprime le « saut de couches autorisé exceptionnellement ». Or air-launchd a besoin d’air-sandbox et d’air-process (couche 1) et d’AirCom (couche 2) : depuis la couche 5, trois et quatre étages de saut. En abolissant l’entorse, j’avais rendu impossible le placement du composant qui a le plus besoin de descendre.

La couche 2 résout cela sans exception, et le précédent est exact : air-sshd est déjà un démon de couche 2 liant air-sandbox, air-process, air-filesystem, air-socket, air-log, air-account, air-keystore — toutes en N-1. L’argument décisif : une couche déclare ce qu’un composant a le droit de lier, pas qui a le droit de lui parler. Les dépendances AirCom sont d’exécution, pas d’édition de liens ; check-layers ne lit que le graphe de crates.

Problème repoussé, pas résolu : la formulation d’ADR-149 D1 reste fausse pour la couche 5, ADR-006 rendant les couches 3 et 4 absentes en profil air-base — la couche 5 y repose directement sur la couche 2. Latent aujourd’hui : l’arbre ne compte aucune crate en couche 3, 4 ou 5 (2 / 33 / 45 pour les couches 0, 1, 2).

La trouvaille : la couture n’est pas à concevoir, elle est livrée. ADR-093 §6 annonçait un trait ServiceHost (v1 systemd → v2 air-launchd). La crate air-service existe, en couche 2, se décrit comme « le seam de supervision v1(systemd)→v2(air-launchd) », et livre le trait, SystemdHost, ShutdownSignal et les deux protocoles systemd en Rust sûr sans binding libsystemd. Le contrat tient en trois méthodes, et il est prouvé par un test (air-sshd/tests/seam.rs). Le premier temps de la roadmap n’est donc pas de concevoir une abstraction : c’est d’écrire une seconde implémentation d’un trait à trois méthodes déjà éprouvé.

Trois temps. (1) Le lanceur minimal — AirLaunchdHost: ServiceHost —, avec un critère de sortie binaire : air-sshd démarre sous air-launchd au lieu de systemd, sans qu’une ligne d’air-sshd change. Ne dépend d’aucune décision en attente. (2) La mise en cage à l’exec (arbitre d’ADR-150), bloquée par la largeur des enveloppes par défaut. (3) La supervision réelle et les cgroups — tuer de façon fiable, compter exactement, attribuer les journaux sans qu’un service puisse mentir, borner les forks. AirCgroupManager n’existe pas ; ce n’est pas un descellement de couche 0 (les cgroups se pilotent par le système de fichiers), c’est un Manager de couche 1 à écrire.

Documents mis en conformité : ADR-010 (fr+en) et la macro-architecture, qui plaçaient air-launchd en couche 5, plus la spec air-process. Le journal, append-only, garde ses mentions historiques.

2026-08-03 — AirCom : le transport tient, c’est la surface qui manque

Instruction d’un incrément d’ADR-001, à la demande du BDFL : qu’une application ou un service Air écrit en Rust n’ait pas à écrire d’unsafe pour parler sur AirCom. Note de conception : notes/aircom-surface-rust-sure-conception-fr.md.

Le relevé contredit la réputation d’AirCom. « Pas encore pleinement utilisable » était l’hypothèse de départ ; l’arbre dit autre chose : ~11 200 lignes en trois crates suivant le motif sans-IO d’ADR-091 — air-com-schema (2 555), air-com-proto (4 640, cœur pur fuzzable), air-com (4 088, pilote I/O). Transport réel en SOCK_SEQPACKET, deux surfaces de première classe (async io_uring, sync bloquante) sur le même cœur, façade Connection/Listener, deux consommateurs en production. Et le data plane de capabilities existe, en Rust sûr : send_capability/receive_capability sur SCM_RIGHTS asynchrone plus memfd scellé.

Le vrai manque est ailleurs, et il est petit. La face C-ABI est devenue le chemin par défaut, alors que la face sûre existe :

  • Côté client, AirValueOwned — enrobage RAII sûr, int/string/bytes/as_str rendant des Result — est utilisé nulle part hors d’air-value et d’un fichier d’air-object. Les signatures call_object prennent &[AirValue], la forme brute. Résultat : l’exemple phare, celui qu’un développeur copie, écrit unsafe { air_value_string(…) }. Un développeur qui suit le tutoriel écrit de l’unsafe dans son application — frontalement contraire à ADR-149 D2.
  • Côté serveur, le manque est déjà nommé par le projet. #[air_class] génère les propriétés observables et la politique de thread mais pas encore les méthodes (AO.4 différé), et air-notifyd câble sa classe à la main en disant lui-même que c’est « la forme exacte que #[air_class] émettra une fois la génération de méthodes livrée ». Le service de référence est un tenant-lieu écrit à la main de ce que la macro doit produire.

L’ampleur : unsafe = 3 dans l’exemple client, 2 dans le serveur, zéro extern "C". Cinq blocs. Le chantier est minuscule rapport à ce qu’il débloque.

Trois lots — le client parle AirValueOwned ; AO.4 finit la génération de méthodes (critère : air-notifyd supprime son câblage manuel) ; les deux exemples sont réécrits. Critère de sortie mécanique : grep -c unsafe sur les deux exemples rend 0, sans changement de comportement observable.

Ce que ça débloque : le portail, prérequis de l’enveloppe par défaut étroite d’ADR-150 (question ouverte n° 1). L’API cliente d’un portail est un appel AirCom qui rend un descripteur — si l’application doit écrire de l’unsafe pour l’appeler, le portail est inutilisable par la population qu’il protège.

Un manque distinct inscrit au passage : receive_capability rend un Vec<u8> — le contenu d’un segment memfd —, pas un OwnedFd. Le chemin est prouvé pour la mémoire partagée ; un portail doit remettre un descripteur arbitraire. Hors périmètre de cet incrément, mais noté pour ne pas le redécouvrir plus tard.

2026-08-03 — Agent forwarding client (ADR-116 §3-c, incrément c-3)

  • Ce que ça règle : le serveur (c-2) savait ouvrir un canal auth-agent@openssh.com vers le client ; personne ne le servait. air-ssh -A demande désormais le forwarding (auth-agent-req@openssh.com, want_reply = false — parité OpenSSH) et sert ces canaux en les proxyfiant vers l’agent ambiant du poste ($SSH_AUTH_SOCK). Un git pull ou un rebond ssh depuis l’hôte distant marche sans y déposer de clé.
  • Le défaut est fermé, et il l’est deux fois. Sans -A : aucune auth-agent-req n’est émise, et tout canal auth-agent qu’un serveur ouvrirait quand même est refusé (OPEN_ADMINISTRATIVELY_PROHIBITED). La seconde moitié est la défense en profondeur : elle tient même si la politique serveur est contournée. Les deux sont testées, dans le même cas.
  • Le relais est opaque, et sans clé. air-ssh ne parse rien du protocole ssh-agent : les octets passent tels quels, bornés par la fenêtre de flux (RFC 4254 §5.2) et MAX_PACKET. Aucune clé privée n’entre dans ce processus — le protocole n’a pas de message pour en rendre une. Prouvé octet pour octet dans les deux sens (tests/agent_forward_client.rs).
  • Aucune donnée du serveur ne choisit un chemin local : la charge d’un auth-agent est vide, et une charge non vide fait refuser l’ouverture (Principe 11). La cible est fixée à l’invocation, avant le premier octet reçu, et ne vient que de $SSH_AUTH_SOCK.
  • --air-agent n’est pas relayable tel quel : ce socket parle le protocole natif d’Air, pas celui d’OpenSSH qu’attendront ssh/git à distance. -A avertit et poursuit sans forwarding (nommer le pont OpenSSH d’inc.3b) plutôt que de livrer un tunnel qui paraît marcher. Même dégradation propre si $SSH_AUTH_SOCK est absente, inexploitable, ou si -N n’ouvre aucune session : un avertissement, jamais un échec de connexion.
  • Le moteur de session du client devient multiplexé (client.rs, run_session) : lecteur — écrivain sérialisé (l’Outbox du moteur -N, désormais partagée) — entrée locale — pompes d’agent intégrées à chaud par une sentinelle Notify. client_exec et client_shell sont deux modes d’un seul moteur ; leurs signatures prennent le réacteur et la cible d’agent.
  • Bornage : 8 canaux d’agent concurrents au plus, identifiants réservés dans 1..=8 (jamais 0, celui de la session — le routage est non ambigu par construction), place rendue par jeton RAII. Un agent injoignable refuse le canal (OPEN_CONNECT_FAILED) sans toucher à la session.
  • Preuves : unitaires du relais (refus sans opt-in, seul auth-agent servi, charge non vide refusée, plafond et place rendue, session fermée n’admet plus rien, ids ≠ session, messages sur canal inconnu inertes) ; décision CLI (-A sans agent, -A avec -N, --air-agent, $SSH_AUTH_SOCK inexploitable) ; frontière -N (le moteur de forwarding refuse un auth-agent, gelé par un test) ; e2e in-process (tests/agent_forward_client.rs, chien de garde dur : opt-out, opt-in avec aller-retour ssh-agent réel, agent mort).
  • Le délai de grâce a révélé un défaut du runtime, pas du protocole. Le timer de dénouement (Timeout io_uring) est droppé en vol quand l’écrivain gagne la course — le cas nominal. Son ASYNC_CANCEL est alors mis en file par Reactor::forget, mais la soumission n’a lieu qu’au prochain turn… qui n’arrivera jamais, puisqu’on sort. Le SQE restait dans la SQ, son op comptée « en vol » sans pouvoir se compléter, et le téardown quiescent du ring (ADR-028 S2) l’attendait en vain : 6,4 s (64 × 100 ms) de blocage à la destruction du Runtime, puis fuite contrôlée du ring. Mesuré sur les deux chemins (exec et shell) ; seul le shell le faisait échouer, parce que seul son test porte un chien de garde à 6 s. Corrigé là où est le défautair_async::drive droppe désormais root explicitement puis pousse la file (Reactor::flush) avant de rendre la main : les annulations partent vraiment. Deux tests de régression dans air-async (aucune SQE laissée en file ; téardown du réacteur prompt). two_executables passe de ~240 s + échec à 1,1 s pour ses trois tests.
  • Reste : c-4 (interop réel bidirectionnel Air↔OpenSSH, tests root e2e). PR à ouvrir.

2026-08-03 — Agent forwarding serveur (ADR-116 §3-c, incrément c-2)

  • Ce que ça règle : ssh -A vers un air-sshd exposait jusqu’ici un refus poli — auth-agent-req@openssh.com était décodé puis rejeté. Le serveur sait désormais créer le SSH_AUTH_SOCK d’une session, l’exporter dans son environnement, et ouvrir un canal auth-agent@openssh.com vers le client à chaque connexion entrante. Un git pull ou un rebond ssh depuis l’hôte distant marche sans y déposer de clé privée.
  • C’est la fonctionnalité la plus dangereuse d’SSH, et le code le dit. Tant que la connexion vit, tout ce qui tourne sur l’hôte — y compris un root qui n’est pas l’utilisateur — peut demander des signatures à son agent. La clé privée ne bouge pas (l’agent ne rend que des signatures) ; c’est son usage qui est prêté. D’où le garde-fou ratifié (note de conception, BDFL 2026-08-03) : double opt-in, fail-closed.
  • Les deux verrous, et l’unique endroit où ils se conjuguent. AllowAgentForwarding (nouveau champ SshdSettings, capnp @22, défaut no, base-only — aucune surcharge Match) et permit-agent-forwarding sur l’entrée authorized_keys (déjà parsé, ADR-128). Le && vit dans un type (AgentForwardingPolicy::permits), pas au point de décision : le jour où un troisième verrou s’ajoute — le SecurityManager d’ADR-089, dont l’accroche // SECURITY-MANAGER: est posée dans open_agent_listener — tous les appelants en héritent. Le défaut du Bool capnp (false) est le défaut voulu : rétro-compatibilité et fail-closed pointent, pour une fois, dans le même sens.
  • Le chemin du socket est choisi par le SERVEUR, et le protocole le rend inexprimable autrement. La RPC monitor gagne FORWARD_LISTEN_AGENT (tag 13) — sans aucune charge — et FORWARD_ACCEPTED_AGENT (tag 17), sans charge non plus. Ce n’est pas une validation qu’on a déplacée : il n’existe aucun champ sur ce trajet 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, refus si l’aléa manque), et le chemin ne remonte pas au worker — qui n’en a aucun usage, la charge d’un auth-agent étant vide. C’est le point de design que la revue devait trancher : extension de la médiation streamlocal, pas réemploi de ForwardListenUnix (dont le chemin vient du client et dont la politique est AllowTcpForwarding/PermitListen — deux capacités distinctes, qu’on ne lie pas).
  • 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 — socket+bind+chmod 0600+listen, mot pour mot celle du -R streamlocal — donc possédé par le compte, et là seulement où le compte pouvait déjà écrire. Nettoyage RAII (UnixListenPath) : le nœud meurt avec la session, sur tous les chemins de sortie. /run/user/<uid> absent ⇒ refus, et le serveur ne le fabrique pas.
  • /run/user, pas /tmp (décision BDFL) : tmpfs privé 0700 possédé par le compte, donc protection double — le répertoire et le mode 0600 du nœud. /tmp, world-writable et sticky, offrirait à tout local un point d’observation et une course sur la création du nom.
  • Un piège trouvé par le test root, pas par la relecture : accompanying_fd_count (monitor/channel.rs) est la table de vérité fd↔étiquette, et son _ => 0 faisait rejeter la poussée d’agent — qui porte un descripteur. Le fail-closed a fonctionné (rien ne passait) ; l’e2e l’a nommé en une ligne.
  • Le monolithique refuse de démarrer avec allow-agent-forwarding yes, comme pour Match (inc.3), ForceCommand (inc.5) et le keepalive (inc.8) : sans monitor, pas de socket médié — servir en silence un réglage qu’on ignore serait une promesse non tenue.
  • Preuves : codec (auth-agent-req à charge vide stricte — toute traîne refuse la demande ; les deux messages RPC réduits à leur étiquette, traîne rejetée, tags disjoints) ; décision worker (clé sans permission ⇒ refus sur place, aucune médiation sollicitée) ; médiateur (table de vérité du double opt-in, refus dans les deux sens sans écoute ouverte, /run/user absent ⇒ refus sans repli /tmp, chemin privé et imprévisible) ; environnement (SSH_AUTH_SOCK absent par défaut — pas même vide —, présent et exact quand accordé, coexiste avec SSH_ORIGINAL_COMMAND) ; démultiplexeur (canal auth-agent à charge vide, octets dans les deux sens, seconde demande refusée sans déranger le monitor) ; config (aller-retour capnp + JSON, absence ⇒ false, non-booléen refusé et non lu false, CLI set/get) ; e2e root (tests/agent_forward_root.rs) : socket 0600 possédé par le compte sous /run/user/<uid>, 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 — l’utilisateur y écrirait sans peine, et rien n’apparaît.
  • Reste : c-3 (client air-ssh : -A/--forward-agent, accept+proxy vers l’agent ambiant, refus de tout canal auth-agent non sollicité) et c-4 (interop réel Air↔OpenSSH). PR #625.

2026-08-03 — ADR-150 : le manifeste demande, la machine octroie

L’ADR sécurité qu’ADR-046 D6 appelait sans le nommer existe. Il débloque partiellement AirSandboxManager.

Son périmètre a été réduit de moitié par la vérification préalable, et l’ADR le dit d’entrée : ADR-010 décidait déjà le bundle .airapp, les entitlements deny-by-default et leurs quatre familles, les trois niveaux de signature, et l’applicateur — air-launchd, couche 5, POLA strict. ADR-112 fournit le mécanisme de cage, ADR-089 l’oracle d’exécution. Ce qui manquait vraiment, c’est l’octroi.

D1 — l’octroi existe, distinct du manifeste. Deux artefacts, deux signataires, deux moments : le manifeste demande (écrit et signé par le développeur, dans le bundle), l’octroi accorde (écrit et signé par la machine, à l’installation, hors du bundle). Un octroi n’accorde jamais plus que ce que le manifeste demandait. C’est l’erreur d’Android avant sa version 6 qu’on écarte nommément : déclarer valait obtenir. Conséquence directe : une mise à jour étant vue comme une désinstallation suivie d’une installation, l’octroi est révoqué — un développeur ne peut pas élargir ses privilèges en silence.

D2 — manifeste et octroi binaires, schéma Cap’n Proto sous enveloppe AIRCFGv1, codec texte fourni aux développeurs. Amende ADR-010 sur ce seul point, le reste tenant inchangé. Deux raisons, et l’ADR écrit que la seconde suffirait seule : la cohérence avec le Principe 11, et le fait qu’un parseur TOML est un parseur généraliste qu’on ne met pas sur le chemin de décision de privilèges, sur une entrée fournie par un tiers.

D3 — l’ordre d’arbitrage, et une correction de ma part inscrite dans l’ADR. J’avais proposé « de la moins chère à la plus chère » ; c’est faux, la vérification de signature est la plus coûteuse des trois. L’ordre est dicté par la dépendance : la signature établit qui est ce binaire, l’octroi ce que cette identité a obtenu, l’enveloppe si cet accord tient encore. Évaluer un octroi avant d’avoir authentifié le binaire n’a pas de sens et se contourne — on remplace le binaire, on garde l’octroi. Cinq conséquences analysées, dont l’uniformité au chronomètre obtenue gratuitement (le coût dominant, la crypto, est payé dans tous les cas de refus — l’ordre inverse aurait divulgué laquelle des conditions a échoué), le coût assumé sur le chemin chaud, et le fait qu’une image rétrécissant l’enveloppe peut empêcher une application installée de se relancer.

D4 — le refus est muet vers l’appelant et tracé au journal, sinon le motif redevient un oracle d’énumération. D5 — la chaîne d’amorçage est hors arbitrage : PID 1 s’exécute avant que l’arbitre existe, sa confiance vient du démarrage vérifié et de dm-verity.

D6 lève une tension que personne n’avait relevée. ADR-089 pose un défaut permissif et ADR-046 D6 un défaut de refus. Ce ne sont pas les mêmes portes : le refus porte sur le lancement, le permissif sur ce qu’un processus déjà admis et déjà confiné tente de faire. Sans cette distinction écrite, deux ADR se seraient lus l’un contre l’autre.

Quatre ADR mis en conformité — en nettoyant, pas en empilant des barrés (politique éditoriale rappelée par le BDFL : git est la mémoire). ADR-010 perd ses Manifest.toml/Entitlements.toml et gagne le renvoi sur l’octroi ; ADR-089 voit son défaut permissif qualifié ; ADR-046 nomme enfin l’ADR qu’il appelait ; ADR-112 passe de « bloqué » à « partiellement débloqué ».

Cinq questions ouvertes, dont la première reste bloquante : la largeur des enveloppes par défaut, sans laquelle AirSandboxManager ne peut pas être écrit.

2026-08-03 — Keepalive serveur ClientAliveInterval/CountMax (ADR-118 inc.8) — dernier incrément serveur

  • Ce que ça règle : un pair mort (machine éteinte, capot fermé, NAT qui oublie) ne ferme pas sa connexion — il se tait. Ni FIN ni RST ne remontent, et le worker qui le servait reste bloqué jusqu’au redémarrage, avec son descripteur, ses écoutes -R et son crédit d’admission. Le keepalive sonde périodiquement, compte les sondes muettes, et ferme au seuil. Modèle OpenSSH : sonde = requête globale keepalive@openssh.com avec want_reply ; tout paquet reçu du client (sa réponse comme n’importe quel trafic) remet le compteur à zéro.
  • ⚠️ Correction d’un présupposé de l’ADR. §5 annonce « réutilise les timers d’air-async » : c’est faux là où le keepalive doit vivre. Il s’applique post-auth, donc dans le worker confiné, qui n’a aucun réacteur — pas un io_uring_* dans son profil seccomp, et run_to_completion poll une future une seule fois avec un 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 — jamais une reprise de contrôle, jamais un tour de boucle en plus. Réconcilier le texte de l’ADR demanderait un RFC (ADR-015) ; le code, le guide et cette entrée portent la correction en attendant.
  • Deux points d’application, parce qu’une connexion attend à deux endroits. (1) La couche connexion (serve_connection_with) : une connexion authentifiée qui n’ouvre aucun canal — un ssh -N, un -L dont personne n’a franchi l’entrée — y attendrait indéfiniment ; (2) le démultiplexeur (forward_demux_blocking), où la connexion passe le reste de sa vie et où le keepalive compte le plus. Couvrir un seul des deux aurait laissé la moitié des tunnels oubliés en place.
  • Le seam d’I/O gagne un verbe, et un seul : SshByteStream::wait_readable(timeout) — attendre sans lire, ce qu’un read ne sait pas faire. Implémenté par ppoll sur le flux bloquant des étages privsep (déjà dans leur profil seccomp) ; le défaut (Ok(true)) laisse tout autre flux se comporter exactement comme avant. Ok(false) ne dit pas « échéance atteinte » mais « rien appris » (délai, EINTR, réveil vide se confondent) : c’est l’horloge qui tranche, et elle seule — un signal ne fait donc jamais partir une sonde en avance.
  • La décision est une fonction pure, sortie des deux boucles (keepalive::KeepaliveTracker::step) : trois nombres et un instant en entrée, Wait/Probe/Disconnect en sortie, aucune I/O. Elle s’éprouve à la nanoseconde près sans monter une connexion. Écart assumé avec la lettre du brief : la fermeture intervient à l’échéance qui suit la count_max-ième sonde muette, et non dans la foulée de son émission — sans quoi la dernière sonde partirait sans qu’aucun intervalle ne lui soit laissé, et count_max = 1 dégénérerait en « aucune tolérance ». Délai de détection : interval × (count_max + 1), affiché par config get.
  • Base-only, sans surcharge Match, et c’en est une décision : le keepalive ne décide d’aucun accès — il dit au bout de combien de temps un pair qui ne répond plus cesse d’occuper un worker. Le refus est porté par le type (les deux réglages vivent hors de l’AccessControlPolicy, seule structure que les blocs surchargent) plutôt que par une règle qu’on aurait pu oublier.
  • Le piège capnp, deuxième round. L’intervalle n’en a pas (0 = désactivé = comportement d’avant). Le compteur, si : son zéro dirait « aucune sonde tolérée », c’est-à-dire fermer à la première échéance. Même parade qu’à l’inc.7 — une union absent/count (@20/@21) dont la branche zéro vaut 3 (le défaut OpenSSH). Et la combinaison incohérente (count_max 0 + intervalle actif) est refusée sur les trois surfaces : CLI, import JSON, chargement de l’artefact (SshdConfigError::BadKeepalive).
  • Monolithique : refus de démarrage, comme pour Match (inc.3) et ForceCommand (inc.5) et pour une raison propre : ce démon lit son flux par io_uring, qui n’offre aucune attente bornée — ses sondes ne partiraient jamais. Servir en silence un réglage qu’on ignore aurait été une promesse d’exploitation non tenue.
  • Non-régression par construction : interval = 0 (le défaut) n’injecte aucune échéance dans les deux ppoll, n’émet aucun octet, et laisse recv_packet sur son chemin d’avant.
  • Preuves : décision pure (échéance exacte, reset par activité, count_max sondes muettes, count_max = 1, réponse à la dernière sonde) ; sonde sur le fil et fermeture dans les deux boucles, avec témoin d’un pair qui répond ; arithmétique du ppoll (la plus proche des deux échéances, dans les deux sens) ; config (aller-retour binaire + JSON, rétro-compat artefact d’avant ⇒ 0/3, refus de cohérence et de bornes, CLI set/get) ; refus de démarrage du monolithique ; e2e root : un vrai ssh -N -L silencieux cinq secondes reste servi (il répond aux sondes), et le même client figé par SIGSTOP voit sa chaîne privsep terminée — avec le témoin keepalive éteint, qui ne la termine pas. Guide : docs/guides/air-sshd-blocs-match-fr.md §7. PR #623.## 2026-08-03 — Principe 11 : « Entrée stricte, sortie lisible »

Onzième principe d’ingénierie, énoncé par le BDFL. Il ne double pas ADR-073 — vérifié avant d’écrire : ADR-073 argumente sur qui peut modifier (le fichier en clair est éditable par quiconque tient le shell). Le Principe 11 porte sur ce qu’est l’analyse syntaxique, et il est en amont — il engendre ADR-073, ADR-150 D2, le mode JSON des commandes et le motif sans-IO fuzzé des protocoles.

L’argument. Un ordinateur ne se trompe jamais en comparant deux octets. Les ennuis commencent avec les chaînes, où comparer ne suffit plus : il faut vérifier qu’un octet est à la bonne position, que rien de parasite ne le précède, qu’un espace insécable collé depuis une page web ne change pas le sens. Un format textuel exige donc un parseur tolérant, et chaque tolérance est un endroit où deux lecteurs divergent. Là où deux lecteurs divergent, il y a une brèche. Position assumée du projet, écrite comme telle : le fichier de conf en texte clair est la plus mauvaise idée d’Unix, reproduite à l’identique sur Solaris, HP-UX, System V.

Une nuance ajoutée à la formulation, et elle protège le principe. « Binaire » ne suffit pas : un TLV maison dont les longueurs viennent de l’appelant est pire qu’un texte — mêmes ambiguïtés, sans la lisibilité pour les repérer, et les débordements les plus graves de l’histoire logicielle sont dans des parseurs binaires. La propriété vient de la disposition fixe et bornée, dont le binaire est la conséquence. Sans cette précision, quelqu’un aurait inventé son format et cru s’y conformer.

L’asymétrie est ce qui rend le principe applicable : strict en entrée, généreux en sortie. Les commandes produisent texte et JSON à la demande. Et quand un texte doit malgré tout entrer — manifeste rédigé par un développeur, import d’administration — il ne passe pas par la porte d’exécution : un outil dédié le compile, et le parseur est déplacé là où son échec n’est qu’un refus d’import, jamais une compromission.

Portée bornée explicitement : le principe ne dit pas « Air refuse le texte ». Le code, la documentation, les journaux et les sorties de commandes sont du texte. Il borne le parseur tolérant sur le chemin d’entrée d’une décision.

Note de cadrage jointenotes/entree-stricte-sortie-lisible-objections-fr.md — qui prend de front les huit objections, et qui en accepte trois plutôt que de prétendre les avoir dissoutes : la perte partielle des commentaires ; le vi à trois heures du matin, qui crée quatre obligations pour Air (outil dans l’image vérifiée, sans dépendance faillible, format publié, chemin de récupération testé) ; et le risque résiduel d’un outil cassé, atténué par l’image vérifiée, le format publié et la bascule A/B, mais non supprimé. Rappel ajouté dans CLAUDE.md et AGENTS.md.

2026-08-03 — Les « manques couche 0 » des specs : cinq comblés, un seul réel

Rédiger le Principe 10 a mis au jour une dette documentaire : les specs annonçaient encore comme bloquants des manques de couche 0 depuis longtemps comblés. Vérification faite sur les six cités, avec le résultat en face.

Manque annoncéÉtat réel au 2026-08-03
setgroups, setresgid, setresuid (« 4ᵉ, le plus prioritaire »)Combléset_groups, set_resgid, set_resuid, plus set_no_new_privs, sous les noms d’ADR-029 (zone 1) et non POSIX
sched_setaffinity/sched_getaffinity (« 5ᵉ »)Combléset_cpu_affinity/get_cpu_affinity + CpuSet
futex synchrone (« 6ᵉ »)Combléfutex_wait/futex_wake dans src/futex.rs
fs::inotifyComblé
MmapRegionComblé
epoll (« 3ᵉ »)TOUJOURS RÉEL — aucune famille couche 0 ; seuls submit_epoll_ctl côté io_uring et un epoll_create1 test-only

La spec air-process allait plus loin : elle annonçait le manque « à combler AVANT drop_privileges » — or drop_privileges est écrit, et il consomme précisément ces appels, dans le bon ordre, en y ajoutant la pose d’une cage AirSandbox.

Le texte n’a pas été supprimé, il a été requalifié. Sa justification — pourquoi un setuid seul est un piège, le saved-set laissant regagner ce qu’on croyait avoir abandonné — reste la doctrine du projet, et elle est désormais portée par le Principe 10. Effacer la section aurait perdu le raisonnement en même temps que la dette.

Une correction de ma part au passage. J’avais d’abord affirmé que les trois manques cités en comparaison étaient tous comblés. C’était faux pour epoll : mon premier grep était trop lâche et avait pris submit_epoll_ctl pour un wrapper de famille. Corrigé dans les trois specs concernées.

2026-08-03 — Le Principe 10 existe enfin

Le fantôme relevé le matin est comblé : « Cloisonnement et séparation de privilèges » est rédigé, en français et en anglais, et les principes d’ingénierie en comptent désormais dix.

Il a été rédigé, pas inventé. Chaque affirmation renvoie à une décision déjà prise ou à du code déjà écrit — c’est ce qui rendait le fantôme gênant : la doctrine existait partout sauf à l’endroit où on la cherche. Le principe pose que tout composant s’exécute avec le strict minimum et sous confinement y compris les composants d’Air (amendement D6 d’ADR-046) ; qu’une réduction de privilèges est correcte ou ne compte passet_groups, puis set_resgid, puis set_resuid, un setuid seul laissant le saved-set intact donc la possibilité de regagner ce qu’on croyait avoir abandonné ; que la séparation de privilèges est une décision d’architecture où le code qui analyse des données hostiles ne détient rien (privsep d’ADR-124/147) ; que le confinement est monotone, d’où la cage posée avant le premier octet lu et PID 1 comme seule exception reconnue ; et que les profils sont fail-closed, un échec de pose étant un refus d’exécuter (ADR-112).

Vérifié avant d’écrire : set_groups, set_resgid, set_resuid et set_no_new_privs existent bien en couche 0 — la spec air-process les annonçait encore comme un « manque sécurité-critique », c’est périmé.

Le principe se clôt sur ce qui le distingue du Principe 1 : Air vise le zéro-bug sur ses fondations, mais un système qui ne tient qu’à condition d’être sans faute n’est pas un système sûr. Le cloisonnement est ce qui fait qu’une erreur reste une erreur, au lieu de devenir une compromission.

Les deux annotations « cité mais non rédigé » posées le matin sont retirées : les citations pointent maintenant vers un principe qui existe.

2026-08-03 — Quatre documents cessent d’affirmer du faux

Suite directe de l’audit : la traque a produit une liste d’incohérences, et celles qui trompent activement ont été traitées en premier. Un document qui affirme du faux sans le dire est plus dangereux qu’une dette signalée.

CLAUDE.md annonçait un gate qui n’existe pas. La ligne « tout bloc unsafe exige un commentaire // SAFETY: (vérifié en CI par check-safety-comments) » donnait une assurance fausse : cet outil n’a jamais existé — seul xtask/src/check_syscalls.rs lit ces commentaires, pour en extraire des numéros de syscalls. La ligne dit désormais que la règle repose sur la revue et reste à outiller. C’est la convention la plus ancienne du projet, et elle se présentait comme mécanisée depuis le début.

CLAUDE.md et AGENTS.md n’énonçaient pas la règle de l’unsafe. Ils demandaient seulement un commentaire de justification — ce qui se lit comme « l’unsafe est permis partout, à condition de le justifier », l’inverse de l’étoile polaire. Les deux fichiers portent maintenant la règle (couche 0 et nulle part ailleurs ; pas d’assembleur, pas de C, pas d’unsafe au-dessus ; ce qui subsiste est une dette, pas une permission), plus la convention // SECOND-PATH: d’ADR-149 D10.

Le « Principe 10 » était un fantôme. Cité neuf fois — huit dans la macro-architecture, une dans la spec air-process — alors que les principes d’ingénierie n’en comptent que neuf. Le contenu existe en pratique (utilisateurs dédiés, NoNewPrivileges, ProtectSystem=strict, seccomp calculé, drop_privileges), mais rien n’a jamais été rédigé. Le document des principes étant immuable, rédiger un dixième principe est un acte doctrinal qui ne se délègue pas : la note ajoutée supprime le fantôme en disant ce qui est, et laisse le choix ouvert — rédiger le principe, ou requalifier les citations. Les deux premières citations pointent désormais vers cette note.

La macro-architecture contredisait ADR-149 sur deux points. Son §1770 titrait « Le saut de couches : autorisé exceptionnellement » — caduc, D1 supprimant l’exception plutôt que de la justifier. Les trois exemples qu’il donnait (air-power et air-firewalld tapant air-sys-syscall, air-bluetoothd sur libudev) sont conservés barrés, requalifiés en dette à résorber et soumis au marquage // SECOND-PATH: : un service de couche 5 qui a besoin de netlink révèle qu’il manque un manager à l’étage intermédiaire — ce que la section disait déjà en creux. Son §1781 interdisait le passage transverse intra-couche, ce qui contredisait le gate check-layers (qui autorise M ∈ {N-1, N}) et le code (air-process consomme air-thread). Les deux avaient raison à des échelles différentes, et D7 les réconcilie : autorisé entre contrôleurs, interdit entre services de couche 5.

2026-08-03 — ADR-149 : l’architecture de code est enfin écrite (contrôleurs, managers, contrat d’API)

D9 ajouté le même jour — le contrat inter-couche. D4 dit ce qu’une couche expose ; D9 dit ce que la couche du dessus a le droit de voir : depuis la couche N, on ne voit que les managers et les types publics de N-1. Jamais un contrôleur, jamais une fonction libre.

L’illustration qui l’a rendu nécessaire : un arbre de dépendances intra-couche, construit pour l’occasion. Au sein d’une couche, les crates ne sont pas au même niveau — elles se consomment entre elles, et c’est légitime (D7). Mais la hauteur dans cette hiérarchie ne fait pas d’une crate un manager : air-runtime est la crate la plus haute de la couche 1 (rang 6, elle agrège air-process, air-thread, air-memory, air-env, air-alloc) et ne porte aucun manager, tandis qu’air-memory et air-system, au rang 1, en portent un. Rang et statut de manager sont orthogonaux : l’un mesure la composition, l’autre l’exposition. La couche 2, elle, compte 45 crates et zéro manager.

Mesure de l’écart : 32 crates de couche ≥ 2 dépendent d’une crate de couche 1 sans manager. Trois cas résument tout. air-runtime (13 dépendants) est le plus grave — ses 31 fonctions publiques sont toutes de vraies capacités, et la crate a dérivé en fourre-tout (constat BDFL) ; sa remise en cohérence est un chantier à part. air-thread (6 dépendants) est le même cas en plus petit. air-base-core (26 dépendants) nuance la règle et la justifie : l’essentiel de sa surface est faite de méthodes de types légitimes, mais de vraies opérations s’y sont glissées — now, sleep, sleep_until, log, open, machine_id.

Deux décisions BDFL et une correction, le même jour. Le gate sera consultatif d’abord, bloquant une fois la mise en conformité faite — avec 32 crates en écart, bloquer d’emblée arrêterait le projet ; consultatif, il mesure la trajectoire et empêche l’aggravation.

Et ma qualification d’air-runtime était fausse, la vérification l’a montré. J’avais écrit « 13 crates de couche 2 en libre-service » : ces 13 consommateurs sont tous des air-libc-*, sans exception. Ce n’est pas une fuite, c’est le runtime consommé par ses clients naturels — une libc a besoin de l’emplacement d’errno, du TLS, du spawn de thread, des destructeurs thread-locaux. Rappel BDFL de ce qu’est vraiment cette crate : le runtime userland qui complète un exécutable Air bâti sur notre std Rust, de sorte qu’aucun fragment de runtime C ou glibc ne subsiste dans le binaire final — un remplaçant de crt0.

L’écart réel est ailleurs et il est de forme : la crate porte bien une façade (pub struct AirRuntime), mais elle ne couvre que errno, set_errno, errno_location, current_tid, les ~28 autres opérations restant libres — dont relocate_self et protect_relro, qu’un double appel ou un appel hors séquence suffit à transformer en corruption de processus.

Et la contrainte qui en découle est sérieuse : le manifeste dit que les quatre fichiers TARGET-ONLY (TCB, TLS, spawn, bootstrap) ne sont sound que sur *-linux-air, sont exclus de la couverture, et ne sont prouvés que par le selftest on-target rt/. La partie qui aurait le plus besoin d’être restructurée est exactement celle qui ne peut pas être validée sur l’hôte. air-runtime devient donc un chantier séquencé à part, adossé au selftest on-target — et une quatrième question ouverte apparaît : faut-il un role propre au substrat d’exécution, dont le gate vérifierait que la liste des consommateurs est close plutôt que la présence d’un manager ?

D10 — les seconds chemins sont marqués, pas découverts. Une règle d’architecture connaît des dérogations légitimes ; le danger n’est pas la dérogation, c’est qu’elle devienne indiscernable d’un oubli. Convention adoptée : tout accès délibérément parallèle à la voie normale porte, au point d’usage, un commentaire // SECOND-PATH: disant quelle est la voie normale, pourquoi elle n’est pas empruntée, et à quelle condition la dérogation tomberait. La forme reprend celle de // SAFETY:, déjà éprouvée — et sa vertu est l’inventaire : grep -rn "SECOND-PATH" rend en une commande la liste des dérogations vivantes.

À outiller vraiment, et le constat qui l’impose : le check-safety-comments que CLAUDE.md annonce comme vérifié en CI n’existe pas — seul xtask/src/check_syscalls.rs lit ces commentaires, pour en extraire des numéros de syscalls. Une convention non outillée n’est pas tenue ; c’est le constat qui a produit cet ADR, il ne faut pas le répéter sur la suivante.

Premier cas consigné, dans le code : crates/air-alloc/src/backing.rs, sur MmapBacking — l’allocateur obtient ses pages en appelant air-sys-syscall directement, sans passer par AirMemoryManager qui médie pourtant mmap. Pas une infraction de couche, mais bien un second chemin vers le noyau dans le domaine mémoire, et sa raison est l’amorçage : l’allocateur sert avant qu’air-memory ne soit utilisable, et l’en faire dépendre créerait un cycle.

air-alloc et air-memory, tranchés. air-alloc porte role = "runtime" — son GlobalAllocator est appelé par le langage lui-même, à chaque Box::new. Mais à une condition : la crate a deux visages, et son API d’arène (Arena, MmapBacking, RawRegion) est une API choisie, pas un substrat. La laisser sous le rôle reviendrait à blanchir une API choisie en substrat — elle doit devenir interne, ce qui est encore gratuit aujourd’hui.

Et l’hypothèse d’un « AirMemoryControleur » est écartée : AirMemoryManager n’est pas rétrogradé. air-memory est bien le point d’entrée du domaine mémoire pour les bindings — la libc y fait passer mmap/mprotect/munmap —, et le rétrograder laisserait ce domaine sans manager. Son problème n’est pas d’être trop haut placé mais d’être trop maigre : trois méthodes, raw_mapping resté dehors.

Un quatrième role : runtime. Le substrat d’exécution n’est ni un manager de domaine, ni du vocabulaire, ni interne — lui imposer un AirRuntimeManager serait du culte du cargo, on n’« appelle » pas un crt0. Le rôle est défini comme le runtime userland — entrée ELF, TCB, TLS, relocation static-PIE, amorçage, allocateur — et jamais le runtime asynchrone.

La désambiguïsation est écrite plutôt que sous-entendue, parce que le corpus penche à l’inverse : sur les emplois qualifiés, « runtime async » sort 36 fois contre 7 pour « runtime userland ». C’est exactement cette confusion qui avait laissé la spec air-runtime décrire un moteur async pendant que le moteur partait dans air-uring/air-async.

Le nom est retenu quand même, pour une raison qui vaut mieux qu’une note documentaire : le gate rejette le mauvais prétendant. Un développeur pourrait écrire role = "runtime" dans le manifeste d’air-async de bonne foi — mais ses consommateurs sont choisis, pas structurellement déterminés, donc le gate refuse. Au passage, ma première formulation de la règle (« liste de consommateurs close ») était fausse : air-std-entry est lié par tout exécutable Air, donc un ensemble ouvert. La bonne formulation est structurellement déterminés, non choisis à la carte.

Trio candidat, cohérent avec ce qu’est un runtime C : air-std-entry (crt1.o), air-runtime (crt0), air-alloc (malloc). Et le rôle dispense de manager, pas de discipline : relocate_self et protect_relro doivent rester portées par un objet qui garantit leur ordre.

La déclaration role, et pourquoi elle ne peut pas être déduite. L’exemption « vocabulaire » de D1 est indispensable — sans elle, dépendre d’air-base-core pour AirError serait une infraction. Mais elle ne doit jamais être inférée par heuristique : toute crate finit par ressembler assez à du vocabulaire pour passer. Constat empirique, et il est de moi : le classificateur automatique écrit pour cet audit rangeait air-uring — 43 fonctions publiques — parmi les crates de vocabulaire. D’où une déclaration de manifeste, sur le mécanisme déjà éprouvé par check-layers : [package.metadata.air] role valant manager, vocabulaire ou interne. Cela répond à la question ouverte n° 2 de l’ADR ; reste à écrire le gate.

Nouvel ADR fondateur, énoncé par le BDFL. Il n’invente rien : il rend opposable une règle donnée à l’origine — les fonctions d’aide d’une couche sont des méthodes d’un contrôleur — qui n’avait jamais été rédigée. Le mot « contrôleur » n’existait dans le corpus qu’au sens MVC d’ADR-002/009 et au sens « pilote d’I/O » d’ADR-091. C’est ce trou qui explique la dérive relevée le matin même : 21 opérations de terminal sans contrôleur, un module engine public, toute la séparation de privilèges en fonctions libres.

D1 — la règle de couche porte sur les opérations, pas sur les types. C’est la reformulation qui supprime l’entorse au lieu de la justifier : air-sys-types traverse toutes les couches non pas malgré la règle mais parce qu’il est hors de son champ — il ne porte aucune capacité. Un type imposé par l’ABI du kernel est public pour le kernel ; le dupliquer en couche 1 sous un autre nom n’apporterait aucune sûreté et coûterait une conversion à chaque frontière. Vérifié : la surface publique d’air-sys-types n’est faite que de constructeurs, accesseurs et conversions. Réserve nommée et bornée — le Drop d’OwnedFd ferme le descripteur par un asm! en ligne ; c’est de la possession, pas un service, et le close explicite vit bien dans air-sys-syscall. D’où la règle : une crate de vocabulaire peut porter les opérations qu’exige la possession de ses types — ses destructeurs — et rien d’autre.

Le point remarquable : c’est la même règle que la doctrine des managers, à une autre échelle. AirNetworkManager rend AirSocket (un type) et ne laisse pas fuir engine (des opérations). Une seule règle, juste aux deux bouts.

Le reste des décisions. D2 rappelle unsafe en couche 0 et nulle part ailleurs. D3 définit le contrôleur comme un jeu d’invariants énonçables et testables ensemble — le but n’est pas de découper pour découper, mais de faire coïncider périmètre de responsabilité et périmètre de test. D4 pose trois rangs et tranche que le contrat d’API d’Air, ce sont les managers et les types publics ; le reste est interne et Air se réserve d’en changer la structure. D5 légitime les fonctions en forme de C comme coquilles de traduction dont le corps n’appelle que le manager, avec le nommage d’ADR-029 : openpty()AirTerminalManager::open_pseudo_teletype_writer(). D6 : une crate porte au moins un contrôleur, jamais zéro. D7 tranche les dépendances intra-couche — autorisées entre contrôleurs, interdites entre services de couche 5, ce qui réconcilie le gate (M ∈ {N-1, N}) et la macro-architecture §1781, qui visaient deux échelles différentes. D8 fixe l’ordre : jalons → managers manquants → leurs specs déterminent les contrôleurs → alors seulement la reprise de l’existant.

Trois trous documentaires relevés au passage, consignés dans les conséquences de l’ADR : CLAUDE.md et AGENTS.md n’énoncent pas la règle de l’unsafe (ils demandent seulement un commentaire // SAFETY:, ce qui se lit comme une autorisation générale) ; le « Principe 10 » est invoqué quatre fois alors que les principes d’ingénierie n’en comptent que neuf — il n’a jamais été rédigé ; et la macro-architecture §1770/§1781 est à reformuler selon D1 et D7.

Une infraction supplémentaire trouvée par l’ADR lui-même : open_pty_master, pty_unlock, pty_peer, pty_number ne sont pas des noms POSIX — POSIX dit posix_openpt, grantpt, unlockpt, ptsname. Ce sont des inventions d’Air qui abrègent, donc zone 1 d’ADR-029, donc en infraction indépendamment du contrôleur manquant.

Non outillé, et c’est le point faible assumé. Aucun gate ne vérifie D3, D4 ni D6. La dérive vient précisément d’une règle non outillée ; la mécaniser est une question ouverte de l’ADR.

2026-08-03 — Le critère de conformité des Managers était trop faible : corrigé

Rappel de doctrine du BDFL, qui tranche la question laissée ouverte le matin même. Les Managers de couche 1 sont des contrôleurs de très haut niveau : ils reçoivent tous les appels des bindings — C, C++, Swift, et ce qui viendra — et ils cachent l’implémentation interne. AirNetworkManager a le droit d’exposer AirSocket ; il n’a pas le droit de laisser voir air_socket::engine, ni de laisser appeler create/bind/listen en direct. Ce doit être AirNetworkManager::create_socket(), ::bind_socket(), ::listen_socket(). Et une fonction en forme de C — openpty()a le droit d’exister, à une condition : que son corps appelle AirTerminalManager::open_pty() et rien d’autre.

Mon critère mesurait au mauvais endroit. Il comptait ce que les toits appellent ; il faut compter ce que la couche 1 expose. Une route qu’aucun toit n’emprunte aujourd’hui reste une fuite d’implémentation : elle existe, et le prochain binding la trouvera. Critère corrigé dans l’amendement d’ADR-077un domaine est conforme si aucune opération n’est publique hors de son Manager ; les types peuvent l’être, c’est même la monnaie du contrôleur.

Le relevé refait donne un tout autre classement.

  • air-filesystem est la référence et l’était déjà : AirFileManager porte 45 méthodes, et la crate n’expose que trois fonctions libres — coalesce, map_event_kind, segment_matches — qui sont des helpers purs, pas des opérations.
  • air-terminal : aucun contrôleur, 21 opérations publiques. AirTerminalManager::open_pty() n’existe pas puisque AirTerminalManager n’existe pas ; le chemin réel est openpty()open_pty_master()air_sys_syscall::terminal::open_pty_master().
  • air-socket : le module engine est public avec 19 opérations, le Manager est réduit à 4 utilitaires (codec sockaddr, deux options).
  • air-process : 14 opérations hors contrôleur, et c’est le plus grave — pas par le nombre, mais parce que c’est toute la séparation de privilèges (Principe 10) : drop_privileges, drop_to_user, set_uids, set_gids, reduce_capabilities_to… Un binding qui les appelle en direct contourne par construction tout arbitrage de sécurité ultérieur.
  • air-signal : 12 opérations hors contrôleur, Manager à 2 méthodes.
  • air-keystore : 25 fonctions libres, mais aucune n’atteint la couche 0 — elles passent toutes par AirFileManager. Ce n’est pas une route parallèle, c’est une façade manquante pour les bindings. Requalifié de « forte » à « moyenne, et d’une autre nature ».

Tranché : les Managers absorbent leur domaine. Le §1 n’est pas amendé à la baisse ; c’est le code qui s’aligne. Trois réserves : AirSandboxManager reste bloqué par l’ADR sécurité, le re-scellement de couche 1 impose un seul cycle de descellement plutôt qu’une série, et les objets déjà bien formés (AirSocket, AirDevice, AirTerminal) ne sont pas en cause — ce sont des types, pas des opérations.

2026-08-03 — Conformité des specs au code livré : quatre écarts consignés

Suite de l’audit des Managers, élargi aux 103 specs. Deux méthodes mécaniques, dont la première s’est révélée être surtout du bruit — c’est dit franchement plus bas.

Deux specs sont périmées dans leur sujet, pas dans leur détail.

  • air-runtime (v1.0, « décisions validées BDFL 2026-06-24 ») décrit un runtime asynchrone. La crate de ce nom ne l’est plus : ADR-052 en a fait le runtime userland (bloc de contrôle de thread, stockage local, relocation, amorçage) — crates/air-runtime/src/ ne contient aucun event loop —, et ADR-092 a scindé l’asynchrone en air-uring (réacteur, couche 1) + air-async (exécuteur, couche 2). Les six sections de la spec décrivent donc ce couple-là, sous des noms qui ne sont plus ceux du code — AirIoBuf et AirTimer n’existent nulle part. La spec garde sa valeur de trace de conception du modèle « futures sur complétions » ; elle ne fait plus autorité sur la surface livrée. À trancher : la scinder en deux, ou la marquer historique et en écrire deux neuves.
  • air-event (v1.0) repose sur « le moteur est air-runtime ». Prémisse morte, pour la même raison. Une façade C-ABI de l’event loop reste souhaitable et non écrite, mais elle s’adosserait à air-async — donc une autre couche et d’autres types. C’est le reliquat de conception d’ADR-092.

Le motif air-keystore se répète : l’écart est de forme, presque jamais de fonction. Les specs annoncent un objet unique, le code livre une fonction plus une paire de typesAirChannelair_channel() + AirSender/AirReceiver ; AirPipeair_pipe() + AirPipeReader/AirPipeWriter ; AirThreadAirThreadBuilder/AirThreadHandle. Les primitives existent et sont testées. Le choix — aligner le code ou assumer la forme fonctionnelle — n’est pas tranché, et c’est la même question que pour air-keystore et les Managers.

air-ssh : la pile a été livrée sous trois noms. Aucune crate air-ssh n’existe ; air-ssh-proto (cœur sans-IO du transport), air-sshd (démon) et air-sshd-schema (configuration binaire) se partagent le périmètre. La spec reste la carte du protocole, pas un plan de découpe. Le client proprement dit n’est pas écrit.

Correction à la roadmap : la phase 2 part de moins loin qu’annoncé. La note du chemin disait « rien » pour la configuration d’interface. C’est vrai du code, pas de la conception : ADR-079 tranche le transport netlink générique et la spec air-netlink 0.1 le décrit — framing, codec TLV, corrélation, motif sans-IO. Il reste à écrire la crate, pas à concevoir le sous-système.

Ce que la méthode a produit de faux, et qu’il faut dire. Un premier balayage a signalé « 19 specs orphelines sans crate ». C’était surtout un artefact : les noms de fichiers de spec ne sont pas des noms de crates (air-base-lib-core documente air-base-lib), et la plupart des autres sont des travaux déclarés à venir, adossés à des ADR a-faire (air-tls, air-http, air-quic, air-wireguard, air-mail, air-url…). De même, quatre types « manquants » d’air-crypto figuraient dans un tableau d’additifs planifiés. Sur onze types signalés absents de tout l’arbre, quatre seulement étaient de vrais écarts.

2026-08-03 — MaxSessions serveur : le compte tunnel-only (ADR-118 inc.7)

  • Périmètre 0|1, assumé et documenté. 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 (connection/mod.rs, « only one session channel ») et le démultiplexeur du worker (forward_demux_blocking.rs), 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 de cet incrément. MaxSessions y vaut donc 1 (le défaut, comportement inchangé) ou 0 ; > 1 est refusé au chargement (le démon ne démarre pas), au config set et à l’import JSON. Servir un 10 comme 1 en silence aurait menti à l’exploitant, et Air n’importe aucune config texte d’OpenSSH qui l’y aurait obligé.
  • La valeur qui fait l’incrément est 0 : le compte tunnel-only. Il ne ferme pas le compte, il lui retire le shell — aucun canal de session (shell/exec/subsystem), mais tout le forwarding (-L/-R/-N) intact. C’est le compte de rebond, et il se pose par bloc Match (Match user=backup max-sessions=0) sans toucher ni aux clés ni au reste du serveur. Prouvé des deux côtés en e2e root : ssh compte@hôte 'cmd' refusé, ssh -N -L … du même compte servi de bout en bout par le monitor.
  • LE piège de l’incrément : le zéro capnp. Tous les autres champs additifs de ce schéma reposent sur « le zéro du type = le comportement d’avant ». Ici c’est faux0 veut dire « aucune session ». Un maxSessions :UInt32 nu aurait fait relire 0 à tout artefact écrit avant l’incrément, c’est-à-dire couper le shell de tous les comptes à la mise à jour, sans qu’aucune configuration ait changé. Parade : 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 arithmétique (stocker 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 côté JSON : clé absente ou null1, jamais 0. Test dédié, et il vaut tous les autres.
  • Deux points d’enforcement, un seul message. La valeur effective (base surchargée par Match, calculée à l’ALLOW comme ForceCommand) voyage par AuthorizeGrantGrantedAccessWorkerParamsConnectionParams et DemuxParams. Fermer un seul des deux aurait laissé à tout compte tunnel-only un chemin vers le shell : ouvrir d’abord un -L, puis demander une session. Le refus est mot pour mot celui d’une session surnuméraire : le client ne distingue pas « zéro » de « une seule » — pas de nouvel oracle de politique (ADR-105).
  • Les deux modes de service l’appliquent. Contrairement à ForceCommand (inc.5) et aux blocs Match (inc.3), qui font refuser le démarrage en monolithique faute de monitor, refuser un canal de session ne demande aucun privilège : c’est la couche connexion, que privsep et monolithique partagent, qui l’oppose. Un serveur monolithique max-sessions 0 est donc bien tunnel-only. (La surcharge Match, elle, hérite de la contrainte d’inc.3.)
  • Surfaces d’écriture : config set <path> max-sessions 0|1 (valeur obligatoire — le champ vaut toujours quelque chose ; 0 s’annonce), config match set|unset <path> <i> max-sessions (deux états seulement : le champ de base n’étant pas optionnel, il n’y a rien à « effacer »). config get et config match list l’affichent en toutes lettres — un 0 sec se lirait « pas de limite », son exact contraire.
  • Preuves : table de vérité d’effective_policy (base 0/1, pose/hérite, dernier-gagne, bloc qui ne matche pas) ; aller-retour binaire des deux valeurs et des trois états de surcharge ; refus de > 1 à l’artefact (base et bloc), à la CLI et au JSON ; rétro-compat (artefact d’avant inc.7 ⇒ 1) ; JSON muet/null1 ; enforcement 1 (premier canal refusé, -L intact) et enforcement 2 (session refusée dans le démux après un -L) ; e2e root : shell refusé, tunnel servi, et le témoin d’un bloc visant quelqu’un d’autre. Guide : docs/guides/air-sshd-blocs-match-fr.md §6. PR #620.

2026-08-03 — Bannière pré-auth USERAUTH_BANNER (ADR-118 inc.6)

  • Le texte vit dans la config binaire, pas dans un fichier. OpenSSH nomme ici un chemin (Banner /etc/issue) que le démon relit à chaque connexion ; Air scelle le contenu dans l’artefact (banner @16 :Data, doctrine ADR-073/040). Deux raisons, et la seconde décide : le texte devient une donnée de configuration versionnée et checksummée avec le reste ; et l’étage qui l’émet — l’enfant pré-auth — est sous cage Landlock deny-all (ADR-124 §D7), il ne peut ouvrir aucun fichier. Un chemin n’aurait rien donné.
  • Le frame était déjà là. message_numbers::USERAUTH_BANNER (53) et air_ssh_proto::userauth_banner() existaient depuis U.1 ; cet incrément les câble et complète leurs preuves (octets exacts sur le fil, message vide, 64 Kio, octets non-UTF-8, étiquette de langue vide et non vide). Constructeur alimenté par de la configuration de confiance, donc non fuzzé — aligné sur userauth_failure/success/pk_ok.
  • Aucune surcharge Match, et c’est la décision de l’incrément. La bannière est pré-auth : la conditionner à users/groups obligerait à résoudre un compte avant de l’avoir authentifié, et sa présence renseignerait un attaquant sur l’existence de ce compte — un oracle d’énumération (ADR-105), soit précisément la fuite que serve_userauth ferme partout ailleurs. Le refus est porté par le type et non par une règle : le champ vit sur SshdSettings, hors du bloc AccessControlPolicy que les MatchRule surchargent, et un test garde cette séparation par la structure. (Une bannière conditionnée à la seule source — que le listener observe sans rien résoudre — serait envisageable par RFC ; hors périmètre.)
  • É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, et aucune I/O compte-dépendante n’est engagée. Étiquette de langue vide (comme OpenSSH) : le serveur ne connaît ni la locale du client ni celle de la bannière.
  • Les deux modes de service, sans réserve. Contrairement aux blocs Match (inc.3) et à ForceCommand (inc.5) — qui font refuser le démarrage en monolithique faute de monitor pour les appliquer —, la bannière ne réclame aucun étage : elle est émise par serve_userauth, que privsep et monolithique partagent. Elle précède aussi l’ouverture de tout canal : un ssh -N/-L/-R la reçoit comme une session interactive. En privsep, elle voyage par héritage mémoire au fork (PrivsepContext::with_banner → enfant), jamais par les ConnectionParams de la transition, qui sont post-auth.
  • Surfaces d’écriture : config set <path> banner '<texte>' (pose, un seul argument — une bannière est multi-lignes) / sans valeur (retire, muet : retirer un texte ne relâche aucun contrôle, contrairement à l’effacement d’une force-command) ; JSON "banner" chaîne ou null, champ 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 la bannière. C’est la seule donnée de cette configuration écrite pour être affichée : porteuse de séquences ANSI, elle pourrait réécrire les lignes que config get vient d’imprimer — donc mentir à l’administrateur sur sa propre configuration, dans la commande même qui sert à la vérifier. Les caractères de contrôle sont neutralisés, les accents restent lisibles (repli octet par octet si la bannière n’est pas de l’UTF-8 valide).
  • Preuves : frame (octets exacts, cas limites) dans air-ssh-proto ; aller-retour binaire présent/absent/vide/non-UTF-8 et rétro-compat (artefact d’avant inc.6 ⇒ None) ; aller-retour JSON + CLI set/clear/get et les refus ; échappement d’une bannière hostile (ANSI, \r, octets non-UTF-8) ; e2e transport (newkeys) : la bannière arrive avant le USERAUTH_SUCCESS, octets exacts et langue vide — et aucun message 53 quand rien n’est configuré ; e2e root (privsep_session_root) : un vrai ssh(1) affiche la bannière à travers la chaîne privsep, avec le témoin sans bannière. Guide : docs/guides/air-sshd-blocs-match-fr.md §5. PR #619.

2026-08-03 — ForceCommand serveur : base + surcharge Match (ADR-118 inc.5)

  • Aucune mécanique d’exécution neuve. Le serveur appliquait déjà une commande forcée par clé (command="…", option critique force-command d’un certificat — ADR-128 §2), substituée par le monitor au lancement de la session (ADR-147 inc.1). Cet incrément ajoute une source de plus — la configuration — et la fusionne dans la contrainte qui atteignait déjà le session-spawner : AuthorizeConstraint::with_server_forced_command, appliquée sur les deux voies privsep (publickey et certificat, raccourci « politique non restrictive » compris — l’oublier là aurait rendu un shell libre à toute connexion par certificat d’un serveur qui impose une commande).
  • Précédence : la configuration prime sur la clé (parité OpenSSH — ForceCommand de sshd_config l’emporte sur l’option de la clé). L’administrateur de la machine décide de ce qui y tourne ; une clé, qu’un utilisateur ajoute à son magasin, ne lui échappe pas. La précédence ne rend jamais un shell libre : les deux valeurs en lice sont des commandes forcées. La demande du client part en SSH_ORIGINAL_COMMAND (jamais la commande de clé supplantée), et — règle inchangée — une commande forcée tourne sur socketpair, pty-req ou non.
  • La surcharge Match a TROIS états, là où les six autres en ont deux, parce que le champ de base est lui-même optionnel : None hérite, Some(Some(cmd)) pose, Some(None) efface. Encodé Option<Option<Vec<u8>>> en Rust et union à trois branches au schéma (forceCommand { inherit | replace | clear }) : l’état ambigu — « la commande vide », qui aurait confondu « efface » et « faute de frappe » — est inexprimable, pas seulement déconseillé. Un replace d’octets vides est refusé au décodage, jamais deviné dans le sens qui relâche.
  • L’effacement est un relâchement, et il est traité comme tel — même discipline que le replace-vide d’inc.4 : set ne peut pas le produire (la commande vide est refusée), config match open … force-command le nomme, {"clear": true} l’écrit en JSON, et toute lecture (config get, config match list, chargement par le démon) l’affiche sur stderr — mais seulement quand la base impose effectivement une commande, un avertissement sans objet apprenant à ne plus lire les avertissements.
  • Deux décisions de sécurité, écrites plutôt que subies. ① La ForceCommand du serveur ne satisfait pas permit-root-login forced-commands-only : la faire compter aurait rendu fail-open un geste anodin — poser un ForceCommand global rouvrirait le login root d’un serveur qui l’avait fermé. Le sens inverse échoue bruyamment et se corrige d’un permit-root-login yes. ② ForceCommand + privilege-separation off ⇒ le démon refuse de démarrer, comme les blocs Match (inc.3) : le chemin monolithique n’applique aucune commande forcée, pas même celle d’une clé ; démarrer en ignorant la sienne servirait des shells libres là où l’administrateur a écrit une commande.
  • Surfaces d’écriture : config set <path> force-command '<cmd>' (pose, un seul argument — la commande est une suite d’octets, ni découpée ni requotée) / sans valeur (retire, avec confirmation à l’écran) ; config match set|unset|open <path> <i> force-command pour les trois états du bloc ; JSON "<cmd>" / null / {"clear": true}, aller-retour fidèle (ADR-032), champ toujours émis.
  • Preuves : table de vérité complète de effective_policy (base seule, pose, efface, hérite, bloc qui ne matche pas, dernier-gagne dans les deux sens) ; aller-retour binaire des trois états, octets non-UTF-8 compris, et refus du replace vide ; précédence prouvée sur la voie certificat en process et sur la voie clé nue en root (authorize_root, dont « un effacement ne lève que la commande du serveur, jamais celle de la clé ») ; e2e root avec un vrai ssh (privsep_session_root) : un Match force-command supplante l’exec du client sous -tt et tourne sans TTY, SSH_ORIGINAL_COMMAND porte la demande du client, la config bat le command="…" de la clé, et un bloc qui vise un autre compte ne fuit pas. Guide : docs/guides/air-sshd-blocs-match-fr.md §4. PR #618.

2026-08-03 — Blocs Match éditables en CLI + garde-fou du replace-vide (ADR-118 inc.4)

  • Aucune sémantique de politique ne change. L’évaluateur d’inc.3 n’est pas touché, le schéma non plus (pas de bump de version, pas de descellement). Cet incrément est du pur outillage et de la visibilité : rendre les blocs Match éditables autrement qu’en écrivant du JSON à la main, et fermer le seul piège d’opérateur que la structure d’inc.3 laissait ouvert.
  • Six verbes, façonnés comme config get|set|export|import : air-sshd config match list|add|set|unset|open|remove <path> …. list affiche les blocs avec leur index (l’index est la priorité) ; add ajoute en fin de liste — pas d’insertion par heuristique de spécificité, qui déciderait à la place de l’administrateur ce qui l’emporte — et refuse un bloc sans condition ni surcharge (no-op silencieux) ; set pose une surcharge, unset la retire (le champ hérite de nouveau), remove supprime et dit qu’il réindexe. Les validations sont partagées avec l’import JSON (parse_source_cidr factorisé) : les deux chemins d’écriture refusent la même chose, avec les mêmes mots — CIDR nié, nom d’hôte, bits hôte débordants, prohibit-password.
  • Le cœur de l’incrément : le replace-vide. Sur un bloc Match, une surcharge remplace la base, et — parité OpenSSH — une liste d’autorisation vide ne filtre personne. Donc Match user=alice + allow_users = [] ne restreint pas à alice : il retire la restriction de compte pour les connexions que le bloc matche. Le geste a la forme d’un durcissement et fait l’inverse. Trois parades : (a) set refuse la liste vide, avec un message qui oriente vers les deux suites légitimes ; (b) un verbe open la pose explicitement — seul chemin CLI vers Some([]), nommé pour ce qu’il fait ; (c) toute lecture avertit — config get, config match list et le chargement de la configuration par le démon, une fois, sur stderr. L’affichage distingue désormais (LISTE VIDE — remplace la base : ce bloc RETIRE la restriction) de « aucune restriction » côté base.
  • L’avertissement n’empêche pas le démarrage, et c’est la décision : le replace-vide est une politique valide, seulement piégeuse. Inspection pure de match_rules déjà décodés — aucune I/O de plus, aucun accès à la base des comptes, rien sur le chemin par connexion : pas d’oracle introduit (ADR-105). Le JSON reste la voie brute (ADR-032, zéro discard) : import peut toujours produire [], et c’est l’avertissement de lecture qui le rattrape.
  • Fail-closed d’écriture : après chaque mutation, l’artefact est réencodé, relu et comparé structurellement à la configuration demandée avant d’atterrir sur le disque — on n’écrit jamais un fichier qu’on ne saurait pas relire.
  • Preuves : tests/config_match_cli.rs — chaque verbe, nominal et refus (index hors bornes, champ inconnu, valeur invalide, CIDR nié / hôte / bits débordants, glob, bloc vide, argument en trop, artefact absent) ; aller-retour add→set→list→export→import→get ; null (hérite) vs [] (ouvre) distingués à l’export ; l’avertissement présent sur Some([]) et absent sur Some([nom]) comme sur None, pour les deux listes ; un [] importé en JSON rattrapé à la lecture ; et le démon qui démarre malgré l’avertissement, émis une seule fois. Guide utilisateur : docs/guides/air-sshd-blocs-match-fr.md. PR #616.

2026-08-03 — Évaluateur Match serveur : la politique devient conditionnelle (ADR-118 inc.3)

  • Le cœur d’ADR-118 (§2) : une liste ordonnée de blocs Match au modèle binaire serveur (matchRules @14 de sshd-config.capnp), chacun = des conditions et des surcharges des politiques d’inc.1 (forwarding) et d’inc.2 (accès). Pas une grammaire texte (doctrine ADR-073) : une structure de données, évaluée en couche 2 par air_sshd::config::effective_policy — fonction pure, couverte par une table de vérité exhaustive. Conditions : users, groups, source (CIDR v4/v6) ; toutes ANDées ; un bloc sans condition matche tout (Match all). Six surcharges optionnelles, portées par des unions inherit|replace (l’état « pas de surcharge avec une valeur pendante » est inexprimable).
  • La sémantique, définie explicitement — l’ADR l’exigeait : on part de la base, on parcourt les blocs dans l’ordre du fil, chaque bloc dont toutes les conditions sont satisfaites applique ses surcharges. La dernière correspondance qui fixe un champ gagne, et l’arbitrage est par champ (deux blocs qui matchent composent ; seuls les conflits sont tranchés par l’ordre). L’ordre de la liste est donc normatif — la trier changerait la politique.
  • Fail-closed partout où il y a un doute. Groupe non résoluble ⇒ le bloc ne matche pas (jamais un joker) ; adresse de pair inconnue ⇒ aucune condition de source n’est satisfaite ; bloc, union hors schéma ou CIDR invalide (longueur d’adresse, préfixe hors bornes, bits hôte débordants, IPv6 v4-mapped) ⇒ BadMatchRule, la configuration entière est refusée et le démon ne démarre pas. Une politique effective illisible ne peut pas être plus permissive que la base : elle n’existe pas. Les CIDR passent par IpNetwork::new, le même validateur que les motifs from= — une adresse source ne se décide pas selon deux jeux de règles selon d’où elle vient.
  • Le point de conception : où la politique effective est calculée, et comment elle voyage. Elle dépend du compte, de ses groupes et de l’adresse — trois faits établis au seul instant de l’ALLOW, dans le monitor. Elle y est donc calculée une fois (AuthorizeGrant), mémorisée avec l’accord, et transportée jusqu’au ForwardMediator à la transition : le bornage du forwarding devient par connexion au lieu de par-démon. Elle ne franchit jamais la frontière RPC — le protocole n’a aucun champ où la loger, et c’est délibéré.
  • Anti-énumération (ADR-105) intacte. Match est pur : aucune I/O de plus que ce que l’inc.2 payait déjà. La résolution des groupes boucle désormais sur la configuration entière (policy_group_names : base + conditions + surcharges des blocs), jamais sur l’appartenance du compte — et le leurre dummy_read la paie aussi, sans quoi un compte inconnu aurait coûté moins de lectures d’/etc/group qu’un compte connu.
  • Trois points laissés au superviseur, documentés plutôt que contournés : (a) pas de condition Host/reverse-DNS, qu’ADR-129 §D4 refuse pour toute décision de sécurité (RFC possible plus tard) ; (b) la règle dernière-correspondance plutôt que première ; (c) le transport de la politique effective jusqu’au médiateur. S’y ajoute un refus de démarrage quand des blocs Match coexistent avec privilege-separation désactivé : le chemin monolithique n’a ni monitor pour évaluer ni médiation pour appliquer, et une demi-politique appliquée serait pire que le refus.
  • Preuves. Table de vérité complète de l’évaluateur (0 bloc, bloc match-all, chaque condition dedans/dehors/absente, conjonction, chaque surcharge, absence de surcharge, ordre et dernier-gagne, réouverture de ce que la base fermait) ; aller-retour du schéma ordre et None compris ; rétro-compat d’un artefact pré-inc.3 ; refus d’un CIDR invalide sous ses six formes et d’une union hors schéma ; aller-retour JSON export/import (les blocs s’écrivent par config import, faute de grammaire plate) ; e2e root dans authorize_root — un bloc qui ferme le forwarding d’un compte et pas d’un autre, un bloc source=CIDR décidé contre l’adresse du listener, l’ordre dernier-gagne, et un bloc qui rouvre le login root que la base ferme. Fuzz existant étendu de fait (même read_sshd_config). PR #NNN.

2026-08-03 — Doctrine d’exécution amendée (ADR-046 §D6) et audit de conformité des Managers

Deux décisions BDFL gravées, et un audit qui a trouvé plus d’écarts que la question ne le laissait attendre.

ADR-046 §D6 amendé (section ## Amendement directe, fr+en). Les deux premières puces de D6 sont rayées. (1) Il n’existe plus d’exécution non confinée : la provenance ne décide plus si un exécutable est confiné mais quelle largeur d’enveloppe il reçoit — y compris pour un service Air. Une seule exception reconnue, PID 1, parce que le confinement est monotone à travers execve : le confiner plafonnerait tout ce que la machine lance ensuite ; il tire sa protection d’être minimal et vérifié. (2) Refus par défaut, exécution confinée sur octroi explicite — trois états (refusé / confiné étroit / confiné selon enveloppe) au lieu de deux ; c’est cette formulation, et non un refus pur, qui garde ouvert le chemin de l’image hybride. Deux corrections de fait : LXC rayé (ADR-112 livré), et l’exigence « ni glibc ni musl » requalifiée en politique d’admission — la libc n’est pas une frontière de sécurité, un binaire Rust peut émettre un syscall sans la franchir ; seuls seccomp/Landlock contraignent. L’ADR ne devait pas promettre une garantie qu’il ne tient pas. Le mécanisme reste renvoyé à l’ADR sécurité dédié — lequel bloque AirSandboxManager.

Audit de conformité des Managers, sur demande BDFL — « reprendre chaque ADR nommant ces Managers et mettre en conformité leur état, leur rôle, leur fonctionnement ». ADR-077 porte désormais l’état faisant autorité, avec un critère reproductible : un Manager joue son rôle si aucune opération de son domaine n’est atteinte par un toit autrement que par lui.

  • Le chiffre : les toits (air-libc-*, air-runtime) font 99 références en couche 1, dont 27 seulement via un Manager.
  • Aucune violation de stratification — aucun toit ne dépend d’air-sys-syscall. La frontière qu’ADR-077 proscrivait est respectée partout ; ce qui ne l’est pas, c’est la médiation : les toits restent en couche 1 mais entrent par la porte de service.
  • AirFileManager est la référence (0 contournement). AirNetworkManager (4 méthodes ; tout passe par air_socket::engine) et AirSignalManager (2 méthodes ; 6 fonctions libres) ne jouent pas leur rôle. AirTaskManager a trois trous, AirEnvironmentManager des doublons.
  • Quatre Managers existaient sans être doctrinésAirEnvironment, AirSignal, AirMemory, AirSystem — désormais reconnus avec les mêmes obligations.
  • ADR-112 amendé : le titre annonce AirSandboxManager, le code livre AirSandbox (poseur de profil). Écart de surface, pas de fonction — le fait porte sur le mécanisme, qui est en production dans air-sshd. Le Manager est bloqué, pas déprioritisé.
  • ADR-108 amendé, et c’est la trouvaille la plus nette : le §1 déclare air-keystore manager de domaine et son manifeste affirmait une « surface objet médiatrice (AirKeystoreManager) — jamais des free-functions ». Les deux étaient faux : ce type n’a jamais existé, et la crate expose précisément des fonctions libres par module. Le fond d’ADR-108 tient ; la forme de la surface est à trancher.
  • Mentions périmées résorbées : AirProcessManagerAirTaskManager (renommage ADR-088) dans ADR-085, ADR-086 et trois manifestes de crates. ADR-088 et ADR-123 le mentionnent légitimement — ils décrivent le renommage.

Notes corrigées en conséquence : la première rédaction disait ces domaines « sans surface objet », ce qui était faux — ils en ont une ; ce qui manque est le rôle de médiateur. La voie B de la roadmap est donc plus petite qu’annoncé.

2026-08-03 — Contrôle d’accès serveur : PermitRootLogin, AllowUsers/AllowGroups (ADR-118 inc.2)

  • Le deuxième incrément d’ADR-118 ajoute au modèle binaire serveur la question qu’authorized_keys ne pose pas : « ce compte a-t-il le droit d’être ouvert sur ce serveur ? ». Trois champs au schéma sshd-config.capnp (permitRootLogin @11, allowUsers @12, allowGroups @13), une AccessControlPolicy en couche 2, et un évaluateur purpermits(username, is_root, has_forced_command, groups) — couvert par une table de vérité complète. Toujours pas de Match : la politique est globale, la conditionnalité est l’inc.3.
  • Le défaut change de sens, et c’est délibéré. Contrairement au bornage du forwarding (inc.1), où le zéro du schéma valait « comportement historique », le zéro de PermitRootLogin est no : un artefact écrit avant cet incrément se relit sans erreur, mais ferme le login root. C’est le défaut sûr voulu ; il est écrit dans le schéma, dans le type, et affiché par air-sshd config get même quand il n’a pas été demandé — un administrateur qui ne verrait rien conclurait « pas de politique ». prohibit-password d’OpenSSH est refusé nommément (Air est publickey-only).
  • Où le contrôle s’applique, et pourquoi en dernier. Dans le monitor privsep (ADR-124 §D5) — le seul étage à détenir la configuration et le droit de résoudre un compte, l’enfant pré-auth étant sous cage Landlock deny-all. Il s’applique après que la clé a été cherchée dans les deux magasins : le placer plus tôt aurait fait payer deux lectures de fichier de moins à un compte hors d’AllowUsers, c’est-à-dire un oracle d’énumération au chronomètre — le signal binaire « lecture / pas de lecture » qu’ADR-105 et dummy_read avaient supprimé. La résolution des groupes suit la même règle : elle boucle sur la configuration, jamais sur l’appartenance du compte, et dummy_read la paie aussi pour un compte inconnu. Le refus est une variante propre (AccessNotAllowed) — lisible dans le journal du monitor, muette sur le fil.
  • La voie certificat aussi. Un certificat est une identité, pas une dérogation : un serveur qui ferme root et laisserait une CA l’ouvrir n’aurait rien fermé. Le chemin monolithique (privsep = false) est couvert par une enveloppe AccessControlled, sans quoi un AllowUsers de la configuration n’y aurait été appliqué nulle part — fail-open par simple bascule d’un booléen. Réserve assumée et documentée : le seam AuthorizedKeys ne transporte pas la commande forcée, donc forced-commands-only ferme root sur ce chemin-là.
  • Preuves. Table de vérité complète de permits + rétro-compat d’un artefact sans les champs + aller-retour JSON export/import (config_json) ; e2e root dans authorize_root — un compte hors AllowUsers refusé clé valable en main, allow_groups sur le groupe primaire, et PermitRootLogin dans ses trois valeurs sur le vrai compte root (dont l’accord forced-commands-only avec command="…"). Le fuzz existant (fuzz_air_sshd_config) couvre les nouveaux champs sans cible supplémentaire. PR #614.

2026-08-03 — Étude : surface d’administration et placement de la configuration

Note versée au dossier, conversation en coursnotes/etude-administration-et-placement-configuration-fr.md. Elle prolonge l’étude du chemin sur deux questions qu’elle ne traitait pas : quels outils d’administration Air doit fournir, et où vit la configuration dans un système à racine immuable. La seconde a fait apparaître un trou réel dans la conception du modèle A/B.

Inventaire des Managers. Huit existent ; trois domaines mûrs n’en exposent aucunair-sandbox, air-device, air-terminal — alors qu’ADR-077 (« Managers de domaine en couche 1 ») impose une surface médiatrice aux toits, et que le lanceur en sera un. Dix Managers manquent pour orchestrer un démarrage, dont AirMountManager et AirPowerManager dès le premier jalon. Piège de nommage relevé : AirNetworkManager est déjà pris et désigne les sockets.

Outils d’administration. « Un Manager = une commande » ne tient pas — des Managers n’ont rien à configurer, une configuration traverse plusieurs Managers, et certaines n’appartiennent pas au poste. Le découpage suit les domaines de configuration. Clarification importante : les Managers ne lisent pas de configuration ; elle appartient aux orchestrateurs, sinon la politique redescend en couche 1, ce qu’ADR-077 refuse. Sept outils manquants identifiés, et trois choix transverses — N binaires plutôt qu’un (chacun confinable séparément), mode hors-ligne pour produire les artefacts à la construction de l’image, validation à l’écriture et jamais au démarrage.

Le trou. Le mécanisme envisagé — remonter /etc en écriture pour y déposer une configuration signée — ne tient pas : on ne remonte pas une racine dm-verity en écriture, c’est la nature de la cible. Et une signature locale ne vaut pas ce qu’elle semble valoir : sans coffre matériel, la clé est un fichier qu’un système compromis utilise.

Le mécanisme proposé : un critère — cette configuration décide-t-elle quel code s’exécute ? — puis la délégation bornée. L’image ne fixe pas les valeurs, elle fixe l’enveloppe ; la partie mutable remplit à l’intérieur ; le lanceur refuse ce qui dépasse. La configuration mutable devient incapable d’exprimer un énoncé dangereux, au lieu d’être protégée par une cryptographie qui ne tient pas. C’est déjà le mécanisme du couple manifeste/octroi, appliqué à la configuration système. Étape préalable : reclasser — le disque externe ne demande aucune configuration mais une politique dans l’image plus un événement d’exécution.

La base de comptes met le critère à l’épreuve : elle est critique et pourtant nécessairement mutable — qui l’écrit crée un UID 0. Elle va sur la partition inscriptible, protégée par le monopole d’écriture d’air-account, la porte admin, et une enveloppe (UID et GID système réservés déclarés par l’image). Point rassurant propre à Air : la compromettre donne une identité, pas du code arbitraire, puisque le lanceur refuse les binaires non signés. Point qui mord : shadow est un actif de confidentialité, et c’est le premier endroit concret où la décision « pas de chiffrement de disque » a un coût identifiable.

Cinq questions ouvertes, dont la naissance du premier compte — l’installeur écrit la base sans porte admin, puisqu’aucun administrateur n’existe encore pour l’ouvrir.

La roadmap du chemin passe en deuxième formulation (§10 de notes/etude-chemin-vers-systeme-air-fr.md), sur cinq critiques de la première — et l’inventaire est cette fois vérifié dans l’arbre, non plus lu sur liste.

  • Trois corrections, toutes à la baisse. La std sur PAL n’est plus un prérequis : elle est mergée (rt/targets/*-unknown-linux-air*.json dans main, air-sshd testé dessus). Le lot de descellement annoncé à cinq syscalls en fait quatreclock_settime est déjà en couche 0 (air-sys-syscall/src/time.rs), comme waitid et signalfd. Et la phase 0 n’en demande que trois : mount, umount2, reboot.
  • Les obligations de PID 1, écrites, élaguent la phase 0. Ne jamais sortir, moissonner les orphelins, monter /proc+/sys+/dev, écrire sur la console, s’arrêter proprement. pivot_root n’y figure pas (pas d’initramfs au jalon 1, §8) et part en phase 3. Surtout : aucune de ces obligations n’appelle AirSandboxManager, AirDeviceManager ni AirTerminalManager, que la première formulation rangeait pourtant en phase 0.
  • Deux voies au lieu d’une suite de phases. Voie A, le chemin critique, séquencée et fermée par des critères observables ; voie B, la résorption d’ADR-077 sur trois domaines mûrs, sans dépendance, avec deux rendez-vous datés avec la voie A (AirTerminalManager au jalon 2, AirSandboxManager dès que des services tournent confinés).
  • Réserve portée sur air-sandbox. La voie B n’est délégable que pour deux domaines sur trois. Sur air-device et air-terminal, la surface médiatrice expose du code écrit et se tromper coûte un renommage. Sur air-sandbox, la surface est le modèle de sécurité — ce qu’est un profil, ce que vaut le défaut, qui l’élargit et par quel geste, comment le manifeste et l’octroi s’y branchent. Ce sont des arbitrages de doctrine, dont plusieurs restent ouverts (ADR-046 D6, statut du mode « étranger confiné », forme de l’arbitre). Le Manager n’est donc pas moins prioritaire, il est moins délégable : l’avance s’y prend en décidant, pas en codant. Même mise en garde qu’au §9, appliquée à un Manager.
  • Le harnais d’image repasse sur le chemin critique, en tête de phase 0 : on n’évalue pas « démarre sous QEMU » sans image ni noyau. Le chargeur d’amorçage est explicitement différé en phase 3, le démarrage direct par -kernel permettant de ne pas poser la question avant.
  • La phase 2 ne produit pas son composant de destination : air-sshd existe et tourne déjà sur notre std ; il ne lui manque que le substrat réseau.

2026-08-02 — Le worker de session toujours confiné : la cage dans tous les cas (ADR-147 inc.2)

  • Le trou d’ADR-105 est fermé. La cage sftp-worker était le geste d’un service (le SFTP la posait pour lui) : une connexion -N, un shell, un exec n’en voyaient jamais la couleur, et le démultiplexeur — le code qui parse le transport hostile — servait déconfiné. Elle est désormais posée par worker::confine_worker dans tous les cas, et avant le premier octet lu du pair : pas seulement avant le démux, mais avant la couche connexion, qui parse elle aussi des données contrôlées par le client et ne fait, en mode différé, que lire et écrire le tuyau clair. Preuve root directe (a_forward_only_worker_runs_under_a_seccomp_filter) : sur ssh -N -L, les enfants du monitor sont à Seccomp: 2 / NoNewPrivs: 1, lus dans /proc/<pid>/status — le champ même qu’ADR-105 avait trouvé à 0.
  • Aucun profil élargi, comme l’ADR l’exige — mais quatre gestes en sortaient, et deux n’avaient pas été vus. Les pompes de redirection tournaient déjà cagées (coexistence SFTP) : shutdown(2) et le MSG_PEEK de sondage y étaient supprimés sous condition, ils le sont maintenant inconditionnellement (les drapeaux confined/caged disparaissent — un drapeau toujours vrai est un mensonge sur le design). Le pilote de session, lui, n’avait jamais tourné cagé (SFTP et shell sont exclusifs) : il appelait ioctl(TIOCSWINSZ) (window-change) et pidfd_send_signal (SIGHUP d’un client qui raccroche), absents du profil et même de l’énumération AirSyscall. Les deux sont désormais médiés par le monitor — nouveau verbe RPC SessionControl (tag 12, gestes WindowChange/Hangup/Discard, sans réponse), exactement le chemin qu’ADR-147 a ouvert pour le lancement. Zéro capacité perdue côté client ; le worker, lui, en perd deux.
  • Le monitor garde une copie du maître du PTY pour appliquer les redimensionnements, et la relâche au raccrochage (avec le SIGHUP) comme à la récolte : sans cela le noyau ne raccrocherait plus le terminal quand le worker referme le sien.
  • Ce que l’ADR disait, et ce que l’implémentation a trouvé. ADR-147 §2 affirme que le profil sftp-worker « autorise déjà ce que le démux et ses pilotes appellent ». C’était vrai des pompes, faux du pilote de session. Dette d’ADR consignée : l’amendement est une décision de rédaction (BDFL), pas de code — et élargir un profil de confinement reste une décision de politique de sécurité, jamais une commodité d’implémentation.
  • Preuves. Test de fermeture statique (worker::tests::the_demux_syscall_set_is_closed_under_sftp_worker : le set exercé est inclus dans sftp_worker().syscalls()), son miroir (the_worker_cage_never_allows_an_escape_class), et les 17 e2e root de privsep_session_root — shell PTY, exec, commande forcée, from=, no-pty, rekey, SFTP cagé, -L/-R, coexistence session + tunnel — qui passent avec la cage posée : le filtre étant KILL_PROCESS, un seul syscall hors profil les tuerait par SIGSYS. Seule exception, déjà rouge sur main (vérifié par git stash) : several_third_parties_reverse_tunnel_concurrently_….

2026-08-02 — Le lancement de session sort du worker : médiation SpawnSession (ADR-147 inc.1)

  • Premier incrément d’ADR-147 (option B) : le worker post-auth ne fait plus aucun fork/clone/execve/openpty. Il demande au monitor par un nouveau verbe RPC SpawnSession (tag requête 11) ; le monitor alloue le PTY sous l’identité temporaire du compte (le nœud /dev/pts/N reste user-owned, comme avant) ou un socketpair, largue vers le compte, execve le shell avec SIGPIPE = SIG_DFL, puis remet au worker deux descripteurs par SCM_RIGHTS — la voie d’I/O et le pidfd — suivis de SessionSpawned{on_terminal} (tag réponse 14). Refus muet SessionSpawnRefused (15). Le worker pompe ensuite exactement comme avant.
  • La commande forcée change de main : command="…" n’est plus appliquée par le worker mais par le monitor, qui la détient de l’authorized_keys qu’il a lu en tant que l’utilisateur. Le fil ne porte que la demande du client ; un worker compromis ne peut donc plus écrire ce qui s’exécute. SSH_ORIGINAL_COMMAND n’est posé que si une commande forcée a réellement supplanté quelque chose.
  • La récolte suit : un pidfd ne confère pas la filiation (waitidECHILD), le worker n’est plus le parent. Le monitor surveille le pidfd dans son ppoll, récolte, et pousse SessionExited{status} (tag réponse 16) ; le worker en fait l’exit-status du canal SSH, dans le même ordre qu’avant (exit-status, EOF, CLOSE).
  • Canal : la règle « au plus un descripteur » devient « exactement le nombre que l’étiquette annonce » — plus stricte, et c’est elle qui autorise les deux descripteurs d’une remise de session. Fuzz du codec étendu au nouveau verbe.
  • Deux touches couche 1, toutes deux minimales et signalées en revue : l’additif air_process::signal_by_pidfd (signaler un processus dont on n’est pas le parent — le SIGHUP d’une fermeture de client), 100 % couvert ; et, dans le chemin enfant d’AirCommand, le chdir déplacé après le largage de privilèges. Le second n’est pas cosmétique : le monitor est root sans CAP_DAC_OVERRIDE (ADR-122 §6, délibéré) et ne peut donc pas entrer dans un home en 0700 — alors que l’utilisateur, lui, le peut. C’est l’ordre d’OpenSSH (chdir après permanently_set_uid), et il ne peut que restreindre. Sans lui, toute session médiée sortait en 127.
  • Iso-comportement, prouvé en root : les 17 tests e2e de privsep_session_root (shell interactif, exec, commande forcée + SSH_ORIGINAL_COMMAND, from=, no-pty, rekey, SFTP cagé, -L/-R, coexistence session + tunnel) passent — à l’exception de several_third_parties_reverse_tunnel_concurrently_…, déjà rouge sur main dans cet environnement. La cage n’est pas encore posée : c’est l’inc.2.
  • Durcissement au passage : la fenêtre SIGPIPE = SIG_DFL d’un lancement devient exclusive en build debug — deux fenêtres qui se chevauchent se restauraient mutuellement en SIG_DFL, laissant le processus sans la protection qu’elles existent pour rendre (visible seulement dans un binaire de test multi-fils).

2026-08-02 — Étude : le chemin d’Ubuntu vers un système Air amorçable

Note versée au dossier — notes/etude-chemin-vers-systeme-air-fr.md. Elle instruit comment passer d’un jeu de composants tournant sur Ubuntu à un système Air amorçable.

Le point de méthode d’abord : « remplacer chaque composant d’Ubuntu par le nôtre » est un mauvais cadre, parce que c’est importer la décomposition d’autrui — une liste produite par trente ans de contraintes qui ne sont pas celles d’Air. Le cadre retenu est fonctionnel : quelles fonctions le système doit-il rendre, puis un verdict parmi quatre — à écrire, différé, dissous, pas nécessaire. La catégorie dissous est celle qu’on oublie et où sont les économies : Air en a déjà un exemple abouti avec air-device, qui rend la fonction d’udev sans être un démon.

Appliqué aux trois composants nommés par le BDFL, le verdict diffère à chaque fois : NetworkManager est à réduire (le premier système amorçable a besoin d’un lien, d’une adresse et d’une route, pas de profils ni d’itinérance) ; wpa_supplicant est à différer explicitement — c’est du protocole et de la cryptographie, un sous-projet comparable à air-sshd, dont aucun jalon n’a besoin puisque QEMU et le catalogue matériel sont en Ethernet ; logind est probablement dissous, ses fonctions se répartissant entre le lanceur et le modèle d’octroi.

L’inventaire par fonction relève un manque plus large qu’attendu côté réseau : Air sait utiliser un réseau configuré (sockets, DNS) mais ne sait pas en configurer un — ni netlink, ni DHCP, ni Wi-Fi.

Quatre jalons, ordonnés par dépendance de démarrage et non par importance : hello-init (PID 1 vivant sous QEMU), un shell sur console série, ssh vers une machine Air — le jalon structurant, qui réexerce d’un geste tout ce que le projet a de plus mûr —, puis l’image qui se met à jour elle-même en A/B.

Un apport du chemin à la doctrine : l’amorçage passe par des images hybridesair-launchd en PID 1, mais encore des binaires Ubuntu pour ce qui n’est pas écrit. Cela n’est possible que si le mode « étranger, donc confiné » est un mode réel et implémenté. Si la politique retenue est le refus pur du binaire non conforme, le chemin hybride est fermé. C’est un argument issu de la pratique en faveur de « refus par défaut, confinement sur octroi explicite ».

Six questions ouvertes, dont le choix du noyau (Air n’en a pas), celui du chargeur de démarrage, et le format d’image.

La note porte aussi une roadmap (§10), explicitement provisoire, retravaillée le 03-08 en deuxième formulation (voir l’entrée de ce jour). Elle corrige une erreur d’inventaire : le descellement de couche 0 nécessaire au premier jalon est bien plus petit qu’annoncé. Trois « manques » relevés n’en sont pas — cgroup se manipule par le système de fichiers (AirFileManager suffit, et clone3 porte déjà le champ CLONE_INTO_CGROUP), netlink a sa famille d’adresses déjà connue de la couche 0 (ce qui manque est le codec rtnetlink, donc du couche 1/2 en sans-IO), et le device-mapper relève du jalon 4.

L’ordre proposé est « les APIs d’abord », ce qui ne contredit pas la mise en garde du §9 : une enveloppe de syscall n’a aucun risque de conception (mount(2) est mount(2)), contrairement à un composant de politique qui se conçoit contre un système. Et ce travail se fait entièrement sur Ubuntu, avec la CI actuelle. La parade au piège de la campagne sur liste papier reprend le patron de hello-std : hello-init est le critère de sortie de la campagne d’API, pas la phase suivante — « terminé » ne veut pas dire « la liste est cochée » mais « ça démarre ».

Peut démarrer immédiatement, sans décision ni descellement : les trois Managers manquants sur des domaines déjà mûrs — AirSandboxManager, AirDeviceManager, AirTerminalManager — ce qui résorbe au passage l’application inégale d’ADR-077 (« Managers de domaine en couche 1 ») relevée par l’inventaire.

2026-08-02 — ADR-014 amendé : confiance matérielle, refus de l’OTP Raspberry Pi

Les décisions prises sur la base de l’étude Raspberry Pi ne vivaient que dans une note de travail, qui ne fait autorité sur rien. Elles sont gravées dans ADR-014 (« Catalogue de matériel Air long-term »), en FR et EN — l’ADR autorise l’édition directe tant que le dépôt est privé.

L’amendement ajoute au catalogue une dimension qu’il ne portait pas : le niveau de confiance matérielle de chaque machine, sur trois échelons (ancré / ancré contraint / non ancré). Il acte qu’Air ne programmera jamais les fusibles OTP d’un Raspberry Pi — activation irréversible, clé ni rotative ni révocable, incompatible avec une promesse de dix ans —, que le Pi reste au catalogue à double titre (validation aarch64 et usage utilisateur sans garantie contre l’accès physique), et que l’utilisateur est informé deux fois, au téléchargement et à l’installation, avec un libellé de référence précis : « aucune garantie de sécurité » serait inexact et contre-productif.

Deux apports au-delà des réponses elles-mêmes. D’abord une exigence dérivée : sur Pi, la partition de démarrage est une FAT en clair non vérifiée qui porte la racine dm-verity — donc le gestionnaire de paquets ne doit en aucun cas pouvoir y écrire, sans quoi la propriété « un attaquant corrompt des applications, jamais le système » tombe.

Ensuite un constat gênant, écrit noir sur blanc : aucune machine du catalogue n’est aujourd’hui au niveau ancré. Les deux entrées x86_64 sont du matériel Apple Intel, dont la chaîne de démarrage n’offre pas de racine exploitable pour Linux — les modèles pré-T2 n’ont aucun démarrage vérifié pour un OS tiers, et sur les modèles à T2, démarrer Linux exige de désactiver la sécurité de démarrage. Les deux entrées aarch64 sont des Pi délibérément non provisionnés. Atteindre le niveau ancré exigera donc d’ajouter une machine à UEFI Secure Boot utilisable — plusieurs candidats déjà listés conviennent. Non tranché.

Conséquence pratique : un chantier entier sort du plan (outillage OTP, provisionnement par machine, signature d’images de démarrage Pi, contre-signature Pi 5).

2026-08-02 — Étude : démarrage vérifié sur Raspberry Pi, et ses limites documentées

Note versée au dossier — notes/etude-secure-boot-raspberry-pi-fr.md. Elle instruit la racine de confiance sur Pi, prérequis du modèle de mise à jour envisagé (image A/B en lecture seule, dm-verity, chargeur qui vérifie avant de démarrer). Faits relevés dans la documentation officielle raspberrypi/usbboot, consultée le 2026-08-02, et non de mémoire.

Le mécanisme existe (Pi 4 et Pi 5) : RSA 2048, empreinte de clé publique dans les fusibles OTP du SoC, unité vérifiée = un boot.img FAT autonome contenant firmware, noyau, arbre de périphériques et initramfs. Mais la documentation énonce cinq limites, dont trois structurantes : l’activation est irréversible et la clé n’est ni rotative ni révocable ; l’OTP est lisible par tout code en mode superviseur (donc inutilisable comme coffre à secrets) ; aucun chiffrement de disque, la partition de démarrage restant en clair. S’y ajoute une contrainte pratique décisive : sur Pi 4 et CM4 on peut tester la chaîne sans programmer l’OTP, sur Pi 5 non — la première tentative y est donc la dernière.

Deux conclusions pour Air. D’abord, un portage UEFI ou un GRUB aarch64 n’apporte rien par lui-même : chargé depuis la carte SD par la chaîne native du Pi, il est remplaçable avec son Secure Boot. Ensuite, et c’est le point rassurant, dm-verity garde sa valeur même sans OTP programmé : la propriété visée par la séparation « gestionnaire de paquets contre mise à jour système » — un attaquant corrompt des applications, jamais le système — tient sur un Pi non provisionné. L’ancrage OTP n’ajoute que la résistance à l’accès physique, menace à qualifier séparément.

Cinq questions restent ouvertes, dont deux appellent une décision avant tout engagement : le Pi est-il plateforme de développement ou cible de production, et accepte-t-on une clé RSA 2048 non rotative sur dix ans ? Emplacement naturel de la réponse : ADR-014 (« Catalogue de matériel Air long-term »).

2026-08-02 — Étude préparatoire : systemd, ses alternatives, et le comparatif launchd

Note de travail versée au dossier avant la décision sur ADR-005 (« Intégration systemd comme socle système, D-Bus optionnel ») — notes/etude-systemd-alternatives-launchd-fr.md. Sans autorité, elle instruit trois questions : ce que systemd fait réellement bien (supervision par cgroups, confinement déclaratif, activation à la demande, readiness typé), ce qu’on lui reproche à raison (périmètre absorbant, couplage de fait, sémantique d’unité subtile, journal binaire) en séparant les griefs techniques des griefs culturels, et le paysage des alternatives hors rc.d — runit, s6/s6-rc, OpenRC, Dinit, GNU Shepherd, procd/ubus, SMF, Upstart.

Le comparatif systemd / launchd d’Apple — le PID 1 de macOS, à ne pas confondre avec le futur air-launchd dont seul le nom est réservé — est traité axe par axe. En résumé : launchd gagne sur l’unicité du modèle (tout est un job, des domaines homogènes), sur l’activation par IPC pensée avec le transport, et sur la retenue de périmètre ; systemd gagne sur le suivi des processus par cgroups, le confinement déclaratif, la comptabilité de ressources et l’ouverture.

Pour Air, la note relève un avantage de départ : là où systemd confine bien et où launchd confine ailleurs (profils sandbox, entitlements), Air dispose déjà d’air-sandbox (ADR-112, « air-sandbox : confinement seccomp/Landlock sûr, compilateur BPF maison ») — donc du confinement déclaratif en Rust safe. Quatre questions restent ouvertes et appellent une décision : faut-il parler sd-bus, que faire de logind, quel périmètre pour air-launchd (déjà nommé par ADR-041 et par le seam ServiceHost d’air-service), et comment gérer la coexistence.

2026-08-02 — Revue des ADR « en cours » : deux revues concurrentes, un statut corrigé, une dette consignée

Revue des 12 ADR marqués en-cours au registre, chacun confronté aux commits mergés et au code. Une revue d’avancement de carbon (#603) tournait en parallèle sans que je le sache, et a conclu autrement sur trois d’entre eux. Ce que ça donne, sans arrondir les angles :

  • Là où je me suis trompé. J’avais conclu qu’aucun ADR n’avait été oublié en « fait ». Carbon a marqué ADR-074 (« Vision air-sshd ») et ADR-092 (« air-async : découpage réacteur / exécuteur ») en fait — à raison : la vision air-sshd est réalisée (démon déployé en 0.7.0, V2.6 close, extensions renvoyées aux ADR enfants), et le découpage d’ADR-092 est en production. J’avais été trop strict, en jugeant ces ADR sur leur périmètre déclaré le plus large plutôt que sur leur objet propre.
  • Là où j’avais raison au moment du relevé, et où les faits ont bougé depuis. J’avais identifié qu’ADR-146 (« Forwarding serveur en privsep par médiation du monitor ») n’avait pas livré le streamlocal médié de son jalon V2.6-3 — vérifié dans le code : le RPC du monitor ne portait que ForwardConnect/ForwardListen/ForwardCancel, et -L/-R Unix ne vivaient que hors privsep. C’était exact à cet instant ; #596 et #597 l’ont livré depuis, sous identité utilisateur, et ADR-146 est passé fait.
  • Là où nous convergeons. ADR-116 (« air-agent ») : reste l’agent forwarding opt-in. ADR-117 (« Redirections & multiplexing SSH ») : ProxyJump/ProxyCommand non commencés, -D et ControlMaster différés. Mêmes conclusions des deux côtés, indépendamment.

Deux actions en propre :

  • ADR-107 (« Pipeline CI/CD linux-air : gate sur notre std, cross-build x86 → exécution native aarch64, cache ») passe de en-cours à a-faire. Aucune de ses sept décisions n’est en CI : le gate tourne toujours sur gnu, pas de job dudect, pas de job fuzz — ci.yml l’écrit lui-même — pas d’exécution test-air ni de job de tests privilégiés. Le seul livrable est cargo xtask test-air, un outil local qu’aucun workflow n’appelle.
  • ADR-092 reçoit une section « Mise en œuvre — état » (FR + EN), précisément parce qu’il est désormais fait : sa substance est livrée, mais son point 3 — la propagation documentaire air-eventair-async — ne l’est pas, et le fait risquait de l’enterrer. 26 occurrences hors ADR subsistent, dont la spec air-event entière. Et ce n’est pas un renommage : cette spec décrit une façade C-ABI bâtie sur air-runtime, quand air-async est un exécuteur Rust bâti sur air-uring. Renommer produirait une spec qui contredit le code. La façade C-ABI reste-t-elle au programme, sous quel nom ? libair-event.so figure au contrat ABI 10 ans de la macro-architecture. La dette est donc bloquée par une décision, pas par du travail — consigné comme tel dans l’ADR et dans sa note d’avancement.

Relevé au passage : ni air-uring ni air-async n’ont de spec de composant ; la seule du domaine est celle, périmée, d’air-event.

2026-08-02 — Release air-sshd 0.7.0 (tag), campagne d’intrusion ADR-105, deux ADR de durcissement

  • Release 0.7.0 : .deb traçables reconstruits (x86_64 + aarch64, avec --version sur tous les binaires livrés, #601) et déployés sur les 3 machines (speedy, carbon, raspi-srv-2), clés d’hôte préservées (postinst idempotent). Tag signé air-sshd-v0.7.0 posé sur ed634134 (clôture du chapitre V2.6 privsep + forwarding).
  • Campagne d’intrusion ADR-105 (2ᵉ campagne) menée contre le 0.7.0 déployé : attaquant carbonspeedy 192.168.1.43:2222, avec observation interne depuis speedy. PASS. Non-régression A1–A6 (wire malformé rejeté sans alloc ; throttle 16/source + token bucket 20+10 s⁻¹ ; publickey-only + 6 essais/conn ; timing existant/inexistant indistinguable via leurre + constant-time). Séparation de privilèges observée de l’intérieur : cage pré-auth uid 124, 0 capability, seccomp-BPF ; io_uring jamais hérité (listener seul) ; worker uid utilisateur, 0 capability ; fail-closed (mort du monitor tue l’enfant confiné, pré- ET post-auth) ; mediation forward et restriction --no-port-forwarding appliquée par le monitor prouvées. Serveur déployé jamais redémarré de toute la campagne.
  • Deux findings défense-en-profondeur → ADR proposés : ADR-147 (filtre seccomp sur le worker de session forward-only ssh -N, réutilise air-sandbox, aucun descellement) et ADR-148 (isolation par namespaces des cages pré-auth/forward-only ; exige des additifs couche 0 unshare/mount/pivot_root + re-sceau). Les deux en Statut : Proposé, à ratifier BDFL avant implémentation.

2026-08-01 — ssh -R vers un socket Unix en privsep : V2.6 est close (ADR-146)

  • V2.6-3 streamlocal-R : dernière brique de V2.6. Un streamlocal-forward@openssh.com n’est plus refusé en mode différé — le monitor crée et écoute le socket Unix 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 que les autres canaux. Nouveaux RPC worker→monitor ForwardListenUnix / ForwardCancelUnix (tags requête 9/10) et poussée ForwardAcceptedUnix (tag réponse 13), chemins bornés par sun_path des deux côtés du fil, fuzzés.
  • Le point de sécurité : c’est la seule opération de tout le forwarding qui écrit quelque part. Le socket+bind+chmod 0600+listen se fait dans une portée étroite sous AirTemporaryIdentity → le socket est possédé par l’utilisateur et ne peut naître que là où l’utilisateur pouvait déjà écrire (aucune escalade, aucun socket root:root). Les invariants du pilote monolithique sont préservés mot pour mot : jamais d’unlink préalable (c’est le bind qui garantit l’absence d’écrasement, EADDRINUSE), mode posé entre le bind et le listen (un socket qui n’écoute pas encore refuse les connexions), chmod qui ne suit pas le lien final (ADR-144). L’accept se fait en root — il ne consulte aucun droit FS — avec la contre-pression POLLOUT du -R TCP.
  • Le nettoyage est structurel : le chemin lié est tenu par un jeton RAII qui le supprime, sous l’identité du compte, à 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 que le monitor a créé est supprimé.
  • Preuves : codec + fuzz ; tests in-process root (socket créé uid=compte, mode 0600 ; chemin occupé refusé et fichier d’autrui intact ; répertoire 0700 de root refusé avec contre-témoin ; annulation et fin de session qui suppriment) ; 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, avec la sonde de restauration d’identité. Zéro additif couche 0/1.
  • V2.6 est close : forwarding privsep complet — -L/-R TCP, -L/-R socket Unix, écoutes multiples, coexistence session/SFTP.

2026-08-01 — ssh -L vers un socket Unix en privsep, joint sous l’identité de l’utilisateur (ADR-146)

  • V2.6-3 streamlocal-L (#596) : un direct-streamlocal@openssh.com n’est plus refusé en mode différé — il devient un canal -L comme un autre (même file, même sérialisation, même pilote Pump). Nouveau RPC worker→monitor ForwardConnectUnix (tag requête 8, chemin borné par sun_path des deux côtés du fil, fuzzé) ; réponses ForwardConnected/ForwardRefused inchangées. La politique opposée est celle de la garde monolithique, à l’identique : AllowTcpForwarding(-L), permission no-port-forwarding de la clé, puis refus dès que PermitOpen borne (un chemin n’y est pas exprimable) — le privsep n’est jamais plus permissif que le chemin monolithique.
  • Le point de sécurité : le monitor (root) joint le socket sous l’identité effective TEMPORAIRE de l’utilisateur (air_process::credentials::AirTemporaryIdentity), dans une portée qui ne contient que socket+connect non bloquant ; l’identité est restaurée au Drop, y compris sur erreur. SO_PEERCRED du service cible voit donc le compte, jamais root, et l’accès au système de fichiers est celui du compte — le processus privilégié n’atteint que ce que l’utilisateur atteignait déjà (invariant « avec son propre uid » du pilote monolithique, préservé en privsep). Le connect en cours est poursuivi en root dans le ppoll existant : la boucle privilégiée ne bloque jamais.
  • Preuves : tests in-process root (pair vu = l’uid emprunté ; socket dans un répertoire 0700 de root refusé, avec contre-témoin root) et e2e root (tests/streamlocal_forward_root.rs : service d’écho en processus séparé rendant son verdict SO_PEERCRED par son code de sortie, + sonde de restauration par une redirection TCP servie après la fenêtre). Contrôle négatif exécuté : sans l’abaissement, les deux tests de sécurité échouent. Zéro additif couche 0/1 ; streamlocal-forward (-R Unix) reste refusé, incrément suivant.

2026-08-01 — Coexistence complète dans le worker privsep : session, forwards, SFTP (ADR-146)

  • V2.6-3 C.5c — la session rejoint le démux (#589) : le dialogue shell/exec est extrait de sa boucle (session_blocking::SessionPump) et devient un canal du démultiplexeur unifié — un shell et un -L/-R sur la même connexion, ce qui était structurellement impossible tant qu’une session possédait le transport. La sortie du shell ne ferme plus la connexion : elle clôt son canal.
  • V2.6-3 C.5c-fix — plus un seul appel bloquant dans le canal de session (#591, revue de #589) : récolte du processus par pidfd dans le ppoll (un SIGHUP piégé ne gèle plus le fil unique, donc plus les redirections voisines), et écriture vers le processus rendue non bloquante avec file bornée par la fenêtre annoncée — le canal de session gagne au passage le contrôle de flux qu’il n’avait jamais eu.
  • V2.6-3 C.5d — le SFTP coexiste, cage comprise : dernière pièce de C.5. Le sous-système devient un canal du démux (sftp_blocking::SftpPump, sans processus ni descripteur — piloté par le transport), servi cage posée (seccomp sftp-worker + Landlock du home), que le SFTP soit le premier service ou qu’il arrive alors que des redirections tournent. La purge de la cage préserve exactement le tuyau clair, le canal de médiation et les cibles des forwards déjà ouverts ; échec de pose = fail-closed (EXIT_CAGE_FAILED). Le profil sftp-worker n’est pas élargi : les deux seuls gestes hors profil du chemin de redirection (shutdown(2), MSG_PEEK de sondage) sont supprimés sous la cage plutôt que tentés — l’action par défaut du profil étant KILL_PROCESS. Seule retouche couche 1 : la cardinalité du tampon de air_process::close_inherited_fds_except (16 → 48), qui n’est pas une propriété de sécurité mais dimensionnait un tri sur la pile.

2026-08-01 — Forwarding serveur sous privsep (ADR-146) + outillage CI et journal

  • ADR-146 (« Forwarding serveur en privsep par médiation du monitor ») — le worker confiné ne fait plus aucun syscall réseau (#579→#586, #589, #591), conception V2.6 option B : RPC monitor ForwardConnect (V2.6-1a), ssh -L en privsep — direct-tcpip médié (V2.6-1b), plomberie ForwardListen/Accept puis ssh -R médié, d’abord séquentiel puis concurrent par démux de N canaux (V2.6-2a→2b-ii), ForwardConnect rendu asynchrone côté monitor pour que la boucle root ne bloque plus sur connect(2) (V2.6-3 C.5a), et enfin démux unifié -L + -R avec écoutes multiples — coexistence des forwards (V2.6-3 C.5b). Puis la session entre dans le démux : shell et forwards coexistent enfin sur la même connexion (V2.6-3 C.5c, #589), suivi de son correctif — reap pidfd non bloquant et back-pressure PTY (C.5c-fix, #591). Autre correctif en cours de route : la pompe direct-tcpip ne tronque plus le résidu sous back-pressure (MSG_PEEK avant EOF, #581).
  • CI — un diff sans code ne relance plus les jobs de code (#587). Critère à deux étages : (1) tout ajout, modification ou suppression d’un fichier source (.rs .c .h .cc .cpp .cxx .hpp .hh .swift .s .asm .capnp), où qu’il soit dans l’arbre, lance tout ; (2) sinon, allowlist stricte de chemins (docs/**, .github/**, points d’entrée racine, plus les inertes que scripts/ rejoint). docs_only devient sans_code. Contrepartie : scripts/test-ci-perimetre.sh devient une étape obligatoire du job changes — le test existait mais aucun workflow ne le lançait, la garde n’était gardée par rien.
  • ADR-143 (« Couverture CI par couche ») amendé (#592) — par le mécanisme d’amendement par RFC qu’institue ADR-015 (« Licence MPL 2.0, contribution DCO, gouvernance évolutive »), décision BDFL. La nouvelle politique de périmètre — critère sans_code à deux étages et gate test-ci-perimetre.sh obligatoire — ne vivait que dans des commentaires YAML et CI.md. Elle est désormais enregistrée là où vivent les décisions, en FR et EN, avec ses trois alternatives rejetées et sa limite assumée : le gate valide la décision de périmètre, pas le contenu des jobs de code. Rend caduque la troisième puce de « Mise en œuvre » d’ADR-143 (« la PR qui touche ci.yml se valide en couverture complète »). Réparé au passage : les références à ADR-031 (« Mesure de couverture en root sur les runners self-hosted ») et ADR-036 (« Filtrage par chemin de la re-vérification d’une couche scellée ») étaient utilisées sans définition depuis l’origine du document et rendaient en texte brut.
  • Suivi et CI.md rafraîchis (#590) : le bandeau d’entrée d’etat-avancement.md annonçait encore « vague 1 en cours, air-keystore en cours » — périmé de cinq jours ; il pointe désormais sur la campagne ADR-146 / V2.6, et l’état des vagues est aligné sur statuts.toml, seule source de vérité. CI.md, lui, décrivait encore la topologie d’ADR-037 (« CI ARM seule + validation x86 par barrière pré-merge ») — révisée le 2026-07-07 par ADR-072 (« CI : x86_64 runner primaire ; aarch64 en cross-check + natif sur main »). Il affirmait donc l’inverse de la réalité depuis près de quatre semaines, et quatre jobs sur six n’y figuraient pas. Runners, jobs et déclencheurs remis au réel, plus la documentation des trois filtres de périmètre (sans_codefull_forced → couches) et des quatre gates documentaires du workflow docs.
  • Journal de bord remis à jour et outillé (#588) : rattrapage des 263 PR non journalisées (#316→#578, 13 entrées de campagne reconstituées depuis l’historique Git), consigne de tenue inscrite dans CLAUDE.md et AGENTS.mdtout agent, LLM ou contributeur humain —, et gate CI scripts/verifier-journal.py : au-delà de 10 PR sans nouvelle entrée, la PR échoue.

Note de reconstitution (2026-08-01) — les entrées du 2026-07-10 au 2026-07-31 ci-dessous ont été reconstituées a posteriori depuis l’historique Git (PR #316 à #578), le journal n’ayant plus été tenu après la PR #315. Elles sont volontairement synthétiques : une entrée par campagne, pas par PR. Le détail fin vit dans les ADR et dans etat-avancement.md.

2026-07-31 — Redirections SSH complètes (ADR-117), bornage (ADR-118), site et doc possédés

Journée dense (~25 PR). Fin de la boucle [A] des redirections de ports, puis durcissements successifs et refonte de la chaîne documentaire.

  • ADR-117 — redirections : -L TCP (#556), -R TCP (#557), -L vers socket Unix direct-streamlocal (#563), -R socket Unix streamlocal-forward@openssh.com (#566), portées par le moteur multiplexé client (mode -N). Fast-follows de durcissement : plafonds, max_packet reçu et unicité -R (#558), recrédit de fenêtre direct-tcpip côté serveur (#561), backpressure direction A + plafond forwarded-* + refus de politique streamlocal (#571).
  • Anti-énumération de comptes : autorisation en temps constant prouvée par dudect (#554), puis suppression du résidu d’I/O par lecture factice sur compte inexistant (#555). Couche 0 descellée pour fchmodat2 + durcissement anti-TOCTOU des permissions de socket streamlocal (#568).
  • ADR-118 inc.1 : AllowTcpForwarding + PermitOpen/PermitListen en configuration binaire (#575) — le bornage du forwarding devient une décision de configuration.
  • ADR-146 (#577) : conception du forwarding serveur sous privsep, par médiation du monitor (option B) — à implémenter.
  • Chaîne documentaire : registre ADR généré, trié par numéro, avec l’avancement de mise en œuvre (#564) ; INDEX régénérable aligné sur le réel (#565) ; récupération des tags en CI avec échec bruyant en leur absence (#567) ; mise en conformité des 145 ADR aux quatre rubriques obligatoires (#574) ; maquette Zola d’un rendu HTML5/CSS possédé (#569) puis recherche possédée et corpus complet (#576) — jalon 1 d’ADR-145 « un site, orientation par public » (#572) ; contenu mdbook élargi, marges cumulées sous 10 % (#578).
  • CI : pas de relance lourde sur fichiers inertes (#562), republication de la doc à la demande (#559), site/ ignoré (#560).

2026-07-31 — air-agent : agent SSH maison, interop ssh-agent bidirectionnelle (ADR-116)

  • inc.1 (#545) : service agent keystore-backed (modèle ssh-add), authentification du pair par peer-cred, protocole natif sans-IO et fuzzé.
  • inc.2 (#547/#548/#549) : couture côté client — trait ClientPublickeySigner + implémentation locale (behavior-preserving), puis signataire via air-agent avec test d’équivalence byte-exact, puis air-ssh --air-agent de bout en bout.
  • inc.3 (#550/#551/#552) : codec du protocole ssh-agent d’OpenSSH (sans-IO + fuzz), pont SSH_AUTH_SOCK (interop ssh-add → notre agent), et authentification d’air-ssh via un agent OpenSSH externe. Interop prouvée dans les deux sens (#553).

2026-07-30 — CLI air-keystore (ADR-115), certificats clos (ADR-109/140), release lockstep

  • Certificats SSH : vérification du certificat d’hôte côté client, confiance sans TOFU (#522) ; ADR-137 — le principal d’un certificat d’hôte est l’empreinte de la clé certifiée, pas le hostname (#523) ; révocation par serial + interop KRL OpenSSH (#524) ; auth par certificat utilisateur sous privsep, le monitor vérifiant (#526, conception ADR-139 #525) ; émission de certificats et clé CA privée (#528, ADR-140) → ADR-109 clos et air-sshd 0.6.0 déployé (#529).
  • ADR-115 — CLI air-keystore en 4 incréments : squelette + orchestration d’émission partagée (#532), mode JSON agent-safe (#533), revoke/known-hosts/host-trust/expiry derrière le gate admin ADR-101 (#534), clôture avec retrait de la CLI provisoire d’air-sshd (#536).
  • Fast-follows de durcissement ADR-141 (#530/#535, #539→#542) : parseur d’options critiques à parité OpenSSH, round-trip byte-exact du certificat (champ reserved préservé), zeroize des tampons d’encodage et du mot de passe CLI, gate admin sur base système fixe.
  • Livraison : dépendances Debian inter-paquets (#537) et versionnage lockstep des paquets livrables — release 0.7.0 (#538, ADR-142).
  • ADR-114 : le JSON minimal maison est promu en crate couche 1 partagée air-json (#521).
  • CI et doc : couverture et tests par couche — ne mesurer et n’exécuter que les couches touchées (#543, ADR-143) ; registre reformaté en table (#544) ; traductions anglaises complètes des ADR, 144 fichiers -en.md (#546).

2026-07-28→29 — Séparation de privilèges d’air-sshd (ADR-122/124/129)

Refonte du démon en monitor par connexion / enfant pré-auth confiné / worker post-auth, conçue puis livrée par paliers V2.1 → V2.6.

  • Socle couches 0/1 : close_range + capbset_drop/read (#489) ; énumération Capability complète (41 caps noyau) et bounding set du monitor réduit à 4/41 (#496, ADR-126) ; AirPrivilegeDrop pose une cage air-sandbox (#491) — descellement breaking couche-1-v3.0 (#492, ADR-123).
  • Pré-auth (ADR-124) : codec RPC monitor↔enfant sans-IO + fuzz (#494), squelette de processus (#495), SIGN — le monitor signe le KEX, l’enfant ne détient plus la clé d’hôte (#498, amendement §D4 #499), AUTHORIZE — le monitor décide en devenant l’utilisateur, StrictModes (#501), quota de signatures fail-closed (#513, ADR-133).
  • Post-auth (ADR-129) : socle de transition — K_S public hérité, tuyau clair par SCM_RIGHTS, worker setuid (#506) ; le worker sert ssh-connection sur le tuyau clair, avec options, cage SFTP et proxy de transport (#507) ; from= enforcé par le monitor (#508) ; bascule du démon en privsep (#509) ; complétude de session — rekey à travers le proxy, SSH_ORIGINAL_COMMAND, audit d’authentification (#511).
  • Options authorized_keys : schéma v2 additif, CLI, décision du monitor (#504, ADR-128).
  • Correctifs de fond : les anneaux io_uring ne sont plus hérités au fork — MADV_DONTFORK (#510, ADR-132) ; air-handle::duplicate oublie l’entrée périmée au lieu de fermer le fd neuf (#517, ADR-135) ; CLI de migration de la clé d’hôte vers hostkeys.aircfg (#516, ADR-134) ; journalisation côté monitor de la raison d’un refus (#519) ; prompt de mot de passe avec écho coupé (#518) ; harnais uinput couche 0 qui n’écrit plus dans la console (#512).
  • Cycle de livraison : air-sshd 0.2.1 → 0.3.0 → 0.3.1 (#514/#515/#520).

2026-07-27→28 — Vague 1 exécutée : keystore, certificats, ML-KEM, air-log, air-sandbox

Exécution en rafale des ADR de conception produits la veille.

  • ADR-108 — air-keystore (#463) : manager couche 1 — wire SSH et clés publiques OpenSSH, paires Ed25519 gérées (zeroize), import puis export openssh-key-v1, magasins typés par rôle (hosts / revocation / hostkeys) scellés, parseurs et décodeurs fuzzés ; migration de la clé d’hôte d’air-sshd vers le magasin hostkeys.
  • ADR-109 — certificats SSH (#464→#468) : codec et vérification Ed25519, magasin des CA de confiance, émission, combinateur de vérification, userauth acceptant une clé porteuse de certificat, chargement des magasins CA/révocation par le démon, KEX vérifiant une clé d’hôte certifiée — interop réelle : un client OpenSSH s’authentifie par certificat.
  • ADR-110 — post-quantique (#469→#473) : primitive ML-KEM-768 (FIPS 203), re-validation par KAT interop OpenSSL + fuzz + dudect, combineur hybride sans-IO, KEX mlkem768x25519-sha256 activé côté démon avec interop OpenSSH.
  • ADR-111 — air-log (#474/#476/#478/#482/#484) : sink de logs opérationnels couche 1, journald natif sans C, repli résilient, émission directe par air-sshd (déconnexions, IP et port source séparés), enveloppe AirCom horodatée avec provenance.
  • ADR-112 — air-sandbox (#477/#480/#481/#483/#485/#486/#487) : compilateur seccomp allow-list → BPF arch-aware écrit en Rust, allow-list sémantique par noms de syscalls, profils prédéfinis pré-auth et worker SFTP, spec Landlock par chemins et accès sémantiques, application au noyau (no_new_privs + Landlock + seccomp, tests privilégiés), fuzz du compilateur, posture fail-closed avec opt-out journalisé.
  • Migration des tests sur la std linux-air (#462) : sys/paths complété, libtest prouvé sur cible, cargo xtask test-air, backend getrandom maison, fork proptest pour la cible, tests should_panic/catch_unwind gatés (panic=abort) — aboutissant à la couche 1 entière (28 crates) et la couche 2 entière (41 crates) vertes sur la std linux-air. La migration révèle au passage un correctif SA_RESTORER en couche 0.
  • Outillage : trois sous-agents Air (security-reviewer, ci-shepherd, barrier-runner) (#502) ; gate de couverture « couvrable-vide », couches 0/1 réconciliées + plancher couche 2 (#490).

2026-07-26 — Campagne de sécurité ADR-105 close, puis 15 ADR de conception (vagues 1 et 2)

  • Durcissement DoS : throttling des connexions pré-auth (#438), rate-limit par source en token bucket en plus de la limite de concurrence (#439).
  • Campagne ADR-105 menée sur 6 axes et close (#440/#443) : audit, DoS, preuve constant-time de la comparaison de tag via harnais dudect (#441), anti-énumération corroborée par mesure réseau (#442).
  • Vague 1 de conception (ADR-107→113) : pipeline CI/CD linux-air (#446), air-keystore (#447), certificats SSH et autorité de certification (#448), KEX post-quantique ML-KEM (#449), air-log / journald natif sans C (#450), air-sandbox seccomp+Landlock avec compilateur BPF maison (#452), doctrine de sourcing cryptographique — pur Rust os-free, ne pas porter de C, DIY sérieux (#453).
  • Vague 2 de conception (ADR-114→121) : air-json (#454), CLI air-keystore (#455), air-agent (#456), redirections et multiplexing SSH (#457), capacités étendues de la config binaire SSH (#458), extensions SFTP OpenSSH + surface statvfs couche 1 (#459), authentification hostbased (#460), provisioning air-desktop-host (#461).
  • CI : les jobs de code sont sautés quand un diff est 100 % documentation (#451).

2026-07-25→26 — SFTP v3 in-process et authorized_keys administrables (ADR-104)

  • authorized_keys : écrivain binaire + import du texte OpenSSH (#430), CLI authorized-keys list/add/remove/import gatée ADR-101 (#432), cascade magasin système + magasin par-utilisateur (#424), création des répertoires parents à l’import (#445).
  • SFTP : cœur sans-IO du serveur v3 (#425), pilote lecture seule prouvé avec le vrai client OpenSSH (#427), écriture put/mkdir/rename/rm (#435), interop scellée en CI avec transfert ≥ 100 Mio à fenêtre respectée (#436). fork_process ajouté en couche 1 (#433).
  • ADR-106 : Air est un target_os distinct et non-POSIX sur substrat noyau Linux (#434).
  • ADR-105 ouvert : campagne de tests de sécurité d’air-sshd (#431).
  • Re-sceau couche-1-v2.4 (comptes binaires ADR-100 + fork_process) (#437) ; air-sshd 0.1.0 → 0.2.0, version deb par paquet (#444).
  • Correctifs : pipe/pipe2/socketpair en int[2] — et six gates ABI qui se sautaient en silence (#426) ; course de port du test de repli DNS UDP→TCP (#429) ; le test uinput ne tape plus dans la console de l’utilisateur (#428) ; filet CI pre-checkout contre les artefacts root post-couverture (#419).

2026-07-24→25 — Base de comptes binaire (ADR-100/101) et discipline de build linux-air (ADR-103)

  • Comptes : schémas Cap’n Proto de la base binaire (#398), lecteur /etc/air/*.aircfg (#399), écrivain + projection /etc + import inverse (#400), CLI air-account (import, user, passwd Argon2id) (#401), CLI groupes (#402), gate mot de passe admin sur toutes les mutations (#403, ADR-101), lecture list/show + mode --json + statut du mot de passe (#406), permissions correctes passwd/group en 0644 et shadow en 0600 (#407).
  • Discipline linux-air : crate air-std-entry fournissant le _start des exécutables (#409), os=air retiré de restricted_std (#410), std::env::args() câblé sur air-runtime (#412), std::time câblé puis rebranché sur la couche 1 et non la couche 0 (#414/#417), trampoline rt_sigreturn passé en .weak (#408), crates C-ABI rlib-only (#405, ADR-102). ADR-103 formalise la discipline (#411) ; le gate cargo xtask check-target l’applique (#415) et vérifie la couverture de livraison (#420) ; cargo xtask deb cible linux-air en build-std, 0 glibc (#404/#413). Trois machines re-déployées depuis une source conforme (#416/#418).

2026-07-23 — air-sshd phases 2 et 3, client air-ssh, configuration binaire

Grosse journée (~37 PR) : le démon devient un serveur SSH complet, et le client naît.

  • Phase 2 — ssh-userauth (ADR-094) : durcissement Terrapin / strict-KEX (#362), codec et StateMachine userauth (#363), publickey Ed25519 avec sonde PK_OK (#364), trait AuthorizedKeys + events AirCom (#365), durcissements essais bornés / anti-énumération / grace avec preuve CI ssh -i (#366), magasin authorized_keys binaire par utilisateur (#367/#390, ADR-096).
  • Phase 3 — ssh-connection (ADR-097) : setsid et terminal de contrôle (#369), cœur des canaux multiplexés (#370), service de session shell/exec (#371), codec de redirection de ports (#372), direct-tcpip (#373) puis tcpip-forward (#376), nom authentifié rendu (#377), démon binaire à connexions concurrentes (#378), séparation de privilèges par drop_to_user avant exec (#379).
  • ADR-098 : combinateurs de concurrence structurée dans air-async (join/join_all/ try_join, Unordered, Notify) (#374/#375).
  • Client air-ssh (ADR-099) : transport + session + binaire, interop OpenSSH double sens (#380), user@host et résolution de nom (#383), -i sur clé openssh-key-v1 avec parseur fuzzé (#384), shell interactif — pump PTY serveur, raw-mode client, login_terminal couche 1 (#385/#386).
  • Configuration binaire (doctrine ADR-073) : schémas bespoke Cap’n Proto serveur et client, enveloppe AIRCFGv1, lecteurs, résolution par-hôte, CLI config get/set avec writer d’artefact binaire, export/import JSON des deux côtés (#388→#397).
  • Re-sceaux couche-1-v2.2 (#381) et couche-1-v2.3 (#387) ; ADR-095 packaging des exécutables air-ssh (#361).

2026-07-13→14 — air-sshd phase 1 : couche transport SSH-2 (ADR-093)

Premier composant réseau réel, construit selon le motif sans-IO (ADR-091).

  • Squelette du transport — cœur sans-IO, seams, Framer, fuzz (#354) ; KEXINIT et négociation moderne-only (#355) ; KEX curve25519-sha256, hash H, signature Ed25519 et dérivation (#357) ; NEWKEYS, régime chiffré AEAD et rekey (#359) ; puis wire-compat OpenSSH scellée en CI par un test d’intégration ssh réel (#360).
  • Additifs air-crypto : constructeurs seedés X25519/Ed25519 (#356), AEAD à nonce explicite SSH (chacha20-poly1305@openssh, aes256-gcm) (#358).
  • Doc publiée : bascule sur VPS https://docs.air-desktop.org, GitHub Pages retiré (#351/#352). Proposition de spec du système de configuration AirDB versée au dossier (#340).
  • ADR-094 ouvert (phase 2 ssh-userauth).

2026-07-12→13 — AirCom (IPC couche 2) et modèle d’objet air-object

  • AirCom : fondation sans-IO et 4 lacunes couche 1 comblées — fd et shm zero-copy (#333) ; spec v1.0 figeant les décisions BDFL — nommage air-com*, wire Cap’n Proto, SEQPACKET + shm, capability = fd, surfaces sync et async, pub-sub en anneau, air-registry 1 fd par service (#334) ; puis 7 incréments (#335→#342, #349) : schéma capnp et Codec fuzzés, StateMachine multi-états + Multiplexer + Handshaker, transport SEQPACKET bout-en-bout avec deux surfaces et exemple 2-process, accept pleinement io_uring, data plane zero-copy et contrôle de flux par crédits, pub-sub anneau shm SPMC fuzzé, contrat air-registry, intégration du modèle d’objet et premier service air-notifyd.
  • air-object (AO.1→AO.5, #343→#347) : air-value (layout figé, C-ABI), cœur AirObject/AirClass avec refcount et AirHandle, propriétés observables + send_message + politiques de thread, proc-macro #[air_class], introspection — avec durcissement de la conformité ABI à chaque étage.
  • Re-sceau couche-1-v2.1 ; chantiers AirCom et air-object déclarés complets (#348/#350).

2026-07-11 — Couche 2 amorcée : air-async (ADR-092) et motif réseau sans-IO (ADR-091)

  • ADR-091 rend normatif le motif réseau sans-IO (anatomie en 9 composants) : toute implémentation réseau doit s’y conformer — c’est le seul motif qui permet de tester la pile indépendamment de l’I/O.
  • air-uring (réacteur couche 1) et air-async (exécuteur couche 2) en 4 incréments (#329→#332) : squelette, sûreté d’annulation io_uring + echo TCP async, éveil inter-thread + timers + back-pressure + I/O fichier, thread-per-core + multishot + provided buffers.
  • Outillage : métadonnée de couche par crate et gate check-layers (#328).

2026-07-10→11 — 🎉 Pivot ADR-088 : std portée sur un PAL safe, sans libc C

Retournement de stratégie juste après M5 : plutôt que faire tourner std sur la libc C maison, on porte std directement sur la couche 1 Rust safe, cible target_os="air".

  • Doctrine : révision doctrinale de la macro-architecture — userland Rust safe, stabilité par couche, surfaces libair / libair_c (#317/#318) ; étude puis ADR-088 (#319/#320) ; ADR-089 réservant la place du SecurityManager (sécurité active per-process, #324) ; ADR-090 — binder la couche 1 dans std par rustc-dep-of-std, découplage plutôt que fork (~71 forks évités) ; AirProcessManager renommé AirTaskManager (#322/#323).
  • Portage std (#326) : alloc, errno, futex et synchronisation, stdio, env, exit, thread, mpsc, fd et fs (+ current_exe), process, net (TCP et UDP), random — prouvé sur deux arches, x86_64 (carbon) et aarch64 (Raspberry Pi, vrai matériel), en static-PIE zéro glibc. Une seule dépendance forkée subsiste (bitflags).
  • Différés soft traités (#327) : os::fd et pipe (débloque Stdio::Piped), options socket (additif couche-0 v1.13), DNS.
  • Correctif d’étape : air-alloc gère l’alignement fort (> 16), ce qui lève ADR-056 D5 et débloque std::sync::mpsc (#325).
  • Re-sceaux couche-0-v1.13 et couche-1-v2.0.
  • CI : artefact de doc uploadé uniquement sur main, pour soulager le quota (#321).

2026-07-10 — 🎉 M5 ATTEINT : std tourne sur Air, zéro glibc, 2 arches

Jalon majeur : un programme Rust std (hello-std) lié intégralement contre libair (zéro glibc) s’exécute on-targethello from std on air + exit 0 sur x86_64-air (carbon natif) ET aarch64-air (raspi natif). Static-PIE, pari ADR-076 tenu de bout en bout.

Le lien de hello-std révélait 12 symboles indéfinis ; comblés en 6 PR :

  • #308 __xpg_strerror_r (alias XSI de strerror_r).
  • #309 open/fcntl — exports non-variadiques cible (#[cfg(target_vendor="air")]).
  • #310 couche 1 air-runtime::process_context (capture auxv + bornes de pile au bootstrap) + AirProcessManager::resource_limitADR-086.
  • #311 getauxval + pthread_self + détection de pile (pthread_getattr_np/ attr_getstack/attr_getguardsize).
  • #312 TLS keys cible (pthread_key_create/delete/setspecific) via #[thread_local] natif (discipline .tbss) + allocateur KEYS extrait/partagé.
  • #313 syscall générique — couche 0 raw_syscall (asm 2 arches) + shim libc cible. EXCEPTION assumée temporaire (ADR-087) : entorse à ADR-021 conv.3 + « libc binde couche 1 » ; à retirer avec l’équipe Rust (le pal unix de std appelle futex/gettid en direct).

Re-sceaux (PR #314, tags signés GPG) : couche-0-v1.12 (raw_syscall) + couche-1-v1.9 (introspection thread ; l’earmark ADR-078 crypto glisse en v1.10). Prochaine phase = OpenSSH / air-sshd (ADR-074). (La campagne M4 réseau + posix_spawn, 2026-07-05→09, n’a pas d’entrée dédiée ici — voir etat-avancement.md et le registre des ADR.)

2026-07-04 — 🧵 libc M3 Phase B : familles signaux + pthread (couche 2)

Toit libc M3 Phase B (branche feat/libc-m3-phaseB, sur la Phase A ADR-066). Deux crates de logique mesurée (aucun #[no_mangle]), empilement strict libc → couche 1 (jamais air-sys-syscall) : air-libc-signal (sur air-signal) et air-libc-thread (sur air-thread::raw_futex). Shims extern "C" dans air-libc-capi::{signal,thread}, ré-export/ancrage air-libc-c, types+prototypes hand-written dans le trailer cbindgen.toml (union struct sigaction, sighandler_t, opaques pthread + initialiseurs, SIG*/SA_*/PTHREAD_*), exclus de la génération cbindgen.

  • Sécurité #1 — 4 fautes INERTES (ADR-064) : sigaction/signal sur SIGSEGV/ SIGBUS/SIGFPE/SIGILL acceptent (retour 0) et mémorisent la disposition (illusion d’API : table process-globale pour oldact) mais n’installent RIEN → la faute prend toujours l’action par défaut (coredump). Les signaux gérables sont installés en async réel (air_signal::install_handler, le kernel appelle le handler C). sigprocmask/pthread_sigmask→masque ; kill/raise→envoi ; sigpendingpending ; sigsuspendreplace_mask + futex_wait interruptible.
  • pthread sur raw_futex (mémoire opaque de l’appelant, jamais AirMutex) : mutex 3 états NORMAL/RECURSIVE/ERRORCHECK (owner = Tid) + timedlock ; cond par compteur de séquence + timedwait (échéance REALTIME/MONOTONIC) ; rwlock lecteur-préférentiel ; once (3 états) ; TLS (key_create+dtor à la sortie de thread via std, hôte). Cycle de vie create/join/detach/self/equal sur le spawn d’air-thread (hôte, pthread_t = Tid). Types opaques ABI Air largeur fixe (mutex 40/cond 48/rwlock 56/once 4, sigset_t 128) + initialiseurs zéro.
  • Additifs REMONTÉS (non inventés) : sortie de thread propre (pthread_exit endort le thread faute de primitive couche 1 + panic=abort), crochet de destruction TLS cible, rt_sigsuspend atomique, kill groupe de processus. Déviations gravées D-M3.1..D-M3.9 (docs/libc-conformance.md).
  • Validation superviseur en attente (cargo/git gated dans l’environnement de production) : gen-capi-header (régénère air_c.h — trailer M3), barrière + nm -D (sigaction/kill/pthread_create/pthread_mutex_lock…), programme C multi-thread, signal non-faute délivré async + faute → coredump, sur x86_64 ET aarch64. Cargo.lock à régénérer (2 nouvelles crates).

2026-06-29 — 🧭 CORRECTION D’ARCHITECTURE : air-rt est un objet couche 1 (ADR-052)

Relecture du layering : le crate rt/crates/air-rt (phase 2) appelle la couche 0 en direct (arch::set_fs, mem::mmap, process::clone_thread, futex, exit_group) — violation de la doctrine d’Air (seule la couche 1 consomme la couche 0 ; personne d’autre). ADR-052 (ratifié BDFL) grave la correction : air-rt est un objet couche 1, AirRuntime, composé de nouvelles briques couche 1 à créer (elles n’existent pas encore) — ThreadControlBlock, ThreadLocalStorage, outils de relocation (cible PIE/static-PIE, ADR-050) — s’appuyant sur air-thread/air-mem/air-proc. AirRuntime ne dépend jamais de air-sys-syscall (la surface kernel) — air-sys-types (types transverses : AirError/Errno, Pid/Tid… ADR-052 D6) reste autorisé. Les wrappers de syscalls (dont set_fs, ADR-051) restent en couche 0 ; leur consommateur devient un objet couche 1 (jamais le runtime). Le tree hors-arbre rt/ ne garde que le point d’entrée ELF _start + la plomberie cible (build-std/nightly). Doc mise à jour / tagué obsolète : ADR-049 D8 (bannière + biffure), ADR-051 (note layering + reformulation « affaire d’air-rt »), macro-architecture (§Couche 1 : objet air-runtime + tableau d’artefacts), family-arch.md/family-process.md, INDEX, rt/README.md/rt/DECISIONS.md, registre (v3.17) + SUMMARY. Gain : la logique de runtime, devenue couche 1, retombe sous le **100 % couverture

  • barrière 2-arches** (le carve-out rt/ ne couvre plus que _start + cible). Code à refactorer (création des objets couche 1, déplacement, retrait dépendance air-sys-syscallair-sys-types conservé) = chantier suivi séparément.

2026-06-28 — 🔒 RE-SCEAU couche-0-v1.7 : 7 syscalls pour le runtime Air et la libc

Descellement unique (ADR-051, ratifié BDFL) ajoutant 7 wrappers typés à la couche 0, pour débloquer la phase 2 du programme *-linux-air (runtime air-rt) et pré-équiper l’objectif libc (ADR-046) — un seul re-sceau plutôt que des cycles répétés. arch_prctl (set_fs/get_fs, x86_64-only, nouvelle famille arch) programme le registre TLS du thread principal x86 (sur aarch64 = tpidr_el0 en asm, affaire d’air-rt ; threads spawnés = CLONE_SETTLS). process : getppid (→ Option<Pid>, 0=parent hors namespace), set_tid_address (primitif de join), sched_yield, umask, getcwd (octets, Principe 3), getrusage (+ types Rusage/RusageWho/Timeval #[repr(C)]). Tous en asm 2-arches avec // SAFETY:. 16 tests dont stratégies sûres pour les cas délicats (arch_prctl : no-op sur la base courante ; set_tid_address : threads détachés + Arc). check-syscalls PASS (numéros validés vs headers uapi, 2 arches), couvrable VIDE (2 exceptions STRUCTURAL/EFAULT-SAFE : bras Err de get_fs/getrusage inatteignables par construction — buffer valide + who typé), audit-exceptions cohérent (77 entrées, STRUCTURAL 46). Reporté en couche-0-v1.8 (phase 5, avec OpenSSH comme consommateur) : termios (ioctl typé), PTY, rt_sigaction/sigaltstack (+ décision ADR-020).

2026-06-28 — 🔒 RE-SCEAU couche-0-v1.6 : couche 0 std-free + futex(2) + types FD natifs + clone3 spawn de thread

Descellement unique de la couche 0 (ADR-048 + Amendement 1, ratifiés BDFL) regroupant : (1) famille futex(2) classique (FUTEX_WAIT/WAKE typés, EINTR remonté ; PR #147) ; (2) internalisation des types FD (OwnedFd/BorrowedFd/RawFd natifs d’Air + close(2) dans air-sys-typescore::os::fd n’existe pas en stable ; PR #148) ; (3) std-free #![cfg_attr(not(test), no_std)] (ffi→alloc/core, Arcalloc, OsStringVec<u8>, std::sync::MutexFutexMutex ; PR #149) ; (4) clone3 spawn de thread (clone_thread + trampoline asm x86_64/aarch64, join CHILD_CLEARTID/futex ; PR #150). Critère de sortie atteint : cargo build -p air-sys-types -p air-sys-syscall --lib compile en #![no_std] (preuve par le compilateur de l’absence de std en code lib). Couverture couvrable VIDE (branches ~84,3 %), // SAFETY: sur chaque unsafe, vérifié sur les 2 arches (bug asm aarch64 x19 réservé LLVM → x21, attrapé par la vérif 2-arches). Débloque la réimpl air-thread std-free (couche 1) sur futex/clone3, prérequis de la libc Air (ADR-046/047). Points à réconcilier par RFC : la spec air-sys-types.md montre encore CloneResult::Child pour les threads (unsound → clone_thread distinct) ; outillage ABI Rust air-abi-check/air-symver non implémenté (ADR-012).

Rôle de ce document

Tableau de bord dynamique du projet. Donne en un coup d’œil :

  • L’état d’avancement courant.
  • Le prochain chantier prioritaire et son contexte.
  • Le backlog priorisé (vue condensée).
  • L’historique des sessions précédentes.

Pour la checklist structurelle exhaustive (tous les documents à produire, arborescence cible, statistiques), voir INDEX.md. Pour le détail des ADRs, voir adrs/registre-adrs-fr.md. Les deux sont à consulter quand on cherche un détail précis ; JOURNAL.md sert à savoir où on en est et ce qu’on fait ensuite.

Convention d’usage en début de session : prompt initial type — « Bonjour, on continue sur Air. Lis docs/JOURNAL.md pour le contexte de reprise, puis on attaque [sujet]. »

État courant

2026-06-25 — 🎯 JALON : air-config COMPLET (5 backends + validation cas-réels). Suite des tranches 1-3 (chaîne verticale), livrés : tranche 4 (backends systemd — symlinks *.wants/mask/drop-ins/default.target, remplacement atomique ; fstab — 6 colonnes déterministes ; PR #122) et tranche 5 (passwd/group/shadow, PR #125 — SÉCURITÉ : shadow créé 0600 dès la naissance via openat2, jamais de chmod après-coup ; hashes en Zeroizing effacés après écriture ; jamais loggés/diagnostiqués ; is_safe_hash anti-injection ; tests comptes synthétiques en tempdir, robustes root). Validation cas-réels (PR #126) : corpus de fixtures /etc réalistes + comparaison sémantique (modèle-vs-modèle, détection de perte) = gate CI reproductible ; harnais advisory /air/etc (examples/etc-fidelity) produisant imported/artifact/reemitted/semantic-diff par backend sur la vraie machine — fidélité sémantique parfaite sur le /etc réel de carbon (138 units, 135 comptes), zéro secret (/etc/shadow jamais lu ; re-scan → 0 hash sous /air/etc, vérifié indépendamment). 5 backends /etc couverts (resolv.conf/hosts, systemd, fstab, passwd/group/shadow). Zéro unsafe/as/dépendance externe/surface C ; 6 cibles fuzz ; 100 % hors exceptions STRUCTURAL ; code généré *_capnp.rs exclu de la couverture (ADR-040). A nécessité le fix couche 0 faccessat2 (re-sceau v1.4, PR #123 — faccessat honorait pas son flag AT_SYMLINK_NOFOLLOW). Reste pour clore la couche 1 : l’ABI C (libair-base.so, doc cadrée ADR-027 addendum).

2026-06-25 — 🎯 JALON : air-config — chaîne verticale complète (tranches 1-3, PR #116/#118/#120). Le modèle de configuration d’Air (ADR-033/040/041) est implémenté de bout en bout, en 3 crates : air-config-compile (parseur du format de source maison — schema-directed, hand-rollé, fuzzé ~1,4 M runs ; binding au schéma + did-you-mean ; conversions typées checked anti-overflow/signe, zéro as ; décodage bytes hex/b64 ; secrets zeroizés/élidés ; encodage Cap’n Proto déterministe → artefact bit-pour-bit reproductible), air-config-schema (4 domaines .capnp : network/services/accounts/mounts + annotations $secret/$domain/$backend ; code Rust généré committé ADR-040 ; build sans tool C++ prouvé ; tool capnp 1.1.0 pinné), air-config (façade : lecteur mmap zéro-copie + vérif magic/checksum/versionInvalidData, jamais de lecture muette ; XDG ; générations + switch atomique + rollback via write_atomic ; trait Backend paramétré par racine + backend resolv.conf/hosts émission swap-atomique + import inverse défensif). Aucune fn unsafe exposée, zéro as, zéro dépendance externe, zéro surface C (capnp runtime pur Rust). 3 cibles fuzz ; 100 % lignes+branches hors exceptions STRUCTURAL ADR-035. Code généré *_capnp.rs exclu de la couverture (--ignore-filename-regex, corollaire ADR-040). Tests backends en tempdir (assert explicite root != /etc). Décisions design : ADR-040 (Cap’n Proto + addendum tool-C++/code-committé), ADR-041 (coexistence /etc), ADR-027 addendum (doc ABI C). Tranche 4 différée (backends systemd → passwd/shadow prudent → fstab). Reste couche 1 : tranche 4 air-config + ABI C (libair-base.so).

2026-06-25 — 🎯 JALON : air-base-lib COMPLET (cœur + services, PR #110). Les services sont livrés : AirLog (journalisation journald via protocole socket natif AF_UNIX, zéro dépendance systemd ; petites entrées CLÉ=valeur / forme binaire ; repli memfd scellé + SCM_RIGHTS sur EMSGSIZE ; non-fatallog() n’échoue jamais, pertes comptées) + identifiants (AirUuid v7/v4, AirId128 via /etc/machine-id, AirMonotonicId). Aucune fn unsafe exposée, zéro as, zéro dépendance externe, zéro surface C ; 94 unit + property + 11 doctests + 2 cibles fuzz (journal_encode, uuid_parse) ; 100 % hors exceptions STRUCTURAL in-code (bras d’erreur kernel inatteignables : add_seals/write memfd, getrandom partiel ≤256 o). Cœur non régressé (byte-identique). tracing-bridge différé (zéro dépendance). §3 Config NON implémenté ici : superseded par ADR-033/040/041 → crate dédiée air-config (compilateur + artefact Cap’n Proto). Couche 1 — état : 8/8 crates cœur + air-base-lib services ✓. Reste pour clore la couche 1 : air-config (chantier dédié) et l’ABI C (libair-base.so, différée).

2026-06-25 — 🎯🎯 JALON MAJEUR : COUCHE 1 — 8/8 CRATES CŒUR IMPLÉMENTÉ (air-process livré, PR #105). Le cœur de la couche 1 est complet : air-base-lib cœur + air-crypto + air-socket + air-filesystem + air-memory + air-device + air-thread + air-process. air-process : AirCommand/AirProcess (clone3+CLONE_PIDFD, redirections dup3, chdir, execve_prepared, wait synchrone waitid-sur-pidfd, signal/kill), AirPipe (pipe2+CLOEXEC), AirExitStatus typé ; drop_privileges (process courant) + drop_privileges_to (drop dans l’enfant post-fork avant exec) — chemin enfant alloc-free (CStrArray + buffer de groupes construits avant clone3, drop_privileges_with_buffer sans alloc → async-signal-safety), ordre sûr no_new_privs→setgroups→setresgid→setresuid→capset→ambient→seccomp→landlock→verify, codes d’échec distincts spawn/drop. Aucune fn unsafe exposée, zéro as. Tests d’intégration réels + 4 tests root (regain de privilège irréversible, accès root révoqué via drop_privileges_to) + Miri ; 100 % lignes+branches hors exceptions ADR-035 in-code (CHILD-EXIT du chemin enfant, STRUCTURAL wait/clone3). A nécessité une extension couche 0 : execve_prepared (exec sans allocation pour le chemin post-fork) — additive, re-scellée couche-0-v1.3. Décisions config gravées en parallèle : ADR-040 (artefact air-config = Cap’n Proto ; addendum : capnpc exige le tool C++ au build → politique code généré committé) + ADR-041 (coexistence /etc : projection générée, RO sélectif, air-config seul écrivain). Reste pour clore la couche 1 : air-base-lib services (logging journald natif + identifiants ; air-config = chantier dédié Cap’n Proto) et l’ABI C (libair-base.so, différée).

2026-06-25 — 🎯 JALON : air-thread livré (PR #97) — threads + synchronisation de la couche 1. AirThreadBuilder (nom, taille de stack, affinité CPU — implémentée via la couche 0 set_cpu_affinity+gettid, handshake par canaux : l’enfant publie son Tid, l’appelant pose l’affinité avant de débloquer le corps) + AirThreadHandle<T> (joinAirResult). AirMutex/AirRwLock/AirSemaphore au-dessus de std::sync, SANS poisoning (poison absorbé via into_inner, testé par empoisonnement délibéré). AirChannel MPSC ; atomics ré-exportés. Aucune fonction unsafe exposée, zéro as, arithmétique saturante. Property-based + Miri (35 tests ; 5 ignorés = affinité en asm + stack, non supportés par Miri). 100 % lignes+branches hors 1 exception STRUCTURAL (usize::try_from(cpu) Err, impossible sur cibles 64 bits, doc in-code). Backend std::sync v1, migration futex maison différée. NB : l’affinité CPU était marquée « différée » dans la spec ; implémentée car la couche 0 (PR #55) la fournit → spec mise à jour. Piloté par agent headless sur carbon. Couche 1 : 7/8 crates cœur implémenté (+ air-thread).

2026-06-24 — 🎯 JALON : air-device livré (PR #94) — énumération/surveillance/identité natives de la couche 1. Natif sysfs + netlink uevent (groupe KERNEL) + evdev, sans libudev/libc. §1–§4 : AirDevice (attributs/propriétés/parent), AirDeviceEnumerator (/sys/class+/sys/bus, dédup canonique, filtres), AirDeviceMonitor (hotplug uevent), typés AirNetDevice/AirBlockDevice/AirUsbDevice. §5 AirInputDevice (evdev : capacités bitmap, horloge monotone EVIOCSCLOCKID, AirInputEvent::from_raw pur). Monitor durci : next_event couvert par test d’intégration root réel (uevent synthétique echo change > /sys/.../uevent) — plus d’exception PRIVILEGE. Aucune fonction unsafe exposée, zéro as, parsing borné get(). 3 cibles cargo-fuzz (uevent/sysfs/input ; input 5,5 M runs, 0 crash). Production 100 % lignes+branches hors 1 exception STRUCTURAL (write_attribute partiel, écriture sysfs atomique ADR-035) ; résidus = code de test (ADR-035 TEST-HARNESS). Ajout couche 1 : air-base-lib::AirInstant::from_monotonic_micros (horodatage evdev ; air-base-lib reste 100 %). Piloté par agents Claude Code headless sur speedy (gate = barrière x86 speedy+carbon + CI ARM). Couche 1 : 6/8 crates cœur implémenté (air-base-lib cœur + air-crypto + air-socket + air-filesystem + air-memory + air-device).

2026-06-24 — 🎯 JALON : air-memory livré (PR #88, par carbon) — allocateurs spécialisés + comptabilité de la couche 1. 5 sections : AirMemoryTracker/AirMemoryUsage (comptabilité per-composant atomique, opt-in, peak monotone via fetch_max) ; AirArena (bump O(1), alignement align_offset/checked_*, contrat gravé : reset/Drop n’exécutent PAS le Drop des valeurs, OOM par try_reserve sans abort, unique unsafe interne avec // SAFETY: par bloc) ; AirObjectPool/AirPooled (Rc/RefCell, croissance sans plafond, RAII respecte Drop) ; AirSlab/AirSlabKey (slots stables anti-ABA, génération wrapping_add u32, zéro unsafe) ; AirBacking (heap ; mmap différé, API non-cassante). Mono-thread (&mut self), aucune fonction unsafe exposée, arithmétique défensive (zéro as). Zéro dépendance externe, ZÉRO surface C. Tests unitaires + property-based + Miri (incl. test concurrent 8 threads sur le tracker → zéro UB/data-race). Couverture 100 % lignes+branches hors 1 exception STRUCTURAL (bras None du take() dans Drop for AirPooled, inatteignable par construction, gardé car un Drop ne doit pas paniquer ; doc in-code ADR-035). Décisions ratifiées : slab wrapping_add, pool sans plafond, peak monotone. 1ère crate couche 1 entièrement pilotée par agents Claude Code headless sur carbon (orchestration multi-machines ; gate de merge = barrière x86 speedy+carbon + Miri + CI ARM, ADR-037). Couche 1 : 5/8 crates cœur implémenté (air-base-lib cœur + air-crypto + air-socket + air-filesystem + air-memory).

2026-06-16 — 🎯 JALON : air-filesystem livré (PR #87, par carbon) — opérations filesystem de la couche 1. 6 sections sur la couche 0 (famille fs + fs::inotify, types air-sys-types::fs) : chemins canoniques & confinement resolve_within (anti-path-traversal par les flags kernel RESOLVE_BENEATH/RESOLVE_NO_MAGICLINKS d’openat2, sécurité par construction) ; écriture atomique (tmpfile + fsync fichier + parent + rename) ; AirTempDir RAII (Drop récursif non-paniquant, symlink-safe) ; copie copy_file_range + repli read/write transparent, copy_tree borné (anti-boucle de symlink) ; watchers sur inotify (debounce, récursivité, corrélation move par cookie, Q_OVERFLOW → variante dédiée Overflow = zéro-perte ADR-032) ; recherche bornée (read_dir, glob maison sans dépendance, find_regex différé). Zéro dépendance externe ajoutée, ZÉRO surface C (gate check-c-surface vert en défaut et --all-features). Correction de contrat : la note spec « inotify à ajouter en couche 0 » était périmée (inotify y est ; décodage inotify_event déjà fait/fuzzé en couche 0 → couche 1 consomme des événements décodés, ne re-parse pas). Tests unitaires + property-based (glob vs oracle, bornage, idempotence) + intégration (confinement, atomicité, copie, watcher inotify réel) + 2 cibles cargo-fuzz (fuzz_glob_match, fuzz_watch_mapping ; 0 crash). Couvrable VIDE (exclusion couverture retirée → air-filesystem mesuré ; lignes ~97 %, les rares bras non couverts sont des gardes défensives STRUCTUREL/ENV documentées in-code, ADR-035). Décision déférée (à acter) : regex (feature search-regex) refusé par la règle des 80 % → find_regex renvoie Unsupported (glob couvre le courant) ; exception nommée vs report définitif laissé à Thierry. Couche 1 : 4/8 crates cœur implémenté (air-base-lib cœur + air-crypto + air-socket + air-filesystem).

2026-06-16 — 🎯 JALON : air-socket livré (PASSE 2, PR #86) — sockets typés + résolution enfichable + client DNS maison. Sockets synchrones sûrs sur la famille net couche 0 : TCP (AirTcpSocket/Listener, connect_to_host IPv6-first), UDP (AirUdpSocket), Unix stream/listener/datagram + passage de FD SCM_RIGHTS (capability AirCom, ADR-001). OwnedFd RAII, as_fd() seam couche 2, zéro unsafe exposé. Résolution maison enfichable et ordonnée (AirNameResolver, sources [hosts, DNS], IPv6-first/IPv4-fallback) — sans libc/NSS/getaddrinfo. Client DNS maison RFC 1035 (AirDnsSource, A+AAAA, UDP + repli TCP sur bit TC, timeout 5 s/2 essais via poll MSG_DONTWAIT+clock_nanosleep). Parseur DNS = surface de sécurité (Principe 3) : slicing borné get(), pas de suivi de pointeur de compression (anti-boucle par construction), checked_* ; fuzz cargo-fuzz obligatoire (fuzz_dns_parse, smoke 300 000 exécutions, 0 crash). Tests unitaires + property + intégration loopback (TCP/UDP, Unix + passage de FD, serveur DNS en-process + repli TCP). AirNetlinkSocket RETIRÉ (décision 2026-06-15) : la couche 0 scellée n’a pas de socket netlink générique → futur crate air-netlink (couche 2, RFC netlink couche 0 requis). Zéro dépendance externe, zéro surface C (check-c-surface vert). Couche 1 : 3/8 crates cœur implémenté (air-base-lib cœur + air-crypto + air-socket).

2026-06-15 — 🎯 JALON : air-crypto livré (PR #80, merge f7057dd) — cœur crypto moderne de la couche 1. 7 sections (random/keys/hash/hmac/aead/kdf/asymmetric) sur RustCrypto/dalek, purs Rust, ZÉRO surface C (gate check-c-surface vert, ban cc respecté). XChaCha20Poly1305 recommandé par défaut (nonce 192 bits, anti-mésusage) ; BLAKE3 différé (blake3 tire cc → conflit zéro-C ; AirHashAlgorithm #[non_exhaustive]) ; streaming AEAD différé (frontière de volume documentée). KAT de conformité officiels (RFC 8439/8032/9106, NIST CAVP, RFC 4231/5869/7748) traversant le câblage de prod (seams pub(crate)) ; property-based ; zeroize-au-Drop vérifié ; fuzz×4. Couvrable VIDE (exclusion couverture retirée → air-crypto mesuré, planchers tenus). Exception crypto nommée dans EXCEPTIONS.md (ADR-034/ADR-016). Couche 1 : 2/8 crates cœur implémenté (air-base-lib cœur + air-crypto).

2026-06-15 — air-base-lib : module encoding (base64/hex) livré (PR #83, merge 9474c8b). Base64 (RFC 4648) + hexadécimal, std-only, zéro dépendance, zéro surface C ; 100 % lignes/branches (encoding.rs), property-based + cibles cargo-fuzz de décodage. Surface validée par Thierry ; livré complet d’un coup (squelette jamais mergé seul) → air-base-lib reste à 100 % sur main. 1ᵉʳ crate produit par la machine carbon (relais multi-machines).

2026-06-15 — 🎯 JALON : 1ᵉʳ crate couche 1 implémenté (air-base-lib cœur, PR #73, merge cb480ac). Erreurs / chaînes-chemins / temps, adossés à icu4x 2.2 (compiled_data, données baked-in). 100 % couvrable VIDE : lignes/branches 100 % du code atteignable ; les 2 seuls bras non couverts — erreur des horloges kernel (AirInstant::now/AirDateTime::now_utc) — sont STRUCTURELLEMENT inatteignables (ADR-035), documentés in-code, sans seam d’injection (cohérent avec la politique clock_settime couche 0). 4 cibles cargo-fuzz (from_utf8/normalize/path/locale), property-based (proptest), doctests exécutables. Décisions calendrier validées par Thierry : en couche 1 (sans formatage), le japonais partage la numérotation grégorienne CE et l’hébraïque un mois ordinal ; l’ère (Reiwa…) et le MonthCode (Adar I/II) relèvent du formatage (couche 2). deny.toml admet Unicode-3.0 (icu4x 2.x a quitté Unicode-DFS-2016). Dette d’outillage consignée (à NE PAS résoudre ici) : les gates xtask couvrable-vide/audit-exceptions sont câblés couche 0 (filtre air-sys-*) ; leur extension à la couche 1 se heurte à une collision de basename (time.rs existe en couche 0 ET en couche 1) + recompute du récap ⇒ RFC/tâche séparée, les exceptions couche 1 restant documentées in-code en attendant. Couche 1 : 1/8 crates cœur implémenté (air-base-lib cœur ✓ ; services à venir).

2026-06-15 — 🔒 Politique « zéro surface C/C++ » VERROUILLÉE au gate (PR #81, merge 4544709 ; issue de l’audit 082). Deux verrous : deny.toml [bans] (ban nominatif cc/cmake/bindgen/pkg-config/aws-lc-sys/ring) + cargo xtask check-c-surface (garde-fou générique anti-*-sys, parseur testé, scope cargo tree -e normal,build), câblé en CI supply-chain et dans cargo xtask barrier. Produit vérifié zéro-C (48 crates, 0 *-sys). Faux positif neutralisé : deny.toml [graph] exclude = ["loom"] retire le sous-arbre cfg(loom) → generator → cc (harnais de modèle de concurrence test-only, déjà exempté dans machete) — le ban cc reste actif sur tout autre chemin. Réintroduction future (ex. ring pour air-tls) = exception NOMMÉE dans docs/EXCEPTIONS.md (ADR-024/034).

Date de dernière mise à jour : 2026-06-14 — 🔒 COUCHE 0 v1 — SCELLÉE. Audit de barrière complète vert sur tout le workspace, 2 arches (x86_64 speedy, aarch64 raspi-srv-2) : cargo fmt --check (stable et nightly), clippy --workspace --all-targets -D warnings (0 warning), cargo test --workspace (918 tests, 0 échec), cargo llvm-cov en root (lignes 97,73 % / branches 82,73 % — chaque rouge ⊆ une exception documentée de COVERAGE-EXCEPTIONS.md ; DEFERRED-TOOLING VIDE → couvrable VIDE au sens strict), cargo audit + cargo deny check + cargo machete verts, // SAFETY: sur CHAQUE bloc unsafe du workspace (lacune time.rs corrigée — PR #64), mdbook build FR+EN sans lien cassé. CODE COMPLET : 11 familles (process/fs/mem/signal/time/net/ipc/security/system/device/ebpf) + io_uring 12 Temps (1, 2a–2d, 3a–3f, 4) + extensions (MmapRegion, privsep, fs::inotify, affinité CPU). Doc développeur FR + EN. Tag couche-0-v1 (annoté, signé GPG). Caveat de sceau — RÉSORBÉ post-sceau (PR #66 mergée, merge 380d389, prompts 060/061) : les gates qualité sont gravés en cargo xtask (5 gates : barrier, couvrable-vide, check-syscalls, audit-exceptions, repro — dev-tooling zéro-dépendance, exclu de la couverture). La reproductibilité ADR-025 est désormais outillée (cargo xtask repro : double-build en env contrôlé, 4 rlibs bit-pour-bit) ; la dette de re-sync des fichier:ligne est outillée (cargo xtask audit-exceptions : format/taxonomie ADR-035 + recompte du récap, échec sur dérive). Ce gate a corrigé une dérive du décompte : registre à 69 entrées production (STRUCTURAL 38, FEATURE-KERNEL 13, PRIVILEGE 10, CHILD-EXIT 8, DEFERRED-TOOLING 0) — CHILD-EXIT 9/total 70 était un sur-comptage introduit au 7b00e75 (055), pas une rangée perdue (décision Thierry, 2026-06-14). cargo xtask check-syscalls certifie par ailleurs 161 numéros de syscalls conformes aux headers uapi des 2 arches (0 écart). Différés assumés (non bloquants) : types epoll spéculatifs (revue fin couche 3), futex synchrone (v2 own-futex air-thread), fanotify, différés io_uring 3a (submit_files_update/msg_ring_fd). Prochaine phase : implémentation couche 1 (air-base-lib cœur d’abord).

Historique — état précédent (2026-06-14, avant sceau)

Taxonomie d’exceptions actée (ADR-035) + 3 candidates COUVERTES — PR #60 mergée (aa47d74) : fs::try_lock, net::get_so_error, process::pidfd_send_signal(Some)DEFERRED-TOOLING VIDE / couvrable VIDE au sens strict ; registre recompté (70 entrées production : STRUCTURAL 38, FEATURE-KERNEL 13, PRIVILEGE 10, CHILD-EXIT 9 ; TEST-HARNESS en annexe — CHILD-EXIT 9/total 70 corrigés en 8/69 au 061, sur-comptage débusqué par xtask audit-exceptions) ; ADR-035 companion d’ADR-031. Puis : intro développeur couche 0 (PR #62, reflet Q4 family-security), traduction anglaise couche 0 (18 docs, PR #63).

  • Phase : design pré-ouverture publique. Implémentation couche 0 démarrée dans un workspace Rust privé.
  • Règle de pilotage actée le 2026-05-27 : le dépôt reste privé tant que la couche 0 n’est pas réellement opérationnelle ; tout travail d’ouverture publique ou de polish associé reste secondaire tant que cet objectif n’est pas atteint.
  • Documents fondateurs (vision, charte, principes d’ingénierie) : finalisés en v1.0.
  • ADRs fondateurs + ADR-018 imagerie : 18 ADRs (001 à 018, plus ADR-012-bis) finalisés et figés.
  • ADRs phase 0 (couche 0, runtime, infrastructure) : série complète, 7 ADRs finalisés (ADR-019 à ADR-025). La zone « design phase 0 » est close.
  • Implémentation couche 0 : deux crates privées déjà présentes dans le workspace : air-sys-types (types fondamentaux) et air-sys-syscall (wrappers syscall). Les 9 familles de syscalls sont implémentées ET couvertes à 100 % (lignes + branches) hors exceptions documentées, validées sur x86_64 (speedy) ET aarch64 (raspi-srv-2) au 2026-06-01 : process, signal, time, ipc, mem, fs, net, security, system + air-sys-types. Suite : 371 (air-sys-syscall) + 80 (air-sys-types) + 74 doctests + 2 + 1 intégration, 0 échec, sur les 2 arches. Reste couche 0 : io_uring, familles device/ebpf à spécifier.
  • Toolchain Rust : pin exacte relevée à 1.96.0 (bump depuis 1.95.0 le 2026-05-31 ; le minimum requis suit toujours la dernière stable utilisée), avec composants rustfmt, clippy, rust-src, llvm-tools-preview et targets Linux x86_64-unknown-linux-gnu + aarch64-unknown-linux-gnu. Motivation : conserver un dépôt Linux-first sur des versions stables récentes, débloquer l’outillage moderne (cargo-deny, cargo-audit, cargo-machete) et bénéficier du linker rust-lld par défaut sur x86_64-unknown-linux-gnu.
  • Specs couche 0 : toutes les familles sont spécifiées (2026-06-11). 11 familles (process, fs, mem, signal, time, net, ipc, security, system, device, ebpf) + io_uring (maître + 13 Temps, FR+EN) + air-sys-types (partiel). Les deux derniers trous — device (uevent/evdev, parsers zéro-alloc, sysfs→fs) et ebpf (bpf() exhaustif 37 cmd + perf_event_open) — sont comblés. Reste à produire : la spec exhaustive air-sys-types (type par type), en parallèle de l’implémentation. Socle de bindings tranché : syscalls directs via core::arch::asm!, sans dépendance externe (pas de rustix).
  • Documents de setup (phases A à D) : finalisés.
  • Macro-architecture : passe 1 (nettoyage) appliquée le 2026-05-20. Section 2 (couche 0) alignée sur les ADRs phase 0 (019-022) et sur le découpage en deux crates air-sys-types + air-sys-syscall. Toutes les références prospectives ADR-018 à ADR-026 neutralisées en marqueurs neutres. Passe 2 (complétion sections couches 1-5) à mener plus tard en parallèle de l’implémentation.
  • Documentation infrastructure : README.md, README-fr.md, LICENSE, .gitignore présents à la racine ; toute la doc (dont docs/EXCEPTIONS.md, docs/COVERAGE-EXCEPTIONS.md, docs/JOURNAL.md) est consolidée sous docs/ (hygiène documentaire 2026-06-01). Restent à produire : CONTRIBUTING.md, SECURITY.md, CODE_OF_CONDUCT.md, CHANGELOG.md et les guides/conventions dérivés.
  • Dépôt GitHub : air-desktop-project/air, privé, créé le 2026-05-20. Premier commit b3737d6 signé GPG (clé A9F56C4D9F59EE03), DCO Signed-off-by, verified par GitHub (reason: valid). Branche main poussée sur origin/main. Compte GitHub fondateur : tdelhaise.
  • Durcissement CI — PR #10 mergée le 2026-06-02 (3f1034e sur main). Corrige le flaky des 3 tests pidfd_* (défaut d’observation de test, pas une course de production : le numéro de fd fermé était réutilisé par un autre thread avant l’observation EBADF). Correctif côté test, API gelée : double défense par construction — dup vers un fd haut (min(soft NOFILE−1, 4096), non réutilisable par les opens concurrents qui prennent le plus petit fd libre) + FD_OBSERVATION_LOCK sérialisant les 3 frères. Plus purge .profraw (cargo llvm-cov clean) avant chaque mesure de couverture et doc du bruit landlock bénin (docs/CI.md). Couverture inchangée (96.61 % lignes / 78.79 % branches — baseline avec exceptions). 3 runs CI verts consécutifs sur les 2 arches. Réserve notée : l’immunité du fd haut tient tant que la suite ne dépasse pas le repère haut — à revisiter si un test ouvre des milliers de fd concurrents.
  • Incident opérationnel raspi-srv-2 — détecté puis RÉSOLU (2026-06-02). Pendant PR #10, raspi ne pouvait plus git fetch (remote inaccessible) : machine figée sur un vieux SHA (202e1d9, sans fs.rs), reset --hard origin/main en échec silencieux, validation initiale sur code périmé (faux « vert », rattrapé via un E0432) ; contournée alors par rsync. Réparé par Thierry : remote SSH + clé de déploiement dédiée — git remote set-url origin git@github.com:air-desktop-project/air.git puis git config core.sshCommand "ssh -i ~/.ssh/air_deploy -o IdentitiesOnly=yes". git fetch fonctionne. Réflexe conservé : après chaque délégation, vérifier le SHA réellement bâti — au moment du fix raspi était encore à c01f3b8 (PR #9) et doit re-fetch pour atteindre 3f1034e (PR #10).
  • Renommage ADR-029 — PR #11 mergée le 2026-06-02 (d1ce8ad sur main). Types façade + paramètres des crates couche 0 dé-abrégés (refactor mécanique, comportement préservé, couverture maintenue, barrière verte 2 arches). Suite : champs publics façade (PR #12), puis io_uring Temps 1. Cette session lande la spec io_uring (maître + Temps 1–4, FR+EN, ADR-028/029) jusque-là non commitée.

Structure physique du dépôt : racine = README.md, README-fr.md, LICENSE, CLAUDE.md, AGENTS.md, .gitignore, Cargo.toml, Cargo.lock, rust-toolchain.toml, docs/, crates/, target/. Toute la documentation vit sous docs/ (y compris EXCEPTIONS.md et COVERAGE-EXCEPTIONS.md, déplacés le 2026-06-01) ; tous les ADRs vivent dans docs/adrs/. Les documents de cadrage et le workspace couche 0 cohabitent dans le même dépôt privé.

Prochain chantier

Implémentation couche 1 — air-base-lib (cœur) d’abord.

🔒 La couche 0 est SCELLÉE (v1, 2026-06-14, tag couche-0-v1). CODE complet (11 familles + io_uring 12 Temps + extensions), barrière complète verte sur les 2 arches, 100 % de couverture hors exceptions documentées (couvrable VIDE au sens strict, ADR-035), doc développeur FR + EN. Le socle syscall n’évolue plus que par RFC (ADR-015). La phase couche 0 est close.

Prochain chantier : démarrer la couche 1, en commençant par le cœur de air-base-lib (erreurs, chaînes/chemins, temps — cf. specs/layer-1/air-base-lib-core.md), au-dessus du socle couche 0 gelé.

En parallèle / hors phase : outillage xtask qualité (dont xtask audit-exceptions pour re-synchroniser les renvois fichier:ligne du registre de couverture — dette ouverte au sceau), et la spec exhaustive air-sys-types type par type si besoin pour la couche 1.

Différés assumés de couche 0 (non bloquants, revus en temps voulu) : types epoll spéculatifs (revue fin couche 3), futex synchrone (v2 own-futex air-thread), fanotify, différés io_uring 3a (submit_files_update/ msg_ring_fd).

Backlog priorisé

Court terme (avant ouverture publique)

  1. Famille ipc. Prochain chantier (cf. ci-dessus).
  2. Famille mem minimale utile. Priorité suivante : mmap_anonymous, mmap_file, munmap, mprotect, memfd_create.
  3. Famille fs minimale. openat2/openat, read, write, pread, pwrite, statx, lseek, fsync, fdatasync.
  4. io_uring — Temps 1. À démarrer une fois les familles synchrones minimales en place, conformément à ADR-022.
  5. Documents d’ouverture publique (CONTRIBUTING.md, SECURITY.md, CODE_OF_CONDUCT.md, CHANGELOG.md) : explicitement secondaires tant que la couche 0 n’est pas réellement opérationnelle.

Moyen terme (en parallèle de l’implémentation)

  1. Révision architecture/macro-architecture-fr.md — passe 2 (complétion). Enrichir sections couches 1 à 5 avec les détails issus des ADRs et specs phase 0+ une fois la couche 1 entamée. La passe 1 (nettoyage) est faite ; la passe 2 reste à mener.
  2. Spec détaillée du module io_uring (9 documents). Voir backlog INDEX.md. À produire au niveau de détail des autres specs de familles. Réf. ADR-022 + specs/layer-0/io-uring-overview.md.
  3. Spec exhaustive de air-sys-types. Compléter specs/layer-0/air-sys-types.md type par type (méthodes, invariants, tests).
  4. Documents communauté : docs/guides/first-contribution.md, docs/guides/getting-started/.

Plus tard

  1. Documents internes mainteneurs : docs/maintainers/release-process.md, moderation.md, onboarding-maintainers.md, cve-handling.md.
  2. ADRs services système couche 5 à produire : screencapture, appearance/theming, share, firewalld, power, printd/bluetooth/nfc, prefs, keychain. Numérotation à assigner au moment de l’instruction (≥ ADR-026, sans pré-réservation).
  3. ADRs ouverts identifiés dans le registre : logging/observabilité au-delà de journald, mise à jour atomique du système, App Store Air, protocole privé AirCom air-wm ↔ apps natives, politique d’accès des agents IA à l’arbre sémantique et aux captures vectorielles.

Dettes connues

  • Protections de branche GitHub non actives. Le tier gratuit GitHub Free bloque l’API de protection de branche sur les repos privés d’organisation. Conséquence : actuellement, aucune protection contre force-push ou suppression de branche sur main ; aucune exigence d’enforcement côté GitHub des signatures (la signature reste appliquée localement par discipline et vérifiée à l’affichage). Mitigations actuelles : Thierry seul mainteneur, tous les commits signés GPG + DCO par convention. Résolution prévue : soit passage en GitHub Team payant si jugé utile avant l’ouverture publique, soit attente automatique de l’ouverture publique (le repo public sur Free débloque les protections). À réévaluer quand le projet approchera de l’ouverture publique ou s’il commence à avoir des collaborateurs externes.
  • Spécifications phase 0 — COMBLÉES (2026-06-11). Les deux dernières familles couche 0 manquantes sont désormais spécifiées au niveau des autres :
    • Famille device : docs/specs/layer-0/family-device.md — socket netlink uevent + itérateur clé=valeur zéro-alloc, evdev (ioctls dédiés EVIOC* + lecture typée input_event), sysfs renvoyé à fs. Consommée par air-wm/air-console (ADR-007).
    • Famille ebpf : docs/specs/layer-0/family-ebpf.md — bpf() exhaustif (37 sous-commandes) + perf_event_open/ioctls, RAII, charge des programmes déjà assemblés (logique → couche 1).
    • Reste : l’implémentation de ces deux familles (specs prêtes à pousser). Traductions EN à produire en passe ultérieure (comme io_uring).
  • Socle de bindings — TRANCHÉ (2026-05-31, dette résolue). La couche 0 appelle les syscalls directement via core::arch::asm!, sans dépendance externe (pas de rustix). rustix avait été évoqué un temps comme hypothèse de travail dans la macro-architecture early-stage ; la piste est abandonnée. Trace historique conservée dans macro-architecture-fr.md (section « Socle de bindings »).
  • Cohérence macro-architecture passe 2. La passe 1 a aligné la section 2 (couche 0). Les sections couches 1 à 5 contiennent encore des détails datant de la rédaction early-stage qui ne sont plus précis par rapport aux ADRs et specs ultérieurs (par ex. nomenclature AirEvent vs runtime async ADR-023, articulation air-aircom côté couche 2 vs io_uring, etc.). À traiter dans une passe 2 menée en parallèle de l’implémentation couche 1, quand le détail concret existera.
  • madvise io_uring + futex (différés de 2a/2c) — RÉSOLUS (2026-06-12, PR #31). submit_madvise (OP 25) et submit_futex_{wait,wake,waitv} (OP 51/52/53) exigeaient un handle de vivacité partagé sur le mapping (slot S1 empêchant munmap tant qu’une op est en vol). C’est désormais MmapRegion/MmapRegionLiveness (air-sys-syscall::mem, refcount Arc, prouvé Miri+loom) — Mapping reste inchangé. Les deux ops sont branchées ; nommage Mapping/MmapRegion et signature futex (&MmapRegion+offset) réconciliés.
  • Hygiène doc couverture Temps 1 io_uring. Le diagnostic 2a (rapport 017) a relevé ~5 branches/lignes STRUCTURAL préexistantes (non introduites par 2a) du cœur Temps 1 non encore tabulées individuellement dans COVERAGE-EXCEPTIONS.md : gardes ok_or(EINVAL)? de build (i32::try_from(fd), NonZeroU32::new(cq_entries)) et de ring_sizes (checked_add/checked_mul de débordement de tailles d’anneau, fuzzées via ring_sizes_decode), et le bras None de leak_forget. Toutes inatteignables par construction (valeurs kernel jamais hors-borne). Tolérées par le plancher CI 96/78 ; à tabuler explicitement dans une passe d’hygiène doc (orthogonale à la livraison 2a).
  • Additions couche 0 découvertes en spécifiant les couches supérieures (2026-06-11, étendu 2026-06-12) — la méthode « doc-d’abord » paie : spécifier la couche 1 révèle des primitifs couche 0 manquants, désormais spécifiés (specs landées) et à implémenter :
    • fs::inotify (docs/specs/layer-0/family-fs-inotify.md) — inotify_init1/add_watch/rm_watch + décodage zéro-alloc des inotify_event. Prérequis de AirFileSystemWatcher (air-filesystem, couche 1).
    • process privsep (docs/specs/layer-0/family-process-privsep.md) — IMPLÉMENTÉ (2026-06-12, PR #34) : setgroups/setresgid/setresuid + getresgid/getresuid + types Uid/Gid/ResUid/ResGid. Test de sécurité vert (enfant forké : après réduction vers nobody, regain root/gid → EPERM, réajout de groupe → EPERM ; saved-set figé). Couverture 100 % hors exceptions, 2 arches. Débloque air-process::drop_privileges. (Numéro setresgid ARM64 corrigé 143→149 ; constat : Uid/Gid et setuid/setgid n’existaient pas — types créés par cette PR.)
    • MmapRegion (docs/specs/layer-0/family-mem-mmap-region.md) — IMPLÉMENTÉ (2026-06-12, PR #31) : mapping partageable refcounté + garde de vivacité (Miri+loom). A débloqué et branché submit_madvise (2a) ET submit_futex_* (2c) ; signature futex et nommage Mapping/MmapRegion réconciliés. (Reste à implémenter dans cette liste : fs::inotify et epoll.)
    • Famille epoll (découvert au Temps 2c) — EpollOp/EpollEvent ont été ajoutés à air-sys-types::io_uring (consommés par la façade submit_epoll_ctl), mais aucun wrapper epoll_create1 n’existe en couche 0 : l’epfd des tests 2c est créé par un epoll_create1 brut test-only. Si d’autres consommateurs apparaissent, produire une petite famille epoll (epoll_create1 + ré-héberger EpollOp/EpollEvent) — à côté de fs::inotify et MmapRegion.
  • Réconciliation macro-architecture (passe 2) — points précisés (2026-06-11). En complément de la dette « cohérence macro-architecture passe 2 » ci-dessus :
    • AirLog / AirId128 (couche 1) n’utilisent PAS sd-journal/sd-id128 (couche 2) : socket natif journald (/run/systemd/journal/socket) et lecture directe de /etc/machine-id. Corriger les mentions « via sd-journal/sd-id128 » de macro-architecture-fr.md (≈ l.311, 313, 624, 625, 1547).
    • Renommage crates couche 1 (ADR-029, explicite, sans abréviation) : air-fsair-filesystem, air-procair-process, air-memair-memory. Mettre à jour la liste de crates de macro-architecture-fr.md. Les familles couche 0 (fs/mem/net/ipc) restent inchangées (acté).
  • Couche 1 — specs COMPLÈTES 8/8 (2026-06-11 → 2026-06-12). air-base-lib (cœur + services), air-filesystem, air-memory (allocateurs arena/pool/slab + comptabilité per-composant ; mono-thread ; backing heap enfichable), air-process (synchrone, pidfd, drop_privileges = orchestration + politiques fournies), air-thread (threads + synchronisation, backend std::sync v1), air-socket (sockets + résolution de noms enfichable, client DNS maison), air-crypto (primitives RustCrypto) et air-device (énumération/surveillance/identité natives sysfs+uevent) spécifiés (API Rust ; ABI C différée, cf. ADR-027). Les 8 crates de la couche 1 sont désormais spécifiées. Specs landées sous docs/specs/layer-1/. Restent hors crates : le compilateur seccomp déclaratif et le helper Landlock→chemins (côté air-filesystem). Implémentation à venir (après les Temps io_uring restants).
  • ADR-033 acté (modèle de configuration, 2026-06-12). Source typée canonique → compilateur validant (barre couches 0/1, fuzzé) → artefact binaire reproductible schema-first (version + checksum) consommé par le runtime ; projections texte JSON/YAML/TOML en import/export ; sûreté par générations + switch atomique + rollback ; backends de compilation enfichables = joint d’indépendance (unit files systemd générés et validés pour V1, jamais une dépendance dure). L’intégrité des binaires est explicitement hors périmètre → renvoyée à un futur ADR (dette identifiée).
  • air-device natif — dette ADR-005 à amender (2026-06-12). air-device décide énumération/surveillance/identité natives (sysfs + uevent kernel), zéro libudev/udevd ; surface en vocabulaire Air (joint), evdev au niveau périphérique (interprétation → compositeur, hors périmètre), 3 sous-systèmes typés (net/block/usb) + générique. Dette : la ligne « libudev en couche 1 » d’ADR-005 est à amender (pré-public : édition directe ou RFC ADR-015) ; la règle du joint (« vocabulaire Air, vérif CI no-systemd-types ») reste à outiller plus tard.
  • air-thread — décisions gravées + 2 nouveaux manques couche 0 (2026-06-12). Le backend de synchronisation (AirMutex/RwLock/Semaphore, sans poisoning) est std::sync par expédient v1 ; migration planifiée vers une implémentation futex maison (« on maîtrise ce qui s’y fait ; std a ses propres contraintes, pas les nôtres »). Cela révèle 2 primitifs couche 0 manquants (à côté de fs::inotify et epoll) : sched_setaffinity (prérequis de AirThreadBuilder::cpu_affinity) et un futex synchrone (futex(2) WAIT/WAKE direct — prérequis de l’implémentation futex maison). (Distinct du futex io_uring déjà livré au 2c : ici un wrapper syscall synchrone classique.)
  • air-socket — client DNS maison à fuzzer (2026-06-12). Décision (override macro-archi) : résolution de noms enfichable et ordonnée (AirNameResolutionSource/AirNameResolver, AirHostsFileSource, client DNS maison sans getaddrinfo/libc — lit resolv.conf, UDP + repli TCP, politique IPv6-first/IPv4-fallback). Le parsing des réponses DNS est de la donnée hostile → fuzzing obligatoire (cargo-fuzz des décodeurs) à l’implémentation.
  • air-crypto — exceptions 80 % + air-tls séparé (2026-06-12). Zéro crypto maison : RustCrypto (SHA2/3, BLAKE3, Ed25519/X25519, AEAD, HKDF/Argon2), zeroize, subtle (constant-time) sont des exceptions à la règle des 80 % (Principe 6, nommées comme icu4x/ADR-016) → à acter dans DEPENDENCIES.md/docs/EXCEPTIONS.md à l’implémentation. Cœur moderne uniquement (RSA/legacy différés) ; TLS différé à une spec air-tls séparée. Rigueur : vecteurs de test connus (RFC/NIST) + fuzzing des décodeurs.
  • io_uring 3a — différés (dette, PR #40, 2026-06-12). Deux opcodes non livrés au Temps 3a, par décision BDFL : submit_files_update (op 20, mise à jour asynchrone de la table de FD fixes dans le flux) et msg_ring_fd (MSG_SEND_FD) — plomberie d’op async (slab S1 d’un tableau de FD) tangentielle à la registration. La mise à jour synchrone (FixedFdTable::set/clear, register opcode 14) couvre le besoin immédiat. À reprendre avec le runtime async (ou un addendum 3a-bis). (Écarts kernel 3a — headers bornés à l’opcode 26, register_clock(Realtime)→EINVAL [variante conservée], CLOEXEC refusé sur descripteur direct, register_napi→EINVAL hors NIC/NAPI — documentés dans la spec 3a §13 bis et COVERAGE-EXCEPTIONS.md.)
  • io_uring — io-uring-overview.md décrit la sémantique soft/hard À L’ENVERS (dette, PR #44, 2026-06-12). L’ancien overview inverse soft (IO_LINK) et hard (IO_HARDLINK). La référence correcte est la spec 3c §2 (vérifiée contre io_uring_enter(2) et confirmée sur kernel réel 6.17) : soft rompt la chaîne sur erreur de complétion (short read inclus), hard ne rompt pas. À corriger dans io-uring-overview.md lors de sa réconciliation (son rôle d’inventaire est déjà repris par le doc maître ; pas bloquant).

Historique des sessions

Sessions 2026-06-16 → 2026-07-08 — Sceau couche 1, campagnes libc, PAL chantiers A/B (résumé de rattrapage)

Le suivi fin a été tenu dans etat-avancement.md (doc vivant) et la mémoire agent ; cette entrée consolide l’arc pour l’archive. Détail décisionnel dans les ADRs 062→076.

  • Couche 1 SCELLÉE couche-1-v1.0 (2026-07-03, ADR-062) puis re-scellée en additif jusqu’à v1.6 : errno_location (ADR-065), signaux/futex (ADR-066), air-account (ADR-067), credentials + drop_to_user (ADR-068), campagne réseau registre air-handle + moteur socket air-socket (ADR-069).
  • Toit libc C-ABI (scopée OpenSSH) : M0→M4 (crt/errno, str/mem, malloc+printf fuzzé, FILE*, fileio, signaux+pthread, temps, comptes passwd/group/shadow, identifiants) + surface socket complète (30 symboles libair_c.so : inet, cycle de vie, transfert, sockopt, getaddrinfo, poll/select, sendmsg/recvmsg/SCM_RIGHTS). Descellements couche 0 v1.10 (ADR-070 sockaddr + ADR-071 sockopt). CI basculée x86 primaire + cross-check/natif aarch64 (ADR-072).
  • Doctrines : configuration binaire (ADR-073), vision air-sshd (ADR-074 — la vraie cible ; OpenSSH-compile n’était qu’une fonction de forçage couche 1), fourniture du PAL (ADR-075).
  • PAL chantier A COMPLET : 6 briques additives couche 1 (Condvar, Once, os_str, env-mutation, yield/name, destructeurs thread_local). Bug de fondation aarch64 corrigé (gdb : le TCB 64 o recouvrait la zone TLS tpidr_el0+16) — validé 2 arches.
  • PAL chantier B — option A (ADR-076) : std natif sur la libc d’Air (pas la glibc) ; cible env=musl+vendor=air. build-std=std compile entièrement pour Air, zéro patch. Cible de production : compléter libair pour lier stdhello-std on-target 2 arches.

Session 2026-06-15 — Extension additive du sceau couche 0 → couche-0-v1.1 (PR #77)

Réouverture délibérée et additive du sceau couche 0 (décision BDFL), pilotée par les contrats couche 1découverte en Passe 2 d’air-base-lib (073) : sa table From<Errno> mappe ETIMEDOUT/EPIPE/ECONNRESET, que air_sys_types n’exposait pas (29 constantes ; ETIME 62 ≠ ETIMEDOUT 110). Ni amendable sans réouverture (couche 0 scellée), ni « inventable » en numéros bruts en couche 1 (ADR-032) → arrêt-signal (073), puis cette extension. PR #77 mergée (merge commit 7d03932, code 32a1f13 signé GPG + DCO), barrière complète verte 2 arches (la barrière du nouveau sceau). Re-sceau : tag couche-0-v1.1 (signé GPG) sur le merge commit ; couche-0-v1 conservé comme jalon d’origine.

Constantes ajoutées (valeurs asm-generic, identiques x86_64/aarch64), chacune justifiée par un contrat landé, rien de spéculatif :

  • ETIMEDOUT (110), EPIPE (32), ECONNRESET (104) — air-base-lib §1 (From<Errno>TimedOut/BrokenResource) ;
  • ELOOP (40), EXDEV (18) — air-filesystem (# Errors) ;
  • alias EWOULDBLOCK ≡ EAGAIN (valeur 11), pour le mapping par nom.

Display reste basé sur la valeur brute (aucun bras per-const) → zéro branche ajoutée ; couvrable VIDE maintenu (lignes 97,74 % / branches 82,73 %), aucune nouvelle exception ADR-035. Le mapping From<Errno> d’air-base-lib est désormais satisfiable par nom → la Passe 2 (073) est débloquée.

Session 2026-06-14 (suite) — Désactivation du trailer Co-Authored-By mergée (PR #74)

CI-only (prompts 069/070). PR #74 mergée (merge commit 4f48144, code 41c6cb6 signé GPG + DCO), CI verte 2 arches avant merge (couverture sautée : couche0=false, ADR-036). Le trailer Co-Authored-By: Claude est désactivé au niveau du repo via .claude/settings.json versionné ({"attribution":{"commit":"","pr":""},"includeCoAuthoredBy":false}) ; .gitignore tracke uniquement settings.json (.claude/* + !.claude/settings.json ; settings.local.json/claude_session.id restent ignorés). Le Signed-off-by (DCO) est conservé — seul Co-Authored-By est retiré. Décision : pas de réécriture de l’historique ; les commits passés (qui portaient le trailer jusqu’à #65) sont conservés tels quels. Le comportement était un défaut client de Claude Code (auto-attribution suite à une mise à jour), pas une consigne du projet : Claude Code n’est en aucun cas l’auteur ; c’est un outil d’automatisation, comme l’est un compilateur. Vérification définitive de la prise d’effet : les commits du prompt 071 (session fraîche post-merge) ; sinon bug client #18253 → repli ~/.claude/settings.json.

Session 2026-06-14 (suite) — Filtrage par chemin de la re-vérification couche 0 mergé (PR #70, ADR-036)

CI-only (prompts 064/065). PR #70 mergée (merge commit 200b374, code 9321036 signé GPG + DCO), CI verte 2 arches avant merge. ADR-036 landé (filtrage par chemin de la re-vérification d’une couche scellée ; SUMMARY/INDEX/ registre v3.4). La couche 0 scellée n’est plus re-mesurée (couverture lignes/branches) sur les PR qui ne touchent pas couche 0 / toolchain / CI / politique de couverture : job changes (git diff, zéro nouvelle dépendance d’action) → variable FULL ; étapes de couverture gatées if: env.FULL == 'true'. Re-vérification COMPLÈTE toujours garantie : (a) PR touchant ces chemins, (b) merge pushmain (main jamais non vérifié — dérive de toolchain bloquée au merge ; confirmé : le run post-merge sur main a re-mesuré la couverture, vert 2 arches), (c) planifié hebdomadaire (schedule cron lundi 04:00 UTC). Une PR couche 1 pure garde fmt/clippy/cargo test --workspace (couche 0 bâtie + testée), couverture sautée. Mécanique de déterminisme de couverture inchangée (purge .profraw, sudo -n, restauration de propriété de target/) — ne tourne que dans la branche complète. Doc : docs/CI.md (section « Filtrage par chemin… ADR-036 »).

Session 2026-06-14 (suite) — sccache branché sur les runners mergé (PR #68)

CI-only (prompts 062/063). PR #68 mergée (merge commit 4541cbd, code 82ff55d signé GPG + DCO), CI verte 2 arches avant merge. sccache comme RUSTC_WRAPPER sur le job test-coverage (store hors target/, $HOME/.cache/sccache → survit au git clean) : install par binaire pré-bâti musl à SHA256 épinglé (v0.10.0, fallback cargo install signalé). La recompilation de la couche 0 scellée par clippy/test est éliminée100 % de hit à cache chaud sur le Pi (53/53, 0 miss) ; durée test-coverage Pi 6 m 23 s (froid) → 5 m 46 s (chaud). Les passes llvm-cov en root sont volontairement non cachées (choix (b), ADR-031 : sous sudo sccache viserait le cache de root, et -C instrument-coverage se cache mal) → gros gain restant = ADR-036 (filtrage par chemin de la re-mesure de couverture). Garde-fou : le gate cargo xtask repro neutralise RUSTC_WRAPPER (preuve de déterminisme bâtie sans cache, vérifié). Doc : docs/CI.md (section « Cache de compilation — sccache »).

Session 2026-06-14 (suite) — Gates qualité gravés en cargo xtask mergés (PR #66) + bascule doc

Post-sceau (prompts 060/061). PR #66 mergée (merge commit 380d389, code def6c1a signé GPG + DCO), CI verte sur les 2 arches avant merge. Crate xtask (dev-tooling, membre du workspace, zéro dépendance externeCargo.lock du produit et repro ADR-025 intacts ; exclue de la couverture via --exclude xtask, non soumise au 100 %). 5 gates (sortie structurée + --json, exit ≠ 0 sur violation, pour CC et la CI) :

  • barrier : un seul point d’entrée (fmt stable+nightly, clippy -D warnings, test, couverture lignes+branches root, audit/deny/machete, pré-filtre // SAFETY: advisory, cohérence Cargo.toml↔DEPENDENCIES.md) — VERTE.
  • couvrable-vide (anti-gaming) : llvm-cov ⨯ registre. Gate DUR = plancher (lignes 97,73 % / branches 82,73 %) ; réconciliation par symbole/ section/proximité ADVISORY (la reachability n’est pas mécaniquement décidable, cf. CI.md) ; sépare prod / test-resident.
  • check-syscalls : numéros déclarés (// SAFETY: SYS_x (arch = N) + consts) vs headers uapi des 2 arches (x86_64 unistd_64.h, aarch64 asm-generic, indirection __NR3264_* résolue) — 161 conformes, 0 écart (garde la classe d’erreur « privsep ARM64 »).
  • audit-exceptions : format/taxonomie ADR-035 + recompte du récapa corrigé une dérive : CHILD-EXIT 9→8, total 70→69 (sur-comptage introduit au 7b00e75/055, pas une rangée perdue — décision Thierry). Registre désormais à 69 entrées (STRUCTURAL 38, FEATURE-KERNEL 13, PRIVILEGE 10, CHILD-EXIT 8, DEFERRED-TOOLING 0).
  • repro : double-build bit-pour-bit en env contrôlé (SOURCE_DATE_EPOCH, --remap-path-prefix, toolchain pinné) — 4 rlibs identiques → atteste ADR-025.

Parseurs testés sur entrées synthétiques (34 tests). CI : job supply-chain exécute check-syscalls + audit-exceptions ; étapes llvm-cov gagnent --exclude xtask. Doc : docs/outillage-xtask.md (+ SUMMARY, CI.md). Dettes de sceau RÉSORBÉES : reproductibilité ADR-025 outillée (repro), re-sync fichier:ligne outillée (audit-exceptions).

Session 2026-06-14 (suite) — Taxonomie d’exceptions ADR-035 + 3 candidates COUVERTES mergées (PR #60)

Suite de la passe de consolidation (le CODE de la couche 0 reste complet). PR #60 mergée (merge commit aa47d74, code 7b00e75 signé GPG + DCO), CI verte 2 arches.

  • 3 candidates « atteignables-mais-dures » COUVERTES par de vrais tests déterministes (leurs exceptions supprimées) :
    • fs::try_lock contention → Ok(false) : harnais multi-processus forké (clone3) — enfant détient le verrou exclusif, parent try_lockOk(false), synchro par pipes (sans sleep), récolte waitid.
    • net::get_so_error (bras val != 0) : connect non bloquant vers un port loopback réservé-puis-fermé → ECONNREFUSED via SO_ERROR, boucle bornée.
    • process::pidfd_send_signal(Some(&info)) : bras Some exercé via un pidfd de soi-même + SignalInfo::new_queue (déjà public — aucun ajout de surface) ; le kernel rejette par EINVAL (si_signo=0 ≠ sig) avant toute délivrance → sûr même vers soi-même.
    • DEFERRED-TOOLING VIDE / couvrable VIDE au sens strict.
  • Taxonomie appliquée à COVERAGE-EXCEPTIONS.md (ADR-035) : EFAULT-SAFE + VALUE-UNREACHABLE permanente → STRUCTURAL ; CHILD-EXIT conservée ; TEST-HARNESS retiré → annexe « résidus internes aux tests » (hors décompte). Récap recompté depuis les lignes réelles (figures antérieures dérivées) : 70 entrées production (STRUCTURAL 38, FEATURE-KERNEL 13, PRIVILEGE 10, CHILD-EXIT 9, DEFERRED-TOOLING 0). (⚠️ ce recompte a introduit un sur-comptage CHILD-EXIT 9 au lieu de 8 — total réel 69, corrigé au 061 par le gate xtask audit-exceptions, cf. session 2026-06-14 « Gates qualité xtask ».)
  • ADR-035 (« Taxonomie des exceptions de couverture », companion d’ADR-031, sans l’amender) créé ; registre ADRs (table + historique v3.3), SUMMARY.md, INDEX.md.

Dette consignée : la re-vérification durable des fichier:ligne du registre d’exceptions (numéros sujets à dérive) — passer à des renvois par symbole ou un check automatisé (futur xtask audit-exceptions). Prochain chantier → clôture couche 0 : doc d’introduction développeur (introduction-fr.md, encart « dev C/C++ ») + reflet de Q4 (seccomp primitif) dans family-security + réconciliation INDEX/état → traduction anglaisesceau barrière complète.

Session 2026-06-14 — Consolidation couche 0 : test flaky uevent durci mergé (PR #58)

Première étape de la passe de consolidation (le CODE de la couche 0 reste complet). PR #58 mergée (merge commit e74d96e, code 04e6a41 signé GPG + DCO), CI verte 2 arches.

  • Correctif test flaky : device::tests::uevent_read_nonblock_without_event_is_eagain (socket uevent groupe KERNEL NONBLOCK exigeant EAGAIN) cassait par intermittence quand un vrai uevent kernel (hotplug/udev) était diffusé pendant la fenêtre sur un runner partagé. Remplacé par uevent_read_nonblock_returns_promptly_eagain_or_valid_event qui teste le vrai contrat NONBLOCK : retour prompt = soit EAGAIN (file vide), soit un UEventMessage bien formé (événement parasite décodable) ; jamais bloquer/ paniquer/trame malformée (EINTR toléré, ADR-021 c.2). Boucle de robustesse (256 lectures/run) ; prouvé non-flaky (20/20). Changement test-only.
  • Normalisation du registre COVERAGE-EXCEPTIONS.md (non controversée, aucun reclassement) : la catégorie TEST-HARNESS (2 entrées) était utilisée sans être ni définie (légende) ni comptée (récap) → ajoutée (Total 60 → 62) ; note d’audit + date.
  • Audit du registre rendu (053-consolidation-flaky-exceptions.out) : 3 candidates « atteignables-mais-dures » signalées (net::get_so_error, fs::try_lock contention, process::pidfd_send_signal(Some)) ; proposition de taxonomie (fusion EFAULT-SAFE/VALUE-UNREACHABLE-permanent → STRUCTURAL, garder CHILD-EXIT, retirer TEST-HARNESS, addendum ADR) — décisions BDFL prises ; application au prompt 055.

Prochain chantier → 055 : appliquer la taxonomie d’exceptions retenue + couvrir les 3 candidates atteignables + addendum ADR-035. Puis : doc d’intro développeur couche 0, traduction EN, sceau barrière complète.

Session 2026-06-13 (suite) — fs::inotify + affinité CPU mergés (PR #55) — 🎉 CODE couche 0 COMPLET

Passe de complétion couche 0 : deux primitifs indépendants révélés par la spécification de la couche 1, livrés en un PR (modules disjoints d’io_uring). PR #55 mergée (merge commit a009f9b, code b609b87 signé GPG + DCO), CI verte 2 arches.

  • fs::inotify (air-sys-syscall::fs::inotify + types air-sys-types::fs) : RAII Inotify (IN_CLOEXEC par défaut) ; inotify_init/add_watch/ remove_watch/read_events ; décodeur emprunté zéro-allocation des inotify_event de taille variable, zéro-perte (ADR-032) — queue tronquée signalée (InotifyEvents::truncated()), jamais avalée. Syscalls vérifiés uapi 6.12 (init1 294/26, add_watch 254/27, rm_watch 255/28). Tests : intégration kernel réelle (CREATE/MODIFY/MOVED_FROM↔MOVED_TO par cookie/DELETE/IGNORED) + décodeur pur + property-based + fuzz 3 M runs (0 crash). Décodeur 100 %/100 %. fanotify hors périmètre ; récursivité = couche 1.
  • Affinité CPU (famille process, spec family-process-affinity.md créée) : set_cpu_affinity/get_cpu_affinity(Option<Tid>, &CpuSet) ; None = tâche appelante (pid 0 typé) ; CpuSet réutilisé depuis air-sys-types::system. Syscalls vérifiés (setaffinity 203/122, getaffinity 204/123). Tests : round-trip set→get, EINVAL/ESRCH, cross-arch. Débloque air-thread::cpu_affinity (couche 1).
  • +1 exception STRUCTURAL (épuisement ressources inotify_init + casts défensifs i64→i32). Aucune dépendance de production ajoutée. Aucun écart kernel.

🎉 CODE de la couche 0 COMPLET : io_uring (12 Temps) + 11 familles de syscalls

  • fs::inotify + affinité CPU — tous implémentés et mergés pour la cible 6.12 (hors interfaces obsolètes → UNSUPPORTED.md). Plus de code à écrire. Prochain chantier → passe de consolidation couche 0 : audit du registre d’exceptions, doc d’introduction développeur, traduction anglaise globale, réconciliation INDEX/état, sceau barrière complète. Différés conservés (epoll types spéculatifs, futex synchrone v2, fanotify, différés io_uring 3a).

Session 2026-06-13 (suite) — io_uring Temps 4 (accès brut) mergé (PR #53) — 🎉 MODULE io_uring COMPLET

Sous-module air-sys-syscall::io_uring::raw : la soupape de sécurité d’ADR-022 (Décision 1) — manipulation directe des SQE/CQE pour les ~5 % de cas que l’API typée (Temps 1–3f) ne couvre pas. L’ordering d’anneau reste géré par la façade ; l’unsafe ne porte que sur le contenu du SQE et la validité des buffers. PR #53 mergée (merge commit c91c693, code 9fe14fb signé GPG + DCO), CI verte 2 arches.

  • Types miroirs #[repr(C)] : RawSubmissionQueueEntry (#[repr(transparent)] sur IoUringSqe, 64 o), RawCompletionQueueEntry (16 o), RawOpcode(u8) ; assertions statiques de taille/offset (uapi 6.12, identiques x86_64/aarch64) → dérive = échec de compilation. Accesseurs typés (pas de calcul d’offset manuel).
  • Soumission brute : raw_get_submission_queue_entry (unsafe) ; publication = submit()/submit_and_wait() (sûrs : store release par la façade). Complétion brute : raw_peek_completion_queue_entry (sûr, load acquire, réf. bornée) + raw_advance_completion_queue (sûr, borné). Capacités SQ/CQ. Aucun pointeur mmap nu exposé.
  • Coexistence brut/niveau 2 (§5) : RAW_USER_DATA_TAG = 1 << 63. La moisson gérée court-circuite sur le tag (slab non touché, CQE non consommé) ; le slab borne sa génération à 31 bits (GENERATION_MASK) ⇒ le bit 63 du user_data géré est toujours nul → brut et niveau 2 coexistent sur le même ring sans collision. debug_assert du tag manquant (testé via #[should_panic]).
  • Tests : layout statique ; intégration kernel (NOP brut ↔ raw_peek/raw_advance ; op brute vs wrapper niveau 2 ; coexistence slab+brut sans collision ; SQ pleine → None ; advance borné ; moisson gérée qui s’efface devant une brute en tête) ; Miri (chemins purs : layout, slab génération 31 bits — 16 tests verts) ; fuzz du décodage RawCompletionQueueEntry (cible io_uring_4_raw_cqe, 2 M runs, 0 crash). raw.rs 100 % lignes / couvrable vide (aucune exception propre au Temps 4) ; ring.rs 100 %/100 %.
  • Correctif annexe (ADR-032) : 3 littéraux Completion de fuzz_api (#[cfg(fuzzing)], non bâti en CI) restés sans le champ multishot depuis le Temps 3d → la fuzz-cible ne compilait plus ; dette silencieuse purgée. Aucun écart kernel.

🎉 Module io_uring COMPLET : les 12 Temps (1, 2a–2d, 3a–3f, 4) sont implémentés et mergés pour la cible 6.12 (hors interfaces obsolètes → UNSUPPORTED.md). Prochain chantier → clôture couche 0 : fs::inotify + sched_setaffinity, puis passe de consolidation. Dette : traduction anglaise globale des documents du module io_uring (à planifier).

Session 2026-06-13 (suite) — io_uring Temps 3f (confinement) mergé (PR #51)

Sous-module air-sys-syscall::io_uring::sandbox : confine un ring pour qu’il ne puisse émettre que les opérations explicitement mises en liste blanche, imposé par le kernel — matérialise la soundness S3. Brique io_uring du modèle de capabilities d’Air (ADR-001 AirCom, ADR-010 entitlements signés), en défense en profondeur avec family-security (seccomp/Landlock). PR #51 mergée (merge commit 39aa92f, code eb494c0 signé GPG + DCO), CI verte 2 arches (coverage root).

  • RestrictionSet (default-deny, #[derive(Default)]) : allow_op / allow_register / allow_sqe_flags / require_sqe_flags + from_entitlements (point d’intégration couche 5, ADR-010 — la politique vit en couche 5). RegisterOp (énum typée des register-ops, ADR-021 conv. 3 — pas de u8 magique) ; SqeFlagSet (drapeaux IOSQE_*, partage la repr. de SubmitOptions).
  • Flux en 3 temps imposé kernel + reflété par l’API : IoUringBuilder::restrict → ring R_DISABLED + REGISTER_RESTRICTIONS ; IoUring::enableREGISTER_ENABLE_RINGS ; restrictions immuables ensuite. Default-deny : dès un allow_op, le kernel refuse tout le reste (-EACCES). Le stub ENOSYS de build() (Temps 1) est remplacé par le vrai REGISTER_RESTRICTIONS (test obsolète restrict_then_build_is_refused_with_enosys retiré).
  • Preuve de sécurité (§8) : openat2 REFUSÉ (-EACCES) sur un ring « réseau seul » alors que le process a le droit d’ouvrir le fichier → la voie de contournement de seccomp est fermée. Aussi : opcode autorisé qui passe, default-deny, require_sqe_flags imposé, soumission avant enable refusée (EBADFD), immuabilité après enable (refus kernel), propagation d’erreur REGISTER_RESTRICTIONS (simulateur), property-based (tout sous-ensemble encode exactement ses opcodes).
  • Couverture (root) : sandbox.rs 100 % lignes + branches / couvrable vide ; unique résidu = conversion défensive usize→u32 du nombre de restrictions (STRUCTURAL, inatteignable). raw : REGISTER_RESTRICTIONS (11), io_uring_register_restriction_op (0/1/2/3), miroir io_uring_restriction (16 o, assert). TOTAL lignes 96,2 % / branches lib 78,2 %. Aucun écart kernel. Prochain chantier → io_uring Temps 4 (raw), dernier du module.

Session 2026-06-13 (suite) — io_uring Temps 3e (multi-thread) mergé (PR #49)

Sous-module air-sys-syscall::io_uring::shared : les modèles d’usage multi-thread du ring, par-dessus le cœur Temps 1, ATTACH_WQ (3a) et msg_ring (2c). Aucun register opcode propre. PR #49 mergée (merge commit f9be984, code 43c957a signé GPG + DCO), CI verte 2 arches (coverage root).

  • unsafe impl Send for IoUring réalisé (ADR-022 D6, documenté depuis le Temps 1 mais jamais implémenté : les pointeurs bruts mmappés rendaient IoUring auto-!Send). // SAFETY: : mmaps/FD process-global, Send = transfert d’ownership exclusif, !Sync préservé (PhantomData<Cell>). Invariant habilitant de tout le Temps. Surface publique inchangée (impl auto).
  • LockedIoUring (Mutex<IoUring>, Send + Sync) : primitive with_lock (couvre toute op d’IoUring en &self) + raccourcis nommés fréquents — pas de ~50 forwarders (Principe 7) ; verrou unique, sans poisoning.
  • RingPool (thread-per-core) : le 1er ring crée le pool io-wq, les suivants ATTACH_WQ (workers kernel bornés, Pi 4) ; ring fds enregistrés ; into_rings (un ring possédé par worker, déplacé) ; handle(w) -> RingHandle
    • submit_message_ring_data_to (routage msg_ring cross-thread par fd ; le 2c, qui prend &IoUring, reste inchangé). SqpollIoUring : SETUP_SQPOLL
    • SQ_AFF, soumission sans io_uring_enter en régime établi, réveil NEED_WAKEUP géré (seule magie cachée, documentée).
  • Soundness (cœur du Temps) : IoUring: !Sync prouvé par doctest compile_fail, LockedIoUring: Send+Sync par doctest + assertion statique ; loom sur la discipline de verrou (exclusion mutuelle, aucune mise à jour perdue) ; tests d’intégration concurrents réels (threads).
  • Couverture (root, après complément 046/PR #49) : shared.rs branches 100 % / couvrable vide — l’exception EFAULT-SAFE sur les bras enter SQPOLL est levée (couverts au simulateur : injection -EINTR sur la couture syscall::enter, errno remonté tel quel ADR-021) ; résidus = fallback SQPOLL-EPERM des tests (PRIVILEGE) + timeout du thread pair (TEST-HARNESS). Flakiness réelle corrigée : locked_io_uring_concurrent_nops (course de complétion) rendu atomique sous un seul with_lock. TOTAL lignes 96,1 % / branches lib 78,1 %. Aucun écart kernel. Prochain chantier → io_uring Temps 3f (confinement).

Session 2026-06-13 — io_uring Temps 3d (opérations multishot) mergé (PR #46)

Sous-module air-sys-syscall::io_uring::multishot : une unique soumission produit un flux de complétions (CQE_F_MORE), par-dessus le cœur Temps 1, les buffers fournis du 3b et les descripteurs directs du 3a. Aucun register opcode ; opcode dédié READ_MULTISHOT (49) + drapeaux d’op. PR #46 mergée (merge commit ba26ff9, code 00ccee3 signé GPG + DCO), CI verte 2 arches (coverage root, raspi a bâti 00ccee3).

  • Cycle de vie S1 : slot vivant tant que CQE_F_MORE, libéré uniquement à la terminale (2ᵉ cas après le NOTIF zero-copy du 2b). Slab étendu (reserve_multishot/is_multishot + champ multishot du slot) ; Completion porte un marqueur multishot lu avant complete ; MultishotToken (sémantique fixée) ; Completion::multishot_token() ; génération anti-CQE-tardif prouvée Miri (test pur du slab).
  • Façades : submit_accept_multishot[_direct] (ACCEPT + ACCEPT_MULTISHOT ; variante directe = slot auto-alloué FixedFdTable), submit_receive_multishot (RECV + RECV_MULTISHOT) / submit_read_multishot (READ_MULTISHOT 49) sur buffers fournis, submit_poll_multishot (POLL_ADD + POLL_ADD_MULTI, edge), submit_timeout_multishot (TIMEOUT + TIMEOUT_MULTISHOT, ticks), cancel_multishot. Pénurie -ENOBUFS TERMINE le multishot, distincte d’une erreur réseau (testé).
  • Choix signalés (surface inchangée) : cancel_multishot utilise l’annulation synchrone (IORING_REGISTER_SYNC_CANCEL par jeton) plutôt que l’op ASYNC_CANCEL (cohérent avec -> Result<()>, pas de CQE d’annulation à drainer) ; submit_poll_multishot edge-triggered (la surface ne porte pas d’option level-triggered ; IORING_POLL_ADD_LEVEL = 1<<3 non exposé).
  • Couverture (root) : multishot.rs 100 % lignes + branches / couvrable vide ; cycle S1 prouvé sous Miri. TOTAL lignes 97,7 % / branches 81,9 %. 9 tests d’intégration + 2 tests slab purs. Aucun écart kernel. Prochain chantier → io_uring Temps 3e (multi-thread).

Session 2026-06-12 (suite) — io_uring Temps 3c (opérations liées) mergé (PR #44)

Sous-module air-sys-syscall::io_uring::linked : chaînes d’opérations liées (IOSQE_IO_LINK/IO_HARDLINK + LINK_TIMEOUT op 15), par-dessus le cœur Temps 1. Aucun register opcode. PR #44 mergée (merge commit 383eb99, code 575d3ab signé GPG + DCO), CI verte 2 arches (coverage root, raspi a bâti 575d3ab).

  • LinkedChainBuilder via ring.link_chain() : first / then (soft, IO_LINK sur le prédécesseur) / then_hard (IO_HARDLINK) / with_link_timeout (LINK_TIMEOUT bornant le maillon précédent ; émission isolée refusée) / submit() -> ChainTokens.
  • Mode staging : les submit_* existants réservent slot + écrivent le SQE (drapeau de lien sur le prédécesseur) sans publier la queue ; publication unique au submit() (un seul store-release). Aucune duplication des prepare_*. Réservation atomique : un maillon non réservable (EBUSY) déclenche un rollback (release idempotent + rewind de la tail) — jamais de chaîne partielle publiée.
  • Sémantique soft/hard CORRECTE confirmée sur kernel réel (6.17) : soft rompt sur erreur de complétion — un short read COMPTE comme erreur (piège testé explicitement) → suivants -ECANCELED ; hard ne rompt pas. link_timeout expirant (maillon annulé) et non-expirant (timeout annulé) testés.
  • skip_cqe_on_success sur les intermédiaires (seule la finale arrive) ; ChainTokens corrèle chaque complétion à son maillon. Helpers ring.rs (local_tail/or_sqe_flags/rewind_to) ; raw.rs (IOSQE_IO_LINK/HARDLINK, OP_LINK_TIMEOUT).
  • Couverture (root) : linked.rs lignes de production 100 % / couvrable vide (résidu branches = court-circuits d’assertions de test ; garde STRUCTURAL préexistante de prepare). TOTAL lignes 97,7 % / branches 81,7 %. 14 tests + ChainTokens sous Miri. Aucun écart kernel. Prochain chantier → io_uring Temps 3d (multishot).

Session 2026-06-12 (suite) — io_uring Temps 3b (buffers fournis ring-mapped) mergé (PR #42)

Sous-module air-sys-syscall::io_uring::provided : buffers fournis ring-mapped (IORING_REGISTER_PBUF_RING 22 / UNREGISTER 23 / PBUF_STATUS 26), par-dessus le cœur Temps 1 et la registration Temps 3a. PR #42 mergée (merge commit 8ca051c, code e56eba0 signé GPG + DCO), CI verte 2 arches (coverage root, raspi a bâti e56eba0).

  • ProvidedBufferRing : register (count puissance de 2, buf_size ; modes app et kernel_mmap), unregister, status, group_id, available. Anneau de descripteurs io_uring_buf[count] (tail u16 @ offset 14, avancé par store-release) + mémoire de données possédée (count×buf_size).
  • Sélection auto : submit_receive_provided/submit_read_provided (IOSQE_BUFFER_SELECT, aucun buffer au slot S1) + bundle: bool (IORING_RECVSEND_BUNDLE). Pénurie ⇒ complétion -ENOBUFS.
  • Guard RAII ProvidedBuffer via Completion::into_provided_buffer(&mut group)
    id/data/data_mut ; Drop réapprovisionne (réécrit le descripteur, store-release du tail). L’emprunt &mut interdit le désenregistrement tant qu’un buffer vit (doctest compile_fail).
  • Incrémental (IOU_PBUF_RING_INC) : CQE_F_BUF_MORE ⇒ même buffer réutilisé, pas de réappro ; réappro à la complétion finale. Accounting available idempotent (out_flags par bid).
  • Legacy évacuésdocs/UNSUPPORTED.md créé : PROVIDE_BUFFERS (31), REMOVE_BUFFERS (32), + OPENAT (18).
  • Écarts kernel signalés (spec 3b §9 bis, COVERAGE-EXCEPTIONS.md) : IOU_PBUF_RING_INC/RECVSEND_BUNDLE/CQE_F_BUF_MORE absents des headers exécuteurs (uapi 6.11/6.12, validés au runtime 6.17) ; bloc-code spec §3 aligné sur la prose (param bundle ajouté à la signature, surface effective inchangée).
  • Couverture (root) : provided.rs lignes 99,6 % / couvrable vide (résidu = bras ? des mmap = EFAULT-SAFE ; gardes bid<count = STRUCTURAL). TOTAL lignes 97,7 % / branches 82,0 %. 9 tests (intégration : recv loopback, -ENOBUFS+réappro, kernel_mmap, status, read/bundle ; synthétiques déterministes BUF_MORE/réappro/None ; property 256 cycles ; simulateur) + 1 doctest compile_fail. Prochain chantier → io_uring Temps 3c (opérations liées).

Session 2026-06-12 (suite) — io_uring Temps 3a (registration) mergé (PR #40)

Sous-module air-sys-syscall::io_uring::registration : les 21 register opcodes (io_uring_register(2), n° 427) du Temps 3a, livrés par-dessus le cœur Temps 1. PR #40 mergée (merge commit 34c164e, code aefec1a signé GPG + DCO), CI verte 2 arches (x86_64 speedy + aarch64 raspi-srv-2, coverage root).

  • FixedFdTable (FILES2/FILES_UPDATE2/FILE_ALLOC_RANGE/UNREGISTER_FILES) : register(sparse)/register_with/set/clear/set_alloc_range/unregister (restitue tous les OwnedFd — zéro discard) / slot() -> FixedSlot<'t>.
  • Variantes « direct » : submit_openat2_direct/accept_direct/socket_direct
    • FixedSlotTarget::{Index, Alloc} (Alloc = enum typé, jamais ~0U exposé). Découverte kernel : SOCK_CLOEXEC/O_CLOEXEC rejeté (EINVAL) sur un descripteur direct ⇒ non posé (le O_CLOEXEC est posé à fixed_fd_install). Usage : submit_read_fixed_file/write_fixed_file (via FixedSlot), submit_fixed_fd_install (FD direct → FD ordinaire).
  • RegisteredBuffers (BUFFERS2/BUFFERS_UPDATE/CLONE_BUFFERS/UNREGISTER_BUFFERS)
    register(Vec)/register_mmap(MmapRegion possédées)/update/clone_from/ unregister/slice() -> RegisteredBufferSlice<'b> + submit_read_fixed/write_fixed.
  • Ring fd enregistré (RING_FDS/UNREGISTER_RING_FDS) : câblé dans le chemin io_uring_enter (IORING_ENTER_REGISTERED_RING). eventfd (4/5/7), personality (9/10, + IoUring::with_personality), io-wq (IOWQ_AFF 17/18, MAX_WORKERS 19 → WorkQueueWorkerLimits), NAPI (27/28), horloge (29).
  • Types ajoutés : FixedFdTable/FixedSlot/FixedSlotTarget/RegisteredBuffers/ RegisteredBufferSlice/Personality/WorkQueueWorkerLimits/NapiConfig/ClockSource (sous-module registration) ; CpuSet créé dans air-sys-types::system (miroir cpu_set_t, 1024 bits ; n’existait pas, ADR-029).
  • Sûreté : FixedSlot/RegisteredBufferSlice liés par lifetime → ne survivent pas au désenregistrement (prouvé par doctests compile_fail — pas de dépendance trybuild ajoutée). Téardown S2 inchangé (kernel auto-nettoie au close ; les OwnedFd possédés se ferment au drop, aucune fuite).
  • Écarts kernel signalés (jamais corrigés en silence, ADR-032) : (1) headers installés bornés à l’opcode 26 (IORING_REGISTER_LAST == 27) — NAPI/CLOCK/ CLONE_BUFFERS (27-30) repris de l’uapi 6.12 amont, validés à l’exécution ; (2) register_clock(Realtime) rend EINVAL (io_uring n’accepte que MONOTONIC/ BOOTTIME) — type conservé, rejet testé ; (3) register_napi rend EINVAL sur les exécuteurs (NAPI non exposé) → chemin succès en exception FEATURE-KERNEL.
  • Couverture (root) : registration.rs branches 100 %, lignes 99.2 % (résidu = corps succès NAPI, FEATURE-KERNEL) ; air-sys-types::system (CpuSet) 100 % branches. TOTAL lignes 97.6 % / branches 81.9 % (planchers 96/78). Tests : 23 intégration/simulateur/bornes + 2 doctests compile_fail.
  • Différés (signalés) : submit_files_update (op 20, mise à jour async de la table dans le flux) et msg_ring_fd (MSG_SEND_FD) — plomberie d’op async tangentielle à la registration, proposés en suite ; la table se met à jour synchroniquement via set/clear (register opcode 14). Prochain chantier → io_uring Temps 3b (provided, buffers fournis ring-mapped PBUF_RING).

Session 2026-06-12 (suite) — io_uring Temps 2d (URING_CMD, opcode 46) mergé (PR #37)

Le passthrough io_uring (IORING_OP_URING_CMD, 46) est livré : transmettre une commande opaque au gestionnaire uring_cmd du fichier visé (socket, NVMe, ublk…). Sous-module air-sys-syscall::io_uring::cmd, surface validée (io-uring-2d-cmd.md) non modifiée.

  • 4 commandes socket typées sûres : submit_socket_inq/outq (SIOCINQ/SIOCOUTQinto_result), submit_getsockopt (buffer de sortie ownership S1 → into_buffer_result), submit_setsockopt (value déplacée → completed). level: SocketOptionLevel.
  • Générique : unsafe trait UringCommand (cmd_op/cmd_len/encode/Output/ interpret) + submit_uring_cmd<C> (vérifie cmd_len ≤ 16, ou ≤ 80 si SQE128, EINVAL en amont — pas de troncature) + submit_uring_cmd_raw (unsafe, dernier recours). Structures NVMe/ublk hors couche 0 (mécanisme seul).
  • Types : UringCmdFlags (FIXED = bit 0, seul flag 6.12) ; SocketOptionLevel (air-sys-types::net — n’existait pas, créé, ADR-029 ; la spec le disait « partagé family-net » mais family-net n’utilisait que des i32 bruts).
  • Bug latent corrigé — SQE128 désormais réellement soumissible : la soumission en SETUP_SQE128 était buggée depuis le Temps 1 — SubmissionRing::prepare indexait les SQE par sizeof(IoUringSqe) = 64 o, donc les SQE de 128 o étaient mal placés (stride faux) et partiellement non zéro-initialisés ; seul le setup SQE128 marchait, jamais la soumission (aucun test ne soumettait en 128 o). Corrigé : sqe_size mémorisé, stride en octets, slot entier zéro-init, provenance mmap pour écrire la zone cmd[] étendue ; submit_op factorisé en submit_filled (pointeur brut). Validé sur kernel réel (round-trip 128 o). Dette « SQE128 soumission » RÉSOLUE (PR #37).
  • Ergonomie signalée : UringCommand::interpret(&self) impose au consommateur de conserver une instance de la commande (passée par valeur à submit) pour interpréter la complétion — fidèle à la surface validée.
  • PR #37 (merge commit b7be2ae), commit 8b91ae7 signé GPG + DCO. Barrière verte 2 arches : fmt (stable et nightly — incident rustfmt résolu), clippy -D, tests (591 lib), llvm-cov root (lignes 97.4 %, branches 81.6 % ; cmd.rs 100 % branches, 0 ligne manquante, couvrable vide), Miri (encode borné), fuzz io_uring_2d_decode (200 k runs), audit/deny/machete. Prochain chantier → io_uring Temps 3a (registration).

Session 2026-06-12 (suite) — extension couche 0 process privsep livrée (PR #34)

Le 4ᵉ manque couche 0 (le plus prioritaire, sécurité) est comblé : la famille process reçoit les 6 wrappers indispensables à une réduction de privilèges correcte (drop_privileges d’air-process, Principe 10).

  • 6 wrappers : set_groups(&[Gid]) / get_groups(&mut [Gid]) -> &[Gid] (setgroups/getgroups, &[] = largage total, zéro alloc), set_resgid / set_resuid (Option<Gid/Uid> ×3 ; None = composante inchangée = (id_t)-1 typé, ADR-021 conv. 1) qui fixent le saved-set ⇒ réduction irréversible, et get_resgid -> ResGid / get_resuid -> ResUid (vérif défensive). // SAFETY: sur les 6 wrappers + 12 helpers asm.
  • Types ajoutés (air-sys-types::process) : Uid/Gid (#[repr(transparent)] sur u32 ; contrairement à Pid/Tid, 0 = root est valide ⇒ pas de NonZeroI32 ; ROOT/from_raw/as_raw, types distincts) + ResUid/ResGid. Constat : ils n’existaient pas (ni setuid/setgid).
  • Numéros de syscalls vérifiés sur l’uapi 6.12 par arche — la spec donnait setresgid ARM64 = 143 (FAUX), corrigé en 149 (header asm-generic). Retenus : setgroups 116/159, getgroups 115/158, setresgid 119/149, setresuid 117/147, getresgid 120/150, getresuid 118/148. Spec corrigée embarquée dans #34.
  • Test de sécurité (le cœur), vert en root : privsep_drop_is_irreversible (enfant forké, observé par waitid) — après set_resgid(nobody³) + set_resuid(nobody³), le regain root → EPERM, regain gid → EPERM, réajout de groupe → EPERM ; getres* confirment le saved-set figé. Corps d’enfants sans branche locale (combinés par &) → le flush LLVM impossible après largage n’ajoute aucune branche non couverte.
  • PR #34 (merge commit 7e07525), 3 commits signés GPG + DCO (9bd2335 code, b3b8cbf spec corrigée). Barrière verte 2 arches : fmt, clippy -D, tests (791), llvm-cov root (lignes 97.3 %, branches 81.7 % ; production rouge = 5 bras erreur EPERM/EFAULT ⊆ exceptions PRIVILEGE/STRUCTURAL, couvrable vide), audit/deny/machete. Prochain chantier → io_uring Temps 2d (URING_CMD).

Session 2026-06-12 — MmapRegion livré (PR #31) : madvise (2a) + futex (2c) débloqués/branchés

Le dernier prérequis transverse de la couche 0 io_uring est levé. MmapRegion (mapping partageable refcounté + garde de vivacité) est implémenté dans air-sys-syscall::mem, et submit_madvise (Temps 2a) + submit_futex_* (Temps 2c) sont branchés — les deux reliquats différés sont fermés d’un coup.

  • MmapRegion = Arc<MmapRegionInner> (ptr/len/prot), munmap au dernier drop (erreur ignorée comme Mapping). MmapRegionLiveness (clone de l’Arc) garée dans le slot S1 → munmap impossible tant qu’une op est en vol : ni use-after-unmap, ni fuite. Mapping reste strictement inchangé (RAII unique, zéro-coût, couverture 100 % intacte). Une alloc Arc assumée (exception ADR-021 c.4, opt-in).
  • Soundness prouvée : Miri (ownership : no UAF / no leak / drop-once) + loom (drops concurrents des gardes → munmap exactement une fois, jamais en avance). Intégration kernel réel : madvise WILLNEED/DONTNEED, futex wait réveillé par wake, futex_waitv multi-attente, drop de la région en vol.
  • Ops branchées : submit_madvise(&MmapRegion, range, advice) (OP 25) ; submit_futex_wait/wake(&MmapRegion, offset, …) (OP 51/52), submit_futex_waitv (OP 53). Signature futex corrigée (&AtomicU32 insoundable → &MmapRegion+offset). Types ajoutés : FutexFlags, FutexWaiter ; opcodes + futex_waitv ABI.
  • Trois durcissements d’API appliqués avant merge (PR #31, second commit) : from_mapping(mapping, prot) (prot explicite, plus de défaut READ|WRITE faux → fin d’un SIGSEGV latent sur mapping read-only) ; futex_word exige WRITE (la réf rendue est mutable) ; FutexWaiter sans mask (le struct futex_waitv kernel n’en porte pas — champ jamais transmis, footgun ADR-032). Test ajouté : from_mapping read-only → bytes() OK, futex_word() EINVAL avant accès.
  • Specs réconciliées (FR + EN) : family-mem-mmap-region.md (§1 from_mapping(prot), futex_word/WRITE ; §3 FutexWaiter sans mask), io-uring-2a §6.2 (madvise) et io-uring-2c §6.1 (futex) passent de « différé » à « implémenté ».
  • PR #31 (merge commit b61250e), 2 commits signés GPG + DCO (c9e3fbc impl, b12be19 durcissements). Barrière verte 2 arches : fmt, clippy -D, tests (780), Miri, loom, llvm-cov root (TOTAL lignes 97.7 % / branches 81.9 % ; couvrable du nouveau code = vide), audit/deny/machete, mdbook. Prochain chantier → io_uring Temps 2d (URING_CMD).

Session 2026-06-11 (suite) — io_uring Temps 2c (async) mergé (PR #28)

Implémentation, durcissement et merge du cœur async-spécifique du Temps 2c d’io_uring.

  • 9 façades + 2 accesseurs typés (PR #28, merge commit 1f2e5cb) : submit_nop_with_result, submit_timeout (+ _remove/_update), submit_cancel (async, CancelTarget Token/Fd/Op/Any + CancelFlags::ALL), submit_poll_add (+ _remove/_update), submit_epoll_ctl, submit_waitid, submit_message_ring_data (MSG_DATA) ; accesseurs into_poll_result, into_waitid_result. SignalInfo/WaitTarget/WaitOptions réutilisés de family-process/signal ; KernelTimespec/CancelTarget du Temps 1.
  • Couverture 100 % hors exceptions (root) : lignes 97.4 % / branches 81.7 %. Ensemble « couvrable » VIDE — zéro région ni branche de prod 2c rouge, et AUCUNE exception 2c (pas de garde u32 ici). Miri (ownership des payloads Timeout/Waitid/Epoll restitués sans UAF), proptest (cancel idempotent), fuzz (io_uring_2c_decode).
  • Types ajoutés à air-sys-types : io_uring::{PollEvents, TimeoutFlags, TimeoutSpec, CancelFlags, MessageRingFlags, EpollOp, EpollEvent} ; signal::SignalInfo::zeroed(). L’epfd des tests vient d’un epoll_create1 brut test-only (pas de fausse famille epoll publique).
  • Spec 2c amendée embarquée dans la PR (réconcilie ETIME_SUCCESS n’altère pas le res, cancel sans ALL rend 0, types epoll, futex→MmapRegion).
  • Différés (signalés) : futex (51/52/53) → PR coordonnée family-mem (handle de vivacité MmapRegion, le même que madvise — un seul handle débloque les deux) ; link_timeout (15) → Temps 3c ; files_update (20), fixed_fd_install (54), msg_ring_fd (40 MSG_SEND_FD) → Temps 3a.
  • 2 arches : speedy x86_64 + raspi-srv-2 aarch64 (incl. l’asm epoll_create1 aarch64). Reste couche 0 : io_uring Temps 2d→4, 3a–3f. Prochain : Temps 2d (URING_CMD).

Session 2026-06-11 (suite) — io_uring Temps 2b (réseau) mergé (PR #26) + ADR-032

Implémentation, durcissement et merge du Temps 2b d’io_uring (12 opérations réseau) — stratégique AirCom (ADR-001 : zero-copy + passage de FD).

  • 12 façades réseau + accesseurs typés (PR #26, merge commit 6055b11) : accept/_with_peer, connect, socket, bind, listen, send, receive, send_message, receive_message, send_zero_copy, send_message_zero_copy, shutdown.
  • Cycle zero-copy à DEUX complétions (CQE résultat F_MORE → CQE NOTIF qui restitue) porté par le has_more du slab (Temps 1, sans extension) ; prouvé sûr Miri (le buffer survit à la 1ʳᵉ complétion). Passage de FD SCM_RIGHTS
    FD reçus matérialisés en OwnedFd CLOEXEC, MSG_CTRUNC signalé, aucune fuite. Invariants Air : MSG_NOSIGNAL/SOCK_CLOEXEC par défaut.
  • Types possédés OwnedSendMessage/OwnedReceiveMessage ajoutés à air-sys-types::net : les SendMessageRequest<'a>/ReceiveMessageRequest<'a> de family-net sont basés sur des emprunts (sync), inadaptés à l’ownership asynchrone S1. Sérialisation sockaddr + cmsg réutilisées de net.rs (pub(crate), DRY). ReceiveMessageMeta, ZeroCopyFlags ajoutés.
  • into_zero_copy_buffers (restitution intégrale des buffers d’un sendmsg_zc, jamais le premier seul) — corrige le point relevé au rapport 019. Spec 2b corrigée en conséquence (types possédés + restitution intégrale).
  • ADR-032 (« Préservation des données confiées : zéro discard silencieux ») gravé : règle transverse — une API Air ne discarde jamais une donnée confiée (restitution intégrale, ressources matérialisées, troncatures signalées, chemins d’erreur compris). Registre v3.0.
  • Couverture 100 % hors exceptions (root) : lignes 97.4 % / branches 81.6 %. Ensemble « couvrable » VIDE (résidu = 3 gardes u32 STRUCTURAL, §2b). proptest + Miri + cargo-fuzz (io_uring_2b_recvmsg). 2 arches (speedy + raspi-srv-2). Reste couche 0 : io_uring Temps 2c→4, 3a–3f. Prochain : Temps 2c (async).

Session 2026-06-11 (suite) — io_uring Temps 2a (filesystem) implémenté et mergé (PR #24)

Implémentation, durcissement et merge du Temps 2a d’io_uring (opérations filesystem), par-dessus le cœur du Temps 1.

  • 23 façades FS + 4 accesseurs typés (PR #24, base main @ 26a8bcd, merge commit a2eea18). read/write/readv/writev, fsync/sync_file_range/ fallocate/ftruncate, openat2/close, statx, fadvise, splice/tee, mkdirat/unlinkat/renameat/linkat/symlinkat, 4× xattr ; accesseurs into_vectored_result/into_statx/into_xattr_result/opened_fd. Sans nouveau type de ring : opcodes IORING_OP_* (uapi 6.12) encodés en SQE, état possédé garé dans le slot S1 (slab généralisé à un payload hétérogène OwnedOp), restitution typée à la complétion.
  • Ownership prouvé sound (Miri) : Vec<u8>/Vec<Vec<u8>>+iovec/Box<Statx>/ CString déplacés dans le slot ; un buffer en vol ne peut être ni libéré ni réutilisé. Bug corrigé : submit_close parquait l’OwnedFddouble close (attrapé par le contrôle IO-safety) ; correctif into_raw_fd (cession au kernel, aucun payload garé).
  • Types ajoutés à air-sys-types::fs (conformes family-fs) : FsyncFlags, SyncFileRangeFlags, UnlinkFlags, LinkFlags, XattrFlags, FadviseAdvice (distinct de mem::MadviseAdvice — espaces de valeurs POSIX_FADV_*MADV_*), et un miroir brut Statx #[repr(C)] (256 o, assert de layout).
  • Couverture 100 % hors exceptions (mesurée en root, ADR-031) : lignes 97.1 % / branches 82.1 % au TOTAL (planchers 96/78). Diagnostic 017 : 0 branche de prod 2a couvrable, seules rouges = 6 gardes u32 (buffers > 4 GiB) documentées STRUCTURAL. Tests : intégration kernel 6.12 (round-trips) + proptest (transfert borné + restitution fidèle, chemins non-UTF-8) + simulateur (EBUSY) + Miri + cargo-fuzz (io_uring_2a_decode).
  • Barrière verte 2 arches : x86_64 (speedy natif) + aarch64 (CI self-hosted raspi + run natif fs_ops au SHA bâti). gh pr checks 24 vert sur les 2.
  • Différé (signalé) : madvise (PR coordonnée family-mem, cf. Dettes), read_fixed/write_fixed + variantes direct/fixed (Temps 3a). Reste couche 0 : io_uring Temps 2b→4, 3a–3f. Prochain : Temps 2b (réseau).

Session 2026-06-11 (suite) — device + ebpf implémentées et mergées : 11 familles couche 0 livrées

Implémentation, durcissement (proptest + fuzz) et merge des deux dernières familles de la couche 0, puis bascule ✅.

  • device (PR #20, base main) : uevent netlink (authenticité nl_pid==0/SCM_CREDENTIALS) + evdev (18 ioctls EVIOC*, lecture typée input_event), sysfs renvoyé à fs. InputEvent.sec/usec en i64 (fidélité struct timeval LP64). 100 % hors exceptions + test d’intégration uinput réel + proptest + fuzz fuzz_uevent_parse.
  • ebpf (PR #21, empilée puis rebasée sur main) : bpf() 37 sous-commandes + perf_event_open + 12 ioctls (dont le pont perf_event_set_bpf_program), RAII. Vrai 100 % hors exceptions STRUCTURAL via un harnais d’intégration privilégié réel (programme compteur assemblé main avec BPF_PSEUDO_MAP_FD + raw_tracepoint, cgroup v2 attach/link, bpffs pin/get, btf_load + carte BTF-typée, introspection, perf complet). proptest + fuzz fuzz_bpf_obj_info. Bugs trouvés/corrigés par le harnais (bpf_program_query offsets, bpf_program_test_run pointeurs NULL).
  • CI — mesure de couverture en root (PR #22, ADR-031). ebpf étant quasi-tout-privilégiée, sa couverture plafonnait ~74 % en non-root et faisait tomber le total sous le plancher 96 %. Le job test-coverage exécute désormais les étapes llvm-cov via sudo -n sur les runners self-hosted (NOPASSWD sur les deux) pour que les tests d’intégration privilégiés tournent sous instrumentation. Modèle de confiance dépôt privé/mono-mainteneur, à revisiter avant l’ouverture publique.
  • 11 familles couche 0 livrées (100 % hors exceptions, 2 arches : speedy x86_64 + raspi-srv-2 aarch64) : process, signal, time, ipc, mem, fs, net, security, system, device, ebpf. #19 (brouillon) fermée, branches mergées supprimées. Reste couche 0 : io_uring Temps 2a→4, 3a–3f. Traductions EN des specs reportées (fin de couche 1).

Session 2026-06-11 — Specs device + ebpf : la couche 0 est entièrement spécifiée

Production des deux dernières specs de famille manquantes, au niveau de détail des autres (docs/specs/layer-0/family-device.md, family-ebpf.md), pour permettre une délégation d’implémentation en une passe.

  • device — trois sous-systèmes : socket netlink NETLINK_KOBJECT_UEVENT (UEventSocket RAII + read qui vérifie l’authenticité nl_pid==0/uid==0) avec décodeur UEventMessage emprunté, zéro-alloc (itérateur clé=valeur) ; evdev (lecture typée InputEvent/slice_from_bytes, ioctls dédiés EVIOC*evdev_device_id, evdev_name, evdev_supported_codes, evdev_abs_info, evdev_grab/release, evdev_set_clock…, jamais d’ioctl générique, ADR-021 c.3) ; sysfs explicitement renvoyé à fs (pas de wrapper, anti-duplication). ~22 fonctions. Décision : décoder un format de fil kernel fixe (uevent, input_event) est du miroir légitime en couche 0 (précédent SignalFdInfo) ; le modèle riche de device → couche 1.
  • ebpfcouverture exhaustive de bpf() : les 37 sous-commandes de enum bpf_cmd (cible 6.12) chacune en fonction dédiée typée (cartes + éléments + batch, programmes, attache/liens, épinglage, introspection par id, BTF, stats/jetons), RAII BpfMap/BpfProgram/BpfLink/Btf. Plus perf_event_open (+ PerfEventScope sans sentinelle -1) et ses ~12 ioctls dédiés, dont perf_event_set_bpf_program (le pont eBPF↔perf). On charge des programmes déjà assemblés ; assembleur, chargeur libbpf, BTF/CO-RE, décodage du ring-buffer → couche 1 (aligné sur le précédent seccomp Q4). ~51 fonctions.
  • ABI vérifiée sur en-têtes kernel : bpf (x86_64 321 / aarch64 280), perf_event_open (298 / 241), NETLINK_KOBJECT_UEVENT=15, ioctls EVIOC*, layouts input_event (24 o LP64), input_id, input_absinfo, énumération bpf_cmd.
  • Docs de suivi réconciliées : INDEX.md (tableau + arbre + stats), etat-avancement.md (lignes device/ebpf 📝→🔜, section 3), JOURNAL (cet historique + État courant + « Prochain chantier » réaligné, qui pointait encore par erreur sur ipc).
  • Suite : déléguer l’implémentation de device et ebpf (squelette-doc-d’abord), puis les Temps io_uring restants. Traductions EN des deux specs à produire ultérieurement (comme io_uring).

Session 2026-06-01 (suite, 3g) — mem + net finis : 9 familles couche 0 à 100 % hors exceptions, 2 arches

Dernière passe de couverture. mem et net portées à 100 % hors exceptions (lignes + branches, mesuré artefact-free --workspace --lib --branch), validées sur speedy (x86_64) et raspi-srv-2 (aarch64). Branche feat/layer0-familles-restantes, 4d9dbd8e46e90d.

  • mem (3d802dd) : branches 35,7 → 58,9 % (reste = exceptions). Erreurs réellement atteignables testées (mprotect/madvise/msync EINVAL sur adresse non alignée ; mlock/mlock2/munlock ENOMEM sur intervalle non mappé ; mlockall EINVAL ; memfd_create nom trop long ; process_vm_* ESRCH PID inexistant ; mmap_fixed succès + EEXIST). debug_assert → 2 should_panic. Test flaky mmap_fixed_noreplace_anonymous retiré. Divergence d’environnement mlockall (réussit sur speedy, EPERM sur raspi) documentée en PRIVILEGE.
  • net (e46e90d) : ~30 tests. Erreurs sockets via fd invalide → EBADF (listen/getsockname/getpeername/sendmsg/recvmsg/sockopts) ; socket EPROTONOSUPPORT ; socketpair EOPNOTSUPP ; clamps val > i32::MAX (rcvbuf/sndbuf/linger/keepalive/backlog) ; parseurs raw_to_socket_addr et parse_scm_rights (buffers tronqués/dégénérés) ; passage de FD SCM_RIGHTS bout-en-bout sur socketpair. Reste = exceptions STRUCTURAL (gardes de parseur inatteignables avec données kernel bien formées ; get_so_error val≠0 = erreur socket asynchrone exigeant poll/epoll non wrappés ; if let sur variante déterministe) + FEATURE-KERNEL (IPv6 absent, skip fcntl).

Couche 0 — 9 familles de syscalls à 100 % couverture (lignes + branches) hors exceptions, cross-arch. COVERAGE-EXCEPTIONS.md : 52 entrées (PRIVILEGE 9, FEATURE-KERNEL 12, CHILD-EXIT 8, EFAULT-SAFE 8, STRUCTURAL 11, VALUE-UNREACHABLE 4). Barrière au HEAD verte sur les 2 arches : fmt / clippy -D warnings / cargo test --workspace (371 + 80 + 74 + 2 + intégration). Chiffres bruts llvm-cov (exceptions restant rouges par construction) : lignes ~96,6 %, branches ~78,8 %. io_uring reste le prochain gros chantier couche 0. PR ouverte (non mergée).

Session 2026-06-01 (suite, 3f) — Couverture 100 % : signal/process/ipc/fs/types + artefact llvm-cov élucidé

Reprise du chantier couverture couche 0 (branche feat/layer0-familles-restantes, 77f4e86b6396ce). Objectif : 100 % lignes+branches hors exceptions, mesuré sur les 2 exécuteurs Linux. API gelée (tests seulement).

Découverte méthodologique majeure — artefact [True:0, False:0] de llvm-cov --branch. Les régions de branche fantômes collées sur des lignes pourtant couvertes viennent exclusivement du binaire de test d’intégration (tests/main_thread_invariant.rs), qui lie la lib sans #[cfg(test)] et n’exécute que getpid/gettid : son instanciation [0,0] ne fusionne pas avec les compteurs cfg(test). Preuve : --workspace = 36 shadows sur process ; --workspace --lib = 0 shadow. Décision actée : la barrière branches se mesure cargo +nightly llvm-cov --workspace --lib --branch (artefact-free) ; les lignes restent en --workspace. Documenté en tête de COVERAGE-EXCEPTIONS.md.

4 familles syscall + air-sys-types portées à 100 % hors exceptions (2 arches) :

  • signal (9806f99) : recette flush enfant forké réutilisée (child_exit), EAGAIN/ESRCH ciblés, 2 should_panic, noop_handler, retrait de is_root mort.
  • process (08103a1) : 2 should_panic ; artefacts [0,0] élucidés ; 40 branches restantes ⊆ exceptions.
  • ipc (a7dfe45) : EventFd::write EINVAL, vmsplice/splice-Some EBADF ; retrait d’un test splice buggé + helper mort.
  • fs (c6945ff) : EBADF sur preadv/pwritev/fallocate/copy_file_range/ sync_file_range, symlinkat EEXIST ; retrait du helper mort syscall0.
  • air-sys-types (b6396ce) : réconcilié (résidu = défense-en-profondeur).

Plus : correction de 2 violations fmt --check préexistantes (lot à l’aveugle, d9b126f). COVERAGE-EXCEPTIONS.md : nouvelle catégorie STRUCTURAL, 42 entrées (PRIVILEGE 8, FEATURE-KERNEL 11, CHILD-EXIT 8, EFAULT-SAFE 7, VALUE-UNREACHABLE 4, STRUCTURAL 4).

État au HEAD b6396ce, identique speedy/raspi : fmt/clippy -D warnings/ cargo test --workspace (328+80+74+2) verts. Familles 100 % hors exceptions : time, system, security (préc.) + signal, process, ipc, fs + types complet.

RESTE (2 familles, gaps branches réels mesurés) : mem (branches 35.71 %, 36 rouges) et net (branches 52.59 %, 55 rouges). PR non ouverte (on n’ouvre qu’au 100 % global). Détail : prompt/couche0-familles-restantes.3f.output.md.

Session 2026-06-01 — Passe 2 des 6 familles + validation cross-arch (x86_64 + aarch64)

Implémentation (passe 2) des 6 familles restantes (ipc, mem, fs, net, security, system) et première validation réelle sur deux exécuteurs Linux : speedy (Mac mini Intel, x86_64, Ubuntu 24.04) et raspi-srv-2 (Raspberry Pi 4, aarch64, Ubuntu 26.04). macOS n’est plus un juge (compile seulement ; rate les tests Linux-gated, les doctests exécutés, et les bugs de signedness/arch). Toolchain montée à stable 1.96.0 (MSRV = dernière stable).

Bugs aarch64-spécifiques trouvés au premier passage réel sur aarch64 et corrigés (internes asm!/valeurs, API publique inchangée) :

  • clone3 / hang des tests fork : en exécution parallèle, l’enfant d’un clone3 fork héritait des mutex du harnais multi-thread verrouillés par des threads absents ; std::process::exit (atexit/flush) deadlockait (futex_do_wait). Le wrapper clone3 lui-même est correct (run sériel OK). Fix : sortie enfant via exit_group direct (helper interne #[cfg(test)]).
  • openat / EINVAL sur aarch64 : O_DIRECTORY/O_NOFOLLOW/O_DIRECT/ O_LARGEFILE/O_TMPFILE ont des valeurs différentes entre x86_64 et aarch64 (ABI asm-generic). OpenFlags figeait les valeurs x86_64 → OpenFlags::DIRECTORY désignait O_DIRECT sur aarch64. Fix : valeurs conditionnelles à target_arch (idem PipeFlags/StatusFlags::DIRECT).
  • 7 doctests (jamais exécutés car le Mac faisait cargo doc, pas cargo test --doc) : imports AsFd manquants, GetrandomFlags non re-exporté au niveau crate, exemple SockFprog (E0716). Corrigés.

État vérifié sur les deux arches (HEAD 61868ca) : fmt/clippy -D warnings/build verts ; cargo test --workspace = **196 + 78 + 1 (intégration)

  • 74 + 2 doctests, 0 échec** ; plus aucun hang process:: sur aarch64.

Outillage couverture : nightly dédié installé sur les deux exécuteurs pour la mesure de branches (cargo +nightly llvm-cov --branch) ; le build/CI reste sur stable.

Audit systématique des constantes cross-arch (fait, résultat négatif). Un programme C de référence compilé sur les deux arches a comparé ~270 constantes kernel (O_, MAP_, MADV_, MFD_, SIG*, SA_, SO_/SOCK_/MSG_, SECCOMP_, LANDLOCK_, CLONE_, RLIMIT_, CAP_, PR_, F_SEAL_*, errno…) aux valeurs hardcodées d’air-sys-types. Seule divergence : la famille O_* (O_DIRECTORY/O_NOFOLLOW/O_DIRECT/O_LARGEFILE/O_TMPFILE), déjà corrigée. Tout le reste est identique entre x86_64 et aarch64 — aucune autre correction nécessaire.

Harnais sous-processus seccomp/Landlock (fait, 2/3). seccomp_set_mode_filter (filtre allow-all) et LandlockRuleset::restrict_self sont désormais fonctionnellement testés dans un enfant forké (clone3 + waitid, sortie exit_group), validés sur les deux arches. La mesure de couverture de leur branche succès reste une exception légitime (l’enfant ne réécrit pas son .profraw ; cf. COVERAGE-EXCEPTIONS.md). seccomp_set_mode_strict reste une exception (bloque exit_group + l’I/O du profil).

Reste à faire (chantier couverture, non terminé) : tests fs/mem manquants (fcntl_*, readlinkat, chemins d’erreur), passage à 100 % lignes + branches sur les deux arches, promotion éventuelle d’exit_group en API publique (ADR-021 conv. 5).

Session 2026-05-27 — Famille time complète

Implémentation de la famille time couche 0 dans les deux crates fondatrices :

  • air-sys-types::time : Clock, Instant, SleepDeadline, SleepError, TimerFdFlags, TimerSetFlags, TimerFdSpec.
  • air-sys-syscall::time : clock_gettime, clock_settime, clock_getres, clock_nanosleep, timerfd_create, wrapper RAII TimerFd::{as_fd, into_fd, arm, disarm, current, read}.
  • Errno::EAGAIN ajouté pour couvrir les lectures non bloquantes sans événement disponible.
  • Tests ajoutés : chemins monotonic/resolution/sleep relatifs, validation cross-clock pour clock_nanosleep, lecture non bloquante timerfd, armement one-shot, round-trip current/disarm.

Résultat : cargo test -p air-sys-types -p air-sys-syscall vert le 2026-05-27, soit 165 tests passants au total.

Session 2026-05-24 (suite) — Famille signal complète

Branche feat/layer-0-family-signal. Implémente la famille signal couche 0 conformément à docs/specs/layer-0/family-signal.md et ADR-020 (signalfd par défaut + sigaction restreint aux 4 signaux synchrones fatals) :

  • Sous-section 1 (signalfd + masques) : signalfd_create, signalfd_create_blocking, SignalFd::{read, update_mask, as_fd, into_fd, from_owned_fd}, block_signals, unblock_signals, set_signal_mask, current_signal_mask, wait_for_signal.
  • Sous-section 2 (envoi) : kill, tgkill, rt_sigqueueinfo.
  • Sous-section 3 (sigaction restreint) : sous-module synchronous_handler avec install_fatal_handler et restore_handler (unsafe), restreints à FatalSignal::{Segv, Bus, Fpe, Ill}.

Dette d’ambiguïté SignalInfo vs SignalQueueInfo — fermée. La PR pidfd_* avait consigné l’ambiguïté entre les deux types (family-process.md nommait SignalInfo, family-signal.md nommait SignalQueueInfo pour ce qui était fondamentalement le même siginfo_t côté kernel). Résolution dans cette PR : un seul type SignalInfo porte le concept ; SignalQueueInfo disparaît.

  • SignalInfo reste opaque côté représentation (#[repr(C)] struct { raw: [u8; 128] }, layout siginfo_t Linux) — sa représentation est inchangée pour ne pas casser pidfd_send_signal qui passe son pointeur au kernel as-is.
  • SignalInfo gagne un constructeur public new_queue(value: SignalValue) qui fixe si_code = SI_QUEUE (-1), seule valeur que le kernel accepte sans drapeau privilégié pour un envoi cross-process userspace (cf. do_rt_sigqueueinfo dans kernel/signal.c : « Not even root can pretend to send signals from the kernel »). Le champ si_signo est laissé à zéro — le kernel l’écrase avec sig à la délivrance.
  • SignalValue { Integer(i32), Pointer(u64) } est promu type public, conforme spec.
  • rt_sigqueueinfo(pid, signal, info: &SignalInfo) consomme la même API.

Déviation mineure de spec assumée. family-signal.md documentait rt_sigqueueinfo(..., info: SignalQueueInfo) par valeur avec champs publics { code: i32, value: SignalValue }. La présente implémentation passe &SignalInfo (référence) construit via new_queue (constructeur discipliné). Raison de sécurité : exposer code brut serait un footgun — le kernel rejette les si_code arbitraires en envoi cross-process avec EPERM. Exposer un constructeur discipliné évite que l’utilisateur ne fabrique un siginfo_t qui se ferait rejeter, ou pire qui « falsifierait » l’origine du signal. La spec sera mise à jour à froid (hors périmètre PR).

pidfd_send_signal non modifié. Périmètre strict respecté : pidfd_send_signal (PR process déjà mergée) n’est PAS touché. Son trou de couverture Some(_) (3 lignes) reste acté. Si un test légitime de cette PR l’avait appelé en Some(...), le trou se serait fermé naturellement — ce n’était pas un objectif et ce n’est pas arrivé (aucun test de cette PR ne fait appel à pidfd_send_signal). Diff vérifié : crates/air-sys-syscall/src/process.rs n’est pas dans la liste des fichiers modifiés.

Constantes Signal ajoutées — 4, vérifiées vs asm-generic/signal.h. SIGILL=4, SIGBUS=7, SIGFPE=8, SIGUSR1=10. Identiques x86_64/aarch64 (ces archs incluent asm-generic/signal.h ; les archs comme MIPS/SPARC/Alpha qui surchargent sont hors ADR-014). Signal total après cette PR : 10/~31 standards POSIX, toujours stub partiel documenté en tête de module.

Borne structurelle Signal::try_from_raw ∈ [1, 64] — correction Principe 4. Pendant la phase de contrôle, défaut identifié : Signal::try_from_raw validait seulement > 0, autorisait donc Signal(99999) qui se serait fait rejeter par EINVAL au runtime côté kernel. Violation du Principe 4 (validation amont) : Signal doit porter sa garantie de validité dans le type, pas attendre le kernel.

Corrections appliquées dans cette PR (périmètre légitime — Signal EST le type central de la famille signal que cette PR complète déjà) :

  • Signal::try_from_raw(raw) rejette désormais aussi raw > 64 (= _NSIG Linux). Renvoie None pour tout raw ∉ [1, 64].
  • Signal::from_nonzero (qui ne validait pas la borne) retiré : aucune utilisation hors du test roundtrip lui-même, simplification de l’API publique. Les constantes internes (SIGUSR1, etc.) passent par Self(unwrap_nz(N)) qui ne touche pas à from_nonzero.
  • unwrap_nz interne renforcé : panique au compile-time si une constante future est définie avec une valeur hors [1, 64]. Défense en profondeur ; toute régression d’invariant détectée à la compilation.
  • Constante NSIG: i32 = 64 exposée au module (privée, mais documentée).
  • 4 nouveaux tests : try_from_raw_accepts_lower_bound_1, try_from_raw_accepts_upper_bound_nsig, try_from_raw_rejects_above_nsig (65, 99999, i32::MAX), unwrap_nz_runtime_call_panics_above_nsig (65, 100).

Conséquence couverture — 4 lignes acquittées « défense en profondeur ». Avant la borne : « no-op silencieux pour signal > 64 » — comportement faible (silently ignore une demande explicite). Après la borne : défense en profondeur derrière une validation amont — un Signal valide ne peut structurellement pas avoir une valeur > 64 ; les branches None restent comme garde par Principe 5 (sur-sécuriser, ne jamais corrompre) au cas où un futur constructeur oublierait la borne. unreachable!() refusé : transformerait une garde silencieuse en panic potentiel, ce qui est précisément ce que Principe 5 nous demande d’éviter.

Les 4 lignes non couvertes en line-coverage dans air-sys-types/src/signal.rs :

  • 3 lignes : branches None => false / None => self / None => self de SignalMask::{contains, with, without} — défensives, inatteignables tant que Signal::try_from_raw borne à [1, 64].
  • 1 ligne : bras None => panic!("unreachable: n > 0 guaranteed by the check above") dans unwrap_nz — inatteignable parce que la garde n <= 0 || n > NSIG au-dessus filtre déjà tout argument zéro avant que NonZeroI32::new soit appelé. Conservé pour satisfaire l’exhaustivité du match const ; même statut « défense en profondeur ».

Toutes catégorie « défense en profondeur, structurellement inatteignable derrière borne validée » — justification cette fois solide (la borne EST garantie par le type).

Vérification non-régression famille process. La borne ≤ 64 ne casse aucun usage existant : tous les signaux utilisés dans family-process sont dans [4, 19] (SIGCHLD=17, SIGKILL=9, SIGSEGV=11, SIGSTOP=19, SIGTRAP=5, SIGABRT=6, SIGBUS=7, SIGFPE=8, SIGILL=4). Les 92 tests air-sys-syscall passent inchangés sur 3 runs stress successifs après l’introduction de la borne.

Stratégie de test pour l’état global signal — mise en œuvre.

  • MaskGuard RAII (test-only, mod tests) : capture le mask courant en toute première ligne de chaque test, restaure intégralement via SIG_SETMASK (jamais SIG_BLOCK/UNBLOCK relatif) à la destruction. Couvre la contamination per-thread (cargo test recycle les workers).
  • tgkill(self_pid, self_tid, sig) pour les tests d’envoi/réception intra-process : cible exactement le thread courant, évite tout routage à un worker collatéral cargo. Combiné avec signalfd_create_blocking, donne une livraison déterministe sans flakiness.
  • Fork-enfant mono-thread pour les opérations process-wide ou unsafe :
    • kill (cible PID, pas TID — risque de router vers un worker tiers en multi-thread).
    • rt_sigqueueinfo (idem, cible PID).
    • install_fatal_handler / restore_handler (handlers PROCESS-WIDE, contamineraient les autres tests).
  • Aucun test ne déclenche réellement un signal fatal (SIGSEGV via ud2, etc.) — l’install/restore est validé par round-trip, le déclenchement effectif reste hors périmètre (crash reporter end-to-end est un cas family-security / future PR).

Aucune nouvelle dépendance externe. Tous les types kernel (signalfd_siginfo 128 octets, sigset_t u64, struct sigaction 24/32 octets selon arch) sont décrits en #[repr(C)] directement. core::ffi::{c_int, c_void} couvre l’ABI des handlers.

Tests bout-en-bout. 37 tests dans air-sys-syscall::signal (mask round-trip, signalfd round-trip, wait_for_signal bloquant, tgkill+signalfd, kill en fork-enfant, rt_sigqueueinfo en fork-enfant pour les deux variantes SignalValue, ESRCH, install/restore en fork-enfant, parsing signalfd_siginfo unit, errno helper local, wait_for_signal timeout-non-supporté). 10 tests dans air-sys-types::signal (4 nouvelles consts, SignalInfo new_queue avec Integer et Pointer + offsets vérifiés sur as_bytes, SignalMask helpers, SignalFdFlags, FatalSignal mapping).

Stress test : 5 runs successifs de cargo test --workspace, 0 échec. La stratégie tgkill+fork pour les opérations process-level neutralise complètement les races multi-thread observées en début d’itération (signal: 10 sur un worker collatéral).

Métriques finales. 92 tests dans air-sys-syscall (22 nouveaux pour signal) + 58 dans air-sys-types (13 nouveaux pour signal, dont 4 de bornes ajoutés en phase de contrôle finale) + 1 intégration. Total 150 tests passants. Couverture production : air-sys-types::errno/signal/process 100 % fonctions ; air-sys-types::signal 98.3 % lignes (4 lignes catégorie « défense en profondeur » détaillées ci-dessus) ; air-sys-syscall 94-95 % lignes avec gaps catégorisés (mêmes catégories que famille process : Err paths via helper commun déjà testé, branches Ok inatteignables sans privilèges, exits enfants fork-pattern, skips AppArmor/Yama).

Session 2026-05-24 — Fin de la famille process : groupes/sessions, prctl, rlimits, capabilities

Branche feat/layer-0-process-complete. Implémentation des sous-sections 4 à 7 de docs/specs/layer-0/family-process.md, complétant la famille process en couche 0 :

  • Sous-section 4 (groupes/sessions) : setpgid, getpgid, setsid, getsid. Convention 1 ADR-021 appliquée — Option<Pid> partout, jamais la sentinelle kernel 0.
  • Sous-section 5 (prctl par opération) : 12 fonctions individuelles typées (set/get_parent_death_signal, set/get_no_new_privs, set/get_thread_name, set/get_dumpable, set/get_keep_caps, set_timer_slack, cap_ambient_raise/lower/clear_all/is_set). Convention 3 ADR-021 strictement appliquée : helper privé raw_syscall_prctl non exposé, chaque opération est sa propre fonction publique typée.
  • Sous-section 6 (rlimits) : getrlimit, setrlimit, prlimit — implémentation pivot sur prlimit64 (syscall moderne, atomique, ciblable). getrlimit/setrlimit deviennent des alias de compatibilité qui délèguent à prlimit(None, ...).
  • Sous-section 7 (capabilities) : capget, capset via Linux v3 capabilities (header + 2× __user_cap_data_struct). Conversion u64 ↔ (lo, hi) confinée à air-sys-syscall ; CapabilityMask côté air-sys-types reste un type pur.

Capability — stub partiel à 20 variantes. Création de l’enum Capability avec les 20 variantes listées par la spec (sous-section 7), discriminants explicites tirés de include/uapi/linux/capability.h et vérifiés un-à-un dans un test (capability_discriminants_match_uapi_linux_capability_h). CAP_SYS_PACCT = 20 est délibérément absent (pas listé par la spec) — aucune confusion possible avec CAP_SYS_ADMIN = 21. Les ~21 capabilities restantes (CAP_SETPCAP, CAP_LINUX_IMMUTABLE, CAP_NET_BROADCAST, CAP_IPC_LOCK/OWNER, CAP_SYS_PACCT, CAP_SYS_TTY_CONFIG, CAP_MKNOD, CAP_LEASE, CAP_AUDIT_, CAP_MAC_, CAP_SYSLOG, CAP_WAKE_ALARM, CAP_BLOCK_SUSPEND, CAP_PERFMON, CAP_BPF, CAP_CHECKPOINT_RESTORE, …) seront ajoutées au fil des PRs qui les utiliseront. Le commentaire de tête de l’enum le documente.

CapabilityMask u64 ↔ split (u32, u32) — test du placement absolu. Les Linux v3 capabilities exposent leur bitfield via deux mots u32 côté ABI (datap[0] = bits 0-31, datap[1] = bits 32-63). Une éventuelle inversion lo/hi dans mask_to_words se compenserait dans un simple round-trip via words_to_mask — le bug serait invisible. D’où trois tests d’absolu : mask_to_words_places_bit_0_in_lo, mask_to_words_places_bit_32_in_hi, words_to_mask_recombines_with_correct_placement. Vérifient le placement exact d’un bit dans chaque mot, sur valeur brute (1_u64 << 32) — indépendamment des variants Capability disponibles. Aucune Capability de ce stub n’occupe le mot haut (max = SysTime = 25) ; quand la PR ultérieure ajoutera SetFCap = 31 puis MacOverride = 32, le mot haut sera exercé via une capability réelle.

Aucun ajout Errno ni Signal. Les 6 errnos et 6 signaux du stub partiel actuel suffisent (vérifié via croisement spec↔code). Discipline « ne pas pré-implémenter sans appelant » respectée.

Vérification des numéros de syscall vs spec — aucune divergence. setpgid 109/154, getpgid 121/155, setsid 112/157, getsid 124/156, prctl 157/167, getrlimit 97/163, setrlimit 160/164, prlimit64 302/261, capget 125/90, capset 126/91. Tous concordent ; la spec n’indiquait pas le numéro de prctl (157 x86_64 / 167 aarch64 dans linux/syscalls.h).

Allocation heap justifiée par convention 4 ADR-021. Une seule fonction de cette PR alloue dans son happy-path : get_thread_name() -> CString. Justification : kernel écrit dans un buffer transient de 16 octets ; la spec exige un type owned côté caller (peut sortir du frame). Allocation bornée à 16 octets (un seul Box<[u8]>), exception explicitement admise par la convention 4. capget ne fait pas d’allocation heap : CapabilitySet (3 × u64 = 24 octets) est retourné par valeur sur la pile, le buffer kernel intermédiaire (KernelCapData[2] = 24 octets) est aussi pile.

Mitigation d’une flakiness pré-existante. Le test waitid_dispatches_processgroup_and_anyprocessgroup (PR clone3+waitid) appelait waitid(AnyProcessGroup | EXITED | NOHANG) en parallèle des tests qui forkent. Sans NOWAIT, cet appel pouvait consommer un événement appartenant à un autre test parallèle (canonical SIGKILL test, fork tests de cette PR), provoquant un ECHILD chez le test légitime. Mitigation appliquée dans cette PR : ajout de WaitOptions::NOWAIT (peek-only) au test dispatch. Avant : 1/N failure rate ; après : 5 runs stress successifs sans failure.

Tests skippés en environnement dev — récap consolidé. À l’issue de cette PR, trois conditions runtime Ubuntu 24.04 par défaut masquent silencieusement des chemins applicatifs :

  1. AppArmor (apparmor_restrict_unprivileged_userns=1) — bloque clone3_with_new_user_namespace_runs_or_skips.
  2. Yama (yama.ptrace_scope ≥ 2) — bloquerait pidfd_getfd_duplicates_fd_from_child (Ubuntu 24.04 par défaut = 1, OK).
  3. Non-root + non-pgrp-leader runner cargo — neutralise le scénario EPERM direct de plusieurs tests (setsid, capset_raise_non_permitted, setrlimit_raising_hard, cap_ambient_raise_non_permitted, clone3_newpid_without_caps). La majorité ont été mitigés via scénarios fork (l’enfant fork’é se rend pgrp leader, puis exerce le chemin EPERM côté setsid ; les autres restent skip-on-root).

Validation effective de ces chemins relève d’un runner CI permissif (sans AppArmor userns restriction, avec ptrace_scope ≤ 1, et idéalement avec un harness cargo test qui rend le test runner pgrp leader). À provisionner avec le pipeline CI.

Trous de couverture restants — catégorisés. Sur ~1960 lignes production dans air-sys-syscall/process.rs, ~25 lignes restent non couvertes après cette PR. Aucune ne révèle un bug ou un risque :

  • 3 lignes : branche Some(&info) de pidfd_send_signal (stub SignalInfo opaque, acted PR pidfd_*).
  • 10 lignes : corps complet de set_no_new_privs. Testable en enfant fork’é dès aujourd’hui (clone3+waitid sont mergés ; l’enfant l’appelle, vérifie via get_no_new_privs, exit code ; parent waitid). Choix de regroupement, pas une impossibilité : le test bout-en-bout sera écrit dans la PR family-security (seccomp) qui constitue le contexte naturel d’usage de NNP — les deux fonctions partagent les mêmes fixtures fork-enfant et la même discipline de scénario.
  • ~12 lignes : branches d’erreur (if ret < 0 { return Err(errno_from_negative_syscall_ret(ret)) }) de wrappers prctl simples (set/get_parent_death_signal, get_no_new_privs, set/get_thread_name, set/get_dumpable, set/get_keep_caps, set_timer_slack, cap_ambient_lower/clear_all/is_set) et de capget. Ces opérations n’échouent pas en pratique sur un kernel sain ; leur site Err est inatteignable sans injection de kernel-failure. La logique de décodage Err elle-même est intégralement couverte via le helper commun errno_from_negative_syscall_ret (un point unique appelé identiquement par tous ces sites), testé en isolation par errno_from_negative_syscall_ret_maps_known_codes sur 6 valeurs négatives synthétiques (-1, -3, -4, -9, -10, -22EPERM, ESRCH, EINTR, EBADF, ECHILD, EINVAL). Seuls les sites d’appel restent non exercés en line-coverage ; la sémantique d’erreur est validée centralement. Aucun risque de logique d’erreur jamais validée.
  • ~1 ligne : Ok(()) de cap_ambient_raise — la fonction ne SUCCÈDE que si l’appelant a cap dans permitted∩inheritable. En non-root standard, c’est jamais. Couvrable seulement avec un binaire ayant des file capabilities pré-positionnées.
  • ~2 lignes : variante SuidDumpable du décodage get_dumpable (valeur kernel 2, dépréciée — kernels modernes retournent EINVAL sur PR_SET_DUMPABLE=2, donc le décodeur n’a jamais à la traiter).

Documentation au code (commentaires explicites) + ce JOURNAL = trace complète. Pas de tricherie sur les chiffres.

Pattern admis transversalement — exits d’échec côté enfant dans les tests fork. À partir de cette PR, le pattern « enfant fork’é encode son résultat de round-trip par code de sortie » (if X.is_err() { std::process::exit(N); } pour N ≠ 0, succès = exit(0)) est acté comme convention de test. Les branches exit(N) d’erreur ne sont prises que si la round-trip échoue côté enfant — c’est-à-dire jamais sur un environnement sain. Ces lignes apparaîtront systématiquement en non-couvertes par llvm-cov, par construction. À ne plus comptabiliser comme dette de couverture à chaque PR : c’est le coût normal d’un pattern de test fork-enfant robuste, supérieur à un test in-process qui contaminerait l’état partagé. Si la round-trip échoue en pratique, la branche s’exécute, le compteur s’incrémente, et la couverture remonte — c’est un canari, pas un trou.

Métriques finales. 70 tests dans air-sys-syscall (38 nouveaux), 45 dans air-sys-types (9 nouveaux), 1 binaire d’intégration inchangé — total 116 tests passants. Coverage production : 100 % air-sys-types, ~93 % air-sys-syscall (130 lignes manquées dont 30 production catégorisées en (A)/(B)/(C)/(D)/(E) ci-dessus + 100 lignes test = skips environnementaux + exits d’échec enfant + branches signal-induced). Régions à 89 % global, conformément à la politique JOURNAL antérieure (régions < 100 % acceptées sur branches d’invariants par construction et skips environnementaux).

Session 2026-05-23 — pidfd_open + pidfd_send_signal + pidfd_getfd

Troisième PR couche 0, sur la branche feat/layer-0-pidfd. Implémentation des trois wrappers pidfd_* de la famille process (spec docs/specs/layer-0/family-process.md sous-section 3) :

  • pidfd_open(pid, flags) -> Result<PidFd, Errno> — syscall 434 (x86_64 et aarch64), Linux 5.3+.
  • pidfd_send_signal(pidfd, signal, info) -> Result<(), Errno> — syscall 424, Linux 5.1+.
  • pidfd_getfd(pidfd, target_fd, flags) -> Result<OwnedFd, Errno> — syscall 438, Linux 5.6+.

PidFd est réutilisé (existe déjà depuis la PR clone3, qui le retournait dans CloneResult::Parent via CLONE_PIDFD) ; aucune réimplémentation. Drop hérité de OwnedFd — fermeture du fd à la destruction du PidFd, sans impl Drop explicite (qui aurait été redondant et aurait pu interdire les move-out de into_fd). Le test pidfd_drop_closes_underlying_fd prouve cette fermeture en post-mortem via waitidEBADF sur un RawFd re-borrowé après drop.

Ajouts dans air-sys-types :

  • PidFdOpenFlags (bitflags u32) : une seule valeur, NONBLOCK = 0x800, conforme au seul drapeau valide en kernel actuel.
  • SignalInfo (stub opaque) : #[repr(C)] pub struct SignalInfo { _opaque: [u8; 128] } dans air-sys-types::signal. Aucun constructeur public — le type est type-reachable mais value-unreachable depuis l’extérieur de la crate. La signature pidfd_send_signal(..., info: Option<&SignalInfo>) reste strictement conforme à la spec ; seul None est utilisable. Test interne signal_info_size_matches_linux_siginfo_t ancre la taille à 128 octets (ABI kernel).
  • Errno::EBADF (9), Errno::ESRCH (3) : deux nouvelles constantes, chacune référencée par un test (drop closure, PID inexistant). Le total Errno reste un stub partiel (6 constantes sur ~140), conformément à la discipline établie dans la PR précédente : on ajoute uniquement ce qui a un appelant.

Vérification des valeurs Errno vs asm-generic/errno-base.h. Les six constantes (EPERM=1, ESRCH=3, EINTR=4, EBADF=9, ECHILD=10, EINVAL=22) sont définies dans asm-generic/errno-base.h et utilisées identiquement par x86_64 et aarch64. Aucune surcharge dans asm/errno.h pour ces deux architectures (les surcharges existent sur Alpha/MIPS/SPARC/ParISC, hors ADR-014). Vérifié à la rédaction.

Dette terminologique signalée — SignalInfo vs SignalQueueInfo. La spec family-process.md (sous-section 3, pidfd_send_signal) nomme le paramètre info: Option<&SignalInfo> ; la spec family-signal.md (sous-section 2, rt_sigqueueinfo) introduit SignalQueueInfo avec champs structurés (code: i32 + value: SignalValue). Côté kernel, c’est pourtant le même siginfo_t qui passe dans les deux syscalls. Cette ambiguïté terminologique entre les deux specs doit être harmonisée en PR family-signal (renommer, fusionner, ou clarifier explicitement les deux types — par ex. SignalInfo = vue opaque côté ABI ; SignalQueueInfo = builder Air ergonomique au-dessus). Cette PR-ci ne tranche pas et laisse SignalInfo opaque pour éviter de figer son design.

Trou de couverture acté — branche Some(&info) de pidfd_send_signal. Les lignes 742-744 (extraction du pointeur depuis &SignalInfo) restent non couvertes en ligne, parce que SignalInfo n’a aucun constructeur public dans cette PR — la branche est type-reachable, value-unreachable depuis l’extérieur. Refus délibéré d’introduire un constructeur pub(crate) ou un SignalInfo::zeroed() juste pour la couverture : ce serait commencer à donner du contenu au stub, contre la décision adoptée pour SignalInfo opaque, et un test avec SignalInfo zéroé serait un faux test (validerait « ça ne plante pas », pas que info est transmis correctement — le kernel pourrait même rejeter un si_code incohérent). Le chemin None est testé bout-en-bout par le test canonique SIGKILL — c’est suffisant pour cette PR. // TODO(family-signal): tester Some(&info) avec un vrai constructeur est porté par le commentaire de la fonction. Couverture ré-atteinte 100 % production dès que la PR family-signal apporte le builder public.

Scénario canonique « parent tue enfant » enfin implémenté. La PR clone3 avait posé un waitid_decodes_child_killed_by_signal qui utilisait l’auto-abort() côté enfant (équivalent kernel pour le décodage siginfo, mais non représentatif de l’usage réel). Cette PR ajoute pidfd_send_signal_canonical_parent_kills_child_with_sigkill : parent fork (CLONE_PIDFD), enfant entre en boucle sleep, parent pidfd_send_signal(pidfd, SIGKILL, None), waitid observe Killed { signal: SIGKILL, .. }. C’est l’orchestration réellement employée en production. Le commentaire de tête de waitid_decodes_child_killed_by_signal a été actualisé (sans réécriture du test) pour pointer vers le scénario canonique au lieu d’un TODO(family-signal). La ligne loop { sleep } côté enfant du nouveau test reste non couverte : SIGKILL est uncatchable, donc aucun atexit handler ne s’exécute côté enfant ; même limitation que std::process::abort(), déjà actée précédemment. Trou test-code, pas production.

Skip environnement-dépendant — Yama ptrace_scope. Le test pidfd_getfd_duplicates_fd_from_child requiert que le kernel autorise le ptrace parent → enfant. Sur les systèmes où /proc/sys/kernel/yama/ptrace_scope ≥ 2, le kernel refuse même cette relation (CAP_SYS_PTRACE requis). Le test détecte cette condition au runtime et skip proprement avec un eprintln! explicite, à la même manière que le skip AppArmor pour CLONE_NEWUSER côté clone3. Aucune ligne production n’est dépendante de cette condition — la couverture production reste équivalente.

Tests skippés en environnement dev Ubuntu 24.04 — exécution réelle relève de la CI sur runner permissif. À l’issue de cette PR, deux restrictions runtime Ubuntu 24.04 par défaut peuvent silencieusement masquer du chemin réel :

  • AppArmor (kernel.apparmor_restrict_unprivileged_userns=1) bloque le test clone3_with_new_user_namespace_runs_or_skips (PR clone3+waitid). Sans privilèges et sans levée AppArmor, la création de CLONE_NEWUSER échoue avant fork : le test ressort en SKIP, son corps n’est pas exécuté.
  • Yama (kernel.yama.ptrace_scope ≥ 2) bloquerait le test pidfd_getfd_duplicates_fd_from_child (cette PR). Sur Ubuntu 24.04 la valeur par défaut est 1 (parent → enfant autorisé), donc le test s’exécute en pratique ; mais sur tout système avec ≥ 2 il passerait en SKIP.

Conséquence pour le suivi qualité : « cargo test passe 70/70 » sur cette machine ≠ « tous les chemins applicatifs sont validés ». Les chemins NEWUSER et pidfd_getfd-via-ptrace ne sont pas exercés sur un dev Ubuntu 24.04 stock. Leur validation effective relève d’un runner de référence en CI configuré sans ces restrictions (image dédiée, capabilities ajustées, ou simplement userns activés et yama.ptrace_scope=0/1). À provisionner quand le pipeline CI sera mis en place ; en attendant, le BDFL est conscient que ces deux tests sont en lecture seule sur sa machine.

Tests ajoutés et couverture obtenue. Sept tests pour les pidfd_* :

  1. pidfd_open_returns_pidfd_usable_for_waitid_over_child_lifetime — happy path bout-en-bout, prouve aussi que le pidfd survit à la mort de l’enfant.
  2. pidfd_open_returns_esrch_for_nonexistent_pid — branche ESRCH, PID 0x7FFF_FFFE au-delà de PID_MAX_LIMIT (~2^22).
  3. pidfd_drop_closes_underlying_fd — preuve RAII, avec caveat documenté sur la flakiness en multi-thread (mitigée par le fait qu’aucun code n’est exécuté entre drop et la vérification EBADF).
  4. pidfd_send_signal_on_closed_pidfd_returns_ebadf — chemin d’erreur de pidfd_send_signal.
  5. pidfd_send_signal_canonical_parent_kills_child_with_sigkill — scénario canonique SIGKILL parent → enfant.
  6. pidfd_getfd_duplicates_fd_from_child — happy path avec skip Yama si ptrace_scope ≥ 2.
  7. pidfd_getfd_on_closed_pidfd_returns_ebadf — chemin d’erreur de pidfd_getfd.

Couverture finale : production 100 % lignes hors les 3 lignes Some(_) de pidfd_send_signal, qui sont le trou délibéré du stub SignalInfo (acté ci-dessus). Régions à 95 % global. Les 35 lignes restantes non couvertes sont toutes en mod tests (skips environnement + branches signal-induced exits déjà actées).

Session 2026-05-22 (suite) — clone3 + waitid, et première exception dépendance

Deuxième PR couche 0 sur la branche feat/layer-0-clone3-waitid. Implémentation des wrappers clone3 (unsafe, fork-style) et waitid (safe) de la famille process, et de la matière de types associée dans air-sys-types : CloneFlags (bitflags), CloneArgs, CloneResult, StackSpec, PidFd, WaitTarget, WaitOptions, WaitStatus, WaitEvent, plus minimum vital pour Errno, Signal, et réexport std::os::fd::{OwnedFd, BorrowedFd, RawFd}.

Périmètre de cette PR sur CloneArgs. Trois champs exposés (flags, exit_signal, stack) — la spec couche 0 (docs/specs/layer-0/family-process.md) en liste neuf. Les six restants (pidfd, child_tid, parent_tid, tls, set_tid, cgroup) seront ajoutés au fil des syscalls qui les motivent (notamment execve pour stack, namespaces de mount pour set_tid). L’évolution est strictement additive ; aucun champ existant ne sera renommé ni retiré. La demande de pidfd se fait via CloneFlags::PIDFD — le PidFd résultant est retourné dans CloneResult::Parent. La création de thread (CLONE_VM/CLONE_THREAD/CLONE_SIGHAND ou stack: Some(_)) est refusée explicitement avec Errno::EINVAL tant que les tests bout-en-bout ne la couvrent pas.

Première exception au Principe 6 — bitflags. L’utilisation de la crate bitflags (~30+ types de drapeaux Linux à venir dans la couche 0) ne respecte pas la règle des 80 % du Principe 6 : la macro bitflags! émet pour chaque type un grand nombre de traits et de méthodes (BitOr/BitAnd/BitXor/Not/Sub/Extend/FromIterator/iter/from_bits/…) dont Air n’utilise qu’une fraction. Le ratio se mesure sur le code émis, pas sur la « surface d’API utile ». Adoption par exception explicite (modèle ADR-016 / icu4x), inscrite dans EXCEPTIONS.md à la racine. Distinction importante consignée pour qu’elle ne se vide pas par mesure subjective : DEPENDENCIES.md reste réservé aux dépendances qui passent le seuil ; les autres vivent dans EXCEPTIONS.md, jamais par redéfinition du dénominateur.

Pin strict appliqué localement sur bitflags. Cette PR pose bitflags = "=2.11.1" (et non "^2.4" / "2.4") dans [workspace.dependencies]. Le pin strict apporte deux garanties complémentaires au Cargo.lock committé : (1) le lock garantit la reproductibilité du build tant qu’il est intact (ADR-025) ; (2) le pin strict ajoute la reproductibilité de la résolution — si le lock est supprimé ou qu’une re-résolution est forcée (ajout d’une autre dép qui contraint le solver, cargo generate-lockfile), une borne lâche ^2.4 peut glisser silencieusement vers la plus récente 2.x compatible, ce qu’=2.11.1 interdit. Le pin reste cohérent entre Cargo.toml, Cargo.lock, et EXCEPTIONS.md. Question délibérément non tranchée ici : politique transversale (=strict pour toute la couche 0/1 ? ^caret au-delà ? autre découpage ?). Cette décision touche aux documents fondateurs (phase-B1, ADRs) et n’a pas sa place dans une PR de wrapper syscall. À instruire dans un ADR ou un futur DEPENDENCY_POLICY.md dédié, où elle sera revue à froid pour ce qu’elle est : une politique transversale, pas un détail technique de clone3.

Errno et Signal adoptés en stubs partiels. Cette PR n’inscrit dans air-sys-types que les constantes effectivement référencées par son code et ses tests : Errno::{EPERM, EINTR, ECHILD, EINVAL} (4 sur les ~140 prévues par ADR-019) et Signal::{SIGCHLD, SIGABRT, SIGKILL, SIGSEGV, SIGSTOP, SIGTRAP} (6 sur les ~31 POSIX standards de family-signal.md). Le périmètre complet — autres constantes, méthodes name()/description() pour Errno, SignalMask/SignalFdInfo pour Signal — viendra dans les PRs dédiées (ADR-019 complet pour Errno, famille signal pour Signal). Discipline assumée : ne pas pré-implémenter des constantes sans appelant ici, sous peine de figer leur design hors de leur PR et de créer du code mort. Le commentaire de tête de chaque module le note explicitement pour qu’un futur contributeur ne tombe pas dans le piège « tant qu’à faire, j’ajoute les autres signaux pendant que j’y suis ».

Stratégie de coverage post-fork. cargo-llvm-cov configure LLVM_PROFILE_FILE avec un placeholder %p ; chaque processus (parent et chaque enfant forké) écrit son propre .profraw, fusionnés à l’agrégation. Conséquence : l’enfant qui termine par std::process::exit(N) (atexit handlers exécutés, profile flushé) contribue à la couverture du wrapper. L’enfant qui termine par std::process::abort() (SIGABRT) ou un signal-induced exit ne flush PAS son profil — limitation acceptée.

Trous de couverture connus et assumés (sur le périmètre de cette PR) :

  • Variants WaitEvent::Stopped, WaitEvent::Continued, WaitEvent::Trapped : couverts uniquement par le helper privé siginfo_to_wait_event testé sur des SigInfo synthétisés en mémoire. Le remplissage effectif de siginfo_t par le kernel pour ces variants (CLD_STOPPED/CLD_CONTINUED/CLD_TRAPPED) n’est PAS testé bout-en-bout. Trois scénarios manquent : (a) SIGSTOP/SIGTSTP envoyé à un enfant + waitid(WSTOPPED), (b) SIGCONT envoyé après reprise + waitid(WCONTINUED), (c) ptrace-trap + waitid côté tracer. Si un futur bug surgit sur la conversion si_code → variante pour l’un de ces trois cas, regarder en priorité le helper siginfo_to_wait_event et ajouter le scénario bout-en-bout manquant — l’absence de test n’équivaut pas à une absence de risque.

  • Test exec dans l’enfant (5ᵉ test clone3 de la spec) : déféré à la PR execve. Sans wrapper Air d’execve, on ne peut pas tester la séquence fork+exec. Le commentaire au-dessus du module de tests le note.

  • Test namespaces CLONE_NEWUSER skippé : sur Ubuntu 24.04+ (et tout système où kernel.apparmor_restrict_unprivileged_userns=1), le test passe par un eprintln!("SKIP …") et un return. Les ~20 lignes du corps du test ne sont pas exécutées dans cet environnement. Le wrapper clone3 lui-même n’a aucun branchement spécifique sur les bits CLONE_NEW*, donc la couverture production n’est pas réduite — c’est uniquement le test environnement-dépendant qui est en lecture seule sur cet environnement. Re-exécuter ce test sur un système qui autorise les userns non privilégiés (ex. Fedora Workstation, conteneur dédié) fermerait les lignes correspondantes.

  • Test capabilities (NEWPID sans NEWUSER) : branche SKIP root non couverte. Le test contient un garde if is_root() { eprintln!(...); return; }. Quand le runner n’est pas root (cas habituel), les lignes du SKIP ne sont pas exécutées. Si jamais le runner tourne en root, le scénario « EPERM attendu » ne s’applique pas et le SKIP entre en jeu ; sinon le corps du test couvre la branche Err(EPERM).

  • Test waitid_decodes_child_killed_by_signal — orchestration canonique différée. Le test fait s’auto-abort() l’enfant (production de SIGABRT côté enfant), ce qui donne siginfo identique à « parent → child via kill/pidfd_send_signal » : si_code = CLD_KILLED/CLD_DUMPED, si_status = SIGABRT. La branche du dispatch waitid exercée est la même — l’objet du test (validation du décodage en WaitEvent::Killed) est rempli. Ce que ce test ne prouve pas : l’orchestration parent→enfant réellement employée en production (où le parent contrôle la mort de l’enfant). Cette orchestration exige un wrapper kill ou pidfd_send_signal, qui appartient à la famille signal et n’est pas dans cette PR. Option refusée délibérément : écrire ~40 lignes d’asm! test-only pour un raw_kill était tentant mais aurait été un syscall kill réimplémenté hors périmètre, sans SAFETY revues, sans conventions ADR-021 — exactement le débordement qu’on a corrigé sur Errno/Signal. Le test garde une branche // TODO(family-signal): qui le pointera vers le scénario canonique quand le wrapper existera.

  • Trou de couverture résiduel sur l’auto-abort() côté enfant. std::process::abort() (et SIGKILL si on avait choisi le scénario canonique) bypasse les atexit handlers, donc le compteur LLVM côté child n’est pas flushé. La ligne std::process::abort(); apparaît non couverte alors qu’elle s’exécute bel et bien (sinon l’enfant ne mourrait pas et le parent ne verrait pas WaitEvent::Killed). Compromis acceptable, hors de portée raisonnable : couvrir cette ligne demanderait un appel explicite à __llvm_profile_write_file() (lien runtime LLVM, non portable hors instrumentation) ou un signal catchable + handler — complications non justifiées par le bénéfice.

Tests : 35 dans air-sys-types (dont les 9 hérités du bootstrap + 26 nouveaux pour Errno/Signal/Clone*/Wait*/PidFd) ; 26 dans air-sys-syscall (dont les 6 hérités pour getpid/gettid + 20 nouveaux pour clone3/waitid/siginfo_to_wait_event/build_kernel_clone_args/errno_from_negative_syscall_ret) ; 1 binaire d’intégration main_thread_invariant inchangé. Total 62 tests passants.

Couverture finale : 100 % lignes production sur les quatre fichiers source ; régions à 96 % global (gap concentré dans les expect() d’invariants par construction et les arms de match jamais pris, conformément à la politique consignée dans la première moitié de la session).

Session 2026-05-22 — Bootstrap couche 0 : workspace + getpid/gettid

Première session d’implémentation. Création de la branche feat/layer-0-bootstrap et mise en place du workspace Cargo (edition 2024, rust-toolchain.toml pinné à 1.85.0) avec les deux crates fondatrices air-sys-types et air-sys-syscall. Deux premières fonctions de la famille process implémentées strictement conformes à la spec (docs/specs/layer-0/family-process.md) : getpid() -> Pid et gettid() -> Tid, syscalls appelés directement via core::arch::asm! sur x86_64 et aarch64, sans passer par la libc. Tests unitaires et test d’intégration harness = false pour l’invariant gettid()==getpid() sur le thread principal (impossible à observer dans un #[test] standard du fait du test runner cargo).

Décision méthodologique consignée — seuil régions llvm-cov. Les régions llvm-cov < 100 % sont acceptées lorsqu’elles correspondent à des branches d’échec d’assertions de test (assert_eq!/assert_ne!) ou de .expect() sur des invariants garantis (par exemple, le retour POSIX strictement positif de getpid et gettid consommé par NonZeroI32::new(...).expect(...)), jamais prises en exécution normale. Le critère du Principe 1 (100 % lignes + branches) reste tenu et reste le seuil normatif ; le seuil « régions » n’est pas un objectif. Motivation : ces branches inatteignables existent par construction (preuve de l’invariant côté kernel/POSIX), les supprimer demanderait soit de dégrader le diagnostic (perdre les messages d’assertion qui aident l’humain en cas de régression sur le wrapper asm!), soit d’enfreindre le Principe 5 en remplaçant un expect() vérifié par un unsafe { new_unchecked } — choix qui transformerait une violation d’invariant bruyante en UB silencieux. À noter pour tout futur contributeur tenté de viser 100 % de régions : c’est un faux objectif.

Décision afférente — préférence stricte pour la construction vérifiée. Sur la couche 0, NonZeroI32::new(...).expect(...) est préféré à unsafe { NonZeroI32::new_unchecked(...) } même quand l’invariant POSIX garantit la non-nullité. Raisons : (1) Principe 5, pas d’optimisation avant mesure sur un chemin non chaud ; (2) si l’asm! des syscalls est jamais mal câblé (numéro de syscall ou registre), new_unchecked(0) est un UB silencieux alors qu’un expect() rend la violation bruyante et diagnostiquable ; (3) la branche d’échec, jamais prise, relève du même statut que les autres branches d’invariants — couverture lignes conservée à 100 %, voir décision ci-dessus.

Session 2026-05-20 (suite 5) — Rédaction du README final (EN + FR)

Refonte du README.md à la racine du dépôt. Le précédent était une version provisoire qui décrivait essentiellement la structure de la documentation. Le nouveau est la vitrine publique du projet prête pour le jour de l’ouverture publique (avec un bandeau de statut à retirer ce jour-là).

Décision sur la langue : README principal en anglais (convention GitHub internationale, anticipation de l’ouverture publique vers une communauté internationale), avec un README-fr.md complémentaire qui fournit la version française. Les deux pointent l’un vers l’autre. Validation BDFL.

Contenu du nouveau README (les deux versions ont une structure identique) :

  • Bandeau de statut « phase de design, pas encore ouvert publiquement » à retirer le jour de l’ouverture.
  • Présentation d’Air en deux paragraphes : stack en couches, deux profils (air-base console, air-desktop graphique), Rust polyglotte via ABI C.
  • État du projet : phase de design, jalons documentaires atteints, pas de code public, pas de date publique.
  • Architecture en bref : les 6 couches en quelques lignes avec liens vers ADR-001 (AirCom), ADR-014 (machines de référence), ADR-025 (builds reproductibles).
  • Table de documentation avec chemins et langues, indiquant clairement ce qui est FR / EN / les deux.
  • Section « Building Air » : « Not yet applicable » + stratégie documentée (toolchain pinnée, Cargo.lock committé, reproductibilité bit-pour-bit). Pas de procédures fantômes.
  • Section « Contributing » : « Not yet open to external contributions », explication du rôle BDFL actuel, énumération de ce qui sera en place à l’ouverture publique (CONTRIBUTING.md, CODE_OF_CONDUCT.md, SECURITY.md, RFC, DCO).
  • Communication : domaine air-desktop.org (réservé, pas de site), org GitHub air-desktop-project, channels à annoncer plus tard.
  • Trademark : intention de dépôt à la fondation, modèle Linux Foundation pour la politique d’usage.
  • Licence : MPL 2.0, renvoi vers LICENSE.

Mises à jour de cohérence : JOURNAL.md mis à jour, nouveau prochain chantier identifié = CONTRIBUTING.md détaillé (le README y fait référence et la matière existe déjà dans docs/setup/phase-B*.md). Le backlog court terme est réordonné en conséquence.

Fichiers committés via Claude Code en local : README.md (modifié), README-fr.md (nouveau), docs/JOURNAL.md (modifié). Commit 048e2cb signé GPG + DCO, vérifié par GitHub (reason: valid), push b3737d6..048e2cb sur origin/main. Diff cumulé : +277 / −54 lignes.

Session 2026-05-20 (suite 4) — Mise en place du dépôt GitHub privé

Sujet soulevé par Thierry : « git c’est aussi un historique… une mémoire ». Création du repo pour versionner immédiatement la documentation, même si on ne produit pas encore de code, afin de capturer l’historique fin des décisions.

Décision stratégique tranchée par BDFL : repo privé d’abord, pas public. Le moment du flip vers public sera l’ouverture publique du projet, à maîtriser plus tard quand README final, CONTRIBUTING, SECURITY, marque protégée et channels communauté seront en place. Cohérent avec la politique d’édition design-phase actuelle.

Nouvelle pratique actée : pour les tâches d’exécution locale (git, gh, build, etc.), Claude produit des prompts autonomes pour Claude Code que Thierry copie-colle dans son terminal. Thierry n’intervient qu’en dernier recours. Mémoire sauvegardée pour les futures sessions.

Travaux effectués :

  • Côté Cowork (préparation) : LICENSE créé (texte officiel MPL 2.0), .gitignore créé (macOS/Linux/Windows/éditeurs/backups, avec note que Cargo.lock sera committé), README.md enrichi d’un bandeau « phase de design pré-ouverture publique » à retirer le jour J, git init -b main exécuté dans le workspace avec configuration locale user.name / user.email. Rédaction du prompt Claude Code détaillé (pré-vols, création repo, commit signé, push, protections de branche, rapport final).
  • Côté Claude Code (exécution locale) :
    • Repo air-desktop-project/air créé en privé sur GitHub.
    • Première authentification gh rafraîchie pour ajouter les scopes admin:gpg_key et admin:public_key.
    • Clé GPG A9F56C4D9F59EE03 ajoutée au compte GitHub tdelhaise.
    • Commit initial b3737d6 signé GPG + DCO Signed-off-by, vérifié par GitHub (verified: true, reason: valid).
    • Push origin main réussi.
    • Protections de branche non appliquées : le tier gratuit GitHub Free bloque l’API de protection sur les repos privés d’org. Documenté comme dette connue (cf. section ci-dessus).
  • Côté Cowork (clôture) : JOURNAL mis à jour avec l’état du dépôt, le SHA du commit initial, la dette « protections de branche », et la nouvelle pratique de délégation à Claude Code.

État final : dépôt opérationnel, signé, sourcé, prêt à recevoir tous les travaux ultérieurs. Premier point de bascule franchi.

Session 2026-05-20 (suite 3) — Enrichissement d’ADR-001 (AirCom vs D-Bus)

Sujet soulevé par Thierry : le choix de ne pas reposer sur D-Bus comme transport principal est l’un des plus visibles et contestables d’Air, mais l’ADR-001 dans sa forme initiale faisait 12 lignes — anormalement court par rapport aux autres ADRs phase 0 (145 à 210 lignes). Pour un projet qui prétend à la transparence et à la qualité argumentative, cette dette de documentation pouvait créer de la défiance chez les contributeurs venant de l’écosystème D-Bus traditionnel.

Approche retenue : enrichir substantiellement ADR-001 selon trois principes posés par BDFL — pragmatisme, sources d’informations, cohérence/incohérence avec les exigences de qualité d’Air. Travaux effectués :

  • Rédaction d’un ADR-001 substantiel (197 lignes, dans la fourchette des autres ADRs phase 0) avec dix sections : contexte (reconnaissance de la place de D-Bus dans l’écosystème), décision en 4 points fermes, justification technique en 5 sous-sections (broker centralisé, typage/versioning, FD passing, zero-copy/back-pressure, sécurité par policy XML — chaque propriété de D-Bus mise en regard d’une exigence Air nommée : Charte principe 4, Principe d’ingénierie 8, ADR-010, ADR-012, ADR-014, etc.), alternatives évaluées (kdbus 2013-2017, bus1 2016-2018, dbus-broker, évolution incrémentale), synthèse de AirCom, périmètre exact du pont D-Bus dans Air (sd-bus peer-to-peer pour systemd, air-dbus-bridge optionnel, xdg-desktop-portal-air comme exception assumée), articulation avec ADR-002/005/010/012/013/022, FAQ anticipée de 6 questions courantes, conséquences (bénéfices/coûts/risques), statut futur.
  • Sources publiques citées : spécification D-Bus officielle, dépôt dbus-broker (Tom Gundersen / David Herrmann), trois articles LWN sur kdbus et bus1. La pédagogie repose sur des faits vérifiables, pas sur des opinions. Note : les liens LWN précis sont à vérifier avant ouverture publique.
  • Inspirations AirCom assumées ouvertement : XPC (macOS), Cap’n Proto, FIDL (Fuchsia), Mojo (Chromium), Binder (Android). Cela coupe court à toute accusation de NIH non avouée.
  • Ton : factuel, sans posture évangéliste, reconnaissance explicite de la place de D-Bus dans l’écosystème, exception xdg-desktop-portal-air assumée, FAQ qui désamorce 6 questions classiques (« pourquoi pas étendre D-Bus ? », « est-ce que c’est du NIH ? », etc.).
  • Mises à jour de cohérence : registre passé en v2.3 (bloc historique enrichi, table inchangée — la décision n’a pas évolué, seule sa documentation l’a fait).

Relu et validé par BDFL sans modification.

Complément vision (priorité 2 du plan initial) : ajout d’un paragraphe pédagogique de 4-5 phrases dans vision-fr.md et vision-en.md, inséré juste après le paragraphe décrivant la couche 2. Le paragraphe (a) reconnaît la place de D-Bus dans l’écosystème Linux (systemd, NetworkManager, BlueZ, polkit, freedesktop), (b) cadre la décision « Air parle D-Bus pour interop, conçoit AirCom pour son cœur natif », (c) explicite les besoins spécifiques (sécurité capability-based, stabilité 10 ans, perf matériel modeste) que D-Bus ne couvre pas par construction, (d) renvoie vers ADR-001 pour le détail. Ton sobre, factuel, cohérent avec le reste de la vision. Le sujet est désormais traité à deux niveaux : visible et accessible dans la vision (porte d’entrée publique), argumenté et sourcé dans ADR-001 (référence technique).

Session 2026-05-20 (suite 2) — Passe 1 de nettoyage de la macro-architecture

Résolution de la dette de cohérence entre architecture/macro-architecture-fr.md (rédigée en early-stage) et les ADRs phase 0 (019-025) finalisés depuis. Travaux effectués :

  • Inventaire complet des divergences :
    • 35 références prospectives ADR-018 à ADR-026 désignant des services système couche 5 non instruits (protocole privé AirCom air-wm/apps, air-screencapture, air.system.appearance/theming, air-share, air-firewalld, air-power, air-printd/air-bluetoothd/air-nfcd, air-prefs, air-keychain), en collision avec les ADRs phase 0 réels.
    • Distinction subtile : ADR-022 prospectif avait deux significations dans le doc (framework networking couche 2 d’une part, air-firewalld couche 5 d’autre part) — neutralisations séparées.
    • Section 2 (couche 0) entièrement obsolète : crate air-sys unique avec 11 sous-modules incohérents avec le découpage actuel des specs, modèle d’erreur AirSysError enum riche contredisant ADR-019 (Errno minimaliste), conventions ADR-021 absentes, choix rustix non confirmé.
  • Révision de la section 2 (couche 0) — réécriture de la sous-section « Crate principale » et des sous-sous-sections détaillées :
    • Découpage en deux crates air-sys-types (types fondamentaux) + air-sys-syscall (wrappers de syscalls).
    • Liste des 10 familles actuellement spécifiées (process, fs, mem, signal, time, net, ipc, security, system, io_uring) avec pointeurs vers les specs.
    • Identification des 3 trous à combler ultérieurement : famille device, famille ebpf, confirmation rustix.
    • Module io_uring introduit avec référence à ADR-022 et ses 4 Temps.
    • Sous-section « Gestion d’erreur » alignée sur ADR-019 (Errno #[repr(transparent)] sur NonZeroI32, sans contexte ; enrichissement aux couches 1+).
    • Nouvelle sous-section « Conventions transverses » consignant les 5 conventions d’ADR-021.
    • Sous-section rustix clarifiée en « hypothèse de travail à confirmer en début d’implémentation, dans le cadre du workflow ADR-024 ».
  • Neutralisation par sed multi-passes des 35 références prospectives. Toutes remplacées par des marqueurs neutres « ADR à produire — service [X] » qui ne pré-réservent aucun numéro. Aucune occurrence prospective résiduelle (vérification par grep exhaustive).
  • Mises à jour de cohérence : INDEX.md passé en v1.3 (statut macro-architecture mis à jour, section « Point d’attention » réécrite, backlog réordonné), JOURNAL.md mis à jour (état courant, prochain chantier = README final, dettes connues purgées des dettes résolues et complétées des dettes restantes).

Vérifications finales : aucune référence ADR cassée, table des matières intacte (10 sections, 1764 lignes — quasi inchangé en volume), références aux ADRs réels uniquement (001-017 fondateurs, 018 imagerie, 019-022/024 phase 0).

Session 2026-05-20 (suite) — ADR-025 « Stratégie de builds reproductibles »

Clôture de la série de design phase 0. Travaux effectués :

  • Trois arbitrages structurants validés par BDFL : (1) périmètre = artefacts Rust natifs uniquement pour la phase 0 (bundles .airapp, images OS = ADRs ultérieurs) ; (2) calendrier = bloquant en CI dès le premier commit phase 0 ; (3) outillage = approche minimaliste Rust natif + discipline, pas de Nix ni de système de build alternatif.
  • Rédaction d’ADR-025-builds-reproductibles-fr.md (~210 lignes) couvrant : contexte (rattachement à Charte principes 3 et 5, ADR-013, ADR-015, phase-C), décision en 4 points fermes, mécanismes techniques (toolchain pinnée via rust-toolchain.toml exact, Cargo.lock committé, container Debian par digest SHA-256, env vars whitelistées, SOURCE_DATE_EPOCH dérivée du commit, RUSTFLAGS avec --remap-path-prefix + codegen-units=1 + --build-id=none, linker mold, discipline sur build.rs et code généré), vérification CI multi-pipelines (pr-full double-build + diffoscope, pr-hardware cross-machine, main-nightly triple-build), articulations avec ADR-010/013/014/015/024 et Principes 1/6/7, conséquences (bénéfices/coûts/risques), statut futur (ADRs ultérieurs pour bundles et image OS).
  • Mises à jour de cohérence : registre passé en v2.2 (ajout ADR-025, retrait de la mention « à produire »), INDEX.md passé en v1.2 (statut ADR-025 = OK, statistiques recalculées, ordre de priorité court terme revu), JOURNAL.md mis à jour (état courant, prochain chantier = révision macro-architecture passe 1).

État final : zone de design phase 0 close. Prochain chantier identifié = révision macro-architecture passe 1.

Session 2026-05-20 — Réorganisation documentation et résolution doublon ADR-018

Cohérence et structuration. Travaux effectués :

  • Réorganisation physique : déplacement des 22 fichiers traînant à la racine vers docs/ selon l’arborescence cible décrite dans INDEX.md (vision/, charte/, principes-ingenierie/, adrs/, architecture/, specs/layer-0/). Suppression des obsolètes (ADR-009-bis, ancien INDEX.md racine, .DS_Store).
  • Résolution du doublon ADR-018 : la série phase 0 instruite (018-erreurs, 019-signaux, 020-conventions, 021-io_uring, 022-runtime, 023-deps, 024-reproducible prévu) a été décalée de +1 pour libérer ADR-018 au profit du « Modèle d’imagerie » fondateur acté en registre v2.0. Nouvelle numérotation : 019=erreurs, 020=signaux, 021=conventions, 022=io_uring, 023=runtime, 024=workflow-deps, 025=reproductibles (à produire).
  • Convention de nommage unifiée : tous les ADRs suivent désormais ADR-NNN-titre-fr.md. Les 6 ADRs phase 0 ont été renommés depuis le style anglais sans préfixe.
  • Propagation des renumérotations : titres internes H1 des 6 ADRs phase 0 mis à jour ; références croisées corrigées dans setup/ et specs/layer-0/.
  • Registre des ADRs : passé en v2.1 (intégration des ADRs phase 0 + bloc historique). Liens vers les fondateurs apparentés corrigés pour utiliser les chemins relatifs ../vision/, ../charte/, etc.
  • Index : docs/INDEX.md passé en v1.1 (arborescence à jour, point d’attention sur la collision macro-architecture, statistiques recalculées).
  • README.md racine : refondu pour décrire la nouvelle structure et pointer vers docs/INDEX.md.
  • Identification de la dette macro-architecture vs ADRs phase 0 (cf. section « Dettes connues »).

État final vérifié : 50 fichiers .md dans docs/, 25 ADRs sous docs/adrs/, aucun lien markdown cassé, aucune référence aux anciens chemins.

Session 2026-05-18 — Documents fondateurs version 1.0

Finalisation et figement des documents fondateurs en v1.0 :

  • Vision (FR + EN), Charte (FR + EN), Principes d’ingénierie (FR + EN).
  • Registre des ADRs v2.0 (restructuré en fichiers individuels par ADR).
  • 17 ADRs fondateurs (001 à 017) finalisés.
  • ADR-018 « Modèle d’imagerie » instruit en phase de design pré-ouverture publique.
  • ADR-009 révisé pour intégrer l’amendement « API exclusivement déclarative » (anciennement envisagé sous nom ADR-009-bis ; politique d’édition design-phase appliquée — pas de pattern -bis avant ouverture publique).
  • Politique d’édition design-phase actée : avant ouverture publique, les amendements aux documents fondateurs s’intègrent directement, pas par fichier -bis.

Sessions antérieures à 2026-05-18 — Spécifications phase 0

Sessions consacrées à la spécification de la couche 0 et du runtime async. Productions :

  • 6 ADRs phase 0 (numérotés à l’époque 018 à 023, renumérotés 019 à 024 le 2026-05-20).
  • 9 specs de familles de syscalls dans specs/layer-0/ (process, fs, mem, signal, time, net, ipc, security, system).
  • Spec partielle air-sys-types (~25 types).
  • Spec io-uring-overview (synthèse, détail Temps 1-4 à produire).
  • Documents de setup phases A (décisions techniques), B1 (structure workspace), B2 (conventions opérationnelles), C (infrastructure), D (communauté).
  • Document de macro-architecture des 6 couches (rédigé en early-stage, dette de cohérence à traiter — cf. section « Dettes connues »).

Licence du document : MPL 2.0 Statut : journal de bord interne, mis à jour à chaque fin de session.