Files
oto-enterprise-os-dtp/ci/README.md
T
Claude Code DTP Worker 0790fa7811 [DTP-Worker] Sprint 8 · buffer · Fiscal/ecf_dgii (2e surface) : la CROSS-COHÉRENCE e-CF ↔ workflow ↔ DocType ↔ RBAC — le CŒUR du livrable — énumérait EN PROSE trois données data-derived (états qui ÉMETTENT un e-CF · champs Currency de base · FormaPago défaut ancrée sur CLAUDE.md #10/Cardnet) sans AUCUN gate d'IDENTITÉ. Le bloc e-NCF existant ne gate QUE le FORMAT de l'identifiant (E+tipo(2)+seq(10)=13) — jamais ces trois ensembles/valeur. Ils sont DATA-DERIVED de out/ecf_plan.json (byte-gaté par check_artifacts) : README:48 « seulement réservation et contrat » = {emission_events[].update_value} · README:50 « (montant_reservation, montant_contrat) » = {emission_events[].base_field} · README:59 « FormaPago défaut = 3 (Tarjeta) … via Cardnet (#10) » = forma_pago_defaut (ancré sur CLAUDE.md #10 : Cardnet = carte, pas Stripe). PIÈGE : check_artifacts ne prouve QUE ecf_plan==build (byte-for-byte) et le bloc racine ne gate QUE le compte de tests e-CF (« 39 ») → AJOUTER un évènement d'émission (facturer un devis) · en RETIRER un · RENOMMER un base_field · CHANGER forma_pago_defaut (Cardnet abandonné → code 2 Transfert) laissait la prose périmée pendant que l'artefact dit autre chose → 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) — « vert trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS ncf/résolution, pas la prose) n'attrape. Même patron EXACT que la cross-cohérence CRM/Commissions déjà gatée (base_field + états soumis). Nouveau bloc « Fiscal cross-cohérence » dans ci/check_readme_claims.sh (après le bloc e-NCF) : (1) états émetteurs recomputés de emission_events[].update_value, prose exigée EXACTE (set-diff · accents NFKD) ; (2) champs de base recomputés de emission_events[].base_field, prose EXACTE ; (3) FormaPago défaut = forma_pago_defaut.code + libellé (⊆ catalogue) + ancrage Cardnet à proximité. 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. Un claim absent échoue AUSSI. 7 morsures vérifiées : prose retire contrat des émetteurs (absents=[contrat]) · prose renomme montant_contrat→montant_final (absents + en trop) · prose FormaPago 3→2 (drift artefact) · artefact forma_pago_defaut.code 3→2 (prose périmée) · artefact ajoute l'évènement devis (états ET base_field mordus · absents=[devis]) · FormaPago prose supprimée (INTROUVABLE) · artefact code→99 hors catalogue (incohérence interne) ; restauré = green : émetteurs [contrat,reservation] · base [montant_contrat,montant_reservation] · FormaPago 3 (Tarjeta) ancré Cardnet · exit 0. État courant : aucune valeur périmée (anti-invention #6, rien à réécrire) — le défaut est la surface ungated. ci/README.md (table + détail « 2e surface e-CF DGII ») mis à jour · working tree byte-restauré (git clean) · 7 gates re-verts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 20:38:52 +00:00

646 lines
51 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 des docs d'entrée : chaque nombre de `README.md` « État courant », de la fiche QA « Verdict agrégé » **et** de la colonne « Tests » par suite des tables de fiches **et** des agrégats en prose (Total CRM / RBAC / e-CF) **et** des comptes par workspace des portails (cartes/liens/rôles, ×2 docs) **et** des comptes RBAC des 3 volets (table Nb rôles/profil, ligne agrégée apply_plan, ventilation userperm) **et** des comptes de mots-clés SEO (total + fr/en/es, ×2 docs) **et** du pipeline vente CRM (états/transitions, ×2 docs + énumération des transitions à séparation des pouvoirs `{action | allow_self_approval=0}` recomputée de `workflow.json`) **et** de l'app mobile Expo (onglets/rôles/langues/identifiants a_confirmer + énumération ordonnée des onglets recomputée de `role_navigation`, bijection onglet⇔portail) **et** du DocType CONFOTUR (champs/données/sections/rôles/évènements de dépôt, ×2 docs) **et** de l'audit 5D (contrôles/dimensions ×2 docs + ventilation verdict pass/fail/à-confirmer + énumération des open items `control→dimension→propriétaire` recomputée de `audit_report.open_items`) **et** du run-book de déploiement VPS (phases ×2 dans la fiche devops + confirmations préalables count & énumération dans le README) **et** du run-sheet de démo (nombre de modules cités dans le diagramme du README) **et** du montage Chat OTOIA par portail (`Custom Block`/configs runtime/portails, tous = 5 aujourd'hui, ×2 docs) **et** du module de recette `qa/acceptance` (couple livrables de sprint + métriques succès MVP, ×2 docs de module au-delà du README d'entrée) **et** du barème de l'auditeur 4Big (nombre de critères en prose + poids par critère de la table + « generator (N critères) », recomputés de `quality_report.criteria`) **et** de la carte de renormalisation par archétype de l'auditeur 4Big (critères EXCLUS de `data_room`/`parser`/`contract`, recomputés des `checks` par archétype de `quality_report.modules`) **et** du contrat de format de l'e-NCF fiscal (`E + tipoeCF(2) + secuencia(10) = 13` : préfixe/longueurs des segments/longueur totale, ×3 README + fiche `erpnext_backend`, recomputés du bloc `e_ncf` d'`ecf_plan.json` + cohérences croisées `1+tipo+seq==longueur` & `|regex digits|==tipo+seq`) **et** de la cross-cohérence e-CF DGII `fiscal/ecf_dgii` (2e surface : états émetteurs recomputés de `{emission_events[].update_value}` + champs de base recomputés de `{emission_events[].base_field}` + FormaPago défaut « 3 (Tarjeta) » recomputée de `forma_pago_defaut` **ancrée sur CLAUDE.md #10** (Cardnet) + cohérences croisées : `forma_pago_defaut.code` ∈ catalogue `formas_pago` · libellé prose ⊆ libellé catalogue · ensembles non vides) **et** de la cross-cohérence du barème de commissions `crm/commissions` (états brouillon exclus recomputés de `{doc_status=0}` de `workflow.json` + champs de base recomputés de `{base_field}` de `commission_plan.json` + invariant 3 « commissionne seulement des états soumis ») **et** du catalogue RBAC `rbac/fixtures_gen` (DocTypes `custom` à créer énumérés == `MANIFEST.custom_doctypes_a_confirmer` + séparation des pouvoirs : le rôle unique porteur de `set_user_permissions` == `custom_docperm.json`) **et** de la composition du graphe schema.org SEO (les `@type` du `@graph``Organization` racine + une `Residence` par projet — recomputés de `seo_schema_org.json` + bijection listing⇔projet & absence d'`offers`) **et** de la carte hreflang SEO (les langues de contenu `alternate` `FR/EN/ES` + `x-default` recomputées de `seo_hreflang.json` + cohérences croisées : carte uniforme sur toutes les pages · `x-default` présent partout · langue par défaut ∈ langues de contenu · `x-default`.href == `canonical` == href de la langue par défaut) **et** du catalogue de projets du DocType `crm/dossier_vente` (le Select `projet` — les 9 projets `P01..P09` avec libellés — **ancré sur** la section « ## Projets » de `CLAUDE.md`, recomputé de CLAUDE.md : ensemble + ordre == options du DocType byte-gaté, plage `P01..P09` + mot d'ancrage dans le README, catalogue contigu/sans-doublon en bonus) **et** du catalogue des DocTypes `custom` à créer du portail `frontend/portails` (2e surface : le §hand-off VPS énumère `CONFOTUR Application`/`Faisabilité`/`Publiciste Log` == `MANIFEST.doctypes_custom_a_creer` recomputé, set-diff normalisé + cohérences croisées : catalogue trié/sans-doublon/non-vide · égal à l'UNION des `doctypes_custom` par workspace) **et** du classement métier/technique des `Role Profile` de `rbac/roleprofile_gen` (2e surface : la colonne « Type » de la table — auparavant un WILDCARD — recompute le flag `metier` par profil de `MANIFEST.profiles` + prose « N profils (X métier + Y technique) » == `counts.role_profiles/portails_metier/portails_techniques` + identité d'ensemble : lignes de profil de la table == `|profiles|` (aucun profil fantôme) + cohérences croisées : `portails_metier + portails_techniques == role_profiles` et recomptent les flags réels) **et** des paramètres canoniques du générateur de faisabilité `faisabilite/generator` (**ancrés sur** CLAUDE.md #9/#10 : les 6 valeurs `3 %`/`8.5 %`/`52 %`/`USD+DOP`/`Letter US`/`Cardnet` recomputées des contraintes #9/#10 de `CLAUDE.md` puis exigées EXACTES dans `genlib/model.py::CANONICAL` — le dict émis dans chaque rendu, hors `out/` donc invisible à check_artifacts — ET dans la prose README, set-diff normalisé) == artefact cité (#6) | ✅ 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).
## 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.
### `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. Deux 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.
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 » (×2 : nav + titre) → `git ls-files 03_agents/*/AGENT.md`.
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.
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_nodes1` du MANIFEST), et le graphe ne porte **aucune** `offers`
cohérent avec « aucun prix pour un statut sans prix » (USD · #10). Un claim
supprimé (« composition INTROUVABLE ») échoue **aussi**.
**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**.
**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).
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.
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.
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.
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.
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.
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.
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.
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 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.
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.
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.
### `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 7 gates statiques 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.