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ôleur — n’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_empty — pas 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
| Rang | Visibilité | Rôle |
|---|---|---|
| Manager de domaine | Public, 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 cas | Ce sur quoi un manager s’appuie quand un domaine est trop vaste pour un seul objet |
| Contrôleur interne | Jamais public | Le 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 —
CStr↔AirPath,errno↔AirResult. 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 :
- déterminer ce qu’il faut pour avancer (les jalons) ;
- en déduire les managers de domaine manquants ou incomplets ;
- leur spécification détermine les contrôleurs dont ils ont besoin pour accomplir leur tâche de domaine ;
- 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-alloc — ne 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
- 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.
- 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"
role | Ce que la crate déclare | Ce que le gate doit vérifier |
|---|---|---|
manager | Elle expose un manager de domaine — point d’entrée de sa couche | Elle porte effectivement au moins un Air*Manager (D6) ; consommable depuis N+1 |
vocabulaire | Elle ne porte que des types | Aucune opération publique au-delà des destructeurs de ses propres types (D1) ; traversable depuis n’importe quelle couche |
interne | Contrôleurs et détails d’implémentation | Non consommable depuis N+1. Seules les crates de sa propre couche y accèdent |
runtime | Elle fait partie du substrat d’exécution — voir la définition ci-dessous | Ses 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
runtimedésigne le runtime userland — le remplaçant decrt0: 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 estair-uring+air-asyncet 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 :
| Crate | Ce qu’elle apporte | Équivalent C |
|---|---|---|
air-std-entry | point d’entrée ELF _start | crt1.o |
air-runtime | TCB, TLS, relocation static-PIE, bootstrap | crt0 / bits de runtime de la libc |
air-alloc | allocateur global | malloc |
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-runtimedemande 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 desair-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 notrestdRust, de sorte qu’aucun fragment de runtime C ou glibc ne subsiste dans le binaire final. C’est un remplaçant decrt0: TCB, TLS, relocation static-PIE,_start→bootstrap, 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 queerrno,set_errno,errno_locationetcurrent_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 — appelerrelocate_selfouprotect_relrodeux 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
stdAir. 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-targetrt/. 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-runtimeest donc un chantier séquencé à part, adossé au selftest on-target, et pas un lot parmi d’autres dans le cycle de descellement. -
air-threadest 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-corenuance 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_sincesur les durées,components/extension/file_name/join/parentsur les chemins,errno/kind/messagesur les erreurs. Elle relève donc derole = "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 duroleattraperait.
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.mdetAGENTS.mdn’é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-processet 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_system→type_file_system) : rejeté — une traduction sans gain de sûreté, payée à chaque frontière. - Traiter
air-sys-typescomme 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
- Le « Principe 10 » : à rédiger, ou à retirer des documents qui l’invoquent ?
Outillage de D3/D4/D6— répondu. La déclaration est lerolede 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 à écrirecargo xtask check-controleurs.- 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) ?
Un quatrièmeTranché par le BDFL le 2026-08-03 : oui,rolepour le substrat d’exécution ?role = "runtime", défini en D9 — etair-allocle porte, son API d’arène devant être rendue interne. Reliquat : le second chemin versmmap(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 classe | Question qu’il répond | |
|---|---|---|
| Anneau | la responsabilité de médiation | qu’est-ce que ce code protège, et de qui ? |
| Couche | le niveau d’abstraction d’un SDK | sur 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.
| Anneau | Ce qu’il tient |
|---|---|
| 0 | Encercle le noyau : l’interface d’appel système. C’est le seul endroit où unsafe existe (D2) |
| 1 | Les 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 |
| 2 | Tourné 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 |
| 3 | Les 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 :
- Ç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.
- Ç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.
- Ç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