preuves : trois gardes pour ce que ma lecture ne tiendra pas

Une revision de documentation vieillit comme le reste. Ce qui tient, c est ce
qu une machine verifie — et trois lacunes etaient nommees sans etre gardees.

P58  HABILITATIONS. autorisation.md posait la regle (un service nomme un
     GROUPE, jamais une personne, D-66) et meta/acces.yml la portait ; rien ne
     la verifiait. P29 gardait les POSITIONS d authentification, personne ne
     gardait les DROITS.

     Le controle qui porte la preuve est un croisement : une entree
     porte_par: role-realm affirme que l habilitation voyage par un role de
     realm projete depuis un groupe LDAP. P58 le confronte a serveur_keycloak.
     Sans ca, un service annonce une habilitation que rien ne transporte, et l
     ecran reste vide sans que personne sache pourquoi.

     CE QU ELLE N EXIGE PAS, et c est le point le plus important : que les
     groupes nommes existent dans l annuaire. Ce serait contredire le regime du
     paragraphe 2 — le depot AMORCE un acces et se retire, les appartenances
     appartiennent a une personne. dev et personnel n existent dans aucun code,
     et ce n est pas un defaut.

P59  ENUMERATIONS ANNONCEES. Les deux ecarts trouves a la main pendant la
     tournee — cinq portes annoncees devant une table de six, huit lignes
     renvoyees vers une fiche qui en compte dix — etaient d une forme que P57
     ne voit pas.

     Ma premiere version a signale CINQ ecarts, et les cinq etaient du bruit :
     dans « reprise dans les deux devis : », le nombre qualifie autre chose que
     la liste. Cent pour cent de faux positifs — la preuve qui crie sur un cas
     sain et qu on apprend a ignorer. Resserree aux deux formes ou le nombre ne
     peut compter rien d autre. Etroite et vraie plutot que large et devineuse.

P60  WIKI PUBLIE. Le wiki est publie DEPUIS le depot ; rien ne mesurait l ecart,
     et il s est creuse de VINGT-SEPT JOURS en silence. Deux unites jamais
     publiees, vingt et une differentes : pour qui lit la forge plutot que le
     depot, toute la revision n existait pas.

     Le harnais est STATIQUE, zero appel reseau — cloner la forge romprait la
     seule propriete qui fasse qu une preuve vaille hors de ce poste. La mesure
     passe donc par un TEMOIN que make wiki-publier depose. Amorce avec la
     valeur MESUREE : le wiki d eregion porte b6167f2, dont le message dit
     source: ac85278.

     Ce qu elle ne prouve pas : un temoin dit ce qui est PARTI, jamais ce qui
     est ARRIVE.

LES TROIS SONT EPROUVEES DANS LES DEUX SENS

Douze essais negatifs, douze refus : groupe non projete, acces.yml disparu,
personne au lieu d un groupe, mecanisme invente, raison manquante, compte
revenu a cinq, septieme porte ajoutee sans toucher au compte, renvoi croise
fausse, temoin absent, temoin d un autre depot. Une garantie qu on n a jamais vu
dire non n est pas une garantie, c est une habitude.

ETAT : NON CONFORME, 58 OK, 1 echec, 1 saute.

P60 est rouge, et c est le comportement voulu : le registre a le droit de
perdre. Le retard qu elle signale est reel et anterieur a elle. Une commande le
ferme, et elle vient ensuite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Crgis8CxCWkAGFA1ecBz3q
This commit is contained in:
Daniel Allaire 2026-09-08 12:47:18 -04:00
parent 36b6926e21
commit c9d31e9a60
13 changed files with 482 additions and 16 deletions

View file

@ -184,7 +184,7 @@ Si `ansible-lint` n’est pas disponible, le signaler clairement. Ne pas invente
## Écrire, puis relire (D-68) ## Écrire, puis relire (D-68)
`--syntax-check` et `ansible-lint` prouvent que le dépôt est cohérent **avec lui-même**. `--syntax-check` et `ansible-lint` prouvent que le dépôt est cohérent **avec lui-même**.
C'est aussi ce que font les 57 preuves de `make prouver` : elles lisent le dépôt, sans le C'est aussi ce que font les 60 preuves de `make prouver` : elles lisent le dépôt, sans le
moindre appel réseau. **Aucune ne demande au système déployé s'il ressemble à ce que le moindre appel réseau. **Aucune ne demande au système déployé s'il ressemble à ce que le
dépôt annonce.** dépôt annonce.**

View file

@ -833,7 +833,16 @@ wiki-publier: ## Publie le wiki (wiki/) vers la forge
sha="$$(git -C "$(CURDIR)" rev-parse --short HEAD 2>/dev/null || echo inconnu)"; \ sha="$$(git -C "$(CURDIR)" rev-parse --short HEAD 2>/dev/null || echo inconnu)"; \
git commit --quiet -m "Publication du wiki depuis le depot (source: $$sha)"; \ git commit --quiet -m "Publication du wiki depuis le depot (source: $$sha)"; \
git push --quiet; \ git push --quiet; \
printf '%s\n' 'Wiki publie.' printf '%s\n' 'Wiki publie.'; \
@# LE TEMOIN — sans lui, l'ecart entre `wiki/` et la forge n'est mesurable par
@# personne. Il s'est creuse de 27 jours en silence avant que P60 n'existe.
@# Ecrit APRES le push : un temoin ne doit jamais dire qu'on a publie si on a echoue.
{ printf '%s\n' '---' \
'# Ecrit par `make wiki-publier`, lu par la preuve P60. Ne pas editer a la main.' \
"remote: $(WIKI_REMOTE)" \
"source: $$sha" \
"date: $$(date +%F)"; } > "$(CURDIR)/docs/audit/wiki-publie.yml"; \
printf '%s\n' 'Temoin depose : docs/audit/wiki-publie.yml (a committer).'
deployer-tout: _instance-requise ## Deploie TOUTE la flotte dans l'ordre des couches deployer-tout: _instance-requise ## Deploie TOUTE la flotte dans l'ordre des couches
@set -e; \ @set -e; \

View file

@ -10,7 +10,7 @@ Ce plan est le **pendant manuel** de `make prouver` : là où le harnais prouve
le côté humain **est** la preuve de l'affirmation « exploitable sans IA » (cf. le côté humain **est** la preuve de l'affirmation « exploitable sans IA » (cf.
`protocole-operateur-independant.md`). `protocole-operateur-independant.md`).
**90 gestes** sur **22 unités** · **19** en « casse-répare » **90 gestes** sur **22 unités** · **20** en « casse-répare »
· **5** doublés d'un garde-fou machine (colonne *Preuve auto*). · **5** doublés d'un garde-fou machine (colonne *Preuve auto*).
> **Honnêteté de couverture.** La colonne *Preuve auto* n'est remplie que lorsqu'une > **Honnêteté de couverture.** La colonne *Preuve auto* n'est remplie que lorsqu'une
@ -150,7 +150,7 @@ le côté humain **est** la preuve de l'affirmation « exploitable sans IA » (c
| 1 | Lis ton réseau physique | : make underlay. Repère les VLAN sous 1000 (l'underlay) et les MTU. Pourquoi le transport doit-il être à 1500 quand l'overlay est à 1450 ? | 👁 observe | — | | 1 | Lis ton réseau physique | : make underlay. Repère les VLAN sous 1000 (l'underlay) et les MTU. Pourquoi le transport doit-il être à 1500 quand l'overlay est à 1450 ? | 👁 observe | — |
| 2 | Regarde sans écrire | : make sdn-plan. Si le cluster dit déjà ce que le plan dit, la sortie tient en une ligne. | 👁 observe | — | | 2 | Regarde sans écrire | : make sdn-plan. Si le cluster dit déjà ce que le plan dit, la sortie tient en une ligne. | 👁 observe | — |
| 3 | Trouve la sortie d'un tenant | : dans make devis-sdn, repère la strophe FRR et l'adresse du prochain saut. À quel équipement appartient-elle ? | 👁 observe | — | | 3 | Trouve la sortie d'un tenant | : dans make devis-sdn, repère la strophe FRR et l'adresse du prochain saut. À quel équipement appartient-elle ? | 👁 observe | — |
| 4 | Change `index` dans un modèle | (jamais en production) et régénère : combien de valeurs ont bougé ? C'est la mesure exacte de ce que la dérivation t'épargne. Pour aller plus loin : docs/sdn-evpn.md (référence technique), Le plan & l'adressage dérivé, Multi-instance & f… | 👁 observe | — | | 4 | Change `index` dans un modèle | (jamais en production) et régénère : combien de valeurs ont bougé ? C'est la mesure exacte de ce que la dérivation t'épargne. Pour aller plus loin : docs/sdn-evpn.md dans le dépôt (référence technique), Le plan & l'adressage dérivé, Mult… | 👁 observe | — |
*Source : [Le réseau des tenants — du câble au VRF](../../wiki/Le-r%C3%A9seau-des-tenants.md) · § À toi de jouer.* *Source : [Le réseau des tenants — du câble au VRF](../../wiki/Le-r%C3%A9seau-des-tenants.md) · § À toi de jouer.*

View file

@ -0,0 +1,96 @@
# Preuve de conformite — Set-OPS — 2026-09-08
> Genere par `make prouver` (`scripts/prouver.py`). **Rejouable** : relancer
> reproduit ce rapport. Chaque preuve rejoue l'outillage existant du depot ;
> aucune validation n'est reimplementee ici. Voir le mode d'emploi :
> [`docs/audit/README.md`](README.md), et le registre trace :
> [`docs/audit/affirmations.md`](affirmations.md).
- **Instance** : `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance` — inventaire `/home/danallaire/Espace Chezlepro/DépôtsSurForge/Set-OPS-public/instance/inventories/principal/hosts.yml`
- **Verdict** : ❌ NON CONFORME (58 OK · 1 echec · 1 saute)
## Preuves
| # | Preuve | Affirmations | Statut | Detail |
|---|---|---|---|---|
| P01 | Lint (ansible-lint) | AFF-006 | ✅ OK |  |
| P02 | Tests unitaires (inventory_host) | — | ✅ OK | >>> le verrou tient : aucune VM n'aurait ete touchee |
| P03 | Diff-vide du plan — TOUTES les instances | AFF-001, AFF-004, AFF-030, AFF-031, AFF-032 | ✅ OK | 4 instance(s) verifiee(s) — OPS-Chezlepro-lab, OPS-Chezlepro, OPS-Technolibre, OPS-Patient0 : plan et inventaire applique coincident. |
| P04 | Groupes <-> playbooks homonymes | AFF-008 | ✅ OK | |
| P05 | Dependances causales de groupes | AFF-009, AFF-084 | ✅ OK | |
| P06 | Validateurs de registres (serveurs/apps/bases/domaines) | AFF-003 | ✅ OK | Registre des domaines valide. |
| P07 | GUI (node --check) | AFF-033 | ✅ OK | JS du GUI : syntaxe valide (node --check). |
| P08 | Orchestration (couches + graphe) | AFF-070 | ✅ OK | Orchestration coherente : 40 groupes classes, aucun cycle, aucune arete en arriere. |
| P09 | Flux reseau (schema + matrice) | AFF-071 | ✅ OK | Flux coherents : 38 rôles, 99 flux, schéma + matrice OK. |
| P10 | Handlers <-> notify | AFF-034, AFF-035 | ✅ OK | Tout notify pointe vers un handler du meme role (49 roles). |
| P11 | Syntaxe des playbooks (--syntax-check) | AFF-083 | ✅ OK | serveur_resolveur_site |
| P12 | Existence des runbooks cites | AFF-010, AFF-011, AFF-012, AFF-083 | ✅ OK | 17/17 runbooks/registres cites presents. |
| P13 | Invariants structurels/doctrinaux | AFF-015, AFF-022, AFF-037, AFF-038, AFF-062 | ✅ OK | LICENSE, socle dossier, pas de couches paralleles, SSH clef-only, nftables off : OK. |
| P14 | Pas de chemin lab/ code en dur | AFF-097 | ✅ OK | Aucun chemin instance/inventories/lab/group_vars code en dur. |
| P15 | Modele public socle valide | AFF-022, AFF-099 | ✅ OK | Modele public socle : domaines/serveurs/applications/bases valides. |
| P16 | Inventaire Ansible complet (--list) | AFF-030 | ⚪ SAUTE | Voute chiffree sans ANSIBLE_VAULT_PASSWORD_FILE (prerequis AFF-026). |
| P17 | Tous les modeles valident (registres + underlay) | AFF-022, AFF-099 | ✅ OK | Les 1 modele(s) decouvert(s) valident. |
| P18 | Gabarit de voute complet | AFF-026 | ✅ OK | Gabarit de voute complet : 23 secret(s) exige(s), tous presents. (Voute reelle non lisible ici : verification sautee.) |
| P19 | Le GUI couvre le schema du plan | AFF-002, AFF-095 | ✅ OK | GUI : les 28 champ(s) des plans reels sont editables (2 plan(s) inspecte(s)), registres toleres : nomenclature. |
| P20 | Adressage 100% derive du seed (aucun stocke) | AFF-001, AFF-003 | ✅ OK | 2 nomenclature(s) : adressage 100% derive du seed index. |
| P21 | Federation : aucun index en collision | AFF-102 | ✅ OK | Federation coherente : 3 instance(s) federee(s), aucun index en collision. |
| P22 | Plan de recette a jour (genere du wiki) | AFF-002 | ✅ OK | Plan de recette à jour (22 sections). |
| P23 | Underlay sans collision avec la plage tenant | AFF-103 | ✅ OK | Underlay conforme : 13 reseau(x), aucune collision avec la plage tenant. |
| P24 | Frontiere nord/sud : acces d'administration declare | AFF-104 | ✅ OK | note : serveur_powerdns declare un port `derive` que le plan du site ne resout pas — aucune regle emise. |
| P25 | Pare-feu Proxmox : est-ouest intra-tenant derive | AFF-107 | ✅ OK | CONFORME : pare-feu Proxmox, 3 tenant(s), 50 groupe(s), 80 regle(s). |
| P26 | Integrations universelles : aucun hote laisse de cote | AFF-108 | ✅ OK | 14 hote(s) x 5 integration(s) universelle(s) : aucune lacune, aucune recopie (0 exemption(s) derivee(s) du service rendu). |
| P27 | Propriete des intrants : hebergeur et tenant separes | AFF-109 | ✅ OK | 8 cle(s) de cluster chez l'hebergeur, aucune recopiee dans les group_vars du tenant. |
| P28 | Pools Proxmox : un par tenant, sans collision | AFF-110 | ✅ OK | CONFORME : 3 pool(s) Proxmox, 34 VM placee(s), aucun nom ni VMID en collision. |
| P29 | Authentification : chaque role declare sa position | AFF-111 | ✅ OK | 32 role(s) serveur declares (interne-sans-auth 2, ldap-direct 2, sans-auth-humaine 21, socle-identite 2, web-sso 5) ; 2 lacune(s) nommee(s) : serveur_loki, serv |
| P30 | SDN EVPN : zones, VNets et sous-reseaux derives | AFF-112 | ✅ OK | CONFORME : SDN EVPN, 3 zone(s), 15 VNet(s), 15 sous-reseau(x), aucune collision. |
| P31 | Documentation : tout ce que le depot FAIT est nomme | — | ✅ OK | 57 scripts expliques et atteignables, 114 cibles make documentees, 65 roles avec README. |
| P32 | Intrants exiges par les roles : tous fournis | — | ✅ OK | CONFORME : 37 exigence(s) de role, toutes satisfaites (131 cle(s) declaree(s) par l'instance). |
| P33 | Aucune collision de port entre roles co-localises | — | ✅ OK | CONFORME : 33 revendication(s) de port, aucune collision entre roles co-localises (37 groupes). |
| P34 | Chaque document declare son lecteur | — | ✅ OK | 43 document(s) declarent leur lecteur (34 genere(s) exempte(s)). |
| P35 | Toute application exigeant une base en a une au plan | — | ✅ OK | 5 application(s) exigeant une base l'ont toutes (4 entree(s) au registre). |
| P36 | Tout detenteur d'etat porte une sauvegarde | — | ✅ OK | 9 hote(s) de l'ecosysteme et 3 du site detiennent de l'etat, tous porteurs de `client_backup` (9 groupe(s) au catalogue). |
| P37 | Le placement du tenant existe chez son hebergeur | — | ✅ OK | placement confronte a l'hebergeur monte (SITE-Chezlepro) : noeud, stockage, pont — tous offerts. |
| P38 | Catalogue des services : la carte dit ce que le moteur fait | — | ✅ OK | Catalogue a jour : 39 role(s) serveur/client tous nommes, 40 groupe(s) cite(s) en table existent tous. |
| P39 | Glossaire : tout mot employe est enseigne | — | ✅ OK | Glossaire complet : 81 terme(s) du jargon expliques, 15 lien(s) valides, 27 page(s) de wiki toutes atteignables. |
| P40 | Parente : l'ecosysteme sait de quoi il descend | — | ✅ OK | Parente coherente : 4 depot(s), tous retrouves, tous porteurs d'un remote. |
| P41 | Resolution d'instance : une seule, partagee | — | ✅ OK | Resolution unique : 53 script(s) passent par `inventory_rules`, 3 exemption(s) nommee(s). |
| P42 | L'edge porte les noms qu'il publie | — | ✅ OK | 4 edge(s) emettent un certificat portant les noms publies (OPS-Chezlepro-lab/principal, OPS-Chezlepro/principal, OPS-Technolibre/principal, OPS-Patient0/product |
| P43 | Frontiere : le devis voit les machines du site | — | ✅ OK | Devis de la frontiere : 7 machine(s) du plan retrouvees, 104 regle(s) du site. |
| P44 | Integrations : le serveur avant ses clients | — | ✅ OK | 4 integration(s) appliquent leur serveur avant leurs clients. |
| P45 | Pare-feu Proxmox : arme sur les VNet SDN, jamais ailleurs | — | ✅ OK | Le pare-feu Proxmox ne s'arme que sur un VNet SDN (4 cas evalues, dont un qui doit rendre VRAI). |
| P46 | Plancher /etc/hosts : un seul role en decide | — | ✅ OK | Un seul maitre du plancher — roles/hosts_statiques/tasks/main.yml : manage_etc_hosts: false ; et le gabarit maitre est pose (roles/hosts_statiques/templates/hos |
| P47 | Zones inverses : couvrir l'occupe, et rien de plus | — | ✅ OK | Les zones inverses couvrent l'occupe et rien de plus (5 cas evalues, dont un site a quatre zones et un tenant a une). |
| P48 | La carte d'orientation designe ce qui existe, et compte juste | — | ✅ OK | La carte designe 87 chemin(s) qui existent, et ses 7 chiffres correspondent a la mesure. |
| P49 | Registre des flux : la matrice d'audit est a jour | — | ✅ OK | Le registre des flux reproduit exactement ce que les `meta/flux.yml` declarent (117 lignes). |
| P50 | Silences : un refus muet est declare, place en dernier, et motive | — | ✅ OK | 2 silence(s) declare(s), tous en sequence > 1 (la plus haute des 169 regles `pass`), tous non consignes et tous motives. |
| P51 | Collections : toutes declarees, toutes epinglees | — | ✅ OK | 3 collection(s) et 2 bibliotheque(s) Python declarees et epinglees : ansible.posix==1.6.2, community.general==10.3.0, community.postgresql==3.10.2 |
| P52 | Materialiser n'exige pas d'entrer dans le tenant | — | ✅ OK | `creer-vm` confirme par l'agent invite (API des hyperviseurs, deja utilisee pour creer), sans exiger d'entrer dans le tenant. |
| P53 | L'interne refuse a voix haute, la bordure se tait | — | ✅ OK | L'interne parle, la bordure se tait — 15 ruleset(s) nftables refusent a voix haute ; pare-feu est-ouest en REJECT, source unique ; frontiere muette (actions : b |
| P54 | L'insemination ne reclame aucun secret du tenant | — | ✅ OK | 2 couche(s) d'insemination (serveur_debian, serveur_ops), 10 role(s) applique(s), aucun secret de tenant reclame. |
| P55 | La cle du SITE ne nait que sur le runner d'un tenant | — | ✅ OK | 14 hote(s) : la cle du SITE ne nait que sur 1 runner(s) de tenant, celle du tenant sur 14. |
| P56 | Gabarit minimal, et rien de retire n'est perdu | — | ✅ OK | Gabarit minimal : 4 role(s), tous indispensables au premier demarrage ; 14 role(s) retire(s), tous repris par le socle ou le durcissement. |
| P57 | Comptes en prose : les chiffres du depot sur lui-meme | — | ✅ OK | Les comptes ecrits en prose correspondent a la mesure (60 preuves, 65 roles, 40 groupes). |
| P58 | Habilitations : chaque service dit a quel GROUPE, et par quoi | — | ✅ OK | 8 habilitation(s) declarees, toutes nommant un groupe, un mecanisme connu et une raison ; les `role-realm` sont projetees. |
| P59 | Enumerations annoncees : le nombre correspond a ce qui suit | — | ✅ OK | 2 enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent (formes non ambigues seulement). |
| P60 | Wiki publie : la forge sert ce que le depot dit | AFF-002 | ❌ ECHEC | Le wiki en ligne est EN RETARD : 9 commit(s) ont touche `wiki/` depuis la publication de `ac85278` (2026-08-10), le dernier etant 36b6926 2026-09-07. Ce que la |
## Couverture des affirmations ✅ du registre
Chaque affirmation ✅ automatisable est couverte par la preuve indiquee ci-dessus.
Les ✅ **structurelles/doctrinales** non rejouables par une commande (ex. AFF-005
`make`=aide, AFF-014 ciblage groupe, AFF-024 `instancier-appliquer`, AFF-051 autorite
d'AGENTS.md, AFF-073/075 gardes `make`, AFF-090 wiki) ont ete verifiees a l'audit ;
elles restent hors du harnais recurrent (rien d'executable a rejouer).
## Declarations d'intention (⚪ invérifiables localement — assumees)
Ces affirmations ne sont pas rejouables hors production ; elles sont **assumees**
comme declarations d'intention, non comme preuves :
- **AFF-036** — « testables avec `--check` autant que possible » : verifiable seulement
contre une flotte vivante.
- **AFF-091** — contenu pedagogique du wiki : affirmations conceptuelles.
- **AFF-096** — « GUI 100 % francais » : revue exhaustive des libelles rendus, non automatisee.
- **AFF-007** — hote d'exemple `web-frontal-01` : placeholder assume.
_Rapport genere le 2026-09-08._

View file

@ -0,0 +1,18 @@
---
# TEMOIN DE PUBLICATION DU WIKI — ecrit par `make wiki-publier`, lu par la preuve P60.
#
# POURQUOI UN TEMOIN, ET PAS UNE MESURE DIRECTE (2026-09-07). Le harnais est STATIQUE :
# il lit le depot, sans un seul appel reseau, et c'est ce qui le rend rejouable partout et
# par n'importe qui. Cloner le wiki de la forge pour le comparer romprait cette propriete
# — la seule qui fait qu'une preuve vaut quelque chose hors du poste de l'exploitant.
#
# On enregistre donc, A LA PUBLICATION, le commit du depot qui a ete publie. P60 compare
# ensuite ce commit a l'etat de `wiki/` : si le dossier a bouge depuis, le wiki en ligne
# est en retard, et elle le dit. Meme discipline que `docs/audit/preuve-<date>.md`.
#
# VALEUR D'AMORCAGE, MESUREE et non supposee : le wiki d'eregion portait le commit
# b6167f2, dont le message est « Publication du wiki depuis le depot (source: ac85278) ».
# C'est donc ac85278 qui a ete publie, le 2026-08-10.
remote: ssh://git@eregion.chezlepro.ca:2222/Alliance-Boreale/Set-OPS-Public.wiki.git
source: ac85278
date: 2026-08-10

View file

@ -105,6 +105,10 @@ inventée, un `web-sso` sans accès de secours ou sans posture de formulaire, un
natif` sans réglage `<rôle>_connexion_locale` **défini dans `defaults`**, et une natif` sans réglage `<rôle>_connexion_locale` **défini dans `defaults`**, et une
déclaration que le code contredit. déclaration que le code contredit.
**Et depuis le 2026-09-07, sa sœur `P58` garde les habilitations** — ce que P29 ne fait
pas : elle tient les *positions* (qui s'authentifie comment), pas les *droits* (qui obtient
quoi). Voir [`autorisation.md`](autorisation.md) §5.
**Depuis le 2026-09-06, elle refuse aussi que ce tableau mente.** Les chiffres et les listes **Depuis le 2026-09-06, elle refuse aussi que ce tableau mente.** Les chiffres et les listes
de la troisième colonne sont confrontés aux déclarations réelles. Ils avaient cessé d'être de la troisième colonne sont confrontés aux déclarations réelles. Ils avaient cessé d'être
vrais sans que rien ne le signale : la ligne `sans-auth-humaine` annonçait 12 rôles, il y en vrais sans que rien ne le signale : la ligne `sans-auth-humaine` annonçait 12 rôles, il y en

View file

@ -403,11 +403,24 @@ en fonctionnement. L'état réel :
|---|---| |---|---|
| Le rôle d'amorçage | **`roles/amorcage_acces/`** — écrit, déployé, et c'est lui qui crée l'unique compte `sysadmin` du §3 | | Le rôle d'amorçage | **`roles/amorcage_acces/`** — écrit, déployé, et c'est lui qui crée l'unique compte `sysadmin` du §3 |
| Les `meta/acces.yml` | **écrits pour les cinq services `web-sso`** : `serveur_forgejo`, `serveur_grafana`, `serveur_icingaweb2`, `serveur_keycloak`, `serveur_nextcloud` | | Les `meta/acces.yml` | **écrits pour les cinq services `web-sso`** : `serveur_forgejo`, `serveur_grafana`, `serveur_icingaweb2`, `serveur_keycloak`, `serveur_nextcloud` |
| La preuve | **toujours à écrire** — c'est la seule ligne d'origine qui tient | | La preuve | **écrite le 2026-09-07 — c'est P58** |
La lacune restante mérite d'être nommée pour ce qu'elle est. Le §5 de Le §5 de [`authentification.md`](authentification.md) formule la règle : *une directive
[`authentification.md`](authentification.md) le formule ainsi : *une directive qu'aucune qu'aucune garde ne vérifie finit par ne plus être vraie*. `meta/acces.yml` a été dans ce cas
garde ne vérifie finit par ne plus être vraie*. `meta/acces.yml` est exactement dans ce cas jusqu'au 2026-09-07 : rien ne vérifiait qu'un service `web-sso` en porte un. **P29** gardait
— rien ne vérifie qu'un service `web-sso` en porte un, ni que le groupe qu'il nomme existe les *positions* d'authentification ; **P58** garde désormais les *habilitations*.
dans l'annuaire. **P29** garde les *positions* d'authentification ; personne ne garde encore
les *habilitations*. Ce qu'elle exige :
| | |
|---|---|
| tout rôle `web-sso` porte un `meta/acces.yml` | **sauf** `formulaire_local: aucun` — une passerelle authentifie devant, elle n'accorde rien. L'exemption est **dérivée** de la déclaration, jamais un nom en dur |
| chaque entrée nomme `groupe`, `accorde`, `porte_par`, `raison` | `porte_par` existe pour rendre visible, dans la déclaration même, **ce qui n'est pas câblé** |
| `groupe` est un groupe, pas un compte | ni `@`, ni `uid=` — c'est D-66, tenu par une machine |
| une habilitation `porte_par: role-realm` est **réellement projetée** par `serveur_keycloak` | c'est le croisement qui porte la preuve : sans lui, un service peut annoncer une habilitation que rien ne transporte, et l'écran reste vide sans que personne sache pourquoi |
> **Ce qu'elle n'exige PAS, et c'est le point le plus important.** Elle ne demande pas que
> les groupes nommés existent dans l'annuaire. Ce serait contredire le §2 : le dépôt
> **amorce** un accès et se retire ; les appartenances appartiennent à une personne.
> `amorcage_acces` ne crée qu'un groupe — `dev` et `personnel` sont créés par l'exploitant,
> et leur absence du code n'est pas un défaut, c'est le régime.

View file

@ -26,7 +26,7 @@ README de rôles). Cette page comble ces deux trous.
| rôles | 65 | `roles/*/` | | rôles | 65 | `roles/*/` |
| README de rôles | 65 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette | | README de rôles | 65 | `roles/*/README.md` — l'écart avec la ligne au-dessus est la dette |
| documents | 39 | `docs/*.md` | | documents | 39 | `docs/*.md` |
| pièces d'audit | 36 | `docs/audit/*` | | pièces d'audit | 38 | `docs/audit/*` |
| unités de wiki | 27 | `wiki/*.md` | | unités de wiki | 27 | `wiki/*.md` |
| décisions en vigueur | 80 | lignes `\| **D-nn** \|` de `decisions-architecture.md` | | décisions en vigueur | 80 | lignes `\| **D-nn** \|` de `decisions-architecture.md` |
| décisions renversées | 3 | lignes `\| **D-nn** —` du même document | | décisions renversées | 3 | lignes `\| **D-nn** —` du même document |

View file

@ -30,7 +30,7 @@ make placement-plan # chaque VM est-elle là où le plan la met
## Le trou qu'il comble ## Le trou qu'il comble
`scripts/prouver.py` porte 57 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le `scripts/prouver.py` porte 60 preuves (dont une conditionnelle, sautée sans la clé de la voûte). Elles sont toutes **statiques** : elles lisent le
dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est dépôt. Zéro appel réseau, zéro SSH, zéro `ansible`. Elles établissent que le dépôt est
cohérent **avec lui-même** — que les handlers existent, que les intrants ont un cohérent **avec lui-même** — que les handlers existent, que les intrants ont un
propriétaire, que rien n'est codé en dur. propriétaire, que rien n'est codé en dur.

View file

@ -178,6 +178,311 @@ def preuve_modele_socle() -> tuple[bool, str]:
PORTEES_AUTH = {"web-sso", "socle-identite", "ldap-direct", "interne-sans-auth", PORTEES_AUTH = {"web-sso", "socle-identite", "ldap-direct", "interne-sans-auth",
"sans-auth-humaine"} "sans-auth-humaine"}
# LES MECANISMES QUI PORTENT REELLEMENT UNE HABILITATION (meta/acces.yml, champ
# `porte_par`). Le champ existe pour rendre visible, DANS LA DECLARATION, ce qui n'est
# pas cable : sans lui, « le groupe sysadmin donne Admin » se lit comme un fait alors que
# c'est parfois un souhait. Un mecanisme hors de ce jeu est donc refuse — inventer un nom
# de transport, c'est se donner l'air d'avoir cable quelque chose.
MECANISMES_ACCES = {
"defaut", # tout utilisateur authentifie l'obtient, sans rien porter
"claim-groupe", # le claim `groups` du jeton, lu par l'application
"role-realm", # un role de realm Keycloak, projete depuis un groupe LDAP
"role-client", # un role d'un client OIDC (ex. realm-management)
"groupe-annuaire", # l'application lit l'annuaire directement
"group-ldap-mapper", # le mapper Keycloak qui projette un groupe en role
}
NOMBRES_FR = {"deux": 2, "trois": 3, "quatre": 4, "cinq": 5, "six": 6, "sept": 7,
"huit": 8, "neuf": 9, "dix": 10, "onze": 11, "douze": 12, "treize": 13,
"quatorze": 14, "quinze": 15, "seize": 16}
def _enumeration_apres(lignes: list[str], i: int) -> tuple[str | None, int]:
"""Le bloc qui suit la ligne `i` est-il une table ou une liste, et de combien ?"""
j = i + 1
while j < len(lignes) and not lignes[j].strip():
j += 1
if j >= len(lignes):
return None, 0
s = lignes[j].lstrip()
if s.startswith("|"):
k = j + 2 if j + 1 < len(lignes) and set(lignes[j + 1].strip()) <= set("|-: ") else j
n = 0
while k < len(lignes) and lignes[k].lstrip().startswith("|"):
n += 1
k += 1
return "table", n
if re.match(r"^([-*+]|\d+\.)\s", s):
n, k = 0, j
while k < len(lignes):
u = lignes[k].lstrip()
if re.match(r"^([-*+]|\d+\.)\s", u):
n += 1
elif not lignes[k].strip() or lignes[k][:1] in " \t":
pass
else:
break
k += 1
return "liste", n
return None, 0
def _lignes_premiere_table(texte: str) -> int:
"""Lignes de DONNEES de la PREMIERE table markdown d'un texte.
« Premiere », et c'est une correction de l'instrument, pas de la cible : le §7 de
`preparer-un-site-hebergeur.md` porte DEUX tables. En les additionnant, la garde
annoncait un ecart la ou il n'y en avait pas — elle accusait le document de ce
qu'elle mesurait mal.
"""
n, dans = 0, False
for l in texte.splitlines():
s = l.lstrip()
if s.startswith("|"):
if not dans:
dans = True # l'en-tete
continue
if set(s) <= set("|-: "):
continue # le separateur
n += 1
elif dans:
break
return n
def preuve_wiki_publie_a_jour() -> tuple[bool, str]:
"""Le wiki publie sur la forge correspond a `wiki/` dans le depot.
LA LACUNE QUE CETTE PREUVE COMBLE (2026-09-07). Le wiki est publie DEPUIS le depot :
« on lit la-bas, on ecrit ici ». Rien ne mesurait l'ecart entre les deux, et il s'est
creuse de VINGT-SEPT JOURS sans que personne ne le voie — deux unites n'avaient jamais
ete publiees, vingt et une differaient. Pour qui lit le wiki plutot que le depot,
toute une revision de documentation n'existait pas.
POURQUOI UN TEMOIN PLUTOT QUE LA MESURE DIRECTE. Le harnais est STATIQUE : il lit le
depot, zero appel reseau. C'est ce qui le rend rejouable partout, par n'importe qui,
et presentable comme piece justificative. Cloner le wiki de la forge pour comparer
romprait cette propriete — la seule qui fasse qu'une preuve vaille hors de ce poste.
`make wiki-publier` depose donc `docs/audit/wiki-publie.yml` : le commit du depot qui
a ete publie. Cette preuve compare ce commit a l'etat de `wiki/`.
CE QU'ELLE NE PROUVE PAS, ET IL FAUT LE SAVOIR AVANT DE S'Y FIER. Elle prouve que le
depot n'a pas bouge depuis la derniere publication REUSSIE — pas que la forge porte
bien ce contenu. Si quelqu'un edite une page dans l'interface de la forge, le temoin
ne le voit pas ; c'est la publication suivante qui l'ecrase, et c'est la regle du
depot. Un temoin dit ce qui est PARTI, jamais ce qui est ARRIVE.
"""
temoin = RACINE / "docs" / "audit" / "wiki-publie.yml"
if not temoin.is_file():
return False, ("Aucun temoin de publication (docs/audit/wiki-publie.yml) : on ne "
"peut pas savoir si le wiki en ligne correspond au depot. "
"Publier — `make wiki-publier WIKI_REMOTE=<url>.wiki.git` — depose "
"le temoin.")
import yaml as _yaml
d = _yaml.safe_load(temoin.read_text(encoding="utf-8")) or {}
publie = str(d.get("source", "")).strip()
if not publie:
return False, "docs/audit/wiki-publie.yml sans clef `source` : temoin inexploitable."
def _git(*a: str) -> str:
return subprocess.run(["git", "-C", str(RACINE), *a],
capture_output=True, text=True).stdout.strip()
if _git("cat-file", "-t", publie) != "commit":
return False, (f"Le temoin designe le commit `{publie}`, que ce depot ne connait "
f"pas — une histoire reecrite, ou un temoin d'un autre depot.")
# Les commits qui ont touche `wiki/` APRES celui qui a ete publie.
depuis = [l for l in _git("log", "--oneline", f"{publie}..HEAD", "--", "wiki/").splitlines() if l]
if not depuis:
return True, (f"Le wiki publie correspond au depot : `wiki/` n'a pas bouge depuis "
f"`{publie}` (publie le {d.get('date', '?')}).")
dernier = _git("log", "-1", "--format=%h %ad", "--date=short", "--", "wiki/")
return False, (f"Le wiki en ligne est EN RETARD : {len(depuis)} commit(s) ont touche "
f"`wiki/` depuis la publication de `{publie}` ({d.get('date', '?')}), "
f"le dernier etant {dernier}. Ce que la forge sert n'est plus ce que le "
f"depot dit. Republier : `make wiki-publier WIKI_REMOTE=<url>.wiki.git`.")
def preuve_enumerations_annoncees() -> tuple[bool, str]:
"""Un nombre qui annonce une enumeration correspond a ce qui suit.
POURQUOI, ALORS QUE P57 GARDE DEJA DES COMPTES (2026-09-07). P57 tient trois comptes
NOMMES — preuves, roles, groupes — parce qu'ils se mesurent contre le depot. Les deux
ecarts trouves a la main pendant la tournee de documentation etaient d'une autre
forme : un nombre qui compte ce que le document lui-meme enumere.
README.md « Il y en a cinq : » au-dessus d'une table de SIX portes
implanter-... « les huit lignes du §7 de … » pour une fiche qui en compte DIX
CE QUE CETTE PREUVE NE FAIT PAS, ET POURQUOI ELLE SERAIT NUISIBLE AUTREMENT. Une
premiere version acceptait toute ligne finissant par « : » et contenant un nombre en
toutes lettres. Elle a signale CINQ ecarts sur le corpus, et les cinq etaient du
bruit : dans « reprise dans les deux devis : » ou « de quatorze machines : », le
nombre qualifie autre chose que l'enumeration qui suit. Cent pour cent de faux
positifs — exactement la preuve qui crie sur un cas sain et qu'on apprend a ignorer.
Elle ne retient donc que les DEUX formes ou le nombre ne peut compter rien d'autre :
A. « Il y en a <N> : » / « Il y a <N> … : » suivi d'une table ou d'une liste ;
B. « les <N> lignes du §<S> de <autre document.md> » — un renvoi chiffre vers la
premiere table de cette section-la.
Une annonce enfouie au milieu d'une phrase reste hors de portee, et c'est assume :
mieux vaut une garde etroite qui dit vrai qu'une large qui devine.
"""
docs = (sorted((RACINE / "docs").glob("*.md")) + sorted((RACINE / "wiki").glob("*.md"))
+ [RACINE / "AGENTS.md", RACINE / "README.md", RACINE / "QUICKSTART.md"])
mot = "|".join(NOMBRES_FR)
forme_a = rf"\bil y (?:en )?a\s+(?:\*\*)?({mot})(?:\*\*)?\b[^:]{{0,20}}:\s*$"
forme_b = rf"\bles\s+(?:\*\*)?({mot})(?:\*\*)?\s+lignes\s+du\s+§\s*(\d+)"
ecarts: list[str] = []
vus = 0
for d in docs:
if not d.is_file() or "/audit/" in str(d):
continue
texte = d.read_text(encoding="utf-8")
lignes = texte.splitlines()
for i, l in enumerate(lignes):
m = re.search(forme_a, l, re.I)
if not m:
continue
genre, n = _enumeration_apres(lignes, i)
if not genre:
continue
vus += 1
annonce = NOMBRES_FR[m.group(1).lower()]
if n != annonce:
ecarts.append(f"{d.relative_to(RACINE)}:{i + 1} annonce {annonce}, "
f"la {genre} qui suit en compte {n}")
for m in re.finditer(forme_b, texte, re.I):
cible = re.search(r"\(([^)]+\.md)\)", texte[m.end():m.end() + 220])
if not cible:
continue
f = d.parent / cible.group(1)
if not f.is_file():
continue
sec = re.search(rf"^##\s*{m.group(2)}\..*?(?=^##\s|\Z)",
f.read_text(encoding="utf-8"), re.M | re.S)
if not sec:
continue
vus += 1
annonce = NOMBRES_FR[m.group(1).lower()]
n = _lignes_premiere_table(sec.group(0))
if n != annonce:
ecarts.append(f"{d.relative_to(RACINE)} renvoie aux {annonce} lignes du "
f"§{m.group(2)} de {cible.group(1)}, qui en compte {n}")
if ecarts:
return False, ("Des enumerations annoncees ne disent plus vrai :\n - "
+ "\n - ".join(ecarts))
return True, (f"{vus} enumeration(s) annoncee(s) correspondent a ce qu'elles annoncent "
f"(formes non ambigues seulement).")
def preuve_habilitations_declarees() -> tuple[bool, str]:
"""Chaque service qui accorde quelque chose declare A QUI, et par quel mecanisme.
LA LACUNE QUE CETTE PREUVE COMBLE (2026-09-07). `docs/autorisation.md` posait la
regle — un service nomme un GROUPE, jamais une personne (D-66) — et `meta/acces.yml`
la portait. Rien ne la verifiait : ni qu'un service `web-sso` en porte un, ni que le
mecanisme annonce existe. Le §5 d'`authentification.md` dit pourtant l'inverse en
toutes lettres : *une directive qu'aucune garde ne verifie finit par ne plus etre
vraie*. P29 gardait les POSITIONS d'authentification ; personne ne gardait les
HABILITATIONS.
CE QU'ELLE N'EXIGE PAS, ET C'EST LE POINT LE PLUS IMPORTANT. Elle ne demande PAS que
les groupes nommes existent dans l'annuaire. Ce serait contredire la decision
centrale du document (§2) : le depot AMORCE un acces et se retire ; les appartenances
appartiennent a une personne, et le depot n'a pas a les reconcilier. `amorcage_acces`
ne cree qu'un seul groupe ; `dev` et `personnel` sont crees par l'exploitant, et leur
absence du code n'est pas un defaut — c'est le regime.
CE QU'ELLE EXIGE :
1. tout role `web-sso` porte un `meta/acces.yml` — SAUF `formulaire_local: aucun`,
qui designe une passerelle : elle authentifie devant, elle n'accorde rien. Le cas
est DERIVE de la declaration, jamais un nom en dur ;
2. chaque entree nomme `groupe`, `accorde`, `porte_par` et `raison` ;
3. `porte_par` vient de `MECANISMES_ACCES` ;
4. `groupe` ressemble a un groupe et non a un compte — ni `@`, ni `uid=`, ni le nom
du compte d'amorcage lui-meme ;
5. LE CROISEMENT QUI PORTE LA PREUVE : une entree `porte_par: role-realm` dit que
l'habilitation voyage par un role de realm PROJETE depuis un groupe LDAP. Ce
n'est vrai que si `serveur_keycloak` projette effectivement ce groupe
(`acces_projection`). Sans ce controle, un service peut annoncer une habilitation
que rien ne transporte, et l'ecran restera vide sans que personne ne sache
pourquoi — un chèque vert sur un perimetre vide, exactement.
"""
import yaml as _yaml
roles = RACINE / "roles"
fautes: list[str] = []
declarees: list[str] = []
def _acces(nom_role: str) -> dict:
f = roles / nom_role / "meta" / "acces.yml"
if not f.is_file():
return {}
return _yaml.safe_load(f.read_text(encoding="utf-8")) or {}
# Le compte d'amorcage : un `groupe` qui vaudrait ce nom-la designerait une PERSONNE.
amorc = _yaml.safe_load(
(roles / "amorcage_acces" / "defaults" / "main.yml").read_text(encoding="utf-8")) or {}
compte_amorcage = str(amorc.get("amorcage_acces_uid", "")).strip()
# Ce que Keycloak projette reellement : groupe LDAP -> role(s) de realm.
projetes = {str(e.get("groupe", "")).strip()
for e in (_acces("serveur_keycloak").get("acces_projection") or [])}
for d in sorted(x for x in roles.iterdir() if x.is_dir() and x.name.startswith("serveur_")):
meta = d / "meta" / "authentification.yml"
if not meta.is_file():
continue
decl = ((_yaml.safe_load(meta.read_text(encoding="utf-8")) or {})
.get("authentification") or {})
portee = str(decl.get("portee", "")).strip()
passerelle = decl.get("formulaire_local") == "aucun"
entrees = _acces(d.name).get("acces") or []
if portee == "web-sso" and not passerelle and not entrees:
fautes.append(f"{d.name}: web-sso sans meta/acces.yml — il accorde quelque "
f"chose a quelqu'un, et ne dit pas a qui")
continue
if portee == "web-sso" and passerelle and entrees:
fautes.append(f"{d.name}: se declare passerelle (formulaire_local: aucun) mais "
f"porte des habilitations — l'un des deux est faux")
for e in entrees:
groupe = str(e.get("groupe", "")).strip()
ref = f"{d.name}[{groupe or '?'}]"
for champ in ("groupe", "accorde", "porte_par", "raison"):
if not str(e.get(champ, "")).strip():
fautes.append(f"{ref}: champ `{champ}` vide ou absent")
mecanisme = str(e.get("porte_par", "")).strip()
if mecanisme and mecanisme not in MECANISMES_ACCES:
fautes.append(f"{ref}: mecanisme `{mecanisme}` inconnu "
f"(attendus : {', '.join(sorted(MECANISMES_ACCES))})")
if groupe and ("@" in groupe or "uid=" in groupe
or (compte_amorcage and groupe == compte_amorcage
and groupe != str(amorc.get("amorcage_acces_groupe", "")).strip())):
fautes.append(f"{ref}: `{groupe}` designe une PERSONNE, pas un groupe (D-66)")
if mecanisme == "role-realm" and groupe and groupe not in projetes:
fautes.append(f"{ref}: annonce `role-realm`, mais serveur_keycloak ne "
f"projette pas le groupe `{groupe}` (acces_projection) — "
f"l'habilitation n'est portee par rien")
if groupe:
declarees.append(ref)
if fautes:
return False, ("Des habilitations ne disent pas vrai :\n - " + "\n - ".join(fautes[:6]))
return True, (f"{len(declarees)} habilitation(s) declarees, toutes nommant un groupe, "
f"un mecanisme connu et une raison ; les `role-realm` sont projetees.")
def preuve_authentification() -> tuple[bool, str]: def preuve_authentification() -> tuple[bool, str]:
"""Chaque role serveur_* declare sa position d'authentification, et ne ment pas. """Chaque role serveur_* declare sa position d'authentification, et ne ment pas.
@ -2446,6 +2751,12 @@ PREUVES: list[dict] = [
"refs": [], "func": preuve_gabarit_minimal_et_repris}, "refs": [], "func": preuve_gabarit_minimal_et_repris},
{"id": "P57", "titre": "Comptes en prose : les chiffres du depot sur lui-meme", {"id": "P57", "titre": "Comptes en prose : les chiffres du depot sur lui-meme",
"refs": [], "func": preuve_comptes_de_la_prose}, "refs": [], "func": preuve_comptes_de_la_prose},
{"id": "P58", "titre": "Habilitations : chaque service dit a quel GROUPE, et par quoi",
"refs": [], "func": preuve_habilitations_declarees},
{"id": "P59", "titre": "Enumerations annoncees : le nombre correspond a ce qui suit",
"refs": [], "func": preuve_enumerations_annoncees},
{"id": "P60", "titre": "Wiki publie : la forge sert ce que le depot dit",
"refs": ["AFF-002"], "func": preuve_wiki_publie_a_jour},
{"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [], {"id": "P43", "titre": "Frontiere : le devis voit les machines du site", "refs": [],
"func": preuve_devis_frontiere_du_site}, "func": preuve_devis_frontiere_du_site},
{"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [], {"id": "P33", "titre": "Aucune collision de port entre roles co-localises", "refs": [],

View file

@ -26,7 +26,7 @@ Trois idées la portent :
- **Le registre** : `docs/audit/affirmations.md` — chaque affirmation du dépôt (README, docs, - **Le registre** : `docs/audit/affirmations.md` — chaque affirmation du dépôt (README, docs,
aide `make`, GUI) reliée à une preuve et un statut (✅/🟡/❌/⚪). aide `make`, GUI) reliée à une preuve et un statut (✅/🟡/❌/⚪).
- **Le harnais** : `make prouver` rejoue les preuves automatisables (**P01–P57**, sans trou dans la série) et écrit - **Le harnais** : `make prouver` rejoue les preuves automatisables (**P01–P60**, sans trou dans la série) et écrit
`docs/audit/preuve-<date>.md`. `make verifier` les inclut : il **échoue** si une preuve échoue. `docs/audit/preuve-<date>.md`. `make verifier` les inclut : il **échoue** si une preuve échoue.
- **Chaque preuve garde une classe d'erreur.** Extrait : - **Chaque preuve garde une classe d'erreur.** Extrait :

View file

@ -156,6 +156,10 @@ ils descendent tous d'un seul nombre — donc ils ne peuvent pas se contredire.
4. **Change `index` dans un modèle** (jamais en production) et régénère : combien de 4. **Change `index` dans un modèle** (jamais en production) et régénère : combien de
valeurs ont bougé ? C'est la mesure exacte de ce que la dérivation t'épargne. valeurs ont bougé ? C'est la mesure exacte de ce que la dérivation t'épargne.
**Pour aller plus loin** : [`docs/sdn-evpn.md`](../docs/sdn-evpn.md) (référence **Pour aller plus loin** : `docs/sdn-evpn.md` dans le dépôt (référence technique),
technique), [Le plan & l'adressage dérivé](Le-plan-et-l-adressage-dérivé), [Le plan & l'adressage dérivé](Le-plan-et-l-adressage-dérivé),
[Multi-instance & fédération](Multi-instance-et-fédération). [Multi-instance & fédération](Multi-instance-et-fédération).
> *Le lien vers `docs/` était relatif (`../docs/…`) : il fonctionne dans le dépôt et
> **casse une fois le wiki publié**, la forge ne recevant que le contenu de `wiki/`. C'est
> pourquoi le corpus cite `docs/` en **texte**, jamais en lien — une seule page dérogeait.*

View file

@ -24,3 +24,14 @@ noms de fichiers `Titre-Avec-Tirets.md` deviennent « Titre Avec Tirets » dans
> `make plan-recette` à partir des sections « ④ à toi de jouer ». La preuve **P22** refuse > `make plan-recette` à partir des sections « ④ à toi de jouer ». La preuve **P22** refuse
> que ce plan soit en retard sur le wiki — modifier une unité sans régénérer fait échouer le > que ce plan soit en retard sur le wiki — modifier une unité sans régénérer fait échouer le
> harnais. > harnais.
> **Et depuis le 2026-09-07, la preuve P60 refuse que la forge soit en retard sur ce
> dossier.** `make wiki-publier` dépose un témoin (`docs/audit/wiki-publie.yml`) : le commit
> publié. P60 le compare à l'état de `wiki/`. Rien ne mesurait cet écart auparavant, et il
> s'est creusé de **vingt-sept jours** en silence — deux unités jamais publiées, vingt et
> une différentes. Pour qui lit la forge plutôt que le dépôt, toute une révision n'existait
> pas.
>
> Ce que le témoin ne dit **pas** : il dit ce qui est *parti*, jamais ce qui est *arrivé*.
> Une page éditée dans l'interface de la forge lui échappe — c'est la publication suivante
> qui l'écrase, et c'est la règle ci-dessus.