---
title: "Quatre contrats qui peuvent maintenant échouer"
subtitle: "La revue du 22 septembre laissait quatre gardes qui réussissaient trop facilement. R0, R1, R2 et R4 les rendent refusantes. R3 et un trou de R1 suivent dans un correctif ; R5 et R6 attendent."
date: 2026-09-27
time: "00:01"
author: VFG
publishedBy: cursor
status: pub
tags: ["devblog", "correctifs", "secrets", "deploiement", "ci"]
---

## TL;DR

L'audit du 22 septembre (`docs/audits/2026-09-22-revue-travail-integre.md`) mesurait des contrats qui n'échouaient pas quand ils auraient dû. Quatre lots sont sur `main` depuis la pull request 81 (commit `1aace85`) : le registre M01 n'envoie plus vers le Musée, `--save-to` refuse d'écraser le fichier de secrets, le déploiement échoue si `blog-admin` ne répond pas, et la CI lance la parité statique. Le merge a déclenché le déploiement, vert, et la nouvelle garde y a vu `blog-admin` vivant. Une relecture a ensuite trouvé un trou dans R1 ; il est bouché, avec R3, dans un correctif de suivi. R5 attend encore FG.

> Une garde qui réussit quand le service est mort ne garde rien.

## D'où viennent ces quatre lots

Le pack `docs/plans/2026-09-22-revue-correctifs/` transforme cet audit en vagues. Il ne remplace pas le plan d'architecture. Il interdit de publier, de déployer, ou de déplacer le corpus V4 sans une instruction distincte.

Ce tour demandait **R0, R1, R2 et R4**. R3 (la vérification par titre) n'était pas au mandat. R5 (43 divergences de contenu) et R6 (livraison) restent bloquées : la première attend une source de vérité nommée par FG, la seconde une instruction de déploiement. Les finitions d'usage déjà sur `main` (commit `ae42f6f`) n'ont pas été refaites. Les huit articles Configuration IA n'ont pas été republiés.

## R0 — M01 ne migre plus le Musée

Le registre disait M01 « débloqué » et demandait de commencer par `data/atlas/` ou `demo/rapport-rj-vs-cr.html`. La fiche M01 ne porte que trois familles : `sections/`, `centralisation/`, et la partie corpus de `ondes/`. L'Atlas et la comparaison RJ/CR sont la fiche Musée.

Le geste a été réécrit dans `docs/SUJETS-OUVERTS.md`. Les surfaces logiques sont livrées : `collections-index` et les trois URLs `/collections/e-chaines/`, `/collections/centralisation/`, `/collections/ondes/`. Le déplacement physique reste interdit tant que G1 n'est pas satisfait. `--delete` est toujours refusé : un ancien chemin survivrait sur le serveur. Aucun fichier de ces dossiers n'a bougé.

## R1 — `--save-to` ne vise plus la source

Le script de rotation écrivait d'abord l'environnement, puis le fichier `--save-to`, sans comparer les deux chemins. Viser le `.env` par erreur remplaçait la configuration par la seule liste des mots tournés. La sauvegarde permettait de revenir, mais le redémarrage suivant pouvait partir incomplet.

Avant toute écriture et avant tout redémarrage, la cible est refusée si elle est :

- le fichier `--env`, y compris en relatif, en absolu, ou via un lien ;
- un dossier ;
- une sauvegarde `<env>.bak-…` ;
- un fichier qui existe déjà.

Une cible neuve est écrite en mode 600. Le test de ligne de commande utilise une fixture temporaire : le fichier d'environnement reste identique, aucune sauvegarde `.bak-` n'apparaît, et la sortie ne contient ni `systemctl` ni « redémarrage des services ». Aucun secret de production, aucun VPS.

## R2 — un admin mort n'est plus un succès

Le workflow `code-deploy.yml` faisait `reload_unit blog-admin || echo …`. Le lecteur pouvait répondre pendant que la publication et le panneau étaient morts, et le job restait vert. Il ne regardait pas le port 5220.

Le redémarrage de `blog-admin` fait maintenant échouer le script. Ensuite, pendant au plus vingt secondes, `GET http://127.0.0.1:5220/admin/api/whoami` sans identifiant doit répondre **401** ou **200**. Tout autre code, ou le silence, sort en erreur **avant** la ligne « blog-read + blog-admin OK ». Aucune variante « lecteur seulement » n'a été ajoutée : elle demanderait une décision écrite et un statut partiel, pas un vert.

La preuve n'est plus seulement statique. Le merge du 24 septembre a lancé `code-deploy` (exécution 74). Le journal montre le signal envoyé à `blog-admin` à 00:40:52, puis `blog-admin /admin/api/whoami · 401` à 00:40:58 : six secondes, le délai de redémarrage de l'unité. C'est donc le nouveau process qui a répondu. Pour que ce ne soit plus une question de chance, le correctif de suivi ajoute deux secondes d'attente avant le premier essai : par la voie « signal », l'ancien process pourrait sinon répondre à la place du nouveau.

## R4 — la parité statique entre dans la CI

`npm run check:static` existait. La CI construisait `dist/` et ne le contrôlait pas. Le job lance maintenant cette recette juste après `npm run build:public`.

Décision assumée : `prepublish` ne gagne pas cette étape. La recette de livraison R6 la liste déjà à part, et c'est le job d'intégration qui doit faire échouer un export fuyard. Le script `check:static` lui-même n'a pas été assoupli.

`scripts/check-links.mjs` n'a pas été réécrit. Il n'a plus d'octet NUL littéral : le séparateur est déjà écrit `\u0000`, ce qui produit le même caractère à l'exécution et laisse le diff lisible. Le test d'hygiène le verrouille. Sept espaces de fin de ligne ont été retirés de la page Baie, et la ligne vide finale du test de dérive aussi.

La recette lancée après `prepublish` sur cette branche : 1040 fichiers dans `dist/`, 41 surfaces publiques, 31 avertissements (liens vers `/_viewer/`, absent de l'export), **0 erreur**.

## Ce que la garde a vu

`npm run prepublish`, une fois les quatre lots et leurs handoffs fusionnés : 0 test en échec, `verify-publish OK`. L'index des handoffs citait alors **182** entrées (101 closes, 6 ouvertes, 75 sans statut). Le registre a été aligné sur ce total. Cet article, ajouté ensuite, fait passer le corpus markdown de 562 à 563 fichiers : le README est recompté dans la foulée.

## Ce que la relecture a trouvé après le merge

`--save-to` refusait le fichier source, un dossier, une sauvegarde et un fichier existant. Il ne regardait pas le **dossier** de la cible. Reproduit sur une fixture : avec `--save-to dossier-absent/mots.txt`, le `.env` était réécrit et sauvegardé, puis l'écriture de la cible échouait. Le mot neuf n'était ni affiché ni enregistré. Les services n'avaient pas redémarré, la sauvegarde permettait de revenir, mais c'est exactement la cible dangereuse que R1 devait refuser. Le refus couvre maintenant un dossier absent ou non inscriptible, avant toute écriture.

R3, laissé hors du premier tour, est fait dans le même correctif : `--verify "Titre"` passe par le comparateur commun, `status` compris. Un écart s'affiche champ par champ, et « alignés » n'est imprimé qu'après une comparaison réelle.

## Ce qui reste

- **R5** : 43 divergences de contrat entre le dépôt et le servi. FG désigne la source de vérité. Pas de réécriture.
- **R6** : la livraison contrôlée n'a pas été déroulée. Le déploiement du 24 septembre est celui que déclenche un merge sur `main`, pas la matrice de recette.
- **M01** : préparation documentaire seulement. Pas de `git mv`.
- **B04** : le mode `--serve` n'a pas été exercé. Il faut un article approuvé et une instruction de publication.

## Les leçons

Un registre qui dit « commence par » choisit le périmètre à la place de la fiche. Le premier mouvement doit nommer les familles autorisées, et l'interdit à côté.

Un outil de secrets qui écrit avant de comparer ses chemins transforme une faute de frappe en indisponibilité. Le refus utile est celui qui n'a encore rien touché, y compris le redémarrage.

Un `|| echo` dans un workflow est une décision de produit : on accepte de réussir sans le service. Si ce n'est pas la décision, la ligne ne doit pas exister.

Une recette qui n'est lancée que par celui qui s'en souvient n'est pas une garde. Elle le devient le jour où la CI l'exige.