Docs · Comprendre · Architecture et choix
Architecture et choix.
runward est une CLI locale et open source qui encadre la livraison du code qu'un agent IA a écrit : l'agent écrit le code, runward passe une garde déterministe sur les décisions et les preuves qui l'entourent et renvoie un verdict rejouable. Il n'écrit jamais le code de votre produit et ne juge jamais la qualité du code : il vérifie que chaque règle porteuse est justifiée par un pointeur de preuve résoluble. Cette page explique la forme (ports et adaptateurs), les deux invariants que toute la conception protège (garde déterministe, zero-LLM/zero-network), le jeu de règles comme donnée relisible, et le journal des ADR qui consigne le pourquoi de chaque choix. Le critère de succès autour duquel l'architecture est bâtie : même arbre de travail ⇒ même code de sortie, chaque violation nommée.
Toute l'architecture tient sur une forme : la porte est un port, et tout le reste s'y adapte.
La garde est un port, pas une API
Le port principal de runward est un contrat de processus, pas une API de bibliothèque : des arguments en entrée, un code de sortie en sortie, un rapport lisible par un humain sur stdout. Tout ce qui peut lancer un processus et lire un code de sortie est un adaptateur de ce port : un job CI, un hook git pre-commit, ou un harnais d'agent IA.
Les codes de sortie sont la partie porteuse du contrat, consommés à l'aveugle par la CI :
| Code de sortie | Signification | Réaction du consommateur |
|---|---|---|
0 |
garde propre (avec --strict : chaque règle attendue justifiée, chaque pointeur typé vérifié, sceau intact) |
franchir la garde / fusionner |
1 |
manques : livrables non remplis, violations de conformité, dérive, sceau rompu, hooks en échec | bloquer ; le rapport nomme chaque violation et son geste de correction |
2 |
aucune mission trouvée, ou mauvais usage de la CLI (commande/flag inconnu) | erreur de configuration, délibérément distincte d'une garde rouge pour qu'une faute de frappe ne se lise jamais comme "garde rouge" |
La distinction entre 1 et 2 est un choix de conception, pas un hasard : une garde rouge (1) et "la question n'a même pas pu être posée" (2) ne doivent jamais se confondre en un seul code, pour qu'un flag mal tapé ne soit jamais pris pour un audit en échec.
Signatures de commandes (pour un agent qui pilote runward)
runward check [--strict] [--hooks] [--coverage] [--freeze] [--json] [-p PATH] -> exit code + report
runward rules --json [-p PATH] -> versioned JSON on stdout
runward compliance <regime> [-p PATH] -> files under runward/compliance/
runward wire [--json] [-p PATH] -> recommended channel (read-only; never wires)
Toutes les opérations sont en lecture seule sur votre code, synchrones et idempotentes ; aucune opération n'exige jamais d'approbation car aucune n'agit sur le monde au-delà d'une écriture dans runward/.
runward wire est délibérément en lecture seule : il détecte le harnais IA qui l'exécute et recommande le canal de déclenchement automatique, mais ne câble jamais rien : c'est l'opérateur qui câble (ADR-0012).
--json sur check est la surface machine stable pour les exécutions pilotées par un agent (ADR-0030). Il renvoie un unique objet JSON déterministe, le code de sortie restant le signal primaire :
- sur une mission trouvée :
{ runward, mission, currentGate, adrCount, strict, verdict, exitCode, gaps, deliverables, conformance? } - quand aucune n'est trouvée :
{ runward, mission: null, verdict: "no-mission", exitCode: 2 }
Les commandes sont des adaptateurs ; la logique est une bibliothèque
Le découpage interne reflète le port externe. src/commands/ contient de fins gestionnaires d'action commander (analyse des options, mise en forme de la sortie, positionnement du code de sortie) au-dessus d'une logique pure dans src/lib/ (analyse du manifeste, lecture des règles, résolution des preuves, rendu de conformité).
Les dépendances pointent vers l'intérieur : commands importe lib, jamais l'inverse, si bien que chaque vérification est testable sans terminal. Le point d'entrée de la CLI câble chaque sous-commande à son gestionnaire et centralise les erreurs d'analyse de Commander sur le contrat des codes de sortie : commande/flag inconnu et autres mauvais usages sont mappés vers 2, distinct du 1 d'une garde rouge.
Les deux invariants, et pourquoi
Tout le reste de la conception est négociable ; ces deux-là ne le sont pas.
1. Aucun appel de modèle, aucun appel réseau ne peut atteindre le verdict
Le verdict doit être déterministe et rejouable, il n'existe donc délibérément aucun port modèle ni port réseau dans l'architecture.
À cadrer précisément : c'est la garde qui est zero-LLM et zero-network. runward n'est pas un outil sans IA : le LLM qui écrit le code vit dans le harnais de l'opérateur, un niveau au-dessus ; tout jugement LLM se situe au-dessus de la garde, jamais à l'intérieur.
La passe de conformité --strict "n'ouvre jamais le code du projet, ne lance jamais de test, et ne juge jamais si le pointeur implémente réellement la règle. Ce jugement reste celui de l'opérateur".
2. runward est un cadre, jamais un runtime
Il lit les fichiers de l'opérateur et n'écrit que dans runward/ et les chemins explicitement demandés. Un démon, un observateur de fichiers, ou une installation silencieuse dans .git/hooks/ transformeraient chacun le cadre en runtime : la seule chose que la conception interdit (ADR-0012).
Le zero-LLM/zero-network comme garantie structurelle, pas comme promesse
La CI (.github/workflows/ci.yml) applique les deux invariants de deux manières complémentaires :
- Une garde statique au mieux cherche dans
src/les modules réseau/runtime, les clients HTTP, les SDK de modèles (openai, anthropic, cohere-ai, @ai-sdk, @aws-sdk, @google/generative-ai, …), lesfetch/WebSocketglobaux, les imports réseau dynamiques,eval/new Function, et les appels shell réseau (curl,wget,ssh, …). Toute occurrence fait échouer le build.child_processn'est autorisé que pour git en local. - La preuve durable : l'exécution des tests du cœur tourne dans un espace de noms réseau sans interface externe :
sudo unshare -ncoupe toutes les routes, et le job vérifie l'absence de route par défaut avant de lancertest/smoke.js, la vérification OSCAL etrunward check --strict. Si le cœur atteignait le réseau, le job échouerait. Cela transforme la promesse en garantie structurelle plutôt qu'en grep. Le commentaire le dit clairement : "This turns the zero-network promise into a structural guarantee, not a grep ofsrc/".
La même CI exécute une auto-garde : runward check --strict sur la propre mission de runward, comme vérification requise, si bien que l'outil passe la garde qu'il livre.
Le jeu de règles est une donnée versionnée, pas du code
Les exigences de la garde vivent dans templates/rules/*.md : un fichier markdown par règle. Chacun porte un frontmatter plus un corps en prose :
- champs porteurs :
title,impact,phases - champs optionnels : le mapping
asi/OWASP-ASI, lestags, unwhyissu deimpactDescription, et unesignaturede preuve
Changer ce que la garde exige est donc un changement de donnée sous revue du mainteneur, avec des diffs relisibles et sans fetch au runtime. Il y a 64 règles livrées.
Un unique parseur lit la forme complète du frontmatter et est partagé par la surface machine (rules --json) et explain ; l'inventaire est toujours trié par slug pour le déterminisme. Le jeu de règles effectif est la copie runward/rules/ propre à la mission lorsqu'elle existe, sinon celle du paquet : une mission peut ainsi épingler ses règles.
Les renommages et suppressions de règles sont suivis comme des migrations, jamais des réécritures silencieuses : un manifeste citant encore un ancien slug est guidé vers son remplaçant (ADR-0006).
Comment la garde de conformité lit cette donnée (--strict)
--strict vérifie, par phase de build gardée, que chaque règle CRITICAL/HIGH mappée sur cette phase est justifiée dans le tableau markdown "Rule conformance" du livrable :
appliedrequiert un pointeur de preuvedeviatedrequiert un ADR existantn/arequiert une vraie raison
Les paires (phase → livrable) gardées sont une source unique partagée par check --strict, la couche de preuve et l'assembleur de conformité :
| Phase | Livrable |
|---|---|
| architect | architecture.md |
| topology | execution-topology.md |
| floor | floor.md |
| govern | governance/threat-model.md |
| handover | handover.md |
Deux propriétés anti-triche sont intégrées :
- Non-vacuité (ADR-0002) : chaque phase a un plancher épinglé de règles CRITICAL/HIGH mappées,
{ architect: 6, topology: 4, floor: 10, govern: 12, handover: 4 }, si bien que le mappingphases:ne peut pas être silencieusement retiré pour faire passer--strictsans rien à vérifier. - Dérive (ADR-0004, bloquante depuis ADR-0021) : un pointeur
applieddont le chemin de fichier ne se résout plus rougit la garde. Une raisonn/aqui est un placeholder de template entre crochets ou fait moins de 8 caractères est rejetée comme triviale.
Les décisions reconstruites sont tenues à la même barre : un ADR encore marqué DRAFT-, Status: hypothesis, ou why: UNKNOWN compte comme non ratifié et fait échouer la garde : "an agent's guess must not pass as a decision".
Les adaptateurs sont des exemples inertes que l'opérateur câble
Six adaptateurs de harnais sont livrés sous templates/adapters/ :
pre-commitgithub-actions.ymlgitlab-ci.ymlclaude-code-settings.jsonkiro-hooks.jsonbmad-review-layer.toml
Ce sont des dépôts inertes : runward les émet, l'opérateur les copie et les câble. runward n'installe rien, n'écrit aucun fichier dans .git/, n'édite aucun .claude/settings.json, n'enregistre aucune CI (ADR-0012). Le compte d'adaptateurs attendu exclut le README, de sorte que remplacer un vrai adaptateur par un fichier parasite ne peut pas passer le compte.
"Agent-operable" signifie qu'un agent peut piloter runward de bout en bout et peut proposer de câbler la garde, en n'agissant que sur l'approbation explicite de l'opérateur, jamais en silence (ADR-0012).
La frontière de câblage est un choix d'autorisation délibéré, cadré comme un consentement (l'agent propose, l'opérateur décide), pas une capacité manquante : c'est exactement pourquoi runward wire se contente de recommander et n'agit jamais. C'est aussi pourquoi la conception est neutre vis-à-vis des fournisseurs par construction : le contrat de port est le code de sortie et rien d'autre, si bien qu'aucun harnais n'est privilégié et qu'un nouveau harnais est un nouveau fichier d'exemple, pas une modification du cœur (ADR-0012).
Scellement, surface machine et lentilles de régime (tout versionné)
- Sceau de preuve :
check --freeze(implique--strict) refuse de sceller une garde rouge, puis hache les fichiers de preuve résolubles dansrunward/evidence-lock.json({ version: 1, sealedAt, files }, clés triées, idempotent à l'octet). Un fichier scellé qui change ou disparaît ensuite fait échouercheck --strictjusqu'à re-vérification (ADR-0021). - Surface machine des règles :
rules --jsonémet{ runward, source, count, rules[] }, trié par slug, additif (des champs sont ajoutés, jamais renommés ni supprimés ; les consommateurs sont des lecteurs tolérants) (ADR-0024). - Pack de conformité :
compliance <regime>émet un markdown cadré par régime plus une component-definition OSCAL 1.2.2, valide au regard du schéma NIST, déterministe pour un arbre et une date donnés, et toujours étiquetée comme brouillon de préparation, jamais comme revendication de conformité (ADR-0016). C'est une preuve d'appui prête pour l'audit qui alimente un programme de conformité ; l'acceptation relève de l'auditeur, et la sécurité est la lentille par défaut (la réglementation est optionnelle). Les mappings de régime sont des données versionnées : un changement réglementaire est un nouveau fichierregimes/<regime>@<version>.json, jamais une édition d'une version livrée, si bien qu'un pack reste ré-assemblable au regard de la lentille exacte que son auditeur a vue (ADR-0022). La CI prouve même que l'OSCAL émis se charge dans un vrai outil tiers, IBM compliance-trestle.
Topologie par défaut et politique de dépendances (verrouillées, avec déclencheurs)
La conception reste délibérément un monolithe CLI mono-processus, rien concernant le langage, la topologie ou la politique de dépendances n'étant laissé ouvert :
- Monolithe CLI mono-processus : un audit de garde est un calcul court, local, majoritairement en lecture, et la distribution ajouterait des modes de défaillance pour rien.
- Langage/runtime : TypeScript strict, ESM, Node ≥ 20 (
package.jsonengines). - Dépendances : exactement trois dépendances runtime, commander, chalk, @inquirer/prompts, car chaque dépendance est une surface d'attaque pour un outil de sécurité ; l'analyse, la couleur et les prompts sont les seules choses qui ne valent pas la peine d'être possédées en propre.
Chacune est verrouillée dans la matrice de décision avec un déclencheur d'évolution nommé.
Le journal des ADR est le registre des décisions
La justification complète de chaque choix structurel vit dans le journal des ADR à docs/adr/ : 32 ADR datés (ADR-0001 … ADR-0032), chacun avec un déclencheur de réévaluation obligatoire et daté.
Ce sont les propres décisions de runward (l'outil qui pratique sur lui-même la méthode qu'il livre), et elles ne sont explicitement pas livrées sur npm et ne sont pas les ADR d'une mission : lorsque vous lancez runward init, votre projet reçoit son propre runward/adr/ échafaudé depuis un template.
Les plus porteurs pour ce domaine :
| Décision | ADR |
|---|---|
| Garde déterministe par règle ; aucun modèle dans le verdict | ADR-0001 |
| Garde stricte durcie contre le passage vide | ADR-0002 |
| La garde comme port ; adaptateurs inertes, câblés par l'opérateur | ADR-0012 |
| Pointeurs de preuve typés vérifiés à la garde | ADR-0019 |
| Signatures de preuve des règles | ADR-0020 |
| Dérive bloquante + scellement des preuves | ADR-0021 |
Surface machine du jeu de règles (rules --json) |
ADR-0024 |
Pour approfondir n'importe quelle décision (son contexte, les alternatives rejetées, les conséquences et le déclencheur de réévaluation), rendez-vous à la référence des ADR à docs/adr/ (indexée par docs/adr/README.md). La note d'architecture y renvoie pour le registre faisant autorité.
Note : la source
runward/architecture.mdest estampillée v0.18.1 et cite encore d'anciens comptes d'ADR (24 / 28) alors que le journal en compte désormais 32 ; les chiffres à jour ci-dessus sont pris directement dans le dépôt, pas dans cette note.
Pourquoi ce choix
La garde comme port, pas l'automatisation livrée
Le choix délibéré central est de traiter la garde comme un port (un contrat de processus : argv en entrée, code de sortie en sortie) plutôt que de livrer l'automatisation qui l'exécute.
L'alternative rejetée (un démon runward, un observateur de fichiers, ou l'auto-installation d'un hook git dans .git/hooks à l'init) aurait fait tourner la garde automatiquement, mais au prix de transformer runward d'un cadre en un runtime et d'exécuter du code surprise dans un dépôt sans l'acte de l'opérateur (ADR-0012). À la place, runward livre des exemples d'adaptateurs inertes que l'opérateur câble, avec un amendement laissant un agent proposer de câbler sur approbation explicite.
Aucun port modèle, aucun port réseau
Le choix connexe, aucun port modèle ni port réseau, est ce qui rend le verdict rejouable ; une garde à juge LLM a été rejetée car non déterministe et deviendrait silencieusement la garde de fait, reproduisant l'incident même de citation-sans-application qui a déclenché ce travail (ADR-0001).
Les règles comme donnée versionnée
Garder les règles comme donnée markdown versionnée (pas du code) a été préféré à un jeu de règles codé en dur et allégé, pour que les changements soient des diffs relisibles sous revue du mainteneur avec des migrations suivies.
Ce qui vient ensuite
La note d'architecture nomme la cible explicitement :
- un jeu de règles curé plus large,
- des lentilles de régime supplémentaires gardées comme données versionnées (ADR-0022),
- plus d'adaptateurs de harnais inertes,
- et un chemin de reconstruction brownfield plus profond (characterize, ADR-0013/0014).
Tout cela doit entrer derrière les contrats existants et ne peut pas toucher aux deux invariants (verdict déterministe, jamais un runtime).
Le déclencheur de réévaluation d'ADR-0012 : rouvrir si un harnais réellement utilisé par la communauté ne peut pas être câblé par un exemple statique sans que runward ait à exécuter/observer/installer quelque chose, auquel cas une fine bibliothèque d'adaptateurs (toujours invoquée par le harnais, jamais un démon) serait reconsidérée.