docs(knowledge): capitalisation racine — post-mortems (90_) et ADR (40_) du triage local

Intégration des propositions ciblant les fichiers racine validés.

90_debug_et_postmortem.md (9 post-mortems) :
- type métier dupliqué dans package shared + bridge ; flakiness "socket hang up" e2e
  (recyclage de port éphémère) ; rtk masque la sortie des CLI build/test ; vi.spyOn sur
  module ESM ; `as const` → TS2769 (jose) ; échec massif suite = template DB corrompu ;
  séparer 2 chantiers mélangés (barrel partagé) ; modèle Prisma fantôme (migration sans
  model) + variante effet iceberg CI

40_decisions_et_archi.md (4 ADR) :
- CI e2e mobile pas un prérequis prod ; vérifier le modèle de données réel avant spec ;
  segmenter l'auth par sensibilité d'action ; IdP Keycloak auth-only + RBAC local (Proposed)

Dédupliqué vs knowledge/ déjà écrit : les ADR/post-mortems apportent l'angle narratif/décisionnel
(le "pourquoi"/"récit de debug"), complémentaire des règles réutilisables en knowledge/, avec
cross-références. Blocs déjà couverts ailleurs (113 liste/détail) non réintégrés.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
MaksTinyWorkshop
2026-06-25 15:58:46 +02:00
parent 81fde91259
commit 2a06429898
2 changed files with 415 additions and 1 deletions
+156 -1
View File
@@ -8,7 +8,7 @@ Objectifs :
- éviter de reposer les mêmes questions - éviter de reposer les mêmes questions
- assumer les compromis - assumer les compromis
Dernière mise à jour : 2026-03-12 Dernière mise à jour : 2026-06-25
--- ---
@@ -32,6 +32,9 @@ Dernière mise à jour : 2026-03-12
- [User views — User public par défaut + MeUser explicite](#decision-user-views) - [User views — User public par défaut + MeUser explicite](#decision-user-views)
- [Mono-tenant déployable vs SaaS multi-tenant](#decision-mono-tenant-deployable) - [Mono-tenant déployable vs SaaS multi-tenant](#decision-mono-tenant-deployable)
- [Rollout MCP Lead_tech dans BMAD — Phase 2 partielle (strict ciblé)](#decision-mcp-rollout-phase-2) - [Rollout MCP Lead_tech dans BMAD — Phase 2 partielle (strict ciblé)](#decision-mcp-rollout-phase-2)
- [CI e2e mobile : pas un prérequis de mise en prod](#decision-ci-e2e-mobile)
- [Vérifier le modèle de données réel avant d'accepter une spec](#decision-modele-donnees-avant-spec)
- [Segmenter l'auth par sensibilité d'action (forte vs sans-friction)](#decision-segmentation-auth-sensibilite)
### 2. Infra ### 2. Infra
@@ -51,6 +54,7 @@ Dernière mise à jour : 2026-03-12
- [Observabilité minimale obligatoire](#decision-observabilite) - [Observabilité minimale obligatoire](#decision-observabilite)
- [Authentification et autorisation centrales](#decision-auth-central) - [Authentification et autorisation centrales](#decision-auth-central)
- [Idempotence et gestion des retries](#decision-idempotence-retries) - [Idempotence et gestion des retries](#decision-idempotence-retries)
- [IdP centralisé (Keycloak) auth-only + RBAC métier local](#decision-idp-keycloak-auth-only)
### 4. n8n ### 4. n8n
@@ -378,6 +382,110 @@ Aligner la doc sur l'implémentation : **déclarer Phase 2 partielle** avec 3 bl
--- ---
<a id="decision-ci-e2e-mobile"></a>
## CI e2e mobile : pas un prérequis de mise en prod
- Date : 2026-05-21
- Statut : Accepted
- Périmètre : global / mobile
### Contexte
Un job CI qui build l'app mobile + lance un smoke test (Maestro, Detox…) sur émulateur/simulateur est tentant, mais coûteux — surtout iOS, qui exige un runner macOS (~10× le prix d'un runner Linux, ~30-45 min/run). Tranché sur app-alexandrie : les e2e mobile (smoke Maestro iOS/Android) ont été retirés de la CI GitHub après constat que le coût ne se justifiait pas.
### Options envisagées
- Garder un job CI mobile bloquant « par principe »
- Garder une CI mobile non bloquante / limitée en coût (paths-filter, iOS sur `push main`)
- Retirer les e2e mobile de la CI et diagnostiquer en local au cas par cas
### Décision
La CI e2e mobile **n'est pas une porte vers la prod**. Pour un dev solo / petite équipe sur plan CI gratuit, retirer les e2e mobile de la CI est défendable.
### Justification
- Les stores (Apple App Review, Google Play) testent le *binaire soumis*, pas la CI GitHub. GitHub ne « refuse » jamais une publication.
- Le vrai garde-fou est le **build de release** (EAS / Xcode / Gradle), qui doit passer avant soumission.
- Un smoke test CI ne confirme que « l'app build et boote » — ce qu'un build local valide déjà. Le seul delta réel (« marche sur ma machine mais pas sur un env propre ») est déjà couvert par le build cloud EAS.
### Conséquences
- Grille de décision à réévaluer selon le contexte :
- **Dev solo / petite équipe, plan CI gratuit** → retrait défendable, smoke en local pour diagnostic ponctuel.
- **Si on garde une CI mobile** → ne pas la rendre bloquante sans données de flake calibrées (émulateurs sujets au flake) ; limiter le coût macOS via paths-filter ou réserver iOS au `push main`.
- **Équipe qui grandit / multiples contributeurs** → la CI mobile reprend de la valeur (env partagé, PR de tiers) — réévaluer.
- Anti-pattern à éviter : un job CI mobile coûteux « par principe » sans avoir identifié quelle classe de bug il attrape que le build local n'attrape pas.
---
<a id="decision-modele-donnees-avant-spec"></a>
## Vérifier le modèle de données réel avant d'accepter une spec
- Date : 2026-06-09
- Statut : Accepted
- Périmètre : global / méthode
### Contexte
Une spec ou une architecture peut supposer une capacité du modèle de données (cumul, multi-valué, relation) qui n'existe pas dans le code réel. Piège récurrent qui fait perdre du temps ou produit une implémentation fausse si on ne vérifie pas. Cas vécu RL799_V2 : l'architecture v2 (décision D1) décrivait une synchro `OfficerMandate → UserRole` supposant un **cumul de rôles**, alors que `User.role` est un enum Postgres **mono-valué** (un seul rôle par user). Dériver un rôle aurait écrasé le rôle existant → violation directe d'une NFR (cumul préservé).
### Options envisagées
- Accepter la spec telle quelle et coder sur le modèle supposé
- Ouvrir le schéma réel (Prisma/SQL) et confirmer la cardinalité du champ avant de cadrer l'implémentation
### Décision
Quand une spec suppose « ajouter un rôle / cumuler / multi-valué / relation », **ouvrir le schéma (Prisma/SQL) et confirmer la cardinalité réelle du champ AVANT de cadrer l'implémentation.**
### Justification
- La hiérarchie de règles s'applique : **contraintes réelles du code > règles archi**. Une spec ne peut pas créer une capacité que le modèle n'a pas.
- Une implémentation sur un modèle supposé produit une régression silencieuse (ici, écrasement de rôle).
### Conséquences
- Vérification systématique du schéma déclaratif avant tout cadrage qui suppose une capacité du modèle.
- Si contradiction archi ↔ code, **ne pas trancher seul** — remonter au décideur (c'est une décision produit/archi, pas technique).
---
<a id="decision-segmentation-auth-sensibilite"></a>
## Segmenter l'auth par sensibilité d'action (forte vs sans-friction)
- Date : 2026-06-13
- Statut : Accepted
- Périmètre : global / auth
### Contexte
Deux populations/usages d'une même app peuvent légitimement exiger des régimes d'auth **opposés** : une action sensible (secrétaire, trésorier, admin VM) appelle une auth forte (IdP, MFA) ; un geste trivial à fort besoin d'adoption (répondre présent, RSVP) appelle l'inverse — aucune friction. Cas vécu RL799_V2 : développement de `presence-sans-friction` (quick-link, token opaque) en parallèle d'un cap Keycloak.
### Options envisagées
- Uniformiser l'auth (même régime fort partout) → tue l'adoption du geste trivial
- Segmenter par sensibilité d'action : friction proportionnelle à la sensibilité
### Décision
**Segmenter l'auth par sensibilité d'action plutôt que d'uniformiser.** La friction acceptable est proportionnelle à la sensibilité de l'action : action sensible → auth forte ; geste trivial → token opaque sans login.
### Justification
- Ce n'est pas une contradiction mais une segmentation : exiger une auth forte sur un RSVP tue l'adoption sans gain de sécurité réel.
- Condition de coexistence : les deux régimes vivent sur des **plans orthogonaux** (routes protégées vs `/api/public/*`), et le token sans-friction est **souverain** — JAMAIS couplé au système d'auth (ni JWT-maison, ni IdP).
### Conséquences
- Test de l'invariant : « le geste sans-friction fonctionne-t-il si l'utilisateur n'a aucun compte / si l'IdP est down ? » → doit être OUI.
- Implémentation côté API : voir `pattern-surface-publique-token-opaque` dans `knowledge/backend/patterns/auth.md` (durcissement du token opaque). La présente décision en est le cadrage stratégique.
---
## 2. Infra ## 2. Infra
<a id="decision-structure-docker"></a> <a id="decision-structure-docker"></a>
@@ -903,6 +1011,53 @@ Principes :
--- ---
<a id="decision-idp-keycloak-auth-only"></a>
## IdP centralisé (Keycloak) en auth-only + RBAC métier local, pour une architecture multi-instances fédérée
- Date : 2026-06-12
- Statut : Proposed (epic futur, horizon < 1 an)
- Périmètre : backend / auth / multi-tenant
### Contexte
Application interne (loge maçonnique, RL799_V2) avec auth JWT-maison (cookie httpOnly) et un RBAC mandate-based récent (union `{role} {offices}` dérivée des `OfficerMandate`). Cap réel à < 1 an : plusieurs instances (1 loge = 1 VPS, données isolées — pas de multi-tenant DB) mais **identité d'authentification centralisée et fédérée** (un Frère membre de 2 loges = 1 identité). Besoin : auth éprouvée (MFA, standards OIDC) + fédération cross-instance, SANS sacrifier l'isolation des données ni le RBAC métier.
### Options envisagées
- **(a) Durcir l'auth maison** — ne répond pas à la fédération.
- **(b) Lib d'auth in-app (type Auth.js)** — ne fédère pas N apps.
- **(c) IdP centralisé (Keycloak / Authentik / Zitadel)** — répond au multi-instances fédéré. **Keycloak retenu** (parti pris assumé).
### Décision
Keycloak en **auth-only**. Frontière en **3 couches** :
1. credential + identité = Keycloak (login, MFA, reset, `email_verified`, first-password) ;
2. **pont = une seule colonne `User.keycloakSub`** ;
3. autorisation métier = app locale (RBAC mandate-based **inchangé**).
Le token OIDC porte UNIQUEMENT l'identité (`sub`, `email`, état civil, `iss`/`aud`/`exp`) ; **JAMAIS** de grade/office/rôle. **Invariant sacré : aucune donnée d'autorisation ne franchit vers Keycloak.**
### Justification
- Le grade/office est local par nature (un Frère peut être Vénérable en loge A et Apprenti en loge B) → ne peut pas vivre dans une identité globale.
- Point de jonction code minimal : un `keycloakTokenValidator` (JWKS/issuer/audience) remplace `verifyJwt` EN AMONT du RBAC ; le middleware RBAC et les guards ne changent pas (le `sub` remplace le `userId`).
- Simplification nette de l'app : disparition du code credential (first-password, reset, hashing) → migre côté Keycloak. Compense partiellement le coût opérationnel d'un serveur de plus.
- Provisioning **app-first** (flux d'enregistrement VM existant + Admin API Keycloak → écrit `keycloakSub`). Rattachement multi-loges par **invitation + login Keycloak prouvé** (jamais auto-rattachement par email). Dé-provision en **ref-count** : identité Keycloak supprimée seulement au départ de la DERNIÈRE loge → croise le chantier RGPD/anonymisation.
- Cohabitation avec l'auth-less : les routes guest (`/api/public/*`) restent HORS Keycloak (cf. décision « Segmenter l'auth par sensibilité d'action »).
### Conséquences
- Epic à risque → découpage en lots Go/No-Go (PoC OIDC → validation token API → pont identité → login frontend → préservation routes guest → bascule + retrait JWT-maison → déploiement Keycloak prod).
- Migration initiale : import des users existants dans Keycloak (par email) + back-fill `keycloakSub` (one-shot à blinder).
- UX : l'écran de login devient celui de Keycloak (thème Freemarker/CSS custom ou page distincte) — continuité visuelle à soigner.
- Volume multi-appartenance INCONNU → architecture prête (un même `sub` partageable par N `User`) mais flux de rattachement fluide implémenté SEULEMENT si le volume le justifie (ne pas sur-construire).
- Points de couture avec l'existant : `invitation-magic-link-v2` (entrée) + anonymisation RGPD (sortie).
- Patterns d'implémentation de la membrane fédérée déjà capitalisés : voir `pattern-membrane-auth-federee` et `pattern-pont-identite-federee` dans `knowledge/backend/patterns/auth.md`. La présente entrée en est le cadrage ADR (le « pourquoi ce choix »).
---
## 4. n8n ## 4. n8n
<a id="decision-n8n-mini-systemes"></a> <a id="decision-n8n-mini-systemes"></a>
+259
View File
@@ -383,3 +383,262 @@ services:
Conteneur `Up (healthy)` mais `docker port <container>` vide, ou `docker inspect ... NetworkSettings.Ports` à `{}` → le conteneur a été recréé sans sa config de ports à jour. Fix : `compose up -d --force-recreate` (après avoir vérifié que le réseau externe existe). Conteneur `Up (healthy)` mais `docker port <container>` vide, ou `docker inspect ... NetworkSettings.Ports` à `{}` → le conteneur a été recréé sans sa config de ports à jour. Fix : `compose up -d --force-recreate` (après avoir vérifié que le réseau externe existe).
> Risque connexe côté DNS Docker : voir `knowledge/infra/risques/docker.md`. > Risque connexe côté DNS Docker : voir `knowledge/infra/risques/docker.md`.
---
## Type métier dupliqué dans un package `shared` avec bridge de conversion
### Contexte
Projet RL799_V2, chantier `refactor/document-types-fusion` (commit `1a0398a`), le 06-05-2026.
Audit cartographique du package `@rl799/shared` : le concept `DocumentType` existait en **deux définitions concurrentes avec des valeurs différentes**, reliées par un bridge de conversion `v1ToDbType`.
### Symptômes
- Deux types nommés `DocumentType` à la racine de fichiers distincts du package partagé :
- `dto/DocumentType.ts` exporte une union V1 (`'planches' | 'planches-tracees' | …`, pluriel/kebab-case, valeurs naturelles pour les URLs publiques)
- `utils/documentPermissions.ts` exporte `DOCUMENT_TYPES = { … } as const` + `DocumentType = (typeof DOCUMENT_TYPES)[keyof …]` (V2 : `'planche' | 'planche_tracee' | …`, singulier/snake_case, valeurs DB)
- Un bridge `v1ToDbType` (côté API) + mapping symétrique (frontend, `DocumentEditModal`) faisaient l'aller-retour.
- ~15 min perdues à comprendre lequel est canonique lors de l'audit.
- Coût caché révélé : `GET /api/documents` retournait V1 alors que `POST /upload` retournait V2 — **incohérence d'API publique silencieuse**, deux endpoints renvoyant deux dialectes du même concept.
### Cause
Un package `shared` existe précisément pour empêcher la duplication de contrats. Y greffer deux dialectes du même type viole sa raison d'être. L'installation est progressive et invisible :
1. Itération 1 : type défini dans `dto/`, valeurs naturelles pour les URLs publiques.
2. Itération 2 : un autre dev a besoin du type pour la DB et le redéfinit localement avec les valeurs DB.
3. Itération 3 : un bridge `v1ToDbType` est créé pour les concilier.
4. Le mismatch est *vu* mais accepté comme « rétrocompat ».
5. Le bridge se propage à 5+ call-sites ; aucun renommage n'est tenté car « trop de choses cassent ».
C'est une dette historique déguisée en nécessité technique.
### Correctif / règle à retenir
- **Détection** : `grep -rn "export.*DocumentType\|export const DOCUMENT_TYPES" packages/shared/src` → plus d'une définition d'un même type avec des valeurs différentes = flag rouge.
- **Remédiation audit-driven (15-30 min de cartographie AVANT de coder)** :
1. lister toutes les valeurs des deux dialectes,
2. identifier qui utilise V1, qui utilise V2, où vit le bridge,
3. choisir le dialecte canonique (en général celui qui matche l'UI vivant ; la DB se migre),
4. migration SQL atomique pour aligner la DB,
5. source unique (`utils/X.ts`), ré-export depuis `dto/X.ts`,
6. suppression du bridge et de tout type fossile,
7. relancer toutes les suites avant push.
- Effort typique : ~2h pour ~25 fichiers + ~10 tests adaptés.
- **Prévention** : refuser en review tout PR qui ajoute une 2ᵉ définition d'un type métier dans le package partagé ; exiger une RFC si un nouveau dialecte est vraiment nécessaire.
---
## Flakiness « socket hang up » en e2e Jest/supertest : recyclage de port éphémère
### Contexte
app-alexandrie, story infra-8 (code review), le 21-05-2026. Une suite e2e Jest API échouait de façon non déterministe.
Complète le diagnostic partiel d'infra-6 (cycle de vie des apps) qui n'avait pas résolu le problème : infra-8 a trouvé la vraie cause racine.
### Symptômes
- Échecs non déterministes : `socket hang up`, mais AUSSI `400` (body Zod rejeté sur un body pourtant valide), `404`/`401`/`501` sur des routes existantes, parfois une suite entière qui tombe.
- Re-run isolé toujours vert.
### Fausses pistes (toutes réfutées par mesure)
- `--runInBand` : le retirer AGGRAVE (plus de serveurs HTTP simultanés) → ce n'est pas un problème de concurrence à supprimer.
- `--detectOpenHandles` : ne montre RIEN → ce n'est pas une fuite de handle.
- Apps NestJS non fermées : instrumentation `{port, closed}``open=0`, tous les serveurs sont bien fermés.
### Cause racine
Pattern `request(app.getHttpServer())` **sans** `app.listen()`. supertest fait alors `app.listen(0)` (port éphémère) à **chaque** requête. Une suite lourde qui crée une app NestJS par test (20+ apps) recycle 20+ ports éphémères. À la transition « app N libère le port P → app N+1 réobtient P », une connexion TCP initiée pile à cet instant est servie par le **mauvais** serveur ou réinitialisée → d'où les symptômes variés.
### Correctif / règle à retenir
- Helper `startE2EApp(moduleFixture, options?)` qui fait `app.listen(<port>)` **une** fois, sur un **port monotone** (compteur incrémental, jamais recyclé dans un run) + `keepAliveTimeout=0` + repli `listen(0)` si `EADDRINUSE`. Toutes les suites bootent via ce helper. Résultat : 37 runs verts consécutifs.
- **Règle** : tout projet e2e Jest/supertest qui boote des apps HTTP en boucle doit utiliser un port stable/monotone, jamais `listen(0)` implicite par requête.
- **Leçon de méthode** : ne pas s'arrêter au 1er symptôme (`socket hang up`). Les symptômes secondaires (`400` sur body valide, `501` introuvable dans le code) sont la clé : ils prouvent que la requête atteint le MAUVAIS serveur → collision de port.
---
## `rtk` (proxy de tokens) masque ou tronque la sortie des CLI de build/test
### Contexte
app-alexandrie, le 26-05-2026. Plusieurs heures perdues en faux diagnostics : `rtk` (Rust Token Killer, proxy de tokens des commandes) intercepte les CLI `jest`, `prisma`, `playwright`, `tsc` et **tronque ou vide leur sortie**.
### Symptômes
- `prisma migrate status` affiche « 0 applied / 0 pending » alors que les migrations sont bien appliquées → faux finding de code review « migrations non appliquées ».
- Sorties `jest` vides ou avec « All parsing tiers failed ».
- Runs relancés inutilement sur la base de sorties trompeuses.
### Cause
Le filtrage `rtk` n'est pas transparent pour les outils à sortie structurée volumineuse : il peut vider ou tronquer le flux, donnant l'illusion d'un échec ou d'un état vide.
### Correctif / règle à retenir
- Pour tout outil à sortie structurée critique (`jest`, `prisma`, `playwright`, `tsc` en mode diagnostic) : invoquer via `rtk proxy <cmd>` pour bypasser le filtrage, OU rediriger vers un fichier et le lire directement.
- **Ne JAMAIS conclure un diagnostic** (migration non appliquée, tests échoués, etc.) sur une sortie passée par `rtk` sans l'avoir confirmé via `rtk proxy` ou la source réelle (ex. requête `pg` directe pour l'état des migrations/index).
---
## `vi.spyOn` sur un module ESM : intercepte-t-il vraiment l'appel ?
### Contexte
Test de résilience d'une notification, RL799_V2, story v2-1-4 (review), le 13-06-2026.
Doute récurrent et coûteux : « mon `vi.spyOn` sur un module ESM intercepte-t-il vraiment l'appel fait par le code testé, qui importe la fonction en top-level ? »
### Symptôme du doute
Le code testé fait `import { batchCreateNotifications } from '...'` au top-level. Le binding semble figé, et un spy posé sur l'objet module pourrait ne PAS intercepter cet appel indirect.
### Réponse vérifiée empiriquement
**`vi.spyOn(moduleNamespace, 'fn')` intercepte bien un import top-level `import { fn }`** — sous Vitest avec interop CJS (projet sans `"type": "module"`). esbuild transforme les imports ESM en accès CJS via des **getters live** (bindings vivants), donc `vi.spyOn` sur le namespace réimporté (`await import(...)`) intercepte l'appel indirect.
### Méthode de levée de doute
Test-sonde temporaire comptant (a) les appels du spy, (b) les effets réels en DB/mock. Si le spy est appelé N× ET zéro effet réel observé → l'interception marche. Ne pas présumer un faux positif sans cette sonde.
Cas vécu : spy `mockRejectedValue` sur `batchCreateNotifications` → vérifié : `201` rendu, 0 notif écrite, catch loggé.
---
## `as const` sur un objet d'options passé à une lib tierce : TS2769 (arrays readonly)
### Contexte
Spike Keycloak RL799_V2 (`session.ts`), le 13-06-2026. Réflexe « je fige mes constantes en `as const` » appliqué à une whitelist d'algorithmes jose.
### Symptômes
- Typecheck cassé en `TS2769 — No overload matches this call` sur un appel qui compilait avant.
- Message peu parlant qui ne pointe PAS vers la readonly-ness.
### Cause
`as const` transforme les `string[]` en `readonly string[]`. Si la signature de la fonction tierce attend un array **mutable** (`string[]`), TS rejette :
```typescript
const OPTS = { keyManagementAlgorithms: ['ECDH-ES'] } as const;
jose.jwtDecrypt(jwe, key, OPTS); // TS2769 : keyManagementAlgorithms est readonly
```
### Correctif / règle à retenir
- Soit typer l'objet explicitement : `const OPTS: { keyManagementAlgorithms: string[] } = { … };`
- Soit spread à l'appel : `{ keyManagementAlgorithms: [...MY_CONST] }`.
- À garder en tête : un `as const` ajouté « pour bien faire » peut faire échouer soudainement un appel qui compilait.
---
## Échec massif soudain de la suite de tests (DB-per-worker) = template/worker DB corrompu, pas une régression de code
### Contexte
RL799_V2, session v2-1-5, le 13-06-2026. Du jour au lendemain, 107 fichiers de tests échouent alors que rien de structurant n'a changé, et le compte total de tests est anormal (beaucoup de `skipped`).
### Symptômes
- 100+ fichiers KO soudainement, sans changement applicatif correspondant.
- Compte de tests anormal, nombreux `skipped`.
### Cause
Architecture « template database clonée par worker ». Deux causes typiques :
- **(a) DB worker orphelines** (`*_test_w1/w2/…`) laissées par un run tué → collision au clonage du run suivant.
- **(b) Template Prisma en P3009** (« migrate found failed migrations ») quand un `migrate deploy` de reconstruction du template a été interrompu en plein milieu — la table `_prisma_migrations` garde une migration marquée `failed`, et toute reconstruction ultérieure refuse d'avancer.
Cas vécu : P3009 sur `cotisation_payment_amount_bounds` (migration sans rapport avec le code touché) après interruption.
### Correctif / règle à retenir
- **Fix** : drop COMPLET du template + des DB worker orphelines, puis reconstruction from scratch (`ensureTemplateReady`) — un template neuf n'a pas de migration `failed`.
- **Réflexe** : avant de débugger un « échec massif », vérifier
`SELECT datname FROM pg_database WHERE datname LIKE '<prefix>_test%';`
et tenter un `migrate deploy` isolé sur le template pour lire l'erreur réelle (souvent masquée par le runner en parallèle).
---
## Séparer deux chantiers mélangés dans un working tree non commité (barrel partagé), sans `git add -p`
### Contexte
RL799_V2, code-review de la story v2-2-1 (module MC), le 18-06-2026. La réconciliation `git status` vs File List de la story a révélé qu'un SECOND chantier disjoint (ODJ Lot 1) contaminait le working tree non commité, partageant un barrel (`packages/shared/src/index.ts`) avec le chantier en revue. `git add -p` interactif est indisponible en agent.
### Symptômes / signal de détection
À faire en début de toute code-review :
```bash
git status --porcelain | grep -v <dossier-artefacts>
```
Comparer à la File List déclarée de la story. Tout fichier modifié hors File List = soit doc incomplète, soit contamination par un autre chantier. Discriminer en `git diff <fichier>` (commentaires/symboles trahissent le chantier d'origine, ex. `// Lot 1 allowedGrades`). Vérifier la disjonction : `grep -rn <symboles-chantier-B> <dossiers-chantier-A>` → si vide, les deux sont indépendants et séparables.
### Procédure de séparation (agent, `add -p` indisponible)
1. `git reset` pour repartir d'un index vide.
2. `git add -- <fichiers purs du chantier A>` (entiers, modifiés + untracked).
3. Pour un FICHIER PARTAGÉ (barrel touché par A ET B) :
- `git diff <barrel> > /tmp/full.patch`,
- repérer les bornes de hunk (`grep -nE '^@@'`),
- reconstruire un patch ne contenant QUE les hunks de A via `sed -n` (entête lignes 1-4 obligatoire + hunks voulus),
- valider `git apply --cached --check /tmp/a.patch`, puis `git apply --cached /tmp/a.patch`.
4. Vérifier la coupe : `git diff --cached <barrel>` (= A seul) et `git diff <barrel>` (= B seul).
5. Committer A (thématique) ; B reste non commité pour SON passage en review.
### Règle à retenir
- Garde-fou : retirer un hunk intermédiaire ne casse pas les hunks suivants tant que les zones ne se chevauchent pas (contexte ≥ quelques lignes d'écart) — `git apply` réapplique chaque hunk via son contexte sur le fichier d'origine.
- Sanity post-séparation : rebuild du package partagé (`tsc -p`) avec le reste du tree présent.
- Cas vécu : commit MC `587d8b5b` isolé du chantier ODJ (hunks `mcChecklist`/`getMaterialChecklist` vs `deriveTemplateItemGrades` dans le barrel).
---
## Modèle Prisma fantôme : migration SQL sans `model` dans schema.prisma
### Contexte
RL799_V2, Epic v2-4 Hospitalier, le 20-06-2026. Un commit de feature a livré un module complet (repository, service, routes, DTOs, schémas Zod, tests) AVEC les migrations SQL créant la table (`CREATE TABLE care_contacts` + enum), MAIS le `model CareContact { }` n'a jamais été ajouté à `prisma/schema.prisma`.
### Symptômes
- CI rouge au build API (`next build`) :
`Type error: Property 'careContact' does not exist on type 'PrismaClient<...>'`.
- Le typecheck local **pouvait passer** si le dev n'avait pas régénéré le client après le pull (client en cache encore aligné sur un état antérieur).
### Cause racine
La table existe en base, le code l'appelle via `prisma.careContact`, mais le client généré (dérivé du **schéma déclaratif**, pas de la base) ignore le modèle. SQL et schéma déclaratif ont divergé.
### Correctif / règle à retenir
- **Fix** : ajouter le `model` + l'`enum` au schéma, calés EXACTEMENT sur le SQL des migrations (nom de table via `@@map`, colonnes via `@map`, index, FK, `onDelete`), puis `prisma generate`. AUCUNE nouvelle migration — la table existe déjà, on réaligne seulement le schéma déclaratif sur l'état réel. Penser aux relations inverses sur les modèles cibles des FK (ici 2 FK vers `users` ⇒ 2 relations nommées distinctes sur `User`, sinon erreur de relation ambiguë).
- **Détection proactive (audit anti-fantôme)** : diff entre les `CREATE TABLE` des migrations et les `@@map`/noms de modèles du schéma —
`comm -23 <(grep 'CREATE TABLE' migrations | extract) <(grep '@@map' schema | extract)`.
Tout écart doit s'expliquer par un DROP/rename/fusion ultérieur ; sinon c'est un fantôme. Attention aux faux positifs (commentaires SQL contenant « CREATE TABLE »).
- **Prévention** : toute évolution de schéma passe par `schema.prisma` D'ABORD (`prisma migrate dev` génère le SQL depuis le schéma). Si on édite une migration à la main (`ADD VALUE` sur enum, data-fix), vérifier que le schéma déclaratif reflète bien l'état final. Test de garde possible : assertion CI que toute table en base a un modèle dans le schéma.
---
## Effet iceberg CI (variante) : extension d'enum/catalogue sans mise à jour des consommateurs exhaustifs
### Contexte
RL799_V2, Epic v2-4 Hospitalier + relance, le 21-06-2026. Suite directe du postmortem « modèle Prisma fantôme » : le déblocage du build CI a révélé 2 strates supplémentaires, toutes de la même nature (extension d'un ensemble « fermé » sans mise à jour des consommateurs exhaustifs). Variante ciblée de l'effet iceberg générique (voir « Effet iceberg en CI — patcher en cascade » plus haut).
### Strates (ordre du pipeline : build → test:api → test:frontend → test:shared)
1. **Build API** : `model CareContact` absent de `schema.prisma` (cf. postmortem fantôme).
2. **test:api** : actions d'audit `care_contact:created` + `soiree:reminder_sent` loggées dans le code mais absentes du catalogue `AUDIT_ACTION_CATALOG` (un test de couverture scanne le code et exige la déclaration).
3. **typecheck frontend** : valeur d'enum `CONVOCATION_REMINDER` ajoutée à `NotificationType` mais absente du `Record<NotificationType, IconConfig>` du composant (`vue-tsc` exige l'exhaustivité ; un fallback runtime `?? {...}` masque le crash mais PAS l'erreur de type).
Le runner s'arrête à la 1ʳᵉ étape échouée → les strates aval sont invisibles tant que l'amont n'est pas réparé.
### Leçons à retenir
- **Leçon 1 — consommateurs exhaustifs d'un enum.** Ajouter une valeur à un enum/catalogue « fermé » casse en aval, sans erreur au site d'ajout, trois familles de consommateurs : (a) `Record<Enum, X>` TS, (b) catalogues testés en couverture, (c) `switch` sans `default`. Checklist : à chaque nouvelle valeur d'enum, `grep -rn "Record<MonEnum\|MON_CATALOG\|switch.*monType"` sur front + shared + back.
- **Leçon 2 — méthode de rejeu CI.** Après un fix CI structurant (build, ordre d'étapes, import cassé), rejouer la suite COMPLÈTE dans l'ordre exact du pipeline avant de pusher, jamais une suite isolée — sinon on découvre les strates une par une au rythme du CI (yo-yo ~6-9 min/run). Le coût des suites locales cumulées (~3 min) est très inférieur. Lire `.github/workflows` pour connaître l'ordre exact des étapes plutôt que de présumer.