diff --git a/40_decisions_et_archi.md b/40_decisions_et_archi.md index 909413b..7330102 100644 --- a/40_decisions_et_archi.md +++ b/40_decisions_et_archi.md @@ -8,7 +8,7 @@ Objectifs : - éviter de reposer les mêmes questions - 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) - [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) +- [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 @@ -51,6 +54,7 @@ Dernière mise à jour : 2026-03-12 - [Observabilité minimale obligatoire](#decision-observabilite) - [Authentification et autorisation centrales](#decision-auth-central) - [Idempotence et gestion des retries](#decision-idempotence-retries) +- [IdP centralisé (Keycloak) auth-only + RBAC métier local](#decision-idp-keycloak-auth-only) ### 4. n8n @@ -378,6 +382,110 @@ Aligner la doc sur l'implémentation : **déclarer Phase 2 partielle** avec 3 bl --- + + +## 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. + +--- + + + +## 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). + +--- + + + +## 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 @@ -903,6 +1011,53 @@ Principes : --- + + +## 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 diff --git a/90_debug_et_postmortem.md b/90_debug_et_postmortem.md index 86d1c23..014b34e 100644 --- a/90_debug_et_postmortem.md +++ b/90_debug_et_postmortem.md @@ -383,3 +383,262 @@ services: Conteneur `Up (healthy)` mais `docker port ` 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`. + +--- + +## 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()` **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 ` 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 '_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 +``` + +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 ` (commentaires/symboles trahissent le chantier d'origine, ex. `// Lot 1 allowedGrades`). Vérifier la disjonction : `grep -rn ` → 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 -- ` (entiers, modifiés + untracked). +3. Pour un FICHIER PARTAGÉ (barrel touché par A ET B) : + - `git diff > /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 ` (= A seul) et `git diff ` (= 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` 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` TS, (b) catalogues testés en couverture, (c) `switch` sans `default`. Checklist : à chaque nouvelle valeur d'enum, `grep -rn "Record