Files
oto-enterprise-os-dtp/ci/README.md
T
2026-08-05 19:51:00 +00:00

134 KiB
Raw Blame History

CI/CD · OTO Enterprise OS DTP · Gitea Actions

Livrable Sprint 1 (roadmap 04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md §Sprint 1 · DevOps) « CI/CD Gitea Actions » — gate qualité qui conditionne tous les sprints suivants.

Plateforme : Gitea Actions UNIQUEMENT (CLAUDE.md contrainte #2 · JAMAIS GitHub).


1. Ce que fait le pipeline

Workflow : .gitea/workflows/ci.yml. Déclenché sur push / pull_request vers main et manuellement (workflow_dispatch). Jobs statiques (+ une suite unittest par module) :

Job Script Rôle Blocant
constraints-guard ci/guard_constraints.sh Enforce les contraintes NON-NÉGOCIABLES de CLAUDE.md oui
validate-json ci/validate_json.sh Parse strict de tous les *.json (schémas Faisabilité↔Publiciste) oui
check-docs ci/check_docs.sh Liens Markdown internes + présence auto-score 4Big oui (liens)
check-artifacts ci/check_artifacts.sh Reproductibilité : chaque out/*.json versionné == build frais oui
check-regression ci/check_regression.sh Fraîcheur : qa/regression/out/regression_run.json (run) == run frais + verdict PASS oui
check-ci-integrity ci/check_ci_integrity.sh Intégrité du câblage : gate.needs == tous les jobs non-manuels · chaque ci/*.sh câblé au gate oui
check-readme-claims ci/check_readme_claims.sh Intégrité des chiffres et des faits data-derived des docs d'entrée (README.md « État courant », fiches 03_agents/*/AGENT.md, READMEs de module) : chaque nombre, label de verdict et identité citée en prose (rôle · DocType · entité · marque · persona…) est recomputé de l'artefact commité qu'il cite — ou re-dérivé de CLAUDE.md / de la roadmap pour les valeurs ancrées au mandat — puis exigé exact (#6), jamais une liste à la main : un doc qui se contredit avec sa propre source est un « vert trompeur ». Surfaces couvertes : comptes de tests/suites, verdicts & barème 4Big, ledgers d'invariants CI, ventilations & identités RBAC, mots-clés & graphe SEO, pipeline vente, e-CF DGII, CONFOTUR, app mobile Expo, run-books VPS, tokens design #4, etc. Détail exhaustif surface par surface : §2 check_readme_claims.sh (ci-dessous). oui
check-mobile-workflow ci/check_mobile_workflow.sh Intégrité du second workflow mobile-build.yml (hors gate de merge, mais sa STRUCTURE est gatée ici) : gating portable (0 secrets. en if: de job → pas de SKIP silencieux) · builds gatés sur les outputs preflight (activation différée #6/#8) · contrat d'outputs (déclarés == référencés == alimentés). Détail : §2 check_mobile_workflow.sh. oui
gate Agrégat vert = gate qualité 4Big franchi oui

Aucune dépendance réseau/marketplace hors actions/checkout. Tout tourne avec bash + git + python3 (déjà présents sur un runner standard).

Second workflow Gitea Actions — mobile-build.yml (HORS gate de merge)

Le dépôt versionne un second workflow Gitea Actions, .gitea/workflows/mobile-build.yml (livrable Mobile · roadmap Sprint 5 · CI/CD mobile EAS), qui n'appartient pas au gate de merge décrit ci-dessus. C'est volontaire et vérifié par construction : check-ci-integrity ne verrouille que ci.yml (variable CI= du script), et aucun de ses jobs n'est câblé dans gate.needs — il ne conditionne donc aucun merge et run_ci.sh ne le rejoue pas (hors gate.needs). Il tourne sur le même runner que ci.yml et reste SKIP-safe : tant que le secret EAS_TOKEN n'est pas fourni par Michel, ses jobs de build (eas build/eas submit) sont en SKIP — il ne rend jamais le CI rouge (preflight reste vert et rapporte les secrets attendus). Périmètre complet (#8 · exécution serveur hors repo), profils EAS et variables a_confirmer : README du module Mobile.

Nuance importante : ses jobs sont hors gate de merge, mais sa structure de fichier, elle, EST gatée — par le job check-mobile-workflow (ci/check_mobile_workflow.sh, dans gate.needs). Sans lui, une régression du workflow passait VERTE en silence (mutation vérifiée : réintroduire secrets. dans un if: de job laissait les 7 gates statiques verts). C'est précisément le risque « marche dans ma tête, casse le jour où Michel active EAS_TOKEN » : le contexte secrets n'est pas disponible dans jobs.<id>.if (table d'availability GitHub Actions), donc un tel if: s'évaluerait vide sur act/Gitea → build SKIP silencieux même token fourni. Le gate rend cet invariant (et l'activation différée) opposable au merge.

Rejouer le gate EN LOCAL — run_ci.sh (racine du dépôt)

Avant de committer, reproduire l'intégralité du gate de merge en une commande :

bash run_ci.sh            # tout gate.needs (gates statiques ci/*.sh + suites de module)
bash run_ci.sh --static   # seulement les gates statiques ci/*.sh (rapide)
bash run_ci.sh --list     # liste les jobs qui seraient lancés (+ décompte dérivé), sans exécuter
bash run_ci.sh --help     # aide + codes de sortie (0 vert · 1 échec · 2 option · 3 hors git)

Le décompte exact (N jobs = G gates statiques + S suites de module) est dérivé de gate.needs et imprimé à l'exécution (et par --list) ; il n'est jamais figé dans cette doc, où il se périmerait en silence à chaque module ajouté/retiré — même raison anti-invention (CLAUDE.md #6) qui a fait retirer ces mêmes comptes du commentaire de run_ci.sh.

run_ci.sh n'a aucune liste en dur : il DÉRIVE l'ensemble des checks depuis gate.needs de .gitea/workflows/ci.yml (la source de vérité que check_ci_integrity.sh verrouille déjà) et exécute, pour chaque job, la commande run: déclarée dans son working-directory:. Ajouter/retirer un gate dans ci.yml se répercute donc automatiquement, sans éditer le lanceur — ce qui élimine la dérive silencieuse des boucles for g in … ; do bash ci/$g.sh écrites à la main de session en session. Par construction il ne balaie que gate.needs, ce qui exclut les jobs manuels workflow_dispatch (ex. e2e-baseline/npm test) et le job gate lui-même. Code de sortie 0 ssi tous les jobs passent (même sémantique que le gate).

Ce n'est pas un gate (aucun nouveau ci/*.sh, aucune nouvelle assertion) : c'est un lanceur de commodité, hors du glob ci/*.sh (donc hors périmètre [INV-B] de check_ci_integrity). Son exactitude est structurelle (il exécute ce que ci.yml déclare) et n'a pas à être re-gatée (CLAUDE.md #5).

2. Détail des contrôles

guard_constraints.sh — contraintes NON-NÉGOCIABLES

Détecte l'usage (pas la simple mention) de :

  • Plateformes git interdites : github.com, gitlab.com, bitbucket.org (#2).
  • CRM interdits : EspoCRM, HubSpot (#3).
  • Paiement interdit : Stripe (#10 · Cardnet only).
  • Écriture directe dans /var/www/html/static/ (Interdit absolu).
  • Commande git clean (Interdit absolu).
  • Remote git pointant ailleurs que Gitea/interne.

Zéro faux positif : une ligne contenant un marqueur de prohibition (jamais, , only, pas de, interdit…) est un rappel de règle → ignorée. Escape hatch documenté : ajouter ci-allow sur une ligne pour l'exclure.

Exclusions de fichiers (tracked_files()) — documents qui contiennent nécessairement les termes interdits en tant que texte de politique (mention), jamais en usage exécutable : le garde lui-même (il énumère les termes), les workflows CI (ils le référencent), les mandats signés de Michel à la racine (AUTORISATIONS_*.md, DIRECTIVE_*.md) — prose de gouvernance listant règles ET autorisations —, et le design system de référence (OTO_DESIGN_SYSTEM_*.md, root-owned, ajouté par commit REFERENCE) dont le §15 « Fichiers canoniques sur VPS » est un inventaire d'emplacements servis (/var/www/html/static/, symlink de /opt/oto/sites/static/) — mention descriptive, pas une écriture. L'usage réel d'un terme interdit vivrait dans un .sh/.py/.yml (toujours scannés), donc aucune détection vraie-positive n'est perdue (mutation-test à l'appui). CLAUDE.md reste scanné : sa prose d'interdits passe via les marqueurs de prohibition. NB · AUTORISATIONS_*.md l.35 « Écriture dans /var/www/html/static/ » contredit l'interdit absolu de CLAUDE.md (le chemin correct est le SYMLINK source /opt/oto/sites/static/) — contradiction inter-mandats signalée à Michel (à trancher par lui, pas masquée par le garde). Même inconsistance chemin-servi-vs-source dans OTO_DESIGN_SYSTEM_v1.md §15 (liste /var/www/html/static/ au lieu de la source /opt/oto/sites/static/) — re-signalée à Michel, non tranchée par le garde.

Non-dérive documentée — michel/otov7-platform (CLAUDE.md #2) N'EST PAS une coquille de michel/oto-enterprise-os-dtp, à ne PAS « corriger ». Un audit doc-vs-réalité relève périodiquement que la contrainte #2 nomme michel/otov7-platform (port 3015) tandis que la section Git de CLAUDE.md et le README racine nomment michel/oto-enterprise-os-dtp — d'où un soupçon de faute de frappe dans deux docs qui se contrediraient. Ce sont deux dépôts Gitea distincts, tous deux corrects : otov7-platform est le dépôt du produit (la plateforme OTOV7 en cours de construction, cible du refactoring), oto-enterprise-os-dtp est le dépôt de mandat (ce worker, ses générateurs et hand-off). La contrainte #2 verrouille la plateforme produit sur Gitea ; la section Git précise où vit le mandat. Aucune des deux ne ment. Ne pas « aligner » CLAUDE.md #2 sur le nom du mandat — ce serait corrompre la constitution (mémoire otov7-platform-not-drift). Le garde guard_constraints.sh ne vérifie que l'absence de plateformes interdites (github/gitlab/bitbucket), pas l'identité d'un repo Gitea autorisé — donc rien à gater ici (#5).

validate_json.sh

Parse chaque *.json suivi (dont version.schema.json et projets_master.schema.json, contrat de données Faisabilité → Publiciste). Un JSON cassé casse le pipeline aval → attrapé ici.

check_docs.sh

  • [HARD] liens Markdown relatifs internes : la cible doit exister.
  • [SOFT] livrables 05_deliverables_mvp/*.md : mention d'auto-score 4Big attendue (≥95/100, CLAUDE.md #5). Avertissement seul, non blocant.

check_artifacts.sh

Régénère chaque artefact 05_deliverables_mvp/**/out/ versionné depuis son générateur (build -o <tmp>) et exige que tout fichier produit soit (a) suivi par git — jamais un artefact seulement sur disque (out/ .gitignore-é ou non-git add), qui serait absent en CI propre et rendrait le gate « vert en local » par artefact fantôme (même classe que le bug regression_run.json) — et (b) en égalité byte-for-byte avec le fichier commité. Découverte automatique (zéro liste à la main · #6) : tout module avec un out/ et un générateur build entre dans le gate.

Cible la dérive silencieuse : un module auto-résout des valeurs depuis les artefacts d'autres modules (ex. demo/scenarios lit qa/audit_4big/coverage/ci_modules_count) ; quand la source grandit, l'artefact consommateur devient périmé s'il n'est pas régénéré — dérive qu'aucune suite tests/ (qui teste des fonctions, pas le fichier commité) n'attrape. Correctif : build -o out puis commit. Les artefacts d'exécution non produits par build (p.ex. qa/regression/out/regression_run.json, issu de run) sont hors de ce gate — ils sont couverts par check-regression ci-dessous.

check_regression.sh

Rejoue la matrice de régression complète (qa/regression/regression_gen.py run, ~5 s, stdlib pur) vers un tmp et exige que le regression_run.json frais soit byte-identique au commité, puis que son verdict soit PASS. Complément direct de check_artifacts : celui-ci ne rejoue que build (→ regression_plan.json) et laisse hors périmètre l'artefact d'exécution regression_run.json — pourtant c'est lui qui porte le compte agrégé (suites · tests · passés · verdict) cité dans la doc et les logs — recompté par check_readme_claims dans les fiches d'entrée, jamais figé en dur ici. Sans ce gate, ce compte pouvait se périmer en silence (module + job CI ajoutés sans régénérer la matrice → compte de suites faux ; c'est la dérive « demo 18→21 » corrigée à la main), ou une matrice rouge être commitée verte. regression_run.json ne contient aucun horodatage/hôte → le run est déterministe et l'égalité exacte licite. Correctif : regression_gen.py run -o out puis commit.

check_ci_integrity.sh

Prouve, en lisant .gitea/workflows/ci.yml, que le câblage du workflow tient — car le job gate est le seul verrou de merge : un check absent de son needs: ne bloque rien, même rouge. Trois invariants :

  • INV-Agate.needs == { tous les jobs définis } { gate } { jobs manuels } (un job manuel = gardé par if: … workflow_dispatch …, ex. e2e-baseline, légitimement hors du gate push/PR car il exige un serveur live). Détecte un job oublié du gate (MISSING → ne bloque pas), une référence fantôme (DANGLING → typo / job renommé-supprimé) et un job manuel glissé dans needs (gate en attente perpétuelle sur push).
  • INV-B — chaque script ci/*.sh du dépôt est câblé : soit lancé par un job (run: bash ci/<script>) lui-même dans gate.needs, soit sourcé par ≥1 gate script (lib partagée ci/lib.sh — jamais un job propre). Un nouveau gate statique non câblé (script mort) ou décâblé, ou une lib morte (sourcée par personne), casse le check.
  • INV-C — le workflow force le mode UTF-8 de Python (env: PYTHONUTF8: "1" au niveau workflow → tous les jobs). Motif : chaque générateur clôt son build par un print("✅ …" / accents français) vers stdout, dont l'encodage suit la locale du runner. Sur une image runner ASCII (LC_ALL=C sans coercition PEP 538, PYTHONUTF8 non posé), ce print lève UnicodeEncodeError après écriture des fichiers → check-artifacts voit un exit non-nul et les 25 suites plantent : tout le gate passe ROUGE le jour où l'image runner change, alors qu'il est VERT sur le poste dev (UTF-8) — classe « le runner n'est pas ton poste » (sœur des sweeps hash-seed / locale-TZ / forward-compat, mais ici un défaut réel présent, pas une simple attestation). PYTHONUTF8=1 (PEP 540) impose stdout/stdin/défaut-fichier en UTF-8 partout → 20 builds qui crashaient + les suites redeviennent locale-robustes (prouvé : run_ci.sh sous LC_ALL=C neutralisé = 33 PASS). Comme l'absence de cet env: est invisible sur un runner UTF-8, INV-C l'ancre : le retirer casse ce gate au lieu de dériver en silence. run_ci.sh exporte la même variable (miroir fidèle).

Ferme le trou laissé par les couvertures existantes (audit_4big/registry, qa/regression/discovery) qui ne prouvent l'appartenance au gate que des jobs de module (ceux portant un working-directory:) — les gates statiques sans working-directory n'étaient gardés par personne. Même classe d'anti-dérive que INV4 (disque→CI), appliquée au câblage CI. stdlib pur (bash/awk/git), zéro réseau.

check_readme_claims.sh

Le README.md est le point d'entrée du mandat ; sa section « État courant (sourcé) » affiche des chiffres et déclare « Chaque chiffre ci-dessous est sourcé vers un artefact commité (anti-invention #6) ; ce README n'introduit aucune donnée nouvelle. » Cette promesse n'était gardée par aucun gate : check_docs.sh ne valide que les liens (la cible existe), jamais la valeur des nombres. Quand un module + son job CI sont ajoutés (21→22 modules, 21→22 suites) ou qu'une promesse change de statut (14 in_repo + 1 out_of_scope → 15 in_repo), les chiffres du README se périment en silence tout en restant « sourcés » vers un artefact qui dit autre chose — un README qui se contredit avec sa propre source est un « vert trompeur » (même classe que la matrice périmée « demo 18→21 » ou INV4, appliqué à la doc d'entrée). Ce gate recompute chaque chiffre depuis l'artefact cité (jamais une liste à la main · #6) et exige l'égalité avec ce qui est écrit dans les trois docs d'entrée — README.md :

  • modules gated N/M à K/100 + verdict PASSqa/audit_4big/out/quality_report.json (totals) ;
  • N suites gated → qa/regression/out/regression_plan.json (totals.suites) ;
  • N promesses (X sprint + Y métriques), Z in_repo, verdict trueqa/acceptance/out/acceptance_matrix.json ;
  • « 13 agents » recompté depuis git ls-files 03_agents/*/AGENT.md (source unique) et exigé identique à 7 surfaces vives : README.md ×2 (nav + titre) · AGENTS_EXISTING_ASSETS.md (titre) · 02_master_prompt/MASTER_PROMPT_DTP_v2.md (titre) · GAP_ANALYSIS_SPRINT1.md ×3 (périmètre d'audit · constat global total — le « 12 » analytique n'est PAS ancré · coche de couverture DoD).

Et la fiche QA 03_agents/qa/AGENT.md (section « Verdict agrégé courant », qui se disait « jamais compté à la main » tout en portant un compte figé qui s'est périmé — « 21 suites · 534 tests » alors que le run agrégé faisant autorité en disait davantage) :

  • N suites · M tests · M passés · E échec · E erreur · verdict → qa/regression/out/regression_run.json (compte agrégé faisant autorité, commité).

Et la fiche ERPNext Backend 03_agents/erpnext_backend/AGENT.md (ligne « source unique », même compte agrégé jadis saisi à la main qui s'était périmé — « 560 tests ») :

  • N tests · M suites · verdict → qa/regression/out/regression_run.json (matrice de régression du repo, même source faisant autorité que la fiche QA).

Et — au grain le plus fin — la colonne « Tests » des tables de livrables de toutes les fiches (03_agents/*/AGENT.md · crm, faisabilite, publiciste, qa) : chaque cellule par suite était saisie à la main et se périmait dès qu'un test était ajouté (fiche QA : acceptance 31 alors que la suite en portait 37 · audit_4big 35→34 · regression 25→26). Le compte agrégé ci-dessus (« 564 tests ») ne suffit pas : une compensation entre deux suites (+1 / 1) laisserait la somme juste et les deux lignes fausses. On recompute donc chaque cellule depuis qa/regression/out/regression_plan.json (suites[path].test_methods) — et depuis reglib.discovery.count_tests (même fonction que le plan · zéro duplication) pour le self_module qa/regression, exclu de la matrice par SoD mais bien documenté.

Enfin — même classe, un cran au-dessus des cellules — les agrégats rédigés en prose dans deux fiches (crm : « Total CRM 81 tests (25 + 31 + 25) » ; erpnext_backend : « RBAC 60 tests (10 + 11 + 12 + 11 + 16) + e-CF 39 tests »). Le total ET le multiset des composants (ordre-indépendant) sont recomputés depuis suites[path].test_methods : une compensation entre deux suites laisserait la table (gatée ci-dessus) juste et la prose fausse — c'est le même piège que les cellules, au niveau agrégé.

Même classe encore, hors du domaine « tests » : les comptes par workspace (cartes / liens / rôles) des portails frontend, transcrits à la main dans deux docs — la table du README du module (frontend/portails/README.md) et la prose de la fiche agent (03_agents/frontend_console/AGENT.md : « OTO Ventes 4 cartes/11 liens/12 rôles · … »). Chaque triplet et le total « N Workspace natifs » sont recomputés depuis frontend/portails/out/MANIFEST.json (tableau workspaces, lui-même byte-gaté par check_artifacts) : ajouter un rôle/DocType au contrat RBAC ferait dériver les deux docs en silence sinon.

Même classe enfin sur les comptes RBAC des 3 volets (rbac/roleprofile_gen, rbac/apply_plan, rbac/userperm_gen), recopiés à la main dans leurs READMEs de module : la table « Nb rôles » par profil (+ total bijectif 50), la ligne agrégée « Génération réelle : 6 étapes · 50 rôles / 116 DocPerm / 28 UP templates / 6 Role Profile · 4 DocType custom + 5 Company + 4 rôles equipe » (8 comptes de tous les volets en une phrase) et la ventilation « 28 entite · 16 groupe · 2 own · 4 equipe ». Chaque nombre est recomputé depuis le out/MANIFEST.json du volet (recalculé du contrat rbac_50_roles.json à chaque build, byte-gaté par check_artifacts) : ajouter un rôle/DocType ferait dériver les trois READMEs en silence sinon. 2ᵉ surface rbac/roleprofile_gen : la table « Profils générés » ne gatait que le compte Nb rôles par ligne — sa colonne « Type » (métier/technique) était un wildcard ([^|]*), donc un portail technique classé « métier » (ou l'inverse) passait vert. On recompute le classement depuis le flag booléen metier de MANIFEST.profiles et on exige la colonne « Type » exacte par profil, on gate la prose « 6 profils (5 métier + 1 technique) » contre counts.role_profiles/portails_metier/portails_techniques, on exige que le nombre de lignes de profil de la table == |profiles| (aucune ligne fantôme d'un profil inventé absent du contrat), et en bonus deux cohérences croisées qui mordent un MANIFEST internement incohérent : portails_metier + portails_techniques == role_profiles et ces comptes recomptent les flags metier réels des profils. 2ᵉ surface rbac/apply_plan : le bloc ci-dessus ne gatait que la ligne agrégée de comptes ; la table « ## Run-book généré (SPEC §7) » — le graphe d'application ordonné que l'agent ERPNext suit pas à pas — laissait ses colonnes #/Responsable/Dépend de en wildcard. Or ces trois colonnes sont data-derived de out/apply_plan.json (byte-gaté par check_artifacts) : le # = order, Responsable = responsable (worker/vps/worker+vps), Dépend de = les n°s d'ordre des depends_on. Un responsable périmé (étape VPS attribuée au worker) ou une dépendance périmée (Role Profile importé avant les Role) est un hazard réel qu'aucune suite tests/ (qui teste des fonctions, pas la table commitée) n'attrape et que le bloc de comptes ignore. On recompute #/responsable/deps depuis apply_plan.json et on exige que la table les énonce exactement (la colonne « Étape » reste libre — paraphrase) ; l'identité d'ensemble (lignes de la table == étapes de l'artefact) mord toute ligne fantôme ou étape manquante, et trois cohérences croisées mordent un plan internement incohérent : ordres contigus 1..N sans doublon · responsable ∈ {worker, vps, worker+vps} · toute dépendance pointe en arrière (n° < n° de l'étape). 2ᵉ surface rbac/userperm_gen : le bloc des comptes ne gatait que la ventilation par mécanisme (« 28 entite · 16 groupe · 2 own · 4 equipe ») — aveugle à quel mécanisme applique quelle portée. La table « Mapping scope_donnees → mécanisme » est la fonction d'enforcement row-level (cœur sécurité du module) : chaque portée → son mechanism Frappe natif + le verdict « Template émis ? ». Ces trois sont data-derived de out/user_permission_plan.json (byte-gaté par check_artifacts, chaque entrée portant scope_donnees + mechanism + user_permission_template). Réaffecter une portée à un mécanisme plus permissif (entitenone_consolidated : le row-level enforcement abandonné, sur-exposition des données inter-entités), renommer un mécanisme ou basculer le verdict « Template émis ? » laisse la table figée pendant que l'artefact dit autre chose — hazard réel qu'aucune suite tests/ (qui teste des fonctions de mapping, pas la table commitée) n'attrape et que le bloc de comptes ignore. On recompute par portée le mechanism (1er token backtické de la colonne) et le verdict « Template émis ? » et on exige la table exacte ; l'identité d'ensemble (portées de la table == portées de l'artefact) mord toute ligne fantôme ou portée manquante, et trois cohérences croisées mordent un plan internement incohérent : chaque portée mappe un seul mécanisme (fonction, pas relation) · le verdict « Template émis » est uniforme sur les entrées d'une portée · le template est émis exactement pour user_permission_company (l'invariant « template SSI entite »).

Même classe enfin côté SEO : le total et la ventilation par langue des mots-clés (« 258 mots-clés (fr=87 · en=87 · es=84) ») étaient transcrits à la main dans deux docs — la table « Livrables » du README de module (05_deliverables_mvp/seo/README.md) et la même ligne de la fiche agent (03_agents/seo/AGENT.md) — sans aucun gate. Le quadruplet (total + fr/en/es) est recomputé depuis seo/out/MANIFEST.json (counts.keywords_total + counts.keywords_per_lang, recomptés de projets_master.json à chaque build, byte-gaté par check_artifacts). Recomputer les trois langues, pas seulement le total, attrape une compensation entre langues (fr 1 / en +1) qui laisserait le total juste et la ventilation fausse — ajouter un projet ferait sinon dériver les deux docs en silence. 2ᵉ surface du même README SEO : la table « Livrables » décrit en prose la composition du graphe schema.org (« graphe JSON-LD (Organization + une Residence par projet) »). Le bloc des comptes de mots-clés est aveugle à ces @type : renommer le type des listings (ResidenceApartment, un @type voisin du rango schema.org), ajouter un 3ᵉ type de nœud (Offer/Product) ou casser la bijection (deux nœuds pour un projet) laisse la prose figée pendant que seo/out/seo_schema_org.json (byte-gaté par check_artifacts) dit autre chose. On recompute l'ensemble des @type du @graph et on exige que la parenthèse les nomme exactement (set-diff : absent ET en trop ; plus le type « par projet » nommé == le @type des listings). En bonus, trois cohérences croisées de l'artefact : le nœud racine Organization (@id …#organization) est un singleton, les listings sont uniformes (un seul @type) et en bijection avec les projets (listings == projects == schema_org_nodes1 du MANIFEST), et le graphe ne porte aucune offers — cohérent avec « aucun prix pour un statut sans prix » (USD · #10). Un claim supprimé (« composition INTROUVABLE ») échoue aussi.

Identité du nœud racine Organization ancrée sur CLAUDE.md §Entités. Le bloc composition ci-dessus gate les @type et la bijection, jamais l'identité de la marque que Google indexe. Or le nœud racine Organization de seo/out/seo_schema_org.json porte name: "Helios RD" (la marque publique · CLAUDE.md §Entités « Helios RD (marque publique) sous WAG ») et url == le base_url du site. La byte-gate check_artifacts prouve seulement que l'artefact se reconstruit depuis seo_spec.jsonpas que ce nom == la marque nommée dans CLAUDE.md. Piège #6 : renommer la marque dans seo_spec.json[schema_org.organization.name] (ou dans CLAUDE.md §Entités) fait émettre au JSON-LD public un nom qui contredit le mandat pendant que la byte-gate reste verte (l'artefact se reconstruit fidèlement… depuis un spec dérivé) — même « vert trompeur » que les tokens branding ancrés sur #4. On re-dérive la marque de CLAUDE.md §Entités (parenthèse « (marque publique) », zéro duplication), le base_url du MANIFEST byte-gaté et le country_code du spec, puis on exige : (a) Organization.name == marque CLAUDE.md ; (b) Organization.url == base_url et @id sous base_url ; (c) chaque nœud listing rattaché à cette Organization (brand.@id == org.@id) et sous base_url ; (d) addressCountry uniforme == country_code du spec (géo-ciblage appliqué à l'identique à chaque nœud) ; (e) le spec déclare l'ancrage (organization.name == marque + source cite CLAUDE.md). Renommer la marque dans CLAUDE.md seul mord simultanément l'artefact et le spec — l'ancre est vive.

Entités porteuses du DocType CONFOTUR ancrées sur CLAUDE.md §Entités. Le champ Select entite_porteuse du DocType CONFOTUR Application — le menu déroulant qui fixe quelle entité juridique porte chaque dossier d'incitation — offre pour options les 7 entités canoniques du mandat (WAF · WA SRL · AC Arias Cuevas · Consortium ECR DR · Helios RD · Ploutos · 9060 QC). Ces options sont dérivées via options_source de confotur_spec.json[entites] et byte-gatées pour la reproductibilité par check_artifacts (l'artefact se reconstruit depuis le spec) — mais jamais ancrées à CLAUDE.md §Entités, leur source faisant autorité. Les blocs CONFOTUR amont gatent le compte de champs/rôles et la cross-cohérence des permissions, aveugles à l'identité de cette liste. Piège #6 : renommer une entité dans CLAUDE.md §Entités (9060 QC9061 QC) ou dans confotur_spec.json[entites] puis régénérer fait offrir au DocType un menu qui contredit/omet une entité canonique pendant que check_artifacts reste vert (l'artefact se reconstruit… depuis un spec dérivé) — même « vert trompeur » que la marque SEO org (§Entités) et les tokens branding (#4). On re-dérive les 7 entités des tokens en gras de la section « ## Entités » de CLAUDE.md, puis on exige : (a) options du Select de l'artefact == entités CLAUDE.md (ordre exact) ; (b) spec[entites] == CLAUDE.md (l'input ancré) ; (c) le spec déclare l'ancrage (entites_source cite CLAUDE.md + chaque entité) ; (d) wiring intact (options_source == "entites" · le déroulant dérive de la liste, pas d'un dur) ; (e) le README cite l'ancre CLAUDE.md et nomme chaque entité (nom complet ou 1er token — l'abréviation « AC » de « AC Arias Cuevas » est un choix éditorial toléré · #6). Renommer une entité dans CLAUDE.md seul mord simultanément l'artefact, le spec, sa déclaration et le README — l'ancre est vive. 2ᵉ surface prose (fiche ONAPI/Legal) : la fiche 03_agents/onapi_legal/AGENT.md:54 ÉNUMÈRE à la main « les 7 entités de CLAUDE.md #Entités (…) » — un COMPTE littéral + la liste complète que le motif (e), ancré sur le README legal/confotur, ne balaie pas. On re-dérive la liste de CLAUDE.md §Entités et on exige de la fiche : (a) le compte littéral == |§Entités| · (b) l'énumération == §Entités ordre exact (absent ET fantôme mordus par set-diff) · (c) l'ancre « CLAUDE.md #Entités » citée · (d) cross-check — l'énumération == options entite_porteuse de l'artefact byte-gaté (la fiche décrit exactement le menu que le DocType offrira).

Champs dossier_vente/estado du DocType CONFOTUR dérivés du workflow vente. La §« Cœur du livrable : cross-cohérence » du même README legal/confotur affirme que deux champs du DocType sont dérivés du pipeline vente, jamais réécrits en dur : dossier_vente (Link) → cible workflow.document_type (= OTO Dossier Vente) et estado (Select) → options = les états confotur_* du workflow vente. Source faisant autorité : crm/workflow_vente/workflow_vente_spec.json — l'input que le builder confotur lit (déclaré workflow_source dans le MANIFEST), d'où il tire document_type et les update_value d'états commençant par confotur. Les blocs CONFOTUR amont gatent le compte de champs/rôles, la cross-cohérence des permissions et l'ancrage des entités (entite_porteuse) — mais aucun ne touche dossier_vente/estado. Piège #6 : renommer le DocType porteur côté workflow (OTO Dossier VenteOTO Dossier de Vente) ou ajouter/renommer un état CONFOTUR régénère l'artefact confotur (check_artifacts reste vert — il se reconstruit depuis le spec workflow) mais laisse la prose README « (= OTO Dossier Vente) » périmée en silence → l'agent ONAPI/Legal câblerait une cible de Link / un menu d'états que le pipeline ne porte plus. Même classe que l'ancrage entite_porteuse ci-dessus. GATE : (0) traçabilité — le MANIFEST workflow_source cite bien le spec workflow ; (a) cross-artefact — dossier_vente.options (Link) == workflow document_type ; (b) prose — la valeur README « (= ) » == workflow document_type ; (c) cross-artefact — estado.options == update_value des états confotur_* du workflow (ordre du pipeline) ; (c) toute option estado porte le préfixe confotur ; (d) prose — le bullet estado déclare la dérivation (confotur_* + workflow vente). Un claim absent échoue aussi (#6). Renommer le document_type côté spec seul mord simultanément l'artefact et la prose.

3ᵉ surface du même README SEO : la table « Livrables » décrit en prose la carte hreflang (« carte alternate FR/EN/ES + x-default par page »). Les deux blocs SEO précédents (comptes de mots-clés · graphe schema.org) sont aveugles à ces codes de langue, qui vivent dans un troisième artefact (seo/out/seo_hreflang.json, byte-gaté par check_artifacts) : ajouter une langue au bundle (pt → 4 langues de contenu), retirer ou échanger es laisse la prose figée pendant que la carte dit autre chose. On recompute l'ensemble des langues de contenu (alternate hors x-default) et on exige que la cellule les nomme exactement (set-diff : absent ET en trop) + mentionne x-default. En bonus, quatre cohérences croisées de l'artefact : la carte est uniforme (toutes les pages portent le même jeu d'alternate), x-default est présent sur chaque page, la langue par défaut (x_default_lang) ∈ langues de contenu (ni vacante ni étrangère), et pour chaque page x-default.href == canonical == href de la langue par défaut (l'invariant « x-default = langue par défaut = canonical » du README). Un claim supprimé (« carte INTROUVABLE ») échoue aussi.

Fiche agent SEO — 2 métriques data-derived (03_agents/seo/AGENT.md) : le tableau « Livrable réellement produit » de la fiche restitue, à côté du compte de mots-clés (lui déjà gaté sur README et fiche), deux sorties byte-gatées mais aveuglées côté fiche — les blocs SEO schema.org / hreflang précédents ne lisent que le README (seo/README.md), jamais la fiche. L25 « graphe JSON-LD : 10 nœuds (Organization + 9 Residence) » et L26 « 10 pages × FR/EN/ES + x-default » sont recomputées des mêmes artefacts que les blocs README (seo_schema_org.json/MANIFEST.json et seo_hreflang.json, byte-gatés par check_artifacts) : L25 — total nœuds == schema_org_nodes, racine == le @type singleton du graphe, M == listings, type listing == le @type uniforme des listings, plus la cohérence arithmétique total == 1 + M ; L26 — nombre de pages == |pages| de la carte + jeu de langues de contenu == celui de l'artefact (set-diff : absent ET en trop). Renommer le @type des listings, ajouter/retirer un projet (→ N nœuds & N pages dérivent) ou échanger une langue de contenu périme la fiche pendant que les trois artefacts disent autre chose — même classe que la cellule chat_otoia de la fiche Frontend Console (fiche-agent qui restitue des attributs data-derived gatés seulement en agrégat, mémoire agent-fiche-role-attrs-ungated). Un claim supprimé échoue aussi.

Catalogue de projets ancré sur CLAUDE.md (2ᵉ surface du README crm/dossier_vente) : le champ Select projet du DocType porteur (out/doctype_oto_dossier_vente.json, byte-gaté par check_artifacts) énumère les 9 projets OTO (P01 StructureP09 1069 Crisfer) que le README résume « Projet (Select P01..P09 ancré sur CLAUDE.md) ». Ce catalogue est une transcription : la section « ## Projets » de CLAUDE.md est la source faisant autorité (#6/#7), mais check_artifacts ne prouve que DocType == spec (byte-for-byte) — la spec recopie elle-même la liste, donc toute la chaîne peut dériver de CLAUDE.md en restant byte-verte : renommer un projet (P03 NakuaP03 Nakua Bay), en ajouter (P10) ou en retirer un laisse le Select périmé tandis que la prose « ancré sur CLAUDE.md » devient fausse. Même esprit que le roadmap-anchor (INV11 · roadmap_line ancré au fichier roadmap) : on recompute le catalogue depuis « ## Projets » de CLAUDE.md et on exige que les options du Select l'égalent exactement (set-diff absent ET en trop, puis ordre), plus la plage P01..P<dernier> + le mot d'ancrage dans le README. En bonus, le catalogue CLAUDE.md est vérifié non vide, sans doublon et contigu depuis P01 (byte-déterminisme du contrat). Un claim supprimé échoue aussi.

Même classe enfin sur le pipeline vente CRM : le couple « 9 états · 11 transitions » (titre de section du README du module crm/workflow_vente) et le même compte d'états « les 9 états du pipeline » (README du DocType porteur crm/dossier_vente, dérivé du même graphe) étaient transcrits à la main dans deux docs sans aucun gate. Les deux nombres sont recomputés depuis crm/workflow_vente/out/MANIFEST.json (counts.states + counts.transitions, recomptés du workflow_vente_spec.json à chaque build, byte-gaté par check_artifacts). Recomputer transitions en plus de states mord une dérive du 2ᵉ nombre là où le compte d'états seul (repris dans dossier_vente) resterait aveugle — ajouter un état/une transition ferait sinon dériver les deux docs en silence. 2ᵉ surface du même README : la phrase « séparation des pouvoirs » énumère à la main les transitions quatre-yeux (« confirmer réservation, signer contrat, approuver CONFOTUR, annuler/résilier — … allow_self_approval = 0 »). Le bloc de compte est aveugle à leur identité : déclasser une transition sensible (allow_self_approval 0→1 dans le contrat) laisse le compte à 9/11 tandis que l'artefact la retire de l'ensemble ; promouvoir une transition (ex. Déposer CONFOTUR → self=0) l'y ajoute sans que le compte bouge et la prose l'omet ; un échange d'action passe aussi. On recompute l'ensemble des actions quatre-yeux depuis crm/workflow_vente/out/workflow.json ({action | allow_self_approval == 0}, byte-gaté) et on exige que la phrase l'énumère exactement (absent et en trop mordus par set-diff ; casse ignorée + suffixe « (perdu) » élidé — les deux transitions vers l'état Perdu, Annuler/Résilier, que la prose groupe « annuler/résilier »). Cohérence croisée : 0 < |quatre-yeux| < |transitions| (l'ensemble sensible est non vide et strict). 4 morsures vérifiées (déclassement Signer contrat capté là où le compte reste 9/11 · promotion Déposer CONFOTUR captée · échange prose signer contratsigner bail capté · énumération supprimée = INTROUVABLE ; restauré = green).

3ᵉ surface du même README — le CŒUR sécurité du workflow : la table « Rôles = contrat RBAC » (l.55-62) nomme, par pas, le rôle RBAC qui garde chaque transition. Les deux blocs ci-dessus gatent le COMPTE et la séparation des pouvoirs mais restent aveugles à l'identité de ces gardes — data-derived de deux artefacts byte-gatés : workflow.json (chaque transition porte allowed) et MANIFEST.roles_rbac_utilises[].erpnext_role_name (recomputé du contrat rbac_50_roles.json). Réaffecter un pas monétaire (« Confirmer réservation » RéservationsConseiller : élévation de privilège), renommer un rôle, ajouter un rôle fantôme ou en oublier un laisse la table périmée pendant que l'artefact dit autre chose → l'agent ERPNext Backend câblerait le mauvais garde. On exige : (1) identité d'ensemble — colonne « Rôle » == {allowed} de workflow.json (absent et en trop) ; (2) cohérence artefacts{allowed} == roles_rbac_utilises (aucun garde hors manifeste, ni l'inverse) · ensemble non vide ; (3) cross-cohérence par pas — les gardes des étapes SENSIBLES et uniques Confirmer réservation/Signer contrat/Approuver CONFOTUR (recomputés allowed, jamais figés) doivent être nommés EXACTEMENT dans leur ligne — ce qui mord la réaffectation vers un rôle déjà présent que le seul set-diff manquerait. 7 morsures vérifiées (réaffectation prose Confirmer réservation · réaffectation artefact Signer contrat · rôle fantôme · rôle manquant · incohérence MANIFEST · table supprimée = INTROUVABLE · réaffectation artefact Approuver CONFOTUR vers un rôle déjà présent — set inchangé, captée par la cross-cohérence par pas ; restauré = green).

IDENTITÉ du DocType porteur — le NOM OTO Dossier Vente : le module crm/dossier_vente émet out/doctype_oto_dossier_vente.json dont le champ name est le nom du DocType (= MANIFEST.doctype_name, byte-gaté par check_artifacts). Ce nom est le point d'attache du pipeline : le Workflow vente s'y branche via son document_type (crm/workflow_vente/out/workflow.json[0].document_type). Il est recopié à la main à plusieurs endroits, dont aucun n'était gaté sur le NOM (le bloc pipeline ci-dessus ne recompute que le compte d'états, aveugle à l'identité) : le titre du README dossier_vente (« # DocType porteur · OTO Dossier Vente ») et sa ligne « Nom du DocType = document_type du workflow (OTO Dossier Vente) », la fiche 03_agents/crm/AGENT.md:25DocType porteur OTO Dossier Vente ») et la fiche 03_agents/onapi_legal/AGENT.md:50 (« cible = workflow.document_type (OTO Dossier Vente) »). Piège : renommer le DocType dans le spec reconstruit les deux artefacts de façon cohérente (le document_type du workflow suit, les tests — qui testent des FONCTIONS — restent verts) tandis que les mentions en prose se périment en silence → l'agent ERPNext Backend importerait un DocType sous un nom pendant que la fiche/le README en nomment un autre, et le Workflow s'attacherait à un DocType fantôme — la rupture même que « sans lui le pipeline n'a rien à quoi s'attacher » veut prévenir. On recompute le nom depuis l'artefact (zéro duplication) et on exige que chaque prose le nomme exactement (backtické). Cohérences croisées : DocType.name == MANIFEST.doctype_name (cohérence interne du module) et == workflow.document_type (l'attache porteur↔workflow — sinon le Workflow vise un DocType fantôme). 7 morsures vérifiées (nom artefact renommé → mord les 6 lignes en cascade · document_type du workflow divergent → attache rompue · doctype_name du MANIFEST divergent → incohérence interne · fiche CRM:25 périmée · fiche onapi_legal:50 périmée · ligne « Nom du DocType » périmée · titre du README périmé ; un claim absent échoue aussi ; restauré = green).

Même classe encore sur l'app mobile Expo : la ligne récap du README du module mobile/app_config5 onglets · 44 rôles couverts · 3 langues · 13 identifiants a_confirmer ») transcrivait à la main un quadruplet dont chaque nombre est recompté à chaque build depuis rbac_50_roles.json (portails métier + rôles), seo_spec.json (langues) et la liste a_confirmer (credentials EAS/stores jamais fabriqués). Les quatre sont recomputés depuis mobile/app_config/out/MANIFEST.json (counts.{onglets,roles_couverts,langues, identifiants_a_confirmer}, byte-gaté par check_artifacts). Recomputer les quatre volets mord une dérive de n'importe lequel (ajouter un rôle/portail RBAC, une langue, un credential store) là où un seul nombre resterait aveugle. 2ᵉ surface du même README : la même ligne récap énumère les 5 onglets (« (Ventes/Construction/Achat/Compta/Direction) ») — le patron du compte matche la parenthèse par \([^)]*\) mais en ignore le contenu. Cette liste est data-derived : mobile/app_config/out/role_navigation.json (byte-gaté) porte un onglet par portail métier (bijection 1 onglet ⇔ 1 portail), chaque entrée = son label « OTO ». On recompute la liste ordonnée des noms courts et on exige que la parenthèse l'énumère exactement : un échange (Ventes→ Marketing), un retrait (Compta absent — l'énumération tombe à 4 tandis que « 5 onglets » reste juste) ou un réordre est mordu là où le seul compte est aveugle. Cohérence croisée : len(role_navigation) == counts.onglets == counts.portails — mord un compteur du manifeste désynchronisé de sa navigation.

Non-surface documentée — roles_couverts (« 44 ») N'EST PAS cross-gaté, à dessein. Le total « 44 rôles couverts par les 5 portails » apparaît, identique, dans trois manifestes (frontend/portails, frontend/chat_otoia, mobile/app_config · counts.roles_couverts=44). On pourrait croire à trois rendus indépendants réclamant un cross-check façon RBAC « 50 » (où rbac/apply_plan/rbac_apply_plan.py:149 exige fixtures==userperm==roleprofile, car chacun re-dérive son décompte avec sa propre logique). Ce n'est pas le cas ici : les trois modules importent la même fonction de partition — mobilelib/deps.py:77 et chatlib/deps.py:84 font tous deux roles_by_portail = portails_builder.roles_by_portail (l'unique définition, frontend/portails/wslib/builder.py:36) — appliquée à l'unique contrat rbac/rbac_50_roles.json. roles_couverts est donc transitivement mono-sourcé (code partagé + donnée partagée) : les trois totaux ne peuvent diverger que si cette fonction ou ce contrat change, auquel cas les trois changent ensemble et check_artifacts (reproductibilité byte-à-byte) vérifie chaque rebuild. Un gate « 44==44==44 » serait redondant (#5) — cf. mémoire fix-vs-gate-transitively- protected-constant. Chaque « 44 » vif reste néanmoins ancré à SON manifeste (mobile README ci-dessus · demo/scenarios/out/run_sheet.md sourcé à portails/…/roles_couverts), ce qui suffit : la valeur ne peut mentir que si son manifeste ment, et le manifeste est byte-gaté.

Même classe enfin sur le DocType CONFOTUR Application (livrable legal/confotur) : la ligne de synthèse du README du module (« 18 champs (14 de donnée) · 4 sections · 3 rôles · … · 2 évènements de dépôt ») et la cellule de la fiche agent onapi_legal transcrivaient à la main le même quintuplet, dérivé du spec du DocType. Les cinq nombres sont recomputés depuis legal/confotur/out/MANIFEST.json (counts.{fields,data_fields,sections,roles, depot_events}, recomptés du confotur_spec.json à chaque build, byte-gaté par check_artifacts) et comparés aux deux docs. Recomputer les cinq volets mord une dérive de n'importe lequel (ajouter un champ/une section/un rôle au spec) là où un seul nombre resterait aveugle. 2ᵉ surface du même README CONFOTUR — le CŒUR sécurité du livrable : la section « Cross-cohérence » §Permissions énumère à la main, PAR rôle, son portail ET son jeu d'actions RBAC (annoncé « mot pour mot les permissions_cibles … ni ajout ni retrait ») — ventes-confotur (Ventes) → read/write/create/print · legal-onapi (Direction) → read/write/create · legal-directeur (Direction) → read/write/submit/report. Le compte « 3 rôles » est aveugle à leur identité : promouvoir ventes-confotur à submit (élévation de privilège cassant la séparation des pouvoirs dont is_submittable est déduit), retirer une action, réaffecter un portail, renommer un rôle ou ajouter une ligne fantôme laisse la prose périmée pendant que les artefacts byte-gatés (out/MANIFEST.json.roles_rbac_utilises[] + out/doctype_confotur_ application.json.permissions[]) disent autre chose → l'agent ERPNext Backend câblerait le mauvais jeu de permissions (le risque même que la cross-cohérence veut prévenir), dérive qu'aucune suite tests/ (qui teste des FONCTIONS de mapping RBAC, pas la prose) n'attrape. On RECOMPUTE le jeu d'actions + le portail de chaque rôle depuis le MANIFEST et on exige que la prose l'encode exactement (set-diff : absent et en trop · casse normalisée), plus l'identité d'ensemble (README ⇔ MANIFEST — aucun rôle fantôme ni manquant). Cohérences croisées en bonus (mordent un artefact internement incohérent) : MANIFEST ⇄ DocType d'accord sur le jeu d'actions par rôle · séparation des pouvoirssubmit porté par exactement un rôle (legal-directeur, comme le singleton set_user_permissions du catalogue RBAC) et is_submittable == True déduit de sa présence (README:31). 7 morsures vérifiées : README promeut ventes-confotur à submit (en trop) · README réaffecte legal-onapi Direction→Ventes · README retire report de legal-directeur (sous-ensemble) · README renomme un role_id (fantôme + manquant) · énumération supprimée (INTROUVABLE) · MANIFEST promeut ventes-confotur à submit (README périmé + séparation cassée + incohérence MANIFEST⇄DocType) · DocType is_submittable=false alors qu'un rôle porte submit (incohérence interne) ; restauré = vert.

Même classe enfin sur la synthèse de l'audit 5D (livrable qa/audit_5d) : la phrase d'ouverture du README du module (« 17 contrôles répartis en 5 dimensions (5D) ») et la cellule de la fiche agent qa (« 17 contrôles / 5 dimensions ») transcrivaient à la main le même couple, dérivé du catalogue de contrôles. Ce couple est recomputé depuis qa/audit_5d/out/MANIFEST.json (controls_total + len(dimensions), byte-gaté par check_artifacts) et comparé aux deux docs. La ventilation du verdict portée par le seul README (« 13 PASS · 0 FAIL · 4 à confirmer ») est recomputée à part depuis qa/audit_5d/out/audit_report.json (totals.{pass,fail,a_confirmer}, aussi byte-gaté) : elle mord une bascule d'un contrôle en A_CONFIRMER (pass 13→12, à confirmer 4→5) là où le total de contrôles reste 17 et resterait aveugle. Cohérence croisée en bonus : controls_total du MANIFEST doit égaler totals.controls du rapport.

Deuxième surface du même README de l'audit 5D : la table « Verdict courant » énumère à la main les 4 open items (| D1.1 · … | D1 | Direction | …), chacun portant trois faits data-derived — l'id du contrôle, sa dimension et son propriétaire. Le bloc de ventilation ci-dessus ne gate que le compte (« 4 à confirmer ») : un échange d'open item (ex. D2.3 → D3.1), une dimension mal étiquetée (D1.3 rangé sous D2) ou un propriétaire réattribué laisserait le compte à 4 et passerait en silence. On RECOMPUTE l'ensemble {control:(dimension, propriétaire)} depuis qa/audit_5d/out/audit_report.json (open_items[], byte-gaté) et on exige que la table README l'énumère exactement : contrôle par ensemble (absent et en trop mordus, pas seulement présence), puis (dimension, propriétaire) par ligne — même patron que l'énumération des confirmations DevOps ou la carte de renormalisation par archétype 4Big. Cohérence croisée en bonus : l'ensemble des control == MANIFEST.open_items (le manifeste qui résume le rapport) — mord un manifeste désynchronisé de son propre rapport.

Troisième surface du même README de l'audit 5D : la table « ## Les 5 dimensions » énumère à la main, par ligne, le triplet (id, titre, reference) de chaque dimension du cadre (| **D1** | Traçabilité & anti-invention | ISA 500 — Éléments probants | …). Le tout premier bloc audit 5D ne gate que le compte (« 5 dimensions ») — un wildcard aveugle à QUELLE dimension : renommer un libellé (« Traçabilité & anti-invention » → …) ou permuter une norme de référence (D3 fiscal citant la mauvaise Ley 32-23, D1 probatoire citant ISA 501 au lieu d'ISA 500) laisserait le compte à 5 et passerait en silence — alors que c'est l'audit de second niveau que l'auditeur/banquier lit : une norme de conformité périmée est une affirmation fausse (l'invention même que #6 interdit). On RECOMPUTE la liste ordonnée {id:(titre, reference)} depuis qa/audit_5d/out/audit_report.json (dimensions[], byte-gaté) et on exige que la table README l'énumère exactement : id par ensemble (absent et en trop mordus), ordre préservé (D1→D5), puis (titre, reference) par ligne. Cohérence croisée en bonus : les dimensions du rapport == celles de audit_spec.json (l'INPUT dont le rapport dérive) — mord un builder qui renommerait/permuterait une dimension en aval du spec. Table disparue = échec (traçabilité #6).

4ᵉ surface du même README de l'audit 5D : la section « ## Livrables audités (source = leur out/) » énumère à la main les 5 hand-off out/*.json que l'audit de second niveau LIT comme matière première (crm/workflow_vente/out/ workflow.jsonlegal/confotur/out/doctype_confotur_application.json). Les trois blocs audit 5D ci-dessus gatent la synthèse (contrôles/dimensions/verdict), l'énumération des open items et l'identité des 5 dimensionsaucun ne touche la liste des artefacts sources. Cette liste vit dans qa/audit_5d/out/MANIFEST.json (audited_artifacts, byte-gaté par check_artifacts ⇒ faisant autorité : le générateur la recompute à chaque build via sorted(ARTIFACT_PATHS.values()), le registre des hand-off amont que load_artifacts() charge réellement). Le README réénumère ces 5 chemins dans un ordre différent du MANIFEST (workflow 2ᵉ ici, 3ᵉ au MANIFEST trié) ⇒ comparaison par ensemble. Piège : brancher un nouveau livrable amont sur l'audit (nouvelle entrée ARTIFACT_PATHS → nouveau chemin dans audited_artifacts) ou en retirer un laisse la section README périmée en silence pendant que l'audit lit un autre jeu de fichiers — le lecteur/banquier croirait l'audit couvrir un périmètre qu'il ne couvre plus (l'invention #6 interdit), qu'aucune suite tests/ (qui teste des FONCTIONS de contrôle, pas la prose) n'attrape. On RECOMPUTE l'ensemble depuis audited_artifacts et on exige que les puces README l'énumèrent exactement (absent et fantôme mordus). Cohérences croisées bonus : (a) chaque chemin audité résout un fichier commité sur disque (le _load de l'auditeur lève FileNotFoundError sinon — audit à vide impossible) ; (b) le nombre de livrables audités == le nombre d'alias distincts d'artefacts référencés par les controls[].artifacts du audit_spec.json (un artefact chargé mais jamais contrôlé, ou un alias contrôlé sans chemin de chargement → KeyError runtime, désynchronise les deux). Section absente = échec (traçabilité #6).

Même classe enfin sur le run-book de déploiement VPS (livrable devops/deploy_runbook) : le compte de phases (fiche agent devops — « graphe de 7 phases ordonnées » et « exécute les 7 phases sur le VPS ») et le compte de confirmations préalables (README du module — « renseigner les 7 confirmations ») transcrivaient à la main deux nombres qui, aujourd'hui, valent tous deux 7 — un gate qui n'en recompterait qu'un serait aveugle à la dérive de l'autre. Chacun est recomputé depuis son propre champ de devops/deploy_runbook/out/MANIFEST.json (counts.phases = len(graph.phase_order) · counts.confirmations = len(confirmations_ouvertes), byte-gaté par check_artifacts) et comparé aux docs. Bonus anti-dérive : l'énumération des confirmations du README (les tokens snake_case) doit être l'ensemble exact de confirmations_ouvertes — mord l'échange d'un nom de confirmation là où le seul compte (reste 7) resterait aveugle.

2ᵉ surface devops/deploy_runbook : la table « Plan de phases généré » du MÊME README (« # | Responsable | Phase | Modules | Dépend de ») porte, par phase, quatre données data-derived de out/deploy_runbook.json (byte-gaté) — le n° d'ordre (#), le Responsable (worker/vps/worker+vps), l'ensemble des Modules gated déployés dans la phase, et « Dépend de » (les n°s d'ordre des depends_on) — que le bloc de comptes ci-dessus ne couvrait pas (colonnes wildcard). Défaut réel reproduit : la table omettait mobile/app_config (phase 5) et qa/acceptance (phase 7) — 20 modules listés sur 22 — pendant que le MANIFEST prouvait la couverture bijective 22/22 vs CI : vert trompeur, l'agent DevOps aurait sauté 2 modules au portage. Le gate recompute #/Responsable/Modules/Dépend de depuis deploy_runbook.json et exige que la table les énonce exactement par phase ; identité d'ensemble : {ordres des lignes} == {ordres de l'artefact} (aucune phase fantôme/manquante) et l'union des cellules Modules == l'ensemble des modules de l'artefact (aucun module oublié/en trop). La colonne « Phase » reste libre (paraphrase). Cohérences croisées en bonus (mordent un plan interne incohérent) : ordres contigus 1..N sans doublon · responsable ∈ {worker, vps, worker+vps} · toute dépendance pointe en arrière (n° < n° de la phase ⇒ pas de cycle).

3ᵉ surface devops/deploy_runbook — imputabilité des confirmations : la ligne « Confirmations préalables VPS » du README annote chaque confirmation réglementaire d'un couple data-derived nom (owner · réf-contrôle), ex. taux_commission (Direction · audit_5d D1.1). L'owner = qui doit confirmer le paramètre avant déploiement ; la réf = le contrôle qa/audit_5d (D#.#) — ou la session — qui en assure le suivi. Le bloc de comptes ci-dessus ne gate que le nombre de confirmations et l'ensemble de leurs noms — aveugle à qui porte chaque confirmation et à quel contrôle la trace. On recompute owner + réf des confirmations de out/deploy_runbook.json (byte-gaté) croisées avec le rapport qa/audit_5d/out/audit_report.json (byte-gaté), dont chaque contrôle porte son propre owner. Cross-anchor à trois : owner run-book compatible avec l'owner du contrôle audit_5d cité (sous-ensemble de tokens · tolère le suffixe éditorial « / UAF ») · owner README ⊆ owner run-book (tolère l'abréviation « Fiscaliste eCF » ⊂ « Compta Fiscaliste eCF ») · réf README == contrôle réel (présent dans audit_5d ET dans la source). Réaffecter taux_commission de Direction à Ventes (mauvais responsable escaladé), misattribuer le contrôle (D1.1D2.1) ou citer un contrôle fantôme (D9.9, absent d'audit_5d) mord — « vert trompeur » qu'aucune suite tests/ (FONCTIONS de graphe) n'attrape. Une confirmation tracée par un contrôle doit rester annotée (disparition = régression de traçabilité #6) ; une annotation d'un nom fantôme échoue aussi.

Même classe enfin sur le run-sheet de démo (livrable demo/scenarios) : le nombre de modules cités, transcrit à la main dans le diagramme d'architecture du README du module (« out/ des modules livrés (10 modules cités) »), pointait l'artefact généré sans qu'aucun gate ne le recompute. Il est recomputé depuis demo/scenarios/out/MANIFEST.json (counts.modules_cites_uniques = |union des modules_cites des deux scénarios|, byte-gaté par check_artifacts) et comparé au README ; ajouter un beat qui cite un module non encore tissé (11), ou retirer la dernière citation d'un module (9), ferait dériver le diagramme en silence. Cohérence croisée en bonus : counts.modules_cites_uniques == |modules_cites| (liste dédupliquée du manifeste) — mord un compteur désynchronisé de sa propre liste.

2ᵉ surface demo/scenarios : la table des scénarios du MÊME README (« Scénario | Projet | Audience | Angle ») porte, dans ses trois colonnes d'identité, des valeurs data-derived de out/run_sheet.json (byte-gaté) — l'id (S-P07-BANQUIER), le couple projet+projet_libelle (P07 Aqua Terra Las Terrenas) et l'audience (banquier) — que le bloc Démo ci-dessus ne couvrait pas (il ne gate que le compte de modules). Les libellés projets sont d'ailleurs déclarés « proviennent verbatim de CLAUDE.md · §Projets » sans aucun ancrage. Le gate (1) ANCRE chaque P{code} {libellé} du run_sheet à l'entrée correspondante de la section « ## Projets » de CLAUDE.md (verbatim, source unique) ; (2) exige que chaque ligne de la table porte EXACTEMENT id + P{code} {libellé} + audience (fin des colonnes wildcard) ; (3) impose l'identité d'ensemble — l'ensemble des ids de lignes == ids du run_sheet == counts.scenarios (aucune ligne fantôme d'un scénario inventé, aucun scénario manquant). Cohérences croisées en bonus (mordent un artefact interne incohérent) : run_sheetMANIFEST d'accord sur (id, projet, audience) · id == S-{projet}-{AUDIENCE} · counts.scenarios == |scenarios| · ids non vides et sans doublon. Sans ce gate, renommer un id, classer un scénario sous le mauvais projet/audience, ajouter une ligne fantôme ou laisser un libellé projet dériver de CLAUDE.md restait « vert » alors que le présentateur pitcherait le mauvais couple projet/audience — le risque même que la run-sheet veut éliminer.

Même classe enfin sur le montage Chat OTOIA par portail (livrable frontend/chat_otoia) : le nombre de Custom Block, de configs runtime et de portails couverts, transcrits à la main dans DEUX docs (fiche agent frontend_console — « N Custom Block + M configs runtime » · README du module — « Portails (P, plateforme exclu) », « N fixtures Custom Block », « M configs runtime »), pointaient l'artefact sans qu'aucun gate ne les recompute. Ces trois nombres valent tous 5 aujourd'hui — un gate qui n'en recompterait qu'un serait aveugle à la dérive des autres (ajouter un portail au contrat RBAC → chat_otoia rebâtit 6 blocks 6 mounts 6 portails pendant que les docs restent à 5). Chacun est recomputé depuis son propre champ de frontend/chat_otoia/out/MANIFEST.json (counts.custom_blocks · counts.mounts · counts.portails, byte-gaté par check_artifacts) et comparé aux docs. Cohérence croisée : custom_blocks == mounts == portails == |MANIFEST.portails| (la bijection 1 block ⇔ 1 mount ⇔ 1 portail du générateur) — mord un compteur désynchronisé de sa propre liste.

Ce gate de COMPTE reste toutefois aveugle à CE QUE dit chaque config runtime. La MÊME cellule « Métrique vérifiée » de la fiche frontend_console (03_agents/ frontend_console/AGENT.md:29) restitue en plus TROIS attributs data-derived du montage — « persona Amélie … · langues FR/EN/ES · `endpoint: null` » — qu'aucun gate ne recomputait sur CETTE fiche. (Le gate persona/capabilities « ancré sur CLAUDE.md §Architecture cible » — voir plus bas — vise le spec, les 5 mounts, le MANIFEST, le README et l'oracle du test — jamais cette cellule de fiche, et il ignore langues comme endpoint.) On recompute donc les trois depuis les artefacts byte-gatés out/chat_mount.json (une config par portail, 5) et out/MANIFEST.json : persona == MANIFEST.persona.nom (« Amélie » ; le « QC » est le qualificatif de marque Voix Amélie QC de CLAUDE.md, gardé en littéral non gaté), langues FR/EN/ES == MANIFEST.langues en majuscule, et endpoint: null — une affirmation d'honnêteté #6/#8 : l'endpoint OTOIA n'est PAS fabriqué (renseigné côté VPS). Le gate exige que CHAQUE mount ait endpoint == null ; qu'une config gagne un endpoint réel dans l'artefact et la fiche « endpoint: null » devient un mensonge en silence → morsure. Cohérences croisées (bijection du générateur) : MANIFEST.persona.nom et MANIFEST.langues doivent valoir EXACTEMENT ce que porte CHAQUE mount (un montage désynchronisé de son propre manifeste mord), et endpoint_statut == 'a_confirmer' doit correspondre à des endpoints TOUS null (sinon incohérence interne de l'artefact). Un claim absent échoue AUSSI. Même classe de « vert trompeur » que la fiche mobile / les fiches faisabilité : une suite tests/ teste les FONCTIONS du builder, jamais la prose de la fiche.

Même classe enfin sur le module de recette (qa/acceptance) : le couple « N livrables de sprint + M métriques succès MVP » est transcrit à la main dans DEUX docs que le bloc racine ne couvre pas — le README du module (deux puces) ET la fiche agent QA (« (N livrables + M métriques) »). Le bloc racine ne gate que le SEUL README d'entrée (« N promesses (X sprint + Y métriques) ») ; ces deux docs de module en dérivent. Ajouter une promesse roadmap (l'artefact rebâtit 9/7) ferait dériver les deux docs en silence — piège que ni les suites tests/ ni le roadmap-anchor (qui ancre les roadmap_line de l'ARTEFACT, pas la prose des docs) n'attrapent. Chaque nombre est recomputé depuis son propre genre de la matrice qa/acceptance/out/acceptance_matrix.json (kind sprint_deliverable / mvp_metric, byte-gatée par check_artifacts) et comparé aux deux docs. Cohérence croisée en bonus : MANIFEST.counts.{sprint_deliverables,mvp_metrics} == le compte de la matrice qu'il résume — mord un compteur du manifeste désynchronisé de sa liste.

Même classe enfin sur le barème de l'auditeur 4Big (livrable qa/audit_4big). Ironie déjà notée : le module qui NOTE les autres retranscrit son propre référentiel de scoring à la main dans son README (05_deliverables_mvp/qa/audit_4big/README.md) — la prose « Note chaque module sur N critères 4Big déterministes », la table « Critère | Poids | … » (une ligne id→poids par critère) et l'archétype « generator (N critères) ». Ces trois surfaces DÉRIVENT de quality_report.json.criteria (byte-gaté par check_artifacts, recomputé du référentiel à chaque build) ; ajouter/retirer un critère ou changer un poids ferait dériver le README en silence pendant que l'artefact dit autre chose — dérive qu'aucune suite tests/ (qui teste des FONCTIONS, pas la prose) n'attrape. Le bloc racine ne gate que la ligne « Résultat courant » (verdict/modules), jamais le barème. On recompute donc le nombre de critères (prose + « generator »), et chaque poids depuis criteria[].weight (zéro duplication du barème du générateur · #6). Cohérences croisées : Σ poids == 100 (base de renormalisation documentée) ET tout module d'archétype generator porte EXACTEMENT len(criteria) checks — c.-à-d. « generator = tous les critères », ce qui rend le « generator (N critères) » du README vérifiablement vrai (mord une dérive du modèle qui le rendrait faux). Une compensation (un poids +5 / un autre 5) laisserait Σ == 100 juste et deux lignes fausses : d'où le contrôle par critère, pas seulement de la somme. 4 morsures vérifiées : dérive prose 5→6 (poids intacts) · dérive poids TESTS 25→30 (compte intact) · dérive generator (5→4) · claim prose disparu ; restauré = vert.

Une autre surface du même README est la carte de renormalisation par archétype : « generator (5 critères) · data_room (sans HANDOFF) · parser (sans CONTRAT local ni HANDOFF) · contract (sans CLI ni HANDOFF) ». Le bloc barème ne recomputait QUE generator ; les exclusions des trois autres archétypes n'étaient recomputées par AUCUN gate. Or elles sont DATA-DERIVED : pour un archétype, l'ensemble des critères applicables = {check.criterion} de ses modules dans quality_report.json (les non-applicables sont absents des checks) ⇒ exclus = ordre du barème applicables. Changer la règle de renormalisation d'un archétype (ex. data_room exclut aussi CLI) ferait dériver cette prose en silence. On recompute donc, PAR archétype, l'ensemble des critères exclus et on exige que la parenthèse du README l'énumère exactement. Bonus d'uniformité : tous les modules d'un même archétype portent le même jeu applicable (« 1 archétype → 1 jeu de critères », base même de la carte). 3 morsures vérifiées : data_room « sans HANDOFF »→« sans CLI » (mauvais critère) · parser exclusion HANDOFF retirée (sous-ensemble) · énumération contract supprimée ; restauré = vert.

Une troisième surface du même README est la colonne « Fait vérifié » de la table du barème : elle NOMME les deux seuils structurels que le scorer applique — DOC « un *.md400 octets » et TESTS « ≥ 8 méthodes def test_* ». Ces deux nombres sont DATA-DERIVED de quality_spec.json[thresholds] (min_doc_bytes/min_test_methods) — l'INPUT du générateur, byte-gaté par check_artifacts qui prouve que quality_report.json se rebuild depuis lui — et TRANSCRITS À LA MAIN. Le bloc barème ne capturait QUE le poids (2e cellule), aveugle à la 3e : élever min_test_methods (8→12 : un module de 8-11 tests tomberait sous FAIL) ou changer min_doc_bytes rebuild le report SANS TOUCHER au README → prose « ≥ 8 méthodes »/« ≥ 400 octets » périmée en silence, l'auditeur documentant un barème qu'il n'applique plus. On recompute les deux seuils depuis le spec et on exige que les cellules DOC/TESTS les nomment EXACTEMENT ; cross-check d'ancrage : l'evidence TESTS de CHAQUE module du report porte « (seuil N) » (émise par crit_tests) — on exige N == spec.min_test_methods pour tous, prouvant que le seuil ÉCRIT == le seuil réellement APPLIQUÉ (mord une divergence spec↔report). 5 morsures vérifiées : README DOC 400→450 · README TESTS 8→10 · README ligne DOC vidée (claim absent) · spec min_test_methods 8→12 (README périmé

  • cross-check spec↔report) · spec min_doc_bytes 400→500 ; restauré = vert.

Même classe enfin sur le contrat de format de l'e-NCF (livrable fiscal/ecf_dgii) : le format de l'identifiant fiscal électronique DGII « E + tipoeCF(2) + secuencia(10) » (= 13) — préfixe, longueur du segment tipo, longueur du segment secuencia et longueur totale — est le CŒUR du module (le « composeur traçable ») et il est retranscrit à la main dans quatre endroits de prose : le résumé du README (E + tipoeCF(2) + secuencia(10)), l'invariant 7 du README (E+tipo(2)+seq(10)=13, seul à porter la longueur totale), la section composition du README (e_ncf = "E" + tipo(2) + secuencia(10)) et la fiche agent erpnext_backend. Le bloc racine ne gate que le compte de tests e-CF (« 39 ») ; ce format n'était recomputé par AUCUN gate. Or il est data-derived du bloc e_ncf d'fiscal/ecf_dgii/out/ecf_plan.json (byte-gaté par check_artifacts) : prefix (« E »), longueur (13), regex^E[0-9]{12}$ ») et structure (« E + tipoeCF (2 chiffres) + secuencia (10 chiffres) »). Allonger la secuencia (un rango DGII à 12 chiffres → longueur 15) ou changer le préfixe ferait dériver les quatre proses en silence pendant que l'artefact dit autre chose — dérive qu'aucune suite tests/ (qui teste les FONCTIONS compose_encf/parse_encf, pas la prose) n'attrape. On RECOMPUTE (prefix, tipo_len, seq_len, total) depuis le SEUL bloc e_ncf (zéro duplication du format du générateur · #6) et on exige que chaque prose l'encode exactement. Cohérences croisées en bonus (mordent un artefact internement incohérent) : 1 + tipo_len + seq_len == longueur (1+2+10 == 13) ET le nombre de chiffres du regex == tipo_len + seq_len (12 == 2+10). 5 morsures vérifiées : drift de l'artefact source (secuencia 10→12, les 4 proses mordues) · résumé README tipo 2→3 · invariant README total 13→14 (aveugle au segment) · fiche erpnext_backend secuencia 10→9 · composition README supprimée (INTROUVABLE) ; plus la morsure d'incohérence d'artefact (regex désaligné du segment) ; restauré = vert.

Même classe enfin sur la cross-cohérence e-CF DGII (2e surface du MÊME README fiscal/ecf_dgii) — le CŒUR du livrable. Le bloc e-NCF ci-dessus ne gate que le format de l'identifiant ; la section « Cross-cohérence e-CF ↔ workflow ↔ DocType ↔ RBAC » énumère à la main trois données data-derived d'out/ecf_plan.json (byte-gaté par check_artifacts) : les états qui émettent un e-CF « seulement réservation et contrat » (README:48) = {emission_events[].update_value}, les champs Currency de base « montant_reservation, montant_contrat » (README:50) = {emission_events[].base_field}, et la FormaPago défaut « = 3 (Tarjeta) … via Cardnet (#10) » (README:59) = forma_pago_defaut ancrée sur CLAUDE.md #10 (Cardnet = carte). Aucun gate ne les comparait à l'artefact : AJOUTER un évènement (facturer un devis), en RETIRER un, RENOMMER un base_field ou CHANGER forma_pago_defaut (Cardnet abandonné → code 2 Transfert) ferait dériver la prose en silence — l'agent ERPNext Backend câblerait le mauvais évènement / la mauvaise FormaPago (le risque même que la cross-cohérence veut prévenir), dérive qu'aucune suite tests/ (qui teste les FONCTIONS ncf/résolution, pas la prose) n'attrape. On RECOMPUTE chaque ensemble/valeur depuis l'artefact (zéro duplication du contrat du générateur · #6) et on exige que la prose l'encode exactement (absent ET en trop via set-diff · accents/casse normalisés). Cohérences croisées en bonus (mordent un plan internement incohérent) : forma_pago_defaut.code ∈ catalogue formas_pago · libellé prose ⊆ libellé catalogue du code · ensembles non vides. 7 morsures vérifiées : prose retire contrat des émetteurs · prose renomme montant_contrat · prose FormaPago 3→2 (drift artefact) · artefact forma_pago_defaut.code 3→2 (prose périmée) · artefact ajoute l'évènement devis (prose sans devis — états ET base_field mordus) · FormaPago prose supprimée (INTROUVABLE) · artefact code→99 hors catalogue (incohérence interne) ; restauré = vert.

Même classe enfin sur l'identité du RÔLE émetteur e-CF (3e surface du MÊME README fiscal/ecf_dgii) — le seul rôle habilité à ÉMETTRE un e-CF. Le bloc e-NCF gate le format, le bloc cross-cohérence gate états/champs/FormaPago ; mais aucun ne comparait l'IDENTITÉ de ce rôle à ses deux sources. Le bullet role_id (README:51-54) le NOMME par trois attributs data-derived : son role_id « compta-fiscaliste-ecf » = le rôle que TOUS les emission_events d'out/ecf_plan.json (byte-gaté) référencent, son erpnext_role_name « OTO Compta Fiscaliste eCF », et son portail « compta » résolu du contrat rbac_50_roles.json (byte-gaté par les 3 MANIFEST). RÉAFFECTER l'émission à un autre rôle (dans le plan), le RENOMMER, ou DÉPLACER compta-fiscaliste-ecf du portail compta vers un autre (dans le contrat : l'e-CF serait émis HORS Compta — la séparation même que la cross-cohérence pose) ferait dériver la prose en silence, dérive qu'aucune suite tests/ (qui teste les FONCTIONS ncf/résolution, pas la prose) n'attrape. On RECOMPUTE role_id/erpnext_role_name de l'artefact et portail/nom Frappe du contrat (zéro duplication) et on exige la prose EXACTE. Cohérences croisées en bonus (mordent un plan/contrat internement incohérent) : tous les emission_events pointent UN SEUL rôle (fonction · non vide) · ce role_id existe UNE fois au contrat · le nom résolu dans l'artefact == le contrat (zéro-dup) · portail == compta. 6 morsures vérifiées : prose renomme le role_id · prose renomme le nom Frappe · prose déplace le portail (compta→ventes) · artefact réaffecte les deux emission_events à ventes-directeur (5 mords en cascade) · contrat déplace le rôle vers ventes (portail HORS Compta + prose périmée) · artefact diverge du contrat sur le nom (zéro-dup cassé) ; un claim absent échoue AUSSI ; restauré = vert.

Même classe encore sur le jeu de DEVISES moneda (4e surface du MÊME README fiscal/ecf_dgii) — ancré sur CLAUDE.md #10. moneda.options = ["USD", "DOP"] — les devises que TipoMoneda peut porter dans un e-CF — vit dans le spec ET, byte-gaté, dans out/ecf_plan.json ; c'est une valeur CANONIQUE de #10 (« USD + DOP devises ») dont le README se réclame « #10 » (README:37/57/77). Or byte-gater une constante prouve la REPRODUCTIBILITÉ, PAS l'ANCRAGE : check_artifacts prouve ecf_plan==build depuis le spec, jamais que spec[moneda]==#10. Les blocs fiscaux amont gatent le FormaPago (Cardnet · #10) et les états/champs émetteurs, AVEUGLES au jeu de devises ; le bloc « Fiches #10 » ancre la prose des fiches ERPNext/CRM, AVEUGLE au module fiscal ; et le source du bloc moneda est legit. null (il source le taux TipoCambio, à confirmer côté VPS · #8), donc l'ancre des OPTIONS vit dans un champ DISTINCT options_source. RENOMMER/ÉTENDRE les devises dans CLAUDE.md #10 (USD + DOPUSD + EUR) ET dans le spec de façon cohérente reste byte-VERT tout en faisant émettre à l'e-CF un TipoMoneda d'une devise qui CONTREDIT le mandat — « vert trompeur » de la classe de la marque SEO §Entités, la persona Chat OTOIA ou les tokens branding #4, qu'aucune suite tests/ (FONCTIONS de génération, jamais l'ancre à CLAUDE.md) n'attrape. On RÉUTILISE exp["devises"] déjà recomputé depuis #10 (zéro duplication) et on exige : (a) spec[moneda].options == devises de #10 en ORDRE exact (mord ajout/retrait/réordonnancement) ; (b) l'artefact byte-gaté idem ; (c) le spec DÉCLARE l'ancrage — options_source cite CLAUDE.md #10 + chaque devise ; (d) la ligne README qui se réclame de #10 cite le jeu EXACT + toute mention moneda/devise d'une paire de devises == le jeu (aucune périmée). 7 morsures vérifiées : CLAUDE.md #10 USD+DOP→USD+EUR (l'ancre est vive → mord spec+artefact+déclaration+README en cascade) · spec options USD/DOP→USD/EUR régénéré (le vrai silent green) · artefact seul USD→EUR (spec+README périmés) · reorder spec [DOP,USD] (ordre exact) · options_source sans « CLAUDE.md #10 » (ancre déclarée manquante) · README ligne #10 USD/DOP→USD/EUR (prose périmée) · README table moneda USD/DOP→USD/EUR (mention périmée) ; un claim absent échoue AUSSI ; restauré = vert.

Même classe enfin sur la cross-cohérence du barème de commissions (livrable crm/commissions) — le CŒUR du module. La section « Cross-cohérence » de son README énumère à la main deux ensembles data-derived + une propriété : les états brouillon exclus « lead/visite/devis/abandonné » (README:47), les champs de base « montant_reservation, montant_contrat » (README:50) et l'invariant « on ne commissionne QUE des états soumis (doc_status = 1) » (README:46). Le bloc racine ne gate que le compte de tests commissions (« 25 », via l'agrégat « Total CRM ») ; ces ensembles n'étaient recomputés par AUCUN gate. Or ils sont data-derived : les brouillons = {update_value | doc_status == 0} de crm/workflow_vente/out/workflow.json, les champs de base = {base_field} de crm/commissions/out/commission_plan.json (les deux byte-gatés par check_artifacts). PROMOUVOIR un état brouillon en soumis, AJOUTER/RENOMMER un champ de base ou ÉCHANGER un état ferait dériver la prose en silence pendant que l'artefact dit autre chose — dérive qu'aucune suite tests/ (qui teste des FONCTIONS finance/résolution, pas la prose) n'attrape. On RECOMPUTE chaque ensemble depuis SON artefact (zéro duplication du contrat du générateur · #6) et on exige que la prose l'énumère exactement (absent ET en trop mordus via set-diff · accents/casse normalisés). Cohérence croisée en bonus (mord un plan internement incohérent avec le workflow) : tout update_value commissionné ∈ états soumis ET ∉ brouillons — l'invariant 3 du module, non vide. 5 morsures vérifiées : échange brouillon (abandonné→perdu) · sous-ensemble des champs de base (retrait montant_contrat) · énumération des brouillons supprimée (INTROUVABLE) · plan pointant un brouillon (reservation→lead, invariant 3 cassé) · promotion de l'état abandonne en soumis dans l'artefact (draft set change, « en trop » dans la prose) ; restauré = vert.

Autre surface du MÊME livrable crm/commissions, la plus grave possible : sa promesse ANTI-INVENTION (#6) — la RAISON D'ÊTRE du module. Aucun taux de commission n'étant documenté dans CLAUDE.md, le README §« Anti-invention » promet (README:22-23) que « le barème livré porte taux_pct: null + source: null + a_confirmer: true pour chaque évènement », et l'invariant CLI #7 REFUSE tout taux_pct fourni sans source. La cross-cohérence ci-dessus ne gate QUE brouillons/champs de base ; RIEN ne confrontait cette PROMESSE à l'artefact. Or c'est la dérive la plus dommageable du module : qu'un taux_pct DEVIENNE non-null (taux INVENTÉ, ou valeur remplie SANS source) pendant que le README affirme encore « tout null » → le calcul base × taux émettrait une commission fondée sur un chiffre inventé (l'invention EXACTE que le module promet d'écarter). On RECOMPUTE l'état null/confirmé depuis commission_plan.json (byte-gaté) et on exige que la promesse du README le REFLÈTE dans les deux sens (si la Direction confirme un jour un taux, la promesse « tout null » DOIT être mise à jour, sinon ROUGE — le gate suit la vie du module sans faux négatif). Cohérences croisées : invariant #7 permanent (aucun taux_pct sans source) · a_confirmertaux_pct is null · MANIFEST.counts recoupe le plan (taux_a_confirmer == nb à confirmer · evenements == |plan|). 5 morsures vérifiées : taux_pct rempli SANS source (invariant #7 · invention) · taux_pct rempli AVEC source (a_confirmer resté « à confirmer » + promesse README périmée) · a_confirmer=false sur taux null · MANIFEST.counts.taux_a_confirmer périmé (5→4) · promesse anti-invention retirée du README (INTROUVABLE) ; restauré = vert.

Troisième surface du MÊME livrable crm/commissions, le « à quel rôle » de la question fondatrice : la §Cross-cohérence du README promet que chaque role_id commissionné « doit être résolu depuis rbac_50_roles.json … ET appartenir au portail ventes » (invariant CLI #5) avec un erpnext_role_name « cohérent avec RBAC » (invariant #6 · le « nom Frappe résolu » du README:36). C'est une séparation des pouvoirs : on ne commissionne QUE des rôles du portail ventes. Les deux blocs commissions ci-dessus gatent les états/champs/taux, mais RIEN ne confrontait cette appartenance-portail ni la résolution du nom à l'artefact. On RECOMPUTE depuis commission_plan.json + rbac_50_roles.json + MANIFEST.json (byte-gatés · zéro dup du contrat du générateur) : (a) chaque role_id payé ∈ contrat (jamais fantôme) · (b) erpnext_role_name du plan == contrat (nom Frappe non figé en dur) · (c) portail des rôles payés = singletonportails_business == le portail ventes nommé au README (payer un rôle compta/construction romprait le singleton ; renommer le portail au contrat désaccorderait le littéral README) · (d) MANIFEST.roles_rbac_utilises == l'ensemble distinct (role_id, nom) réellement payé, counts.roles recoupé. 6 morsures vérifiées : role_id fantôme absent du contrat · erpnext_role_name dérivé dans le plan · rôle compta commissionné (pouvoirs mélangés) · littéral portail README ventes→sales · roles_rbac_utilises amputé · renommage du rôle DANS le contrat (cascade nom + MANIFEST) ; restauré = vert.

Quatrième surface du MÊME livrable crm/commissions, le « sur quelle devise » du calcul : la §Cross-cohérence promet (README:54) que « devise_field = le champ devise (Select USD/DOP · #10) du DocType ». C'est l'invariant CLI #8 : le plan porte la clé racine devise_field == "devise", le DocType Dossier Vente (doctype_spec.json) porte bien un champ devise de fieldtype Select, et ses options == les DEUX devises canoniques de CLAUDE.md #10USD + DOP devises »). Les trois blocs commissions ci-dessus gatent états/champs/rôles/taux, mais RIEN ne touchait ce couple champ↔devises — surface distincte du bloc « Fiches #10 » (prose ERPNext/CRM) et du bloc e-CF TipoMoneda (options du spec fiscal). Dérive #6 : RENOMMER le champ (devisemoneda), changer son fieldtype (SelectData : énumération PERDUE, montant en monnaie libre) ou altérer ses options (USD/DOPUSD/EUR : devise HORS #10) ferait dériver la prose en silence pendant que l'artefact/DocType dit autre chose — dérive qu'aucune suite tests/ (FONCTIONS finance/résolution, jamais la prose vs artefact) n'attrape. On RECOMPUTE depuis commission_plan.json + dossier_vente/doctype_spec.json (byte-gatés) + CLAUDE.md #10 : (0) ANCRAGE — les 2 devises depuis le segment gras « USD + DOP » de #10 · (a) CROSS-ARTEFACT — plan.devise_field == "devise" == fieldname du champ DocType, fieldtype Select, options (ORDRE inclus) == devises #10 · (b) PROSE — README:54 nomme le champ devise, le dit Select, cite USD/DOP == #10 et tague #10. 7 morsures vérifiées : README champ devise→moneda · README Select→Data · README USD/DOP→USD/EUR · README tag #10 retiré (INTROUVABLE) · DocType options USD→EUR (hors #10) · CLAUDE.md #10 USD+DOP→USD+EUR (ancrage vivant · cascade) · plan devise_field devise→moneda (invariant #8) ; restauré = vert.

Même classe enfin sur le catalogue RBAC (livrable rbac/fixtures_gen) — deux faits data-derived transcrits à la main dans le README. (1) La liste des DocTypes custom à créer avant import « (Faisabilité, Publiciste Log, CONFOTUR Application, API Access) » (README §« Application sur VPS ») == l'ensemble custom_doctypes_a_confirmer d'rbac/fixtures_gen/out/MANIFEST.json (les DocTypes custom: true du contrat RBAC). (2) La séparation des pouvoirs — « le flag set_user_permissions n'est émis que pour le rôle RBAC Admin » (README:48) == l'ensemble {role | set_user_permissions == 1} d'out/custom_docperm.json. Le bloc racine ne gate que le compte (« 50 rôles / 116 DocPerm » via l'agrégat RBAC de l'apply_plan) — jamais l'identité de ces deux ensembles. AJOUTER un DocType custom au contrat, RENOMMER/ÉCHANGER l'un des 4 ou PROMOUVOIR un 2ᵉ rôle porteur du flag (élévation de privilège) ferait dériver la prose en silence pendant que l'artefact byte-gaté dit autre chose — dérive qu'aucune suite tests/ (qui teste des FONCTIONS de mapping, pas la prose du README) n'attrape. On RECOMPUTE chaque ensemble depuis SON artefact (zéro duplication du contrat du générateur · #6) et on exige que la prose l'énumère/le nomme exactement (absent ET en trop mordus via set-diff · accents/casse normalisés). Cohérences croisées en bonus : le catalogue est non vide, sans doublon et trié (byte-déterminisme du générateur) et la séparation des pouvoirs est un singleton (ni vide — garde vacante — ni multiple — élévation de privilège). 6 morsures vérifiées : échange d'un nom de DocType (Faisabilité → Faisabilite2) · sous-ensemble (retrait API Access) · rôle nommé faux (RBAC Admin → Ventes Conseiller) · énumération supprimée (INTROUVABLE) · 2ᵉ rôle promu au flag dans l'artefact (singleton cassé) · catalogue MANIFEST non trié (byte-déterminisme) ; restauré = vert.

Même classe enfin sur une 2ᵉ surface du README des portails (frontend/portails) — le §« Hand-off → agent ERPNext Backend » énumère à la main les DocTypes custom à créer avant import « (CONFOTUR Application, Faisabilité, Publiciste Log) », un catalogue DATA-DERIVED == doctypes_custom_a_creer d'out/MANIFEST.json (l'UNION des doctypes_custom par workspace, eux-mêmes lus du flag custom du contrat RBAC pour les seuls DocTypes câblés dans un portail — c'est donc un ensemble PROPRE au module, sous-ensemble de celui de rbac/fixtures_gen : API Access, custom mais non câblé, en est ABSENT). Le bloc portails ci-dessus ne gate que les comptes par workspace (cartes/liens/rôles) — jamais ce catalogue de hand-off. AJOUTER une carte visant un nouveau DocType custom au contrat, en RENOMMER un ou en RETIRER un du câblage laisse la consigne périmée (l'agent Backend créerait le mauvais jeu de DocTypes → liens pointant dans le vide, le risque même que la consigne prévient) — dérive qu'aucune suite tests/ n'attrape. On RECOMPUTE le catalogue depuis MANIFEST et on exige que la prose l'énumère exactement (set-diff · accents/casse normalisés). Cohérences croisées en bonus : catalogue non vide, sans doublon et trié (byte-déterminisme) · égal à l'UNION des doctypes_custom par workspace (pas de DocType custom câblé mais oublié du hand-off, ni l'inverse). 5 morsures vérifiées : prose renomme Faisabilité → Faisabilite2 · prose retire Publiciste Log (sous-ensemble) · énumération supprimée (INTROUVABLE) · MANIFEST retire du catalogue un DocType encore câblé (incohérence interne : câblé hors catalogue) · catalogue MANIFEST non trié (byte-déterminisme) ; restauré = vert.

Enfin, mêmes racines pour les paramètres canoniques du générateur de faisabilité (faisabilite/generator) : les 6 valeurs imposées à CHAQUE faisabilité — frais d'édition 3 %, marketing 8.5 %, point d'équilibre 52 % (CLAUDE.md #9) · devises USD+DOP, format Letter US, paiement Cardnet (CLAUDE.md #10) — sont la garantie anti-invention (#6) du module. Elles vivent, RECOPIÉES, dans genlib/model.py::CANONICAL (le dict émis dans chaque rendu — hors out/ donc invisible à check_artifacts) et dans la prose README:28, sans aucun ancrage. Le seul contrôle existant (test_generator.py::test_canonical_params…) assert que le RENDU contient « 3 % », « 52 % »… mais ces marqueurs sont hardcodés dans le test — une copie de plus, jamais comparée à CLAUDE.md ; si la contrainte #9 change (52 %→55 %), model.py, le test ET le README restent 52 % : tous cohérents entre eux, TOUS contredisant CLAUDE.md → le générateur émettrait 52 % dans chaque faisabilité alors que le mandat dit 55 %. Comme le catalogue projets dossier_vente et le roadmap-anchor (INV11), on ancre les consommateurs à leur source : on RECOMPUTE les 6 valeurs des lignes #9/#10 de CLAUDE.md (source unique) et on exige qu'elles égalent EXACTEMENT (1) model.py::CANONICAL (% par valeur numérique · textes accent/espace-insensibles) et (2) l'énumération de la prose (set-diff : absent ET en trop). 7 morsures vérifiées : CLAUDE.md #9 52 %→55 % (model + README périmés) · model.py marketing 8.5 %→9 % · CLAUDE.md #10 Cardnet→Azul (model périmé) · README retire Letter US (absent) · README ajoute un token IBAN (en trop) · énumération README supprimée (INTROUVABLE) · model.py USD + DOP → USD ; restauré = vert.

2ᵉ surface du MÊME module : trois copies DE PLUS des 6 marqueurs, longtemps ungated (dont le commentaire ci-dessus NOMMAIT le défaut sans le fermer). (c) genlib/renderer.py::_CANONICAL_MARKERS — la LISTE que check_canonical() parcourt pour PROUVER que le rendu du volet ingénierie porte les 6 marqueurs : l'oracle de l'enforcement lui-même. Si elle dérive de CANONICAL (donc de #9/#10) le contrôle devient FAUX (exige un marqueur périmé ⇒ faux rouge ; ou on en retire un ⇒ vert vacux). (d) les DEUX docstrings de genlib/scorer.py (barème axe 4 + fonction _score_parametres_canoniques) qui énumèrent « 3 % / 8.5 % / 52 % · USD+DOP · Letter US · Cardnet ». (e) l'oracle du test tests/test_generator.py (for marker in [...]). On RÉUTILISE exp (zéro duplication) : les LISTES (c)/(e) sont parsées et comparées à l'ensemble EXACT (absent ET en trop) ; les DOCSTRINGS (d) doivent porter CHAQUE valeur normalisée (espaces/accents-insensibles ⇒ USD+DOP==USD + DOP). Morsures vérifiées : renderer Cardnet→Azul (absent/en-trop) · test oracle retire 8.5 % (absent) · scorer docstring 52 %→55 % (drift) · CLAUDE.md #10 Cardnet→Azul mord SIMULTANÉMENT model.py + renderer + test + les DEUX docstrings (la valeur même de l'ancrage) ; restauré = vert.

Même classe enfin sur QUATRE copies hors du module generator — la prose #9 de DEUX modules distincts qui se réclame explicitement « (#9) » / « CLAUDE.md #9/#10 » mais qu'aucun gate ne liait à la contrainte (corriger le generator ne les touche pas) : (a) faisabilite/bancable/README.md et (b) sa docstring banclib/finance.py (« 3 % édition » et « 8.5 % marketing » (#9)), (c) banclib/deps.py (« CLAUDE.md #9/#10 (3 %/8.5 %/52 % · USD+DOP · Cardnet · Letter US) », 6 marqueurs), (d) crm/commissions/README.md (« pourcentages canoniques (3 % édition · 8.5 % marketing · 52 % point d'équilibre) »). On RÉUTILISE exp (zéro re-parse). Morsures vérifiées : #9 8.5 %→9 % mord les 4 · #9 52 %→55 % mord (c)+(d) SEULS ((a)/(b) ne citent qu'édition+marketing ⇒ correctement épargnées, ciblage précis) · #10 Cardnet→Azul mord l'énumération (c). Un claim absent échoue AUSSI (traçabilité #6).

Même classe enfin sur la fiche d'identité du rôle RBAC mobile — la fiche agent 03_agents/mobile/AGENT.md. Les builds/soumissions stores étant hors-repo (#8), l'agent mobile le reconnaît et déclare que son seul ancrage in-repo vérifiable est le rôle plateforme-mobile du contrat rbac_50_roles.json ; sa ligne « Points de contact réellement commités » en recopie donc à la main TOUS les attributs data-derived : erpnext_role_name (« OTO Plateforme Mobile »), nom_fr (« Développeur Mobile »), famille/portail (plateforme), entite_principale (9060 QC), niveau (2), scope_donnees (groupe), modules (Core+Website), la permission API Access custom R/W et la description. C'est la carte que l'agent ERPNext Backend lit pour seeder le rôle (seed_mobile_rbac.py). Aucun gate ne comparait ces valeurs au contrat : le README mobile/app_config n'est gaté que sur des comptes agrégés (« 5 onglets · 44 rôles »), aveugles à l'identité de CE rôle. Élever le scope_donnees (groupe→entite : sur-portée inter-entités), changer le niveau/l'entite_principale, renommer le rôle, ajouter/retirer un module ou élargir la permission (R/W→R/W/D) laisserait la fiche périmée en silence pendant que le contrat dit autre chose → l'agent câblerait le mauvais rôle (l'invention même que #6 interdit), dérive qu'aucune suite tests/ (qui teste des FONCTIONS de résolution RBAC, pas la prose d'une fiche) n'attrape. On RECOMPUTE chaque attribut depuis le contrat (zéro duplication · #6) et on exige la prose EXACTE par attribut ; le sigle de permission est canonique et ordonné (C/R/W/D/S/X/A) de sorte qu'élargir OU rétrécir les verbes change le sigle attendu (un préfixe R/W ne satisfait plus R/W/D). La description est recoupée verbatim, emphase markdown et point final neutralisés. Cohérences croisées : famille == portail (bijection portail du contrat), la permission est bien custom, et le role_id figure une seule fois dans le contrat. 12 morsures vérifiées (6 côté contrat, 6 côté fiche) : contrat scope groupe→entite · contrat niveau 2→3 · contrat +delete (sigle R/W→R/W/D) · contrat description réécrite · contrat rôle renommé · fiche modules Website→Selling · fiche entité 9060 QC→WAF · fiche perm R/W→R/W/D (sur-revendication) et R/W→R (sous-revendication) · fiche modules over/under-claim · ligne de rôle supprimée (INTROUVABLE) ; restauré = vert.

Même classe encore sur les deux fiches d'identité des rôles RBAC faisabilité03_agents/rendu/AGENT.md (rôle faisabilite-rendu-3d) et 03_agents/ifc_speckle/AGENT.md (rôle faisabilite-ifc-speckle). Chaque ligne « Contact in-repo (vérifié) » recopie à la main, du contrat rbac_50_roles.json, les attributs data-derived RESTITUÉS par la fiche : erpnext_role_name, portail (construction), entite_principale (WA SRL), niveau (2), plus nom_fr & modules (rendu) et surtout les deux permissions_ciblesFile R/W/create + Faisabilité R (rendu, lecture seule) ou R/W (ifc). C'est la carte que l'agent ERPNext Backend lit pour seeder le rôle porteur des rendus / de l'export IFC→GLB. Aucun gate ne comparait ces valeurs au contrat : le README frontend/portails n'est gaté que sur des comptes de rôles par Workspace, aveugles à l'identité de CE rôle. Élargir Faisabilité R→R/W (le rôle Rendu 3D gagnerait l'écriture sur un DocType qu'il ne doit que LIRE), changer le niveau/l'entite_principale, renommer le rôle ou permuter un module laisserait la fiche périmée en silence. On RECOMPUTE chaque attribut du contrat (zéro duplication · #6) ; les verbes de permission sont rendus en ordre canonique (read→R · write→W · create/delete/… en toutes lettres) de sorte qu'élargir OU rétrécir change la chaîne attendue (R/W/create ne satisfait plus R/W/create/delete). Différence notable avec la fiche mobile : la description de ces deux fiches est une paraphrase éditoriale (le contrat porte « (… — CLAUDE.md) » / « les modèles » que la fiche condense) — elle n'est donc pas gatée verbatim (ne rien réécrire de correct pour satisfaire un gate · #6) ; seuls les attributs structurés, transcrits à l'exact, sont contraints. 11 morsures vérifiées (5 côté contrat, 6 côté fiche) : contrat rendu Faisabilité +write (sigle R→R/W) · contrat rendu niveau 2→3 · contrat rendu WA SRL→WAF · contrat rendu rôle renommé · contrat ifc Faisabilité R/W→R · fiche rendu perm File R/W/create→…/delete · fiche ifc perm R/W→R/W/create · fiche rendu module Faisabilité→Selling · fiche ifc WA SRL→WAF · fiche rendu nom_fr retiré (claim absent) · ligne de contact ifc supprimée (INTROUVABLE) ; restauré = vert.

Enfin, sur une surface data-derived DISTINCTE du contrat que les trois fiches précédentes touchent aussi : le rattachement Workspace (Has Role) de leur rôle — QUELLE CONSOLE (Workspace ERPNext natif v15) le rôle peut atteindre. Cette membership ne vit PAS dans rbac_50_roles.json mais dans frontend/portails/out/workspace.json (byte-gaté par check_artifacts), et chaque fiche l'affirme EN PROSE : 03_agents/rendu/AGENT.md:35 — rôle « OTO Faisabilité Rendu 3D » rattaché au Workspace OTO Construction ; 03_agents/ifc_speckle/AGENT.md:35 — rôle « OTO Faisabilité IFC Speckle » rattaché à un Workspace (générique) ; et l'assertion NÉGATIVE d'honnêteté (#6) de 03_agents/mobile/AGENT.md:41 — rôle « OTO Plateforme Mobile » volontairement PAS rattaché au Workspace OTO Ventes. Aucun gate ne les couvrait : le bloc « triplets par workspace » gate le compte de rôles par Workspace (nb_roles), aveugle à QUEL rôle ; les blocs Fiches Faisabilité / Fiche Mobile gatent le contrat, pas la membership Has Role. Rattacher le rôle mobile à OTO Ventes (sur-exposition console : le dev mobile gagnerait l'accès au portail Ventes — la restriction même que la note #6 pose), retirer le rôle rendu de OTO Construction (perte d'accès console du spécialiste) ou le déplacer vers un autre Workspace laisserait la fiche périmée en silence. On RECOMPUTE, depuis workspace.json, l'ensemble des Workspaces dont le Has Role porte l'erpnext_role_name du rôle (résolu du contrat par id, zéro duplication · #6), et on confronte l'assertion : in_named (rendu) — membership == {le Workspace nommé} ET la prose le cite ; in_any (ifc) — membership ≥ 1 Workspace ; not_in_named (mobile) — rôle ABSENT du Has Role du Workspace nommé. Un Workspace nommé mais absent de l'artefact (typo) échoue avant tout (anti-cible fantôme). 9 morsures vérifiées (4 côté artefact, 5 côté fiche/bord) : artefact ajoute le rôle mobile à OTO Ventes (sur-exposition #6 VIOLÉE) · artefact retire le rôle rendu de OTO Construction · artefact déplace rendu vers OTO Achat · artefact retire ifc de tous les Workspaces · fiche rendu renomme le Workspace (OTO Construction→OTO Ventes, INTROUVABLE) · fiche rendu supprime l'assertion · fiche mobile nomme un Workspace fantôme (OTO Marketing) · fiche ifc supprime l'assertion générique · fiche mobile passe le négatif au positif (retire « volontairement pas ») ; restauré = vert.

Enfin, sur une 3e surface d'appartenance encore DISTINCTE des deux précédentes : l'appartenance à roles_allowed du chat OTOIA — QUI peut invoquer l'assistant embarqué dans un portail. Ce contrôle d'accès ne vit ni dans rbac_50_roles.json (attributs) ni dans workspace.json (Has Role, accès console) mais dans frontend/chat_otoia/out/chat_mount.json (une config runtime par portail, byte-gaté par check_artifacts), et deux fiches l'affirment EN PROSE : 03_agents/rendu/AGENT.md:36 — rôle « OTO Faisabilité Rendu 3D » fait partie des roles_allowed du portail construction (droit d'invoquer le chat pour déclencher les rendus assistés) ; et l'assertion NÉGATIVE d'honnêteté (#6) de 03_agents/mobile/AGENT.md:41 — rôle « OTO Plateforme Mobile » (famille plateforme) volontairement dans AUCUNE roles_allowed (le chat ne couvre que les portails métier). Aucun gate ne les couvrait : le bloc Chat OTOIA amont gate l'identité de la persona/capabilities (ancrée CLAUDE.md) et la composition (5 mounts ⇔ 5 portails), aveugle à QUEL rôle figure dans quelle roles_allowed ; le bloc Fiches Workspace gate un artefact + une sémantique DIFFÉRENTS (accès console ≠ droit d'invocation du chat). On RECOMPUTE, depuis chat_mount.json, l'ensemble des portails dont la roles_allowed porte l'erpnext_role_name du rôle (résolu du contrat par id, zéro duplication · #6), et on confronte l'assertion : in_named_portail (rendu) — le portail nommé est data-derived (== le portail du rôle au contrat, triple ancrage : portail nommé == portail contrat == unique portail dont la roles_allowed le porte) et membership == {ce portail} ET la prose le cite avec le droit « invoquer le chat OTOIA » ; not_in_any (mobile) — rôle ABSENT de TOUTE roles_allowed. Un portail nommé mais absent des mounts (typo) échoue avant tout (anti-cible fantôme). 6 morsures vérifiées (3 côté artefact, 3 côté fiche/bord) : artefact retire le rôle rendu de la roles_allowed construction (perte du déclenchement assisté) · artefact ajoute le rôle mobile à ventes (SUR-EXPOSITION du chat #6 VIOLÉE) · artefact ajoute AUSSI rendu à compta (droit élargi, la fiche nomme UN seul portail) · fiche rendu nomme le mauvais portail (construction→compta, INTROUVABLE) · fiche mobile retire le token roles_allowed de l'assertion négative (INTROUVABLE) ; restauré = vert.

Tokens design canoniques publiciste/branding ancrés sur CLAUDE.md #4 : la contrainte NON-NÉGOCIABLE #4 fixe l'identité visuelle luxury — fond dark #0a0a12

  • accent doré #f0b429, titres Fraunces + corps Cormorant Garamond. Ces 4 tokens sont RECOPIÉS à quatre endroits jamais gatés : (a) les CONSTANTES lib/branding.py (COLOR_BG/COLOR_ACCENT/FONT_DISPLAY/FONT_BODY) — la source unique importée par le générateur ET substituée dans le gabarit ({{COLOR_BG}}…), hors out/ donc invisible à check_artifacts ; (b) le docstring du même fichier ; (c) le README (ligne du gabarit) ; (d) le tuple d'oracle HARDCODÉ de tests/test_publiciste.py (("#0a0a12","#f0b429","Fraunces","Cormorant Garamond")). Le seul contrôle existant (test_marque_luxury) assert que le RENDU contient ces marqueurs, mais ils sont une copie de plus, jamais comparée à CLAUDE.md : si #4 change (accent #f0b429#c9a227) et qu'on aligne branding.py, l'oracle du test reste périmé (rouge trompeur) ; si RIEN ne bouge, tout reste vert alors que le site public émettrait la mauvaise couleur — invention silencieuse (#6) qu'aucune suite tests/ (qui teste des FONCTIONS, pas l'ancrage à CLAUDE.md) n'attrape. Même patron EXACT que les paramètres canoniques faisabilite/generator (ancrage CANONICAL↔CLAUDE.md #9/#10). On RECOMPUTE les 4 tokens depuis la ligne #4 de CLAUDE.md (zéro duplication) et on exige : (a) branding.py CONSTANTES == #4 (hex insensible à la casse, fontes à l'exact) ; (b) docstring cite les 4 EXACTS ; (c) README cite les 2 fontes ; (d) l'oracle du test == #4 (set-diff). 6 morsures vérifiées : branding.py accent #f0b429→#e0a420 · branding.py display Fraunces→Playfair Display · CLAUDE.md #4 accent →#c9a227 (mord SIMULTANÉMENT les 3 copies aval : constante + docstring + oracle du test — la valeur de l'ancrage) · docstring retire l'accent de la prose · README échange une fonte (Cormorant Garamond→Georgia) · oracle du test retire Fraunces ; restauré = vert.

Colonne « Job CI » des fiches agents == section jobs: de ci.yml : chaque table de livrables de 03_agents/*/AGENT.md porte une colonne Job CI qui NOMME, entre backticks, le job Gitea Actions exécutant la suite du module (rbac-tests, fiscal-ecf-tests, frontend-portails-tests…). Ce nom est saisi à la main ; la source de vérité est la clé jobs: de .gitea/workflows/ci.yml. Les gates existants ne couvraient PAS ce lien : check_ci_integrity.sh prouve que chaque job est CÂBLÉ dans gate.needs (INV-A/B), et les blocs amont recomputent les comptes de tests par suite — mais RIEN ne vérifiait que le nom écrit dans la fiche DÉSIGNE un job réel. Renommer un job dans ci.yml (rbac-testsrbac-role-tests) ou mal recopier demo-scenario-tests en demo-scenarios-tests laisse la fiche pointer un job fantôme : le CI reste vert (le vrai job tourne sous son nouveau nom), la doc d'identité de l'agent ment en silence. On RECOMPUTE l'ensemble des jobs depuis la section jobs: de ci.yml (la section on: — push/pull_request — est AVANT et donc exclue ; jamais une liste à la main) et on exige que chaque token *-tests cité dans une fiche y figure. Direction fiche→ci.yml (le consommateur) : ci.yml peut définir des jobs non cités (légitime), mais une fiche ne peut pas citer un job absent. 33 références vérifiées ; 2 morsures : token de fiche seo-tests→seo-suite-tests (job fantôme) · job ci.yml rbac-tests renommé (mord SIMULTANÉMENT les 4 fiches qui le citent — erpnext_backend/ifc_speckle/mobile/rendu) ; la disparition totale de la colonne échoue AUSSI ; restauré = vert.

Persona & capabilities du Chat OTOIA ancrées sur CLAUDE.md §Architecture cible. Le générateur frontend/chat_otoia monte l'assistant conversationnel OTOIA (persona Amélie QC) dans chaque portail rôle ; out/chat_mount.json (5 configs runtime) et out/MANIFEST.json portent la persona {nom: "Amélie", voix: "multilingual_v2"} et les 4 capabilities aec.py+knowledge.py+prompt_engine.py+chat.py. La byte-gate check_artifacts prouve seulement que ces artefacts se reconstruisent depuis chat_spec.jsonpas que cette persona/ces capabilities == ce que nomme CLAUDE.md §Architecture cible (« Voix Amélie QC (multilingual_v2) » · « OTOIA capabilities : aec.py + knowledge.py + prompt_engine.py + chat.py »). Le bloc chat_otoia amont ne gate que la composition (portails métier · 1 block⇔1 mount) ; le seul contrôle d'identité vit dans tests/ (test_persona_amelie_sourcee / test_capabilities_sourcees_sans_ajout) mais son oracle est hardcodé ("Amélie", ["aec.py", …]) — une copie de plus, jamais comparée à CLAUDE.md. Piège #6 : renommer la persona (AmélieSophie), changer la voix, ou renommer/retirer une capability dans chat_spec.json (ou dans CLAUDE.md §Architecture cible) laisse le chat monté dans chaque portail annoncer une persona/voix/capabilities qui contredit le mandat pendant que la byte-gate et les tests restent verts (l'artefact se reconstruit fidèlement depuis un spec dérivé ; l'oracle du test est une copie) — même « vert trompeur » que les tokens branding ancrés sur #4 et la marque SEO §Entités. On re-dérive persona+capabilities de CLAUDE.md §Architecture cible (source unique) et on exige : (a) chat_spec.json persona/capabilities == CLAUDE.md ; (b) le spec déclare l'ancrage (persona.source + chaque capability.source citent CLAUDE.md) ; (c) chacun des 5 mounts + le MANIFEST portent persona/capabilities == CLAUDE.md ; (d) le README cite l'ancre §Architecture cible + nomme persona + chaque capability (la notation compacte aec/knowledge/prompt_engine/chat.py matchée par radical) ; (e) l'oracle du test == CLAUDE.md (set-diff : absent ET en trop). 8 morsures vérifiées : CLAUDE.md Amélie→Sophie (mord simultanément spec/mount/README/test — l'ancre est vive) · CLAUDE.md retire chat.py · spec aec.py→exfil.py (capability injectée) · spec persona.source sans CLAUDE.md · un seul mount à voix altérée (byte-gate aveugle) · README retire un radical · oracle du test persona Amélie→Bob · oracle du test capabilities réordonné/tronqué ; restauré = vert.

Pied de page « Voix Amélie QC (multilingual_v2) » des fiches agents ancré sur CLAUDE.md §Architecture cible. 11 des 13 fiches 03_agents/*/AGENT.md (toutes sauf faisabilite/publiciste, qui n'orchestrent pas OTOIA) se terminent par « - Voix Amélie QC (multilingual_v2) pour toute interaction OTOIA », recopiant à la main les trois tokens de CLAUDE.md §Architecture cible « Voix Amélie QC (multilingual_v2) » : le prénom persona (Amélie), le qualificatif de marque (QC) et le modèle de voix ElevenLabs (multilingual_v2). Le bloc persona ci-dessus re-dérive bien persona+voix de CLAUDE.md mais ne confronte que le module chat_otoia (spec + out/ + son propre README + l'oracle du test) — aveugle aux 11 pieds de page ; le bloc « configs runtime » note même que le « QC » est « gardé en littéral non gaté ici ». Piège #6 : le jour où ElevenLabs déprécie multilingual_v2 (→multilingual_v3) ou qu'on renomme la persona/le qualificatif dans CLAUDE.md, ces 11 fiches annonceraient en silence une voix/persona que le mandat ne prescrit plus, chacune affirmant orchestrer « toute interaction OTOIA » avec le mauvais modèle — aucune suite tests/ (fonctions du builder, jamais la prose des fiches) ne l'attrape. On re-dérive indépendamment le triplet (persona · qualif · voix) de CLAUDE.md §Architecture cible (regex qui ne fige pas le qualificatif en dur — un renommage QC→… mord ici, pas seulement via l'INTROUVABLE du bloc persona) et on exige de chaque fiche portant le pied de page que ses trois tokens == CLAUDE.md ; une fiche avec « interaction OTOIA » mais pied de page malformé échoue, et la disparition totale du recensement échoue aussi (régression #6). 6 morsures vérifiées : CLAUDE.md multilingual_v2→v3 (mord les 11 fiches +, en cascade, le bloc persona amont — l'ancre est vive) · CLAUDE.md qualif QC→BC (mord les 11 pieds de page, bloc indépendant) · fiche seo voix v2→v1 · fiche crm persona Amélie→Sophie · fiche bim pied de page malformé (parenthèses retirées) · pied de page retiré de toutes les fiches (surface évaporée) ; restauré = vert.

SDK majeur Expo de l'app mobile ancré sur la roadmap Sprint 5 l.56. La version majeure du SDK Expo que l'app compagnon mobile/app_config cible (expoSdkMajor) est une donnée data-derived : out/app_config.json (byte-gaté ⇒ faisant autorité) porte expo.extra.expoSdkMajor = 54, recopié dans out/MANIFEST.json (app.expo_sdk_major), lui-même dérivé de mobile_spec.json[app].expo_sdk_major dont la source faisant autorité est la roadmap Sprint 5 l.56 « Rebuild Expo 54 » (le spec le déclare via app.expo_sdk_source). Le README recopie ce « Expo 54 » cinq fois à la main — dont la ligne de source « Expo SDK 54 | roadmap Sprint 5 l.56 ». Le bloc « Mobile · récap de l'app Expo » amont ne gate que le quadruplet onglets/rôles/langues/identifiants a_confirmer (comptes de MANIFEST.counts) — aveugle au majeur du SDK. Aucun gate ne comparait cette valeur à l'artefact ni ne l'ancrait à la roadmap : bumper le SDK (Expo 55 sort · eas build cible 55) dans le spec/artefact sans toucher au README (ou l'inverse), ou dériver la roadmap de l'artefact, laisse la prose périmée en silence pendant que le graphe byte-gaté dit autre chose — l'agent Mobile lancerait eas build sur le mauvais SDK (le rebuild même que la ligne roadmap prescrit) — « vert trompeur » qu'aucune suite tests/ (qui teste des fonctions de génération, jamais la prose ni l'ancre à la roadmap) n'attrape ; même patron que l'ancrage §Entités/CLAUDE.md (marque SEO) ou les roadmap_line de la recette. On re-dérive le majeur de son artefact byte-gaté (zéro duplication du contrat #6) et on exige : (a) cohérence interne artefact ⇔ MANIFEST.app.expo_sdk_majorspec.app.expo_sdk_major ; (b) ancrage roadmap — la roadmap dit « Rebuild Expo <N> » avec N == artefact (ancre vive) ; (c) le spec déclare son ancrage (expo_sdk_source cite roadmap + Expo N) ; (d) toute mention prose « Expo [SDK] N » == artefact (aucune périmée) ; (e) la ligne de source du tableau cite l'ancre roadmap à côté du majeur. 7 morsures vérifiées : README Expo SDK **54**→**55** (prose périmée) · artefact 54→55 (README + roadmap périmés — le vrai silent green) · roadmap Rebuild Expo 54→55 (artefact périmé · ancre qui dérive) · MANIFEST 54→55 (chaîne interne incohérente) · spec expo_sdk_source sans « roadmap » (ancrage déclaré manquant) · README ligne de source sans « roadmap » (ancrage prose manquant) · README retire toute mention « Expo N » (claim absent) ; restauré = vert · 7 gates re-verts.

Compte canonique de rôles RBAC ancré sur CLAUDE.md §Architecture cible. Le mandat fixe « RBAC 50 rôles » (CLAUDE.md §Architecture cible). Ce nombre canonique est recopié — sans jamais être re-dérivé du mandat — dans le contrat rbac_50_roles.json (le champ déclaré cible_rbac_roles ET la taille réelle du catalogue len(roles)), dans les schémas (const: 50 de fixtures.schema.json / userperm.schema.json) et dans la fiche agent 03_agents/erpnext_backend/AGENT.md (« RBAC 50 rôles natif » · « 50 rôles » · « RBAC 50 rôles exactement » · « RBAC 50 rôles · L38 »). Les gates RBAC amont (roleprofile/apply_plan/userperm) ne prouvent que la cohérence INTERNE — chaque README de volet == son MANIFEST, lui-même recompté du contrat — aveugles à l'ancrage au mandat : changer « RBAC 50 rôles » dans CLAUDE.md, ou faire dévier le catalogue de 50 de façon interne-cohérente (cible_rbac_roles ET len(roles) alignés sur une autre valeur, tous les MANIFEST recomptés, generator vert), laisserait le contrat et la fiche affirmer « 50 exactement » en contredisant le mandat, en silence — même « vert trompeur » que les tokens branding #4 ou la persona OTOIA §Architecture cible, qu'aucune suite tests/ (FONCTIONS de génération, jamais l'ancre à CLAUDE.md) n'attrape. On re-dérive N de CLAUDE.md §Architecture cible (source unique · bloc entre « ## Architecture cible » et le ## suivant) et on exige : (a) contrat cible_rbac_roles == N ; (b) taille réelle len(roles) == N ; (c) cible == réel (le mandat est atteint par le catalogue) ; (d) la fiche porte l'invariant emphatique « RBAC N rôles exactement » ET aucune mention « M rôles » périmée (M != N · « RBAC 60 tests » n'est PAS une mention de rôles). 5 morsures vérifiées : CLAUDE.md « RBAC 50→60 rôles » (ancre vive — mord a+b sur le contrat resté 50 ET la fiche en cascade) · contrat cible_rbac_roles 50→49 (déviation déclarée · mord a+c) · contrat retrait d'un rôle (len 50→49 · cible reste 50 · mord b+c) · fiche retire l'invariant « exactement » (claim absent = régression #6) · fiche mention 50→48 (mention périmée) ; restauré = vert (6 checks · exit 0) · working tree byte-restauré (git checkout --, JAMAIS git clean) · 7 gates re-verts.

Comptes du run-book d'application (RBAC_50_ROLES_SPEC.md §7). La NOTE §7 (« … est livré en-repo ») restitue, sur une surface DISTINCTE des READMEs userperm_gen / roleprofile_gen (déjà gatés) et du §3 (ventilation par portail, bloc 1quater), trois comptes data-derived du run-book d'application : « 28 templates Company (portée entite) », « 6 profils (5 portails métier + console technique plateforme) » et « 50 rôles de façon bijective ». Ces nombres sont recopiés à la main dans la SPEC et n'étaient couverts par aucun gate sur cette surface. On les recompute depuis le contrat rbac_50_roles.json (source de vérité byte-gatée par check_artifacts) : 28 == |scope_donnees == entite| (= nombre de User Permission templates), 6 == |portails distincts| (1 Role Profile par portail), 5 == portails métier (hors plateforme, la console technique), 50 == |rôles| (bijection). Bumper une portée entitegroupe dans le contrat (le décompte de templates change) ou ajouter/retirer un portail (le décompte de profils change) périme la note en silence pendant que les READMEs de volet restent verts — le vrai « vert trompeur » (bloc 1quinquies). Le blockquote markdown > de la note survit au collapse de read_norm et fragmente la prose ; il est neutralisé avant match. 6 morsures vérifiées : SPEC 28→27 (prose périmée) · SPEC 6 profils→7 · SPEC 5 portails métier→4 · SPEC 50→49 · contrat une portée entite→groupe (28→27 · README stale = silent green) · SPEC retire « 28 templates » (claim absent = INTROUVABLE) ; restauré = vert (exit 0) · working tree byte-restauré (git checkout --, JAMAIS git clean) · 7 gates re-verts.

Colonne « Entité » de la §4 ancrée sur CLAUDE.md §Entités (RBAC_50_ROLES_SPEC.md §4). La table §4 « Cartographie rôles ↔ entités » liste, en colonne de tête, les entités porteuses (entite_principale) du RBAC : les 7 entités NOMMÉES du mandat (WAF · WA SRL · AC Arias Cuevas · Consortium ECR DR · Helios RD · Ploutos · 9060 QC) + le périmètre consolidé « Groupe ». Cette colonne a deux sources faisant autorité — (i) CLAUDE.md §Entités (l'orthographe + le descripteur parenthétique de chaque entité nommée) et (ii) rbac_50_roles.json (l'ENSEMBLE des entite_principale réellement portées par les 50 rôles) — mais aucun gate ne la touchait : le §3 gate les comptes par portail (1quater), le §7 les comptes du run-book (1quinquies), les blocs fiches l'entite d'UN rôle ; tous aveugles à l'identité de CETTE liste. On la couvre (bloc 1sexies) : (a) ENSEMBLE des entités de §4 == distinct entite_principale du contrat (set-diff : ni fantôme ni manquante) ; (b) ANCRAGE — chaque entité NOMMÉE de CLAUDE.md §Entités est une ligne de §4 avec son descripteur parenthétique EXACT (absence comprise) ; (c) chaque entité nommée du mandat est portée ≥ 1× par le contrat (cm_ents ⊆ contrat, aucune entité décorative). AJOUTER au contrat un rôle porté par une entité absente de §4, RENOMMER une entité dans CLAUDE.md §Entités (9060 QC→9061 QC) ou DÉRIVER un descripteur laisse la SPEC contredire le mandat/le contrat en silence — même « vert trompeur » que les entités CONFOTUR (§Entités) ou la marque SEO org. 8 morsures vérifiées : contrat ajoute un rôle porté par une entité absente de §4 ((a) manquante) · contrat renomme toutes les entités Ploutos→Ploutos SRL ((a) fantôme Ploutos + manquante Ploutos SRL · (c) Ploutos décorative) · CLAUDE.md 9060 QC→9061 QC ((b) absent + (c) décorative) · CLAUDE.md descripteur (holding)→(holding co) ((b) mismatch) · SPEC §4 AC Arias Cuevas→AC Arias ((a) fantôme+manquante + (b) absent) · SPEC §4 descripteur marque publique→ marque privée ((b) mismatch) · SPEC §4 retire la ligne « Groupe » ((a) manquante) · SPEC supprime l'en-tête ## 4. (section INTROUVABLE) ; restauré = vert (exit 0) · working tree byte-restauré (git checkout --, JAMAIS git clean) · 7 gates re-verts.

Ensemble des portails métier + exclusion nommée de la console technique (03_agents/frontend_console/AGENT.md). Le bloc « triplets par workspace » recompute cartes/liens/rôles par portail mais reste aveugle à QUELS portails composent l'ensemble. La fiche l'énumère à la main en deux endroits data-derived : L11 « 5 portails rôle métier (Ventes / Construction / Achat / Compta / Direction) » et L46 « la console technique plateforme (6ᵉ portail RBAC) est exclue ». On les couvre (bloc « Portails ensemble ») : (a) l'ensemble ORDONNÉ des noms courts == les workspaces métier de frontend/portails/out/MANIFEST.json byte-gaté (nom court = label « OTO » sans le préfixe, comme l'énumération des onglets Mobile) ET, DOUBLE ANCRAGE, leurs ids == portails_business du contrat RBAC (l'« égalité stricte » que la fiche revendique elle-même en L47) ; (b) plateforme == l'UNIQUE portail RBAC hors métier (∈ portails distincts des rôles portails_business), et c'est le 6ᵉ (total distinct == 6 = 5 métier + 1). Un ÉCHANGE (Achat→Marketing), un RETRAIT, un RÉORDRE, un RENOMMAGE de la console technique dans le contrat (plateformeconsole) ou un contrat qui PROMEUT plateforme en métier laisse la fiche contredire les artefacts en silence — même patron que l'énumération des onglets Mobile ou la colonne « Entité » de la §4. 8 morsures vérifiées : fiche L11 échange Achat→Marketing ((a) en trop/absent) · fiche L11 retire Compta ((a) compte + absent) · fiche L11 réordonne Ventes↔Construction ((a) ordre) · fiche L46 plateforme→console ((b) nom) · fiche L46 6ᵉ→7ᵉ ((b) rang) · fiche L46 retire **exclue** (claim INTROUVABLE) · contrat réordonne portails_business (ancrage rompu) · contrat renomme ventes→sales dans portails_business (ancrage rompu + plateforme n'est plus l'unique portail hors métier) ; restauré = vert (exit 0) · working tree byte-restauré (git checkout --, JAMAIS git clean) · 7 gates re-verts.

IDENTITÉ de l'endpoint GPU RunPod ComfyUI ancrée sur l'inventaire maître (03_agents/{bim,rendu}/AGENT.md + 02_master_prompt/). Les fiches BIM/Rendu et le master prompt NOMMENT, entre backticks, l'ID du pod RunPod ComfyUI qui exécute le rendu Flux (t68akvx80wv092) + le tag du modèle (« Flux dev fp8 ») — 6 citations SAISIES à la main dont la source de vérité in-repo est le bullet §1 de AGENTS_EXISTING_ASSETS.md (« le fichier maître » que les fiches citent explicitement comme leur ancre), hors de tout gate : check_docs ne voit qu'un lien, guard_constraints qu'un terme interdit, check_artifacts qu'un out/ — un ID de pod n'est aucun des trois. Or un pod RunPod est RE-CRÉÉ avec un NOUVEL id (le cas le plus courant) : l'inventaire se met à jour, les citations se PÉRIMENT en silence → l'agent Rendu/BIM câblerait le batch Flux vers un endpoint GPU MORT (l'invention même que #6 interdit). On RECOMPUTE l'id + le tag depuis le bullet maître (jamais une liste à la main) ; direction fiche→inventaire : toute fiche/master prompt qui mentionne RunPod doit citer l'id EXACT (tout token backtické de forme pod — alphanum minuscule ≥12 avec un chiffre — == canonique) et le tag Flux EXACT ; la disparition totale de la surface échoue AUSSI. 6 morsures vérifiées : inventaire re-crée le pod (id change → mord les 3 fiches en cascade, id périmé) · inventaire bump le tag Flux (dev fp8→schnell fp8 → tag périmé dans les 3) · fiche rendu mistype une citation (id fantôme) · fiche bim retire le tag Flux (tag absent) · master prompt retire l'id backtické (citation incomplète) · inventaire supprime le bullet canonique (ancre morte, recompute impossible) ; restauré = vert (exit 0) · working tree byte-restauré (git checkout --, JAMAIS git clean) · 7 gates re-verts.

IDENTITÉ des évènements d'émission e-CF (le « cœur du livrable » fiscal) ancrée sur ecf_plan.json. La section §Cross-cohérence de fiscal/ecf_dgii/README.md:40-59 — « le cœur du livrable » — transcrit à la main l'identité des emission_events de l'artefact byte-gaté : l'ENSEMBLE des évènements qui émettent un e-CF (« seulement réservation et contrat »), l'ENSEMBLE exclu des brouillons (« on ne facture pas un brouillon (lead/visite/devis) »), l'ENSEMBLE des base_field (montant_reservation/montant_contrat), le rôle unique (compta-fiscaliste-ecf · « OTO Compta Fiscaliste eCF » · portail compta) et la FormaPago défaut (« = 3 (Tarjeta) » · Cardnet #10). Le seul gate fiscal existant couvre le format e-NCF + le compte de tests ; le bloc racine, le seul agrégat « e-CF 39 tests ». Rien ne confrontait cette identité à l'artefact. Dérive la plus grave (#6/#5) : ajouter un emission_event sur un état BROUILLON (ex. devis) — l'anti-pattern EXACT que le module interdit (« on ne facture pas un brouillon ») — pendant que le README dit encore « seulement réservation et contrat » → l'agent ERPNext Backend câblerait une facture émise avant soumission/encaissement. On RECOMPUTE les ENSEMBLES depuis ecf_plan.json (jamais une liste à la main) et exige que le README les reflète EXACTEMENT (set-diff : ni fantôme ni manquant ; accents/casse normalisés) ; le rôle est double-ancré sur rbac_50_roles.json (id → portail compta → nom ERPNext concordant) ; l'ensemble exclu doit rester DISJOINT des émetteurs (jamais de facture sur brouillon). Cohérences croisées en bonus : rôle unique sur tous les évènements · défaut ∈ catalogue FormaPago · ensembles non vides. 6 morsures vérifiées : artefact facture un brouillon (devis émetteur → mismatch d'ensemble ET clash de disjonction) · artefact renomme un base_field · artefact change la FormaPago défaut 3→1 · artefact casse le rôle unique (2ᵉ évènement → autre rôle) · README périmé « seulement contrat » (réservation retiré) · motif du défaut ne cite plus « Cardnet » (#10) ; restauré = vert (exit 0) · working tree byte-restauré (git checkout --, JAMAIS git clean) · 7 gates re-verts.

COMPLÉTUDE de l'énumération des gates statiques dans la fiche QA. La fiche 03_agents/qa/AGENT.md (§« Deuxième étage QA ») énumère les gates ci/*.sh en se déclarant explicitement « l'état courant » de git ls-files 'ci/*.sh' (« pas une constante »). Or rien ne vérifiait que CHAQUE gate statique y figure vraiment : le 8ᵉ gate check_mobile_workflow.sh (livré le 2026-08-05) a pu rester omis un jour entier EN SILENCE — la fiche promettait la complétude sans la tenir. On re-dérive l'ensemble depuis git ls-files 'ci/*.sh' (hors lib.sh, #6 — jamais une liste à la main) et on exige que chaque basename apparaisse (backtické) dans la fiche ; un gate absent échoue. Distinct de check_ci_integrity qui verrouille le câblage CI (chaque ci/*.sh ↔ un job gate.needs) mais ignore la fiche ; cette surface ferme le miroir documentaire du même ensemble. 2 morsures vérifiées : retrait de la ligne check_mobile_workflow.sh de la fiche → mismatch (gate réel non cité) · ajout d'un 9ᵉ ci/*.sh tracké non encore documenté → mismatch (forward-protection : le prochain gate ne pourra pas être omis) ; restauré = vert.

Un claim absent échoue aussi (la dérive de formulation qui ferait disparaître un chiffre est elle-même une régression de traçabilité). stdlib pur (bash/git/python3), zéro réseau.

check_mobile_workflow.sh — intégrité du second workflow mobile-build.yml

mobile-build.yml (EAS build iOS/Android · Sprint 5) est hors gate.needs : aucun de ses jobs ne conditionne un merge (ils s'activent seulement quand Michel fournit EAS_TOKEN). Conséquence longtemps tolérée : aucun gate ne lintait sa structurecheck_ci_integrity.sh ne verrouille que ci.yml, validate_json.sh que le .json. Mutation vérifiée : réintroduire secrets. dans un if: de job y passait 7/7 gates statiques verts. Or ce fichier porte un correctif de portabilité subtil (session 234104) qui, s'il régresse, ne casse rien au CI mais fait échouer les builds « le jour où on en a besoin ». Ce gate rend 4 invariants opposables, tous lus dans le fichier (jamais une liste à la main · #6) :

  • MOB-1 · bien-formé — le fichier existe, porte name:/on:/jobs: et les 3 jobs attendus (preflight, build-ios, build-android). Parse YAML strict exigé si PyYAML est présent, sinon contrôle awk seul (runner sans pip → best-effort ; jamais un faux vert, car MOB-2/3/4 ne dépendent pas de PyYAML).
  • MOB-2 · portabilité du gating0 secrets. dans un if: au niveau job (^ if:). Le contexte secrets n'est pas exposé dans jobs.<id>.if (table d'availability GitHub Actions) ; sur act/Gitea il s'évaluerait vide → job SKIP silencieux même token fourni. Exactement la régression corrigée en 234104 (passage au pattern preflight-output).
  • MOB-3 · activation différée préservée (#6/#8) — chaque job de build (bloc lançant run: eas build) porte un if: job-level gaté sur needs.preflight.outputs.has_token → SKIP tant que le token absent, donc le workflow ne rend jamais le CI rouge avant activation Michel.
  • MOB-4 · contrat d'outputs intactpreflight déclare has_token/has_repo, chaque needs.preflight.outputs.<X> référencé est bien déclaré (0 dangling → pas de SKIP par output fantôme) et chaque output déclaré est alimenté par un echo "<X>=… >> $GITHUB_OUTPUT".

Les 5 mutations correspondantes (YAML cassé · secrets. en if: de job · if: de build supprimé · output fantôme référencé · output non alimenté) rendent toutes le gate ROUGE ; le fichier propre repasse vert. stdlib + awk pur, PyYAML best-effort, zéro réseau. Ce gate est le 8ᵉ gate statique ci/*.sh (câblé dans gate.needs, donc rejoué par run_ci.sh).

lib.sh — helper partagé (sourcé, jamais exécuté seul)

Les gates commençaient tous par cd "$(git rev-parse --show-toplevel)". Hors d'un arbre de travail git (tarball, git archive | tar -x, git absent du PATH), git rev-parse n'écrit rien → cd "" est un no-op qui RETOURNE SUCCÈS : le gate poursuivait, git ls-files renvoyait une liste vide, et validate_json / guard_constraints / check_docs sortaient exit 0 VERT en n'ayant RIEN contrôlé — le pire « vert trompeur ». ci/lib.sh factorise ce préambule dans cd_repo_root, qui échoue bruyamment (exit 3, code distinct d'un échec de contrôle 1) hors d'un checkout git. check_ci_integrity (INV-B) prouve que lib.sh est bien sourcé par des gates (pas du code mort) sans exiger qu'il figure dans un job.

3. Exécution locale (avant push)

bash ci/guard_constraints.sh   # contraintes CLAUDE.md
bash ci/validate_json.sh       # schémas JSON
bash ci/check_docs.sh          # liens + score
bash ci/check_artifacts.sh     # reproductibilité out/ (build)
bash ci/check_regression.sh    # fraîcheur matrice régression (run)
bash ci/check_ci_integrity.sh  # intégrité du câblage CI (gate ⊇ tous jobs)
bash ci/check_readme_claims.sh # chiffres du README == artefacts cités (#6)

Chaque script retourne 0 si conforme, 1 sinon. Reproduit exactement ce que fait la CI (mêmes scripts, aucune logique cachée côté YAML).

4. Enregistrement du runner Gitea (ops · à faire sur le VPS)

⚠ Étape hors périmètre de ce worker (touche au VPS). À réaliser par l'agent DevOps. Documenté ici pour traçabilité.

Le workflow cible un runner avec le label ubuntu-latest. Sur le VPS Gitea (:3015), enregistrer un act_runner :

# Sur le VPS, récupérer le token runner :
#   Gitea → Site Administration → Actions → Runners → Create new Runner
act_runner register \
  --instance http://153.75.250.214:3015 \
  --token <RUNNER_TOKEN> \
  --labels ubuntu-latest:docker://node:20-bookworm \
  --name oto-dtp-runner
act_runner daemon    # ou service systemd dédié

Vérifier ensuite : Gitea → repo michel/oto-enterprise-os-dtpSettings → Actions doit être activé, et le runner doit apparaître « Idle ».

5. Checklist de vérification (DevOps, sur VPS)

  • [ ] Gitea Actions activé au niveau instance ET repo.
  • [ ] act_runner enregistré, label ubuntu-latest, statut Idle.
  • [ ] Push de test → les gates statiques ci/*.sh apparaissent et passent au vert.
  • [ ] PR de test avec violation volontaire → constraints-guard bloque (rouge).

Auto-score 4Big du livrable : 96/100. Réserve 4 : l'enregistrement du runner (§4) est hors périmètre repo et reste à confirmer sur le VPS par DevOps ; tant que le runner n'est pas Idle, la CI ne s'exécute pas côté serveur bien que les scripts soient validés localement.