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

ADR-149 — Architecture de code : contrôleurs, managers de domaine, et contrat d’API

Statut : Accepté (2026-08-03, énoncé par le BDFL). Document fondateur de l’architecture de code d’Air, toutes couches. Complète ADR-077 (« Managers de domaine en couche 1 »), qui en devient un cas particulier, et ADR-029 (« Nommage de la surface publique ») dont il reprend la discipline sans la modifier.

Catégorie : Architecture (fondateur, transverse). Toute décision structurante ultérieure sur la découpe du code passe par un RFC amendant cet ADR (ADR-015).

Contexte

Air est organisé en couches numérotées de 0 à 5. Cette organisation était décrite dans docs/architecture/macro-architecture-fr.md et outillée par le gate cargo xtask check-layers. Elle tient.

Ce qui ne tenait pas, c’est l’intérieur d’une couche. La règle donnée à l’origine — les fonctions d’aide d’une couche sont regroupées comme méthodes d’un contrôleurn’a jamais été écrite. Le mot « contrôleur » n’apparaît dans le corpus qu’au sens MVC d’ADR-002 et ADR-009, et au sens de « pilote d’I/O » d’ADR-091. Faute d’être opposable, la règle a dérivé : l’audit du 2026-08-03 (ADR-077, amendement) a trouvé 21 opérations de terminal sans aucun contrôleur, un module engine public exposant 19 opérations de socket, et toute la séparation de privilèges offerte en fonctions libres.

Ce document écrit la règle. Il ne l’invente pas — il la rend opposable.

Décision

D1 — La règle de couche porte sur les opérations, pas sur les types

Amendé le 2026-08-22 — voir l’amendement en fin de document. La maxime encadrée ci-dessous se contredisait : « les types ne traversent pas les couches », puis, trois lignes plus bas, « et il traverse ». La formulation corrigée et la règle de non-redéfinition sont dans l’amendement.

Une couche N n’invoque les opérations que de la couche N-1. Les types ne traversent pas les couches : ils sont leur langue commune.

C’est la formulation qui fait disparaître ce qu’on prenait pour une entorse. air-sys-types (couche 0) est consommable depuis n’importe quelle couche non pas malgré la règle, mais parce qu’il est hors de son champ : il ne porte aucune capacité.

Pourquoi les types du kernel sont publics dès la couche 0. Un type imposé par l’ABI du kernel est immuable et public pour le kernel. L’alternative — le nommer __kernel_type_file_system en couche 0 et le recréer en type_file_system en couche 1 — ajouterait une traduction qui n’apporte aucune sûreté et coûte une conversion à chaque frontière. La couche 0 en fait donc un type public, et il traverse.

Le critère est vérifiable. La surface de fonctions publiques d’une crate de vocabulaire ne contient que des constructeurs, des accesseurs et des conversions. Au 2026-08-03, air-sys-types expose exactement new, from_raw, try_from_raw, as_raw, to_raw, from_nonzero, zeroed, as_bytes, as_ptr, contains, is_emptypas une opération.

Réserve nommée, et bornée. air-sys-types n’est pas purement du vocabulaire : le Drop d’OwnedFd ferme le descripteur par un asm!("syscall") en ligne (crates/air-sys-types/src/fd.rs). Ce n’est pas une infraction, et il faut dire pourquoi : c’est de la sémantique de possession, pas un service. Remonter un OwnedFd remonte la responsabilité de fermer — c’est ce que « possession » signifie, et c’est l’idiome de std::os::fd::OwnedFd. L’opération explicite close, celle qui rend l’erreur à l’appelant, vit bien dans air-sys-syscall, la crate d’opérations. D’où la règle :

Une crate de vocabulaire peut porter les opérations qu’exige la possession de ses propres types — c’est-à-dire ses destructeurs — et rien d’autre. Toute autre opération dans une crate de vocabulaire est une infraction.

D2 — unsafe en couche 0 et nulle part ailleurs

Règle déjà énoncée dans la macro-architecture (« étoile polaire »), rappelée ici parce qu’elle commande la découpe : la couche 0 est mince et son unique raison d’être est d’isoler les appels unsafe. Au-dessus : Rust pur et idiomatique — Result, Option, pas d’assembleur, pas d’appel à du C, pas de unsafe.

(Cette règle est absente de CLAUDE.md, d’AGENTS.md et des principes d’ingénierie, qui ne demandent qu’un commentaire // SAFETY: — ce qui se lit comme une autorisation générale. Écart relevé le 2026-08-03 ; à corriger dans ces documents, cf. §Conséquences.)

D3 — Le contrôleur : l’unité de responsabilité et de test

Un contrôleur est un objet qui regroupe, sous une responsabilité unique, les opérations d’un périmètre donné. Il n’existe pas pour faire joli : il existe pour borner — ce qu’il garantit, ce qu’il n’assume pas, comment on l’utilise, et comment on le teste.

Critère de granularité :

Un contrôleur = un jeu d’invariants qu’on peut énoncer ensemble et tester ensemble.

Corollaire opérationnel : si l’on ne peut pas écrire « ce contrôleur garantit que… » en une seule fois, c’est qu’il y en a deux. Le but n’est pas de multiplier les contrôleurs, mais de faire coïncider le périmètre de responsabilité et le périmètre de test — condition d’un code solide et testable à 100 % (Principe 1).

Exemple d’application, air-terminal : trois contrôleurs, parce que leurs invariants et leurs harnais ne se recouvrent pas — allocation de pseudo-terminaux, attributs termios, session et terminal de contrôle.

D4 — Trois rangs, et un seul est contractuel

RangVisibilitéRôle
Manager de domainePublic, exposé à la couche N+1« Super-contrôleur » : la surface haute de la couche. Reçoit tous les appels des bindings (C, C++, Swift, autres) et cache l’implémentation
Contrôleur spécialiséPublic ou interne selon le casCe sur quoi un manager s’appuie quand un domaine est trop vaste pour un seul objet
Contrôleur interneJamais publicLe secret d’implémentation. Destiné à évoluer, changer, disparaître

Le contrat d’API d’Air, ce sont les managers de domaine et les types publics. Tout le reste est interne, et Air se réserve d’en changer la structure comme bon lui semble. Un développeur utilisant les APIs d’Air est informé qu’il ne doit jamais dépendre d’un contrôleur interne.

C’est la même règle qu’en D1, à une autre échelle : le manager rend des types et ne laisse pas fuir d’opérations. 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().

D5 — Les fonctions en forme de C sont des coquilles de traduction

La libc d’Air doit fournir ses fonctions standard, aux noms hérités de POSIX. Ces fonctions ont le droit d’exister — à une condition :

Une fonction en forme de C n’est légitime que comme coquille de traduction : son corps appelle le manager (ou le contrôleur) et rien d’autre. Elle n’ajoute que l’adaptation C-ABI — CStrAirPath, errnoAirResult. Aucun chemin parallèle vers le noyau.

C’est ce qu’ADR-077 §1 disait déjà en parlant d’« adaptation C-ABI » ; il faut le lire comme une interdiction, pas comme une préférence de style.

Le nommage suit ADR-029, sans exception. La fonction exportée porte le nom de l’autorité — zone 2, verbatim, pour que le développeur le retrouve dans les man pages. La méthode du contrôleur porte un nom qu’Air invente, donc zone 1, en toutes lettres :

Fonction exportée (zone 2, POSIX)Méthode appelée (zone 1, Air)
openpty()AirTerminalManager::open_pseudo_teletype_writer()
opentty()AirTerminalManager::open_teletype_writer()

Conséquence immédiate, déjà en infraction. 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 : zone 1, donc infraction à ADR-029, indépendamment du contrôleur manquant.

D6 — Une crate porte au moins un contrôleur, jamais zéro

L’idiome d’origine était un contrôleur = une crate. Il s’est révélé trop rigide : un contrôleur n’a parfois pas de raison d’exister sans un deuxième, voire un troisième. La règle est donc assouplie par le haut seulement :

Une crate peut regrouper 1, 2, 3 ou N contrôleurs. Elle ne peut pas en porter zéro.

Une crate qui n’expose que des fonctions libres n’est pas conforme. C’est le cas de plusieurs crates au 2026-08-03 — l’inventaire fait autorité dans l’amendement d’ADR-077.

D7 — Dépendances intra-couche : autorisées entre contrôleurs, interdites entre services

docs/architecture/macro-architecture-fr.md §1781 pose qu’« une couche N ne peut consommer une autre couche N sans passer par une couche inférieure commune », tandis que le gate check-layers autorise M ∈ {N-1, N} et que le code le pratique (air-process consomme air-thread, toutes deux en couche 1). Les deux avaient raison à des échelles différentes :

  • entre contrôleurs d’une même couche : autorisé. Un contrôleur qui s’appuie sur un autre contrôleur du même rang est la composition normale d’une couche ;
  • entre services de couche 5 : interdit. Deux démons ne se consomment pas directement ; ils passent par AirCom (couche 2) et s’exposent mutuellement des services. C’est ce que le §1781 visait réellement.

Le gate reste inchangé ; la macro-architecture est à préciser.

D8 — Ordre de marche

La mise en conformité ne commence pas par le code. L’ordre est :

  1. déterminer ce qu’il faut pour avancer (les jalons) ;
  2. en déduire les managers de domaine manquants ou incomplets ;
  3. leur spécification détermine les contrôleurs dont ils ont besoin pour accomplir leur tâche de domaine ;
  4. alors seulement, reprendre l’existant pour l’amener vers cette découpe.

Créer des contrôleurs avant d’avoir le manager qui les justifie reviendrait à deviner le périmètre — donc à deviner les tests, ce qui est exactement ce que cet ADR cherche à éviter.

D9 — Le contrat inter-couche : depuis la couche N, on ne voit que les managers de N-1

D4 dit ce qu’une couche expose. D9 dit ce que la couche du dessus a le droit de voir — c’est la même règle, projetée sur la frontière :

Une crate de couche N ne peut atteindre la couche N-1 que par ses managers de domaine et ses types publics. Jamais un contrôleur, jamais une fonction libre. Un manager est public par définition : c’est le point d’entrée de sa couche.

C’est ce que la hiérarchie interne d’une couche rend nécessaire à écrire. Au sein d’une même couche, les crates ne sont pas au même niveau — elles se consomment entre elles, et cette composition est légitime (D7). Mais la hauteur dans cette hiérarchie ne fait pas d’une crate un manager : au 2026-08-03, la crate la plus haute de la couche 1 — air-runtime, qui agrège air-process, air-thread, air-memory, air-env, air-allocne porte aucun manager, tandis qu’air-memory et air-system, presque au socle, en portent un. Les deux dimensions sont orthogonales : le rang mesure la composition, le manager mesure l’exposition.

Deux granularités, et il faut les deux

  1. Au niveau des crates — vérifiable immédiatement : une crate de couche N ne dépend d’une crate de couche N-1 que si celle-ci expose un manager, ou si elle est de vocabulaire.
  2. Au niveau des symboles — la règle réelle : dans une dépendance autorisée, seuls le manager et les types publics sont référençables.

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 pas être inférée par heuristique : toute crate finit par ressembler assez à du vocabulaire pour passer. (Constat empirique du 2026-08-03 : un classificateur automatique écrit pour cet audit rangeait air-uring — 43 fonctions publiques — parmi les crates de vocabulaire.) L’exemption doit être un engagement du manifeste, comme la couche l’est déjà :

[package.metadata.air]
layer = 1
role  = "manager" | "vocabulaire" | "interne" | "runtime"
roleCe que la crate déclareCe que le gate doit vérifier
managerElle expose un manager de domaine — point d’entrée de sa coucheElle porte effectivement au moins un Air*Manager (D6) ; consommable depuis N+1
vocabulaireElle ne porte que des typesAucune opération publique au-delà des destructeurs de ses propres types (D1) ; traversable depuis n’importe quelle couche
interneContrôleurs et détails d’implémentationNon consommable depuis N+1. Seules les crates de sa propre couche y accèdent
runtimeElle fait partie du substrat d’exécution — voir la définition ci-dessousSes consommateurs sont structurellement déterminés, non choisis à la carte. Dispensée de manager ; sa surface est revue à part

Cette déclaration réutilise exactement le mécanisme qu’a éprouvé check-layers — une zone [package.metadata] inerte pour Cargo, donc sans impact API ni descellement — et elle répond à la question ouverte n° 2 de cet ADR.

role = "runtime" — le substrat d’exécution

Définition. Le rôle runtime désigne le runtime userland — le remplaçant de crt0 : point d’entrée ELF, bloc de contrôle de thread, stockage local de thread, relocation static-PIE, amorçage, allocateur global. Jamais le runtime asynchrone, qui est air-uring + air-async et ne porte pas ce rôle.

Pourquoi cette désambiguïsation est écrite plutôt que sous-entendue. Dans le corpus, sur les emplois qualifiés du mot, « runtime async/asynchrone » sort 36 fois contre 7 pour « runtime userland » : le sens dominant est l’inverse de celui qu’on donne ici. Et cette ambiguïté a déjà coûté — c’est elle qui a laissé la spec air-runtime décrire un moteur async alors que le moteur était parti dans air-uring/air-async (ADR-092).

Le nom est néanmoins retenu, parce que le gate rattrape le contresens. Un développeur ouvrant le manifeste d’air-async — dont l’en-tête dit « exécuteur de futures » — pourrait écrire role = "runtime" de bonne foi. Mais air-async est consommé par air-com, air-notifyd, air-sshd et n’importe quelle crate de couche 2 future : ses consommateurs ne sont pas structurellement déterminés, ils sont choisis. Le gate le refuserait. Un rôle dont la vérification rejette le mauvais prétendant vaut mieux qu’une note que personne ne relit au moment de taper.

La règle du gate n’est pas « liste close ». Première formulation envisagée, et fausse : air-std-entry est lié par air-account-cli, air-keystore-cli, air-sshd, air-agent — par tout exécutable Air, donc un ensemble ouvert. La bonne formulation est :

Les consommateurs d’une crate role = "runtime" sont structurellement déterminés — tout exécutable lie le point d’entrée, toute libc lie le runtime — et non choisis à la carte. C’est ce qui la distingue d’une API de domaine, et ce qui la dispense de manager.

Les trois candidats, et la cohérence du trio — c’est exactement ce qu’est un runtime C :

CrateCe qu’elle apporteÉquivalent C
air-std-entrypoint d’entrée ELF _startcrt1.o
air-runtimeTCB, TLS, relocation static-PIE, bootstrapcrt0 / bits de runtime de la libc
air-allocallocateur globalmalloc

Ce que le rôle ne dispense pas. runtime exempte de manager, pas de discipline : les opérations dangereuses hors séquence — relocate_self, protect_relro — doivent être portées par un objet qui en garantit l’ordre, et non exposées nues. Cf. la contrainte de séquencement d’air-runtime ci-dessous.

Le cas air-alloc, tranché. La crate porte role = "runtime" : son GlobalAllocator est un ZST implémentant core::alloc::GlobalAlloc, enregistré par #[global_allocator] dans rt/, et appelé par le langage — chaque Box::new, chaque croissance de Vec. Consommateurs structurellement déterminés s’il en est.

À une condition, qui découle de la garde ci-dessus. air-alloc a deux visages, et un seul relève du runtime : à côté de GlobalAllocator, il expose Arena, MmapBacking, RawRegion, Chunk — une API d’arène explicite. Une arène qu’un développeur peut instancier n’est pas une dépendance structurelle, c’est une API choisie, et elle relève d’un contrôleur. Laisser le rôle la couvrir reviendrait à blanchir une API choisie en substrat. L’API d’arène est donc à rendre interne — ce qui est encore gratuit au 2026-08-03, air-alloc n’étant consommé que par air-libc-alloc et air-runtime.

Et AirMemoryManager n’est pas rétrogradé — il doit grossir. L’hypothèse d’un « AirMemoryControleur » a été écartée : air-memory est bien le point d’entrée du domaine mémoire pour les bindings (mmap, mprotect, munmap de la libc passent par lui, D5), et le rétrograder laisserait ce domaine sans manager, contre D4. Son problème n’est pas d’être trop haut placé, c’est d’être trop maigre : trois méthodes, avec raw_mapping resté dehors.

L’état constaté au 2026-08-03

32 crates de couche ≥ 2 dépendent d’au moins une crate de couche 1 sans manager. Les cibles, par nombre de consommateurs : air-base-core (26), air-runtime (13), air-account (7), air-thread (6), air-crypto (5), air-keystore et air-std-entry (4), air-terminal et air-base-lib (3).

Trois cas résument l’ensemble :

  • air-runtime demande un traitement à part, et une correction de lecture. Un premier relevé le donnait « en libre-service auprès de 13 crates de couche 2 ». C’est faux, et la vérification l’a montré : 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.

    Ce qu’est réellement air-runtime (rappel BDFL, 2026-08-03) : le runtime userland qui complète un exécutable Air construit sur notre std Rust, de sorte qu’aucun fragment de runtime C ou glibc ne subsiste dans le binaire final. C’est un remplaçant de crt0 : TCB, TLS, relocation static-PIE, _startbootstrap, transitions post-fork.

    L’écart réel est ailleurs, et il est de forme. La crate porte une façade — pub struct AirRuntime — mais elle ne couvre que errno, set_errno, errno_location et current_tid. Les ~28 autres opérations sont libres : bootstrap, relocate_self, protect_relro, run_child, run_parent, reset_after_fork_in_child, init_spawned_thread_tls, register_thread_local_destructor, main_thread_stack, auxval, spawn, join, stack_canary. Même motif qu’AirNetworkManager : une façade symbolique, le domaine dehors. Plusieurs de ces opérations sont dangereuses à exposer nues — appeler relocate_self ou protect_relro deux fois, ou hors séquence, corrompt le processus.

    Contrainte impérative de mise en conformité (BDFL). La restructuration ne doit pas casser le runtime dont dépend tout exécutable bâti sur la std Air. Or le manifeste de la crate est explicite : les quatre fichiers TARGET-ONLY (TCB, TLS, spawn, bootstrap) ne sont sound que sur *-linux-air, sont ignorés par 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 est donc un chantier séquencé à part, adossé au selftest on-target, et pas un lot parmi d’autres dans le cycle de descellement.

  • air-thread est le même cas en plus petit : 36 opérations (spawn, join, lock, send, recv, futex_wait, futex_wake), aucun manager, 6 consommateurs de couche 2.

  • air-base-core nuance la règle, et la justifie. L’écrasante majorité de ses fonctions sont des méthodes de types légitimes — checked_add/saturating_add/duration_since sur les durées, components/extension/file_name/join/parent sur les chemins, errno/kind/ message sur les erreurs. Elle relève donc de role = "vocabulaire". Mais de vraies opérations s’y sont glissées : now, sleep, sleep_until, log, open, machine_id — qui touchent le noyau ou l’état du système. La crate de vocabulaire la plus consommée du projet (26 dépendants) demande donc, elle aussi, un nettoyage — et c’est exactement la fuite que la vérification du role attraperait.

D10 — Les seconds chemins sont marqués dans le code, 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 soit indiscernable d’un oubli. Le premier lecteur ne sait pas si le chemin parallèle qu’il a sous les yeux est un choix ou une négligence, et il n’a aucun moyen de recenser les autres.

Convention. Tout chemin délibérément parallèle à la voie normale — un accès qui ne passe pas par le contrôleur ou le manager qui devrait le médier — porte, à l’endroit exact où il est pris, un commentaire // SECOND-PATH: disant : quelle est la voie normale, pourquoi elle n’est pas empruntée ici, et à quelle condition la dérogation tomberait.

La forme reprend celle de // SAFETY:, déjà éprouvée dans l’arbre — même position (au point d’usage), même caractère obligatoire, même vertu : elle se grepe. L’intérêt n’est pas le commentaire pris isolément, c’est l’inventaire : grep -rn "SECOND-PATH" rend en une commande la liste complète des dérogations vivantes, ce qui les rend maintenables au lieu d’être redécouvertes une par une.

À outiller, et cette fois vraiment. L’ADR-035 et CLAUDE.md annoncent qu’un check-safety-comments vérifie en CI que tout bloc unsafe porte sa justification. Cet outil n’existe pas (constat du 2026-08-03 : seul xtask/src/check_syscalls.rs lit ces commentaires, pour en extraire les numéros de syscalls). La convention SECOND-PATH ne doit pas connaître le même sort — c’est précisément parce qu’une règle non outillée n’est pas tenue que cet ADR existe.

Premier cas consigné : MmapBacking

air-alloc obtient ses pages par mmap en appelant directement air-sys-syscall, sans passer par AirMemoryManager qui médie pourtant ce syscall. Ce n’est pas une infraction de couche — une crate de couche 1 consomme la couche 0, c’est son rôle — mais c’est bien un second chemin vers le noyau dans le domaine mémoire, et il doit être déclaré comme tel.

La dérogation a une raison : l’allocateur doit servir très tôt dans l’amorçage, et le faire dépendre d’AirMemoryManager créerait un ordre d’initialisation à respecter, voire un cycle. Sa levée éventuelle dépend donc de la séquence de bootstrap — la même contrainte que celle qui rend air-runtime difficile à restructurer.

Conséquences

Ce qui est déjà outillé et ne change pas. Le gate cargo xtask check-layers mécanise D1 pour les dépendances de crates ; ADR-029 couvre D5 pour le nommage.

Ce qui n’est pas encore outillé. Aucun gate ne vérifie D3, D4, D6 — « une crate porte au moins un contrôleur », « aucune opération publique hors du contrôleur », « une fonction en forme de C n’appelle que le manager ». C’est mécanisable (analyse de la surface publique et des corps de fonctions) et devrait l’être : la dérive constatée vient précisément de ce qu’une règle non outillée n’est pas tenue. À ordonnancer.

Documents à corriger (relevé du 2026-08-03) :

  • CLAUDE.md et AGENTS.md n’énoncent pas D2 ; ils demandent seulement un commentaire // SAFETY:, ce qui se lit comme une autorisation générale de l’unsafe ;
  • les principes d’ingénierie n’en comptent que neuf, alors que le « Principe 10 » (cloisonnement, séparation de privilèges) est invoqué par la spec air-process et par la macro-architecture à trois reprises. Ce principe n’a jamais été rédigé ;
  • docs/architecture/macro-architecture-fr.md §1770 (« saut de couches autorisé exceptionnellement ») et §1781 (intra-couche) sont à reformuler selon D1 et D7.

Coût de mise en conformité. La couche 1 est scellée. Déplacer des opérations d’une fonction libre vers un contrôleur change la surface publique. Comme pour ADR-077 §2, à mener en un seul cycle de descellement, pas en série.

Alternatives rejetées

  • Dupliquer les types du kernel en couche 1 (__kernel_type_file_systemtype_file_system) : rejeté — une traduction sans gain de sûreté, payée à chaque frontière.
  • Traiter air-sys-types comme une exception nommée à la règle de couche : rejeté — une règle qui admet une exception se met à en admettre d’autres. La distinction opérations / types la rend sans exception.
  • Interdire les fonctions en forme de C : rejeté — la libc doit fournir ses noms POSIX. C’est leur corps qui est contraint, pas leur existence.
  • Un contrôleur = une crate, strictement : rejeté — trop rigide (D6).
  • Multiplier les contrôleurs par principe : rejeté — le contrôleur sert à borner un périmètre de test, pas à découper pour découper.

Questions ouvertes

  1. Le « Principe 10 » : à rédiger, ou à retirer des documents qui l’invoquent ?
  2. Outillage de D3/D4/D6répondu. La déclaration est le role de D9. Et le BDFL a tranché le 2026-08-03 : le gate est consultatif d’abord, bloquant une fois la mise en conformité faite. Avec 32 crates en écart, un gate bloquant d’emblée arrêterait le projet ; consultatif, il mesure la trajectoire et empêche l’aggravation. Reste à écrire cargo xtask check-controleurs.
  3. Contrôleurs spécialisés publics ou internes : le cas par cas est-il tenable, ou faut-il une règle par défaut (interne sauf déclaration explicite) ?
  4. Un quatrième role pour le substrat d’exécution ? Tranché par le BDFL le 2026-08-03 : oui, role = "runtime", défini en D9 — et air-alloc le porte, son API d’arène devant être rendue interne. Reliquat : le second chemin vers mmap (D10) tombe-t-il un jour ? La réponse dépend de la séquence d’amorçage, pas d’une préférence d’architecture.

Amendement — anneau et couche sont deux axes, et les types traversent (2026-08-22)

Origine. Constat du BDFL, en relisant l’inventaire des crates : « l’archi en couches, quand elle a été écrite, se positionnait comme un développeur qui regarde les SDK à sa disposition. Or notre regard à nous, qui implémentons le système, est différent. »

Cet amendement ne remplace rien : il sépare deux notions que le mot « couche » recouvrait sans le dire, et il lève une contradiction dans la formulation de D1.

D11 — Deux axes, et il faut les nommer séparément

Ce que l’axe classeQuestion qu’il répond
Anneaula responsabilité de médiationqu’est-ce que ce code protège, et de qui ?
Couchele niveau d’abstraction d’un SDKsur quoi ce SDK est-il bâti ?

Les six « couches » de docs/architecture/macro-architecture-fr.md mélangeaient les deux : 0, 1 et 2 sont des anneaux, tandis que 3, 4 et 5 étaient des étages de SDK — un axe différent, empilé sur le mauvais.

Le symptôme était visible et je ne l’avais pas lu comme tel : air-launchd déclare layer = 2 dans son manifeste et la macro-architecture le place en « couche 5 ». Aucun des deux ne mentait sur le code — ils répondaient à deux questions différentes.

D12 — Les quatre anneaux

Les anneaux disent ce qu’Air protège, et dans quel ordre. Ils sont au nombre de quatre, et il n’y en aura pas d’autres.

AnneauCe qu’il tient
0Encercle le noyau : l’interface d’appel système. C’est le seul endroit où unsafe existe (D2)
1Les premiers contrôleurs intelligents sur les objets du noyau, et ceux qui apportent les concepts propres à Air — cages, confinement, sécurité. Interne : rien n’en sort directement
2Tourné vers le public. Tout ce qu’Air expose : SDK publics, démons, outils en ligne de commande, l’ABI C. Franchir sa surface, c’est entrer dans Air
3Les applications et services externes. Cet anneau n’est pas du code d’Air : il est défini par la cage que l’anneau 2 pose autour de ce qui s’y exécute

Ring 2 mérite une précision, sans quoi il paraîtra mal peuplé. Certains de ses membres médient réellement l’accès au noyau (libair_c, air-launchd) ; d’autres n’exposent qu’une commodité bâtie sur l’anneau 1 (air-tui, air-url). Les deux y sont pour la même raison, et elle n’est pas la médiation : ils sont la surface qu’un tiers touche. Un lecteur qui chercherait ce que air-tui médie ne trouverait rien et le croirait mal rangé.

Ring 3 n’est pas un étage de code, c’est une frontière. Ce qui le définit n’est pas ce qu’on y écrit, mais ce que l’anneau 2 pose autour : l’octroi arbitré et la cage qui en dérive (ADR-150 D3, D12).

D13 — Ce qui gouverne entre les anneaux, et ce qui gouverne dedans

La règle de couche (M ∈ {N-1, N}) porte sur les anneaux 0 → 1 → 2. Au-delà, elle n’a plus d’objet : il n’y a pas d’anneau 4.

Mais l’anneau 2 comptera des dizaines de crates — la libc, le modèle d’objet, les cœurs de protocole, les démons, les CLI, et demain les SDK d’interface. À l’intérieur d’un anneau, la règle de couche ne dit rien. Ce n’est pas un trou, à condition de nommer ce qui prend le relais :

Entre les anneaux, la règle de médiation. À l’intérieur du dernier, la discipline des rôles.

C’est-à-dire D3, D4, D7 (deux services ne se consomment pas directement — ils passent par AirCom) et D9 (depuis l’anneau N, on ne voit que les managers de N-1). Ces règles existent déjà et sont mesurées : les écarts de xtask/controleurs-reference.toml sont des infractions de rôle, pas de couche.

D14 — La maxime de D1 était contradictoire ; voici la formulation qui tient

D1 encadre ceci :

Une couche N n’invoque les opérations que de la couche N-1. Les types ne traversent pas les couches : ils sont leur langue commune.

et conclut trois lignes plus bas, à propos du même air-sys-types : « La couche 0 en fait donc un type public, et il traverse. » Les deux phrases se contredisent. « Ne traversent pas les couches » voulait dire « ne sont pas soumis à la règle de traversée », et se lit comme une interdiction — au point d’avoir fait craindre qu’un SDK de couche 4 dût re-emballer les types de couche 2.

Formulation corrigée :

Une couche N n’invoque les opérations que de la couche N-1. Les types ne sont pas soumis à cette règle : ils sont la langue commune des couches, et ils traversent librement.

D15 — Un SDK ne redéfinit pas un type auquel il n’ajoute rien

Si un SDK de couche N n’ajoute rien à un type de couche N-1, il ne le redéfinit pas. Le type traverse tel quel.

Trois raisons, et aucune n’est esthétique :

  1. Ça ne rend aucune sûreté. Un type recopié champ pour champ porte exactement les mêmes invariants que l’original — c’est-à-dire les mêmes, ou moins.
  2. Ça coûte à écrire, et le coût est récurrent : chaque champ ajouté en N-1 doit être reporté, et l’oubli est silencieux.
  3. Ça coûte à l’exécution. Il faut recopier les données du type N-1 dans le type N à chaque franchissement — du temps processeur dépensé pour ne rien garantir.

C’est l’argument que D1 tenait déjà contre la duplication des types du noyau (« une traduction qui n’apporte aucune sûreté et coûte une conversion à chaque frontière »), étendu à toutes les frontières de SDK.

La réciproque borne la règle : redéfinir est légitime quand le type de couche N apporte quelque chose — un invariant que N-1 ne garantit pas, une validation, une sémantique plus riche. C’est alors un parse, don’t validate (Principe 4), pas une recopie.

Ce que cet amendement change dans l’outillage

cargo xtask check-layers exempte de la règle une crate nommée en dur : air-sys-types. Or D1 fonde l’exemption sur une propriété — ne porter aucune opération —, pas sur un nom, et douze crates de vocabulaire existent aujourd’hui.

L’exemption se généralise à role = "vocabulaire", et le critère de pureté est celui que D1 énonce déjà : « la surface de fonctions publiques d’une crate de vocabulaire ne contient que des constructeurs, des accesseurs et des conversions », plus les destructeurs qu’exige la possession.

C’est la pureté qui donne le droit de traverser. Une crate de vocabulaire prouvée sans opération traverse librement ; une crate impure est traitée comme n’importe quelle autre.

Aucune exception nommée, aucun cas particulier — conformément à ce que D1 rejetait déjà : « une règle qui admet une exception se met à en admettre d’autres ». Le motif vocabulaire-impur de check-controleurs (3 entrées au 2026-08-22) devient le gardien de ce droit.

Ce que cet amendement ne change pas

Aucune crate ne bouge. Les 92 layer = N du code restent tels quels. Ce qui change est documentaire : docs/architecture/macro-architecture-fr.md cesse d’empiler six couches sur un axe unique, et les composants qu’il rangeait en couches 3 à 5 — air-wm, air-console, air-view/air-ui/air-tui, air-url, les démons — sont de l’anneau 2.


Licence du document : MPL 2.0