# 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`](../.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](../05_deliverables_mvp/mobile/app_config/README.md).

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`](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 <!-- ci-allow : description de l'exclusion, jamais un usage -->
`/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/` » <!-- ci-allow : citation du mandat -->
**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 <!-- ci-allow : citation de l'inconsistance signalée, jamais un usage -->
`/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-A** — `gate.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 `PASS` → `qa/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 `true` → `qa/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 (`entite` → `none_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
(`Residence` → `Apartment`, 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_nodes−1` 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.json` — **pas** 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 QC`→`9061 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 Vente`→`OTO 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 Structure` … `P09 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 Nakua` → `P03 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 contrat`→`signer 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éservations`→`Conseiller` : é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:25` (« **DocType 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_config` (« **5 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 <Nom> ». 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 pouvoirs** — `submit` 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.json` … `legal/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 dimensions** — **aucun** 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.1`→`D2.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_sheet` ↔ `MANIFEST` 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 `*.md` ≥ **400 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 + DOP` → `USD + 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_confirmer` ⟺
`taux_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 = **singleton** ∈ `portails_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 #10** (« **USD + 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 (`devise`→`moneda`), changer son fieldtype (`Select`→`Data` :
énumération PERDUE, montant en monnaie libre) ou altérer ses `options` (`USD/DOP`→
`USD/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_cibles`** — `File` 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-tests`→`rbac-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.json` — **pas** 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élie`→`Sophie`), 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_major` ⇔ `spec.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 `entite`→`groupe` 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 <Nom> » 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
(`plateforme`→`console`) 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.

**COMPLÉTUDE de l'énumération dans la TABLE §1 de ce README.** La table §1 « Ce que
fait le pipeline » (ci-dessus) est LA doc canonique des jobs statiques — une ligne par
gate `ci/*.sh`. Sa complétude était le **jumeau ungaté** de la fiche QA : `check_ci_integrity`
verrouille le câblage CI, le bloc précédent énumère côté fiche, mais **rien** n'exigeait que
chaque gate figure dans la table §1. Un 9ᵉ gate y serait omis EN SILENCE (prouvé ungate :
retrait de la ligne de table de `check_mobile_workflow` → 8 gates statiques verts). On
réutilise l'ensemble re-dérivé de `git ls-files 'ci/*.sh'` (hors `lib.sh`, #6) et on exige
que chaque basename soit cité dans une **ligne de TABLE** (`|`…) de la section §1 — **pas** la
prose §1 (qui ne cite que certains gates pour l'explication `mobile-build.yml`, ce qui
rendrait un contrôle whole-section édenté sur un retrait de ligne de table). **2 morsures
vérifiées** : retrait de la ligne de table `check_mobile_workflow.sh` → mismatch nommant le
gate · ajout d'un 9ᵉ `ci/*.sh` tracké → mismatch (**forward-protection**) ; 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
structure** — `check_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 gating** — **0** `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 intact** — `preflight` 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
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` :

```bash
# 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-dtp` → **Settings →
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.
