ADR-136 — Descellement additif couche-1-v3.6 : air-json, le codec JSON possédé entre en couche 1
Statut : Accepté (2026-07-29). RFC de structure (ADR-015). Applique le modèle de re-sceau du sceau couche 1 (ADR-062) sous la délégation additive du superviseur (ADR-065). Met en œuvre ADR-114 (vague 2 #2), dont il est l’incrément d’exécution.
Catégorie : Descellement ADDITIF de la couche 1 scellée
(couche-1-v3.5 → couche-1-v3.6). Aucun retrait, aucun changement de
signature, aucune modification de comportement d’une primitive scellée : toute
surface antérieure reste valide telle quelle. Ce qui est ajouté est une crate
entière et neuve — air-json — dont personne ne dépendait hier.
Le tag n’est pas posé par cet ADR. Conformément au modèle de re-sceau, la pose du tag
couche-1-v3.6revient au superviseur, après revue.
Contexte
ADR-114 a tranché : le codec JSON maison né dans air-sshd/src/json.rs
(ADR-099 E.6) est promu
en crate couche 1 partagée, parce que la CLI air-keystore
(ADR-115, vague 2 #3) exposera un mode JSON
agent-safe et a besoin du même codec. Rien, dans cette décision, n’exigeait de
toucher à quoi que ce soit de scellé — mais une crate couche 1 de plus, c’est une
surface couche 1 de plus, et le sceau porte sur la surface, pas sur les fichiers.
D’où le présent ADR : il n’y a rien à desceller, il y a quelque chose à inscrire.
Décision
Descellement additif couche-1-v3.6, sur une seule crate — et cette crate est
neuve.
1. La crate air-json (couche 1, no_std + alloc, zéro I/O)
Surface publique, entièrement nouvelle :
AirJsonValue— le modèle de valeur :Null,Bool(bool),Number(i64),String(String),Array(Vec<AirJsonValue>),Object(Vec<(String, AirJsonValue)>). Les noms de variantes sont ceux de la grammaire RFC 8259 — une autorité que le développeur peut consulter (ADR-029, zone 2).AirJsonValue::get/as_str/as_i64/as_bool/as_array— les accesseurs, qui rendentNonehors de leur variante plutôt que de paniquer.AirJsonValue::write_to/to_json_string— le sérialiseur (forme compacte).parse(&[u8]) -> AirResult<AirJsonValue>— le parseur.MAX_DEPTH— la borne d’imbrication, publique parce qu’elle fait partie du contrat : un appelant qui génère du JSON imbriqué a le droit de savoir jusqu’où il peut aller.
L’unique dépendance est air-base-core (arête 1→1). air-json n’appelle aucun
syscall : elle ne touche ni air-sys-syscall, ni air-filesystem, ni rien qui ouvre
un descripteur. C’est un pur codec, et c’est ce qui la rend consommable sans
précaution par la couche 1 comme par les couches au-dessus.
2. Erreurs : AirError, pas un enum maison
Le JsonError d’origine (six variantes Copy) est remplacé par la monnaie
d’erreurs d’Air (ADR-019) : parse rend
AirResult<AirJsonValue>, et toute faute est un AirErrorKind::InvalidData — la
catégorie « données mal formées », qui est exactement ce dont il s’agit. Ce qui
distingue les cas vit dans le message, qui nomme la faute et localise l’octet
(octet inattendu à la position 42, nombre invalide à la position 7…).
Le raisonnement : un appelant de couche 2 — CLI, démon — mélange déjà dans le même
Result des erreurs d’I/O, de configuration et de parsing. Lui rendre un enum de plus
l’obligerait à écrire une conversion à chaque frontière pour, au bout du compte, ne
matcher que « c’est mal formé ». La position, elle, ne se perd pas : elle est dans le
message, là où elle sert — à pointer la faute à un humain ou à un agent.
3. air-sshd cesse d’héberger le module
crates/air-sshd/src/json.rs et ses tests sont supprimés ; air-sshd dépend
d’air-json (arête 2→1) et ses deux binaires (air-sshd config export|import,
air-ssh config export|import) consomment la crate. La non-régression est portée par
les tests d’interchange E.6 existants (tests/config_json.rs,
tests/config_client_json.rs), inchangés et verts.
4. Durcissement exigé par le statut de crate publique
Le codec héritait déjà de « borné + jamais-panique + fuzzé ». Le passage en couche 1 lui impose la barre complète (ADR-114 §3) :
- couverture 100 % lignes et branches, per-crate. Obtenue sans exception : le
parseur a été retouché là où sa forme d’origine portait des bras inatteignables
(un
itoamaison dont leunwrap_or(0)ne pouvait jamais servir, unbuffer.get(..n)dont leNoneétait impossible). Ces bras n’étaient pas des protections : c’étaient des angles morts. Ils ont été remplacés par des formes dont chaque chemin d’erreur est atteignable — et atteint par un test ; - fuzz — cible
fuzz_air_json_parse(renommée depuisfuzz_air_sshd_json), qui éprouveair_json::parsesur octets arbitraires : jamais de panique, imbrication bornée, round-trip stable ; - proptest —
parse ∘ serialize = identitésur des valeurs arbitraires bornées en profondeur, point fixe de la sérialisation, et « sur des octets arbitraires,parserend toujours unResult».
5. Ce que le périmètre reste
Inchangé (ADR-114 §3) : objets, tableaux, chaînes (échappements standards +
échappement Unicode BMP), entiers i64, true/false/null. Les nombres
fractionnaires et exponentiels restent refusés — Principe 5 : on ne les ajoutera
que le jour où un besoin mesuré l’exigera. Deux tolérances héritées sont
conservées et documentées plutôt que durcies en silence : les zéros de tête
(007) et les clés dupliquées (la 1re gagne). Les durcir serait un changement de
comportement, pas une extraction.
Conséquences
- Un seul codec JSON possédé, fuzzé, partagé — la CLI
air-keystore(ADR-115) s’y branchera sans rien réécrire, et le mode JSON agent-safe repose sur un parseur dont les invariants sont prouvés, pas supposés. - Aucun ajout à l’arbre du produit :
air-jsonn’a aucune dépendance externe, sa fermeture est exactement celle d’air-base-core. Pas deserde_jsonen production (ADR-114 §2 ; ADR-024). air-sshds’allège d’un module qui n’avait pas de raison d’y vivre.
Ce que cet ADR ne fait pas
- Aucun retrait, aucune signature modifiée dans la couche 1 existante. Pas une primitive scellée n’est touchée.
- Aucune consolidation du JSON d’
air-account-cli.air-account-clipossède son propre module JSON (un écrivain seulement :write_bytes/write_string, qui préserve la fidélité des octets non-UTF-8 d’un nom de compte ou d’un chemin — ce qu’AirJsonValue::String, qui porte unStringUTF-8, ne sait pas faire). ADR-114 ne le mentionne pas, et le périmètre de cet incrément est l’extraction depuisair-sshd. La consolidation est remontée comme candidate ultérieure (§Points remontés), pas embarquée ici. - Aucune extension du périmètre (pas de flottants, pas de paires de substitution UTF-16, pas de JSON en streaming).
- Aucune consommation par
air-keystore: c’est la vague 2 #3 (ADR-115).
Alternatives rejetées
- Garder le module dans
air-sshdet le ré-exporter. Rejeté :air-keystoreest couche 1,air-sshdcouche 2 — la dépendance serait à contre-sens du layering (ADR-077), et un binaire d’administration des clés n’a rien à tirer d’un démon SSH. - Prendre
serde_json. Rejeté par ADR-114 §2, et les trois raisons tiennent : règle des 80 % (ADR-024), os-free (ADR-088), et JSON jamais lu au runtime (ADR-073). - Conserver
JsonErroret le convertir aux frontières. Rejeté : voir §2 — le coût (une conversion à chaque appelant) achèterait une finesse de matching que personne ne demande.
Points remontés au BDFL
- Une crate couche 1 de plus, donc un re-sceau. Rien de scellé n’a bougé ; c’est
la surface de la couche qui s’étend. Le tag
couche-1-v3.6est à poser après revue. - Le codec avait des bras morts. Trois chemins d’erreur du parseur d’origine étaient inatteignables par construction — ils avaient l’apparence de la prudence sans en avoir l’effet. Ils sont retirés au profit de formes dont chaque chemin est testé. C’est le genre de dette qu’un seuil de couverture à 100 % révèle et qu’un seuil à 96 % laisse dormir : argument de plus pour la barre couche 1.
air-account-cligarde son écrivain JSON. Deux codecs coexistent donc dans l’arbre, avec des contrats différents (fidélité aux octets non-UTF-8 d’un côté, modèle de valeur UTF-8 de l’autre). Une consolidation est possible — elle demanderait àair-jsonune variante d’octets bruts, c’est-à-dire une extension de périmètre, donc une décision, pas un refactoring. À instruire quand la CLIair-keystore(ADR-115) aura montré ce dont un consommateur a réellement besoin.