Trois régressions silencieuses au rejeu, une par question posée. Les contrats étaient remplacés comme les véhicules, au motif qu'une liste rejouée n'a pas d'identité stable. C'est vrai d'une voiture, faux d'un contrat : son type vient d'une énumération fermée. On appariait donc rien alors qu'il n'y avait rien à deviner, et on payait l'état utilisateur de la seule saisie opt-in du produit. La déclaration de travaux ne gardait qu'un mois pour tout le logement. Une piscine finie en juin et une véranda finie en août partageaient une date limite — celle de la dernière déclarée, l'autre disparaissant avec son sujet. Un mois par chantier, un sujet par mois, et deux chantiers du même mois restent une visite unique au service des impôts. Le formulaire repart à vide à chaque rejeu, parce que la question porte sur ce qui vient d'arriver ; le logement, lui, accumule et oublie à treize mois. Enfin, carte-grise-changement-adresse attendait depuis sa publication un demenagement_recent que personne n'écrivait. On attendait un écran de correction d'adresse pour le poser ; le rejeu du quiz le fait depuis le 28/07, il tient les deux adresses dans la même fonction. On pose le mois, jamais un booléen — sans quoi la fiche annoncerait un mois pour la carte grise à vie, ce que D-059 venait de corriger sur la piscine. Vérifié sur une copie de la production : deux déclarations foncières vivantes après rejeu, cinq contrats gardant leur identifiant, un second rejeu qui ne bouge plus rien. 905 tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
40 KiB
Audit complet — 28 juillet 2026
Revue exhaustive du dépôt : code, base de connaissance, documents, exploitation.
Mise à jour du 28/07/2026 (même jour, seconde passe) : la quasi-totalité des correctifs proposés ci-dessous ont été implémentés, vérifiés (
typecheck,vitest,knowledge:check,eslint,next build) et déployés sur le conteneurfokan-app(rebuild + migration + redémarrage). Voir la § 0 pour le détail, item par item. Le corps du document ci-dessous est conservé tel quel : c'est le diagnostic d'origine, qui garde sa valeur de référence.
Un audit plus récent existe : audit-2026-07-30.md couvre la base de connaissance, les cinq piliers et les documents, et y ajoute le classement des ajouts à valeur. Il ne rejoue pas la sécurité ni l'exploitation — c'est ce document-ci qui reste la référence sur ces deux volets, et son seul point resté ouvert (E3, les sauvegardes) n'a pas bougé.
Ce que le dépôt a fait depuis (même journée, troisième passe) — les chiffres et le périmètre de cet audit sont ceux d'avant D-019 et D-020, et ne décrivent plus l'état courant :
Ce que l'audit dit Ce qui est vrai depuis Depuis 66 fiches, 288 tests, 4 piliers 76 fiches, 5 piliers — Enfance & scolarité s'est ouvert ; les tests sont passés à 363, puis à 598 au 30/07/2026 D-019, D-020, puis D-022 → D-034 Le périmètre s'arrête avant la carte Cinq couches de carte existent (nucléaire, sécheresse, ZFE, PPRT, inondation), et une couche est devenue de la configuration D-022 → D-031 Cinq fiches papiers en fenêtre libre « ne parlent jamais » (§ F1 du pilier papiers) Quatre d'entre elles sont datées sur les vacances réelles de l'académie du foyer D-019 Délais de rendez-vous ANTS : « ❓ à tester » Testé : aucun point d'accès programmable. Piste close D-019 § 6 : « les piliers sont quatre et gravés » Cinq. Le rangement des contrats dans Papiers, lui, reste tranché comme en D-018 D-019 Ce qui reste ouvert de cet audit : E3, les sauvegardes — toujours le seul risque que le cadrage qualifie d'inacceptable — et F3, le sourcing des intervalles de courroie, qui reste un travail éditorial.
Méthode : lecture intégrale des 14 243 lignes de src/, des 66 fiches YAML, des scripts et des documents ; exécution réelle de tsc --noEmit, vitest run et knowledge:check ; recoupement systématique des attributs du DSL avec le code qui les pose ; recherche des exports sans consommateur ; revue de la surface publique HTTP.
Ce qui est sain, et vaut d'être dit :
| Vérification | Résultat |
|---|---|
tsc --noEmit |
✅ aucune erreur |
vitest run |
✅ 288 tests, 20 fichiers, 0 échec |
npm run knowledge:check |
✅ 66 fiches valides (animaux 6, papiers 12, maison 36, véhicules 12) |
| Fiches sans page SEO | ✅ aucune — la bicéphalie du § 11.2 tient |
| Fiches sans contenu mail | ✅ aucune |
| Secrets dans le dépôt ou l'historique git | ✅ aucun ; .env n'a jamais été suivi |
| Fichiers orphelins (jamais importés) | ✅ aucun |
| Invariants d'architecture § 19.3 | ✅ tenus — le moteur (reconcile.ts, windows.ts, applicability.ts, sujets.ts, notifications.ts, fabrication.ts) est pur, sans I/O, testé ; aucun code moteur ni aucune règle ZFE n'est gravé dans une fiche |
Le cœur du produit est en bon état. Les défauts ci-dessous sont concentrés sur la surface HTTP publique, l'exploitation (CI, migrations, sauvegardes) et deux promesses non tenues au foyer.
0. État des correctifs — 28/07/2026
Traités dans l'ordre du § 8, puis le reste. Trois précisions du porteur de projet ont infléchi l'implémentation par rapport à ce que ce document proposait initialement — elles sont notées.
| # | Sujet | Statut | Ce qui a été fait |
|---|---|---|---|
| F1 | Mail « garder le lien » | ✅ Corrigé | src/emails/garder-lien.tsx + envoi réel dans POST /api/interet, via les identifiants SMTP déjà présents en .env |
| E3 | Sauvegardes | ⏸️ Différé — puis sorti du périmètre le 06/08/2026 | Non traité à la demande du porteur de projet (« on verra plus tard »). Re-tranché en D-054 § 3 : le sujet sera traité à l'échelle du serveur miaw entier, pas dans ce dépôt — une sauvegarde propre à fokan divergerait de celle des ~25 autres conteneurs. Reste le seul risque que le cadrage qualifie d'inacceptable (§ 24.2), désormais assumé et daté |
| E1 | CI | ✅ Corrigé | .github/workflows/ci.yml — npm ci → typecheck → lint → vitest → knowledge:check → build |
| E2 | Migrations versionnées | ✅ Corrigé | drizzle/0000_...sql (baseline, marquée appliquée sans exécution — la base vivante existait déjà) + drizzle/0001_...sql (nouvelles colonnes) ; npm run db:generate / db:migrate / db:baseline ; jouées automatiquement au démarrage du conteneur (Dockerfile) |
| S3 | Réclamation de foyer | ✅ Corrigé — option b | La réclamation ne dépend plus du rendu de /foyer/[id] : src/lib/membership.ts exige désormais subscriptionStatus: "active" en plus de « zéro membre », et le webhook Stripe (checkout.session.completed) inscrit directement le payeur quand une session existait déjà avant paiement |
| S5 | Consentement de rétractation | ✅ Corrigé | Horodaté uniquement dans le webhook Stripe, avec stripeCheckoutSessionId comme preuve croisée (nouvelle colonne) ; POST /api/checkout ne fait plus l'écriture prématurée |
| S1 | Limitation de débit | ✅ Corrigé | src/lib/rate-limit.ts (limiteur en mémoire, sans Redis) appliqué à /api/quiz, /api/adresse, /api/vehicule/taxonomie, /api/interet, /api/event |
| S2 | Plafonds /api/invitations |
✅ Corrigé | Plafond par utilisateur/jour, plafond par adresse invitée (tous foyers confondus), refus si invitation pendante |
| F3 | Sourcing des intervalles de courroie | ⏸️ Hors code | Confirmé : ce n'est pas un correctif de code (cf. corps du document) |
| F2 / § 3 | Voie déclarative du quiz (P.2) | ✅ Corrigé | motorisationsParModele (nouvelle fonction, vehicules-recherche.ts) + mode ?marque=&modele= sur /api/vehicule/taxonomie + bornage par année dans codesEnLice + nommage du moteur par puissance choisie + les 3 défauts mineurs du § 3.4 (reset motorisations, perte silencieuse du champ K, vignette par véhicule) |
| — | Rejeu du quiz par un abonné | ✅ Ajouté (F8, précisé par le porteur de projet) | Le quiz reste la référence unique pour mettre à jour un foyer : src/engine/quiz-rejeu.ts, GET/POST étendus, garde-fou d'un rejeu par mois (porté à cinq par période d'abonnement le 07/08/2026, D-060), entrée dans le hub (/quiz?rejouer=<id>), assets singleton (foyer, logement) mis à jour en place, assets multiples (véhicules, personnes, animaux) remplacés, contrats appariés sur leur type depuis D-063 — voir la réserve dans le corps du § F8 |
| §4 | Code mort | ✅ Corrigé | Card/Badge/BadgeTone/PilierDot (primitives.tsx), Knowledge (schema.ts), LIBELLE_CRITAIR (critair.ts) retirés ; simulerRappelFoyer gardé et exposé via npm run rappels:simuler |
| S4 | Jeton .ics dédié |
✅ Corrigé | Colonne icsToken, route GET /calendrier/[token], génération à la demande via GET /api/foyer/[id]/ics (authentifié, membre, abonné) ; l'ancienne route répond 404 |
| S6 | En-têtes de sécurité | ✅ Corrigé | headers() dans next.config.ts (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS) ; les deux liens externes existants avaient déjà rel="noopener noreferrer" |
| S7 | Mot de passe Postgres + réseau | ✅ Corrigé | Mot de passe généré (32 caractères), rotation appliquée à chaud sur la base vivante (ALTER USER), POSTGRES_PASSWORD déplacé dans .env, réseau fokan-net dédié créé (la base n'est plus sur ai-net, l'app y reste pour rester joignable par Caddy) |
| F5 | Course sur les attributs d'asset | ✅ Corrigé — verrou en mémoire, pas Postgres | src/lib/mutex.ts : la course se joue entre deux appelants du même processus Next.js (POST /api/quiz et le worker pg-boss) — un verrou en mémoire suffit et évite de tenir une connexion Postgres ouverte pendant des appels réseau lents |
| F6 | rattraperCritairFoyer incomplet |
✅ Corrigé | Appelé aussi depuis l'enrichissement hebdomadaire (enrichissement-georisques.ts), qui tourne pour tous les foyers |
| F7 | Fragilités webhook Stripe | ✅ Corrigé | Lecture défensive de items.data[0] ; past_due/trialing gardent l'accès actif, seuls canceled/unpaid le coupent |
| F8 | Copie trompeuse écran contrats | ✅ Corrigé | Texte aligné sur D-007 + le rejeu du quiz (voir ci-dessus) |
| F9 | Funnel silencieux | ✅ Corrigé | Le catch de /api/event journalise désormais l'erreur |
| E4 | Monitoring / healthcheck | ✅ Corrigé (partiellement) | GET /api/health + healthcheck Docker sur fokan-app (la base en avait déjà un) ; la lecture des heartbeats events.name = cron_* par une supervision externe reste hors périmètre |
| E5 | CI/lint/Dockerfile | ✅ Corrigé | ESLint (eslint.config.mjs, eslint-config-next@15.5.21 — épinglé sur la version exacte de Next, npm run lint) ; Dockerfile passé à npm ci (et un bug latent démasqué par ce changement — .npmrc n'était jamais copié dans l'image — corrigé au passage) ; le risque dangerouslySetInnerHTML sur marked.parse() n'a pas été traité (contenu interne, validé en CI, risque jugé nul aujourd'hui) |
| §6 | Rangement des contrats dans les piliers | ➡️ Tranché | Le porteur de projet a choisi de garder les contrats dans le pilier Papiers — aucune fiche déplacée |
Notes sur les trois précisions du porteur de projet :
- F4 (
demenagement_recentmort) : confirmé différé le 28/07 — noté danspilier-vehicules.mdetpilier-animaux.mdcomme un chantier futur, aucun code touché. Corrigé le 07/08/2026 (D-063), voir le § F4. - F8 : au lieu de rouvrir la déclaration uniquement dans le récap annuel (proposition initiale de ce document), le porteur de projet a demandé une fonctionnalité plus large — le rejeu complet du quiz par un abonné, avec garde-fou mensuel. C'est ce qui a été implémenté ; voir la réserve ci-dessous sur le remplacement des assets multiples.
- S3 : l'option (b) a été retenue telle que proposée.
Une réserve à connaître sur le rejeu du quiz (F8) : les assets à instance unique (foyer, logement) sont mis à jour en place, donc leurs échéances gardent leur état (muet, responsable, historique). Les assets à instances multiples (véhicules, personnes, animaux) sont en revanche remplacés entièrement à chaque rejeu — il n'existe pas de correspondance fiable entre « l'ancienne deuxième voiture » et « la nouvelle deuxième voiture » d'une liste rejouée sans identifiant stable, et deviner aurait pu produire une correspondance fausse en silence. Ce choix est assumé et annoncé à l'écran avant validation, mais mérite d'être revu si l'usage réel montre que la perte d'état (mute, responsable) sur ces échéances-là gêne les foyers qui rejouent.
Les contrats en sont sortis le 07/08/2026 (D-063) : ils portent un type pris dans une énumération fermée, ce qui est une identité et non une ressemblance à évaluer. L'argument du remplacement ne les concernait donc pas, alors qu'ils sont la seule saisie opt-in du produit — c'est-à-dire l'endroit où l'effacement se remarque le plus.
Dette technique introduite, à surveiller : le remplacement complet des assets multiples au rejeu (F8) est un compromis de rapidité d'implémentation, pas une contrainte définitive. Une évolution future pourrait apparier les véhicules par reception_numero, les contrats par type, etc., pour préserver leur état à la marge — non fait ici par prudence (une correspondance approximative aurait été pire qu'un remplacement franc, cf. le raisonnement de D-004 § 3 appliqué ailleurs dans le code).
07/08/2026 (D-063) — l'appariement des contrats par
typeest fait, et c'est bien la moitié de ce que cette ligne annonçait. Les véhicules restent remplacés :reception_numeroest facultatif, souvent absent, et deux exemplaires du même modèle le partagent — ce serait une correspondance approximative, précisément ce que le paragraphe ci-dessus refuse.
1. Sécurité
S1 — Aucune limitation de débit, sur aucune route · élevé
Les cinq routes publiques n'ont ni authentification, ni plafond, ni détection d'abus :
| Route | Ce qu'un appel coûte |
|---|---|
POST /api/quiz |
1 foyer + n assets + réconciliation + 4 appels Géorisques + 1 CatNat + 1 VigiEau + 1 job pg-boss, avec une attente serveur jusqu'à 10 s |
GET /api/adresse |
1 à 2 appels sortants (BAN, ADEME) |
GET /api/vehicule/taxonomie |
requêtes LIKE '%…%' et agrégats sur un référentiel de 2,9 M de lignes |
POST /api/interet |
insertion libre d'une adresse mail |
POST /api/event |
insertion d'un JSON non borné, householdId arbitraire |
Le risque n'est pas le vol de données, c'est la disponibilité et la réputation : un script trivial fait de nous un client abusif de Géorisques, de la BAN et de l'ADEME — des API d'État sans quota documenté, dont le code reconnaît lui-même la fragilité (ENTRE_FOYERS_MS = 1000 dans le passage hebdomadaire). Se faire fermer la porte de Géorisques coûterait tout le pilier logement.
Correctif proposé — un limiteur par IP, sans Redis (§ 22) : une table rate_limits (cle, fenetre, compteur) en Postgres ou un LRU en mémoire du process suffit à cette échelle. Plafonds distincts : /api/quiz très strict (quelques créations par IP et par heure), les routes de lecture plus larges. Et un plafond de taille de corps sur les deux routes d'écriture.
S2 — /api/invitations permet d'envoyer un lien magique à n'importe qui, sans limite · élevé
Un membre authentifié peut déclencher signInMagicLink vers n'importe quelle adresse, autant de fois qu'il le veut. C'est un vecteur d'e-mail bombing depuis notre relais d'envoi — or le § 26 fait de la réputation d'envoi une infrastructure vitale : « un rappel en spam = la promesse rompue ». Un seul abus suffit à la détruire.
Correctif proposé — plafond par utilisateur et par jour, plafond par adresse invitée, et refus si une invitation non consommée et non expirée existe déjà pour ce couple (foyer, adresse).
S3 — Un GET sur /foyer/[id] peut faire changer le foyer de propriétaire · élevé
resolveMembership (src/lib/membership.ts:32) est appelé au rendu de la page (src/app/foyer/[id]/page.tsx:122) et écrit en base : le premier visiteur authentifié d'un foyer sans membre en devient membre, définitivement.
Scénario réel, sans malveillance : quelqu'un fait le quiz, partage son lien (« regarde, 34 échéances »), le destinataire a un compte fokan et ouvre le lien — il devient propriétaire du foyer. L'auteur du quiz ne pourra plus jamais le réclamer, et rien ne le lui dira. Un préchargement de lien, un aperçu de messagerie ou un crawler porteur d'une session produisent le même effet.
Correctif proposé — la réclamation ne doit pas être un effet de bord du rendu. Deux options : (a) un geste explicite (POST /api/foyer/[id]/reclamer, bouton « c'est mon foyer »), (b) la réclamation reste automatique mais uniquement dans le webhook Stripe, où elle est déjà déclenchée (D-015) et où le paiement prouve l'intention. L'option (b) est la moins coûteuse et suffit au parcours réel.
S4 — Le flux .ics et le hub n'ont pas la même porte · moyen
Depuis D-017, le hub payant exige isPaid && isMember (page.tsx:130). GET /foyer/[id]/calendrier.ics n'exige que isPaid : l'UUID du foyer suffit à télécharger tout le calendrier d'un foyer abonné — titres, dates, enjeux, adresse comprise dans les libellés d'assets.
Ce n'est pas un oubli sans raison : un client d'agenda ne sait pas s'authentifier, un flux webcal a besoin d'une URL-capacité. Mais alors la capacité doit être un jeton dédié et révocable, pas l'identifiant du foyer — qui circule dans chaque lien de partage, chaque mail, chaque historique de navigateur.
Correctif proposé — colonne ics_token sur households (aléatoire, régénérable), URL /calendrier/<token>.ics, et 404 sur l'ancienne forme.
S5 — Le consentement de rétractation est horodaté au mauvais moment, par n'importe qui · moyen
POST /api/checkout n'est pas authentifiée et écrit retractationWaivedAt sur n'importe quel foyer dont on connaît l'identifiant, avant toute redirection vers Stripe. Deux conséquences :
- un tiers peut horodater un consentement qui n'est pas le sien ;
- le consentement est enregistré même si le paiement est abandonné.
La preuve légale exigée au § 10.7 perd donc précisément ce qui en fait une preuve : elle n'établit plus que la personne qui a payé est celle qui a coché.
Correctif proposé — conserver le refus serveur si la case n'est pas cochée (c'est le bon garde-fou), mais horodater dans le webhook checkout.session.completed, en enregistrant l'identifiant de session Stripe comme preuve croisée. Le consentement devient alors indissociable du paiement.
S6 — Aucun en-tête de sécurité · moyen
next.config.ts n'expose pas de headers(). Ni CSP, ni HSTS, ni X-Content-Type-Options, ni X-Frame-Options, ni Referrer-Policy. Si Caddy ne les pose pas en amont (à vérifier sur le serveur), deux conséquences concrètes : le hub est encadrable dans une iframe tierce, et l'URL du foyer part en Referer vers chaque lien d'action sortant — c'est-à-dire vers les futurs partenaires d'affiliation (§ 10.4). L'URL du foyer est une capacité : elle ne doit fuir nulle part.
Correctif proposé — un bloc headers() global, Referrer-Policy: strict-origin-when-cross-origin au minimum, et rel="noreferrer" sur tous les liens d'action des fiches.
S7 — Postgres avec un mot de passe trivial, sur un réseau partagé · moyen
docker-compose.yml fixe POSTGRES_USER: fokan / POSTGRES_PASSWORD: fokan en clair, et rattache le service au réseau externe ai-net, partagé avec d'autres conteneurs de l'hôte. Le port n'est pas publié, ce qui est bien — mais tout conteneur de ai-net peut se connecter à la base avec un identifiant qui se devine du premier coup, et y lire l'intégralité des foyers.
Correctif proposé — mot de passe généré, injecté depuis .env (jamais en clair dans le compose), et un réseau fokan-net dédié ; ai-net n'est nécessaire que si un autre service doit joindre l'app, ce qui n'est pas le cas aujourd'hui.
2. Fonctionnel
F1 — On promet au foyer un mail qui ne partira jamais · élevé
garder-lien.tsx:65 affiche, après validation : « C'est noté — le lien de ce foyer part sur {email}. Tu peux fermer cette page. »
POST /api/interet insère une ligne dans leads et n'envoie aucun mail. sendMail n'est appelé que depuis lib/auth.ts (liens magiques) et jobs/notifications.ts (rappels). Le foyer ferme la page en confiance, et le lien n'arrive pas.
C'est le défaut le plus grave du lot, non par sa complexité mais par sa nature : c'est le seul geste de confiance qu'on demande au visiteur (D-005 : l'adresse se demande après le service rendu), et il n'est pas honoré. Le foyer perd son hub et n'en saura jamais la raison.
Correctif proposé — un React Email « voici le lien de ton foyer », envoyé depuis la route avec la dégradation gracieuse habituelle de sendMail. Tant qu'il n'existe pas, corriger la copie (« c'est noté »), sans promettre d'envoi.
F2 — La chaîne véhicule décroche dès que le foyer n'a pas sa carte grise · élevé
C'est le point soulevé, et il est exact. Détail en § 3.
F3 — Aucun foyer ne peut aujourd'hui recevoir une date de courroie · élevé (produit, pas défaut)
courroieWindow (windows.ts:136) refuse de dater quand distribution_confiance_intervalle === "a_verifier" — règle juste, née d'un vrai faux positif. Or, dans le référentiel amorcé, un seul moteur porte un intervalle (K9K U8), et sa confiance d'intervalle est justement a_verifier, parce que Renault publie une fourchette de marque et non une préconisation par moteur.
Conséquence : couvertureDistribution().peutNotifier vaut 0. La machine complète — ingestion, identification, unanimité, fenêtre, fiche de prise en charge, scheduler — fonctionne et ne produit, pour l'instant, que des libellés informatifs. C'est documenté en toutes lettres (pilier-vehicules.md § 7.2), et c'est le seul travail qui fasse basculer le pilier du registre « information » à celui de « rappel ».
Ce n'est pas un correctif de code, c'est du sourcing : quelques intervalles officiels bien choisis (§ 7.4 du document pilier) suffisent.
F4 — La fiche carte-grise-changement-adresse ne s'applique à personne · moyen
demenagement_recent n'est posé nulle part dans l'application — vérifié par recoupement automatique de tous les attributs du DSL contre le code qui les écrit. C'est le seul attribut mort sur 29. Déjà relevé (pilier-logement.md § 10.6, pilier-vehicules.md § 8.4) et toujours vrai.
La page SEO existe et se trouve ; l'échéance, elle, n'existe pour aucun foyer, alors que le délai est d'un mois et la sanction de 135 €.
Correctif proposé — ne pas le demander au quiz (ce serait une question mémoire, § 4.2). L'inférer : le jour où le hub permet de corriger l'adresse du logement, ce changement pose demenagement_recent: true sur les véhicules du foyer, avec une fenêtre d'un mois. En attendant, assumer et documenter que cette fiche est « SEO seule ».
✅ Corrigé le 07/08/2026 (D-063), par l'inférence proposée ci-dessus — appliquée à un endroit qui existait déjà : le rejeu du quiz (F8), qui tient l'ancienne adresse et la nouvelle dans la même fonction. On attendait un écran de correction d'adresse à construire ; il était là depuis le 28/07. Un écart avec la proposition, et il compte : l'attribut porte le mois du déménagement, pas true — un booléen serait resté vrai à vie et aurait rejoué la faute que D-059 venait de corriger sur declaration-fonciere-90j (« avoir une piscine » au lieu de « venir d'en construire une »). La fiche a donc une vraie fin de délai, et une rétention de quatre mois au-delà de laquelle elle cesse simplement de s'appliquer.
Ce que ce défaut apprend, au-delà de lui-même : il a été relevé trois fois dans trois documents, chaque fois avec la bonne analyse, et différé chaque fois en attendant une fonctionnalité — laquelle a été livrée entre-temps sans que personne ne rapproche les deux. Un défaut qu'on diffère « jusqu'à ce que X existe » mérite d'être relu le jour où X existe.
F5 — Course sur les attributs d'asset à la fin du quiz · moyen
POST /api/quiz:118 lance Promise.race([enrichirRisquesFoyer(id), timeout 10 s]). Quand le plafond tombe, l'enrichissement en cours continue de tourner, et la route poste en plus un job pg-boss qui relance enrichirRisquesFoyer sur le même foyer. Deux exécutions concurrentes font alors le lire-modifier-écrire décrit — et redouté — dans les commentaires du fichier : la plus lente écrase les attributs de l'autre.
L'effet est réparé au passage hebdomadaire, donc invisible ; mais il se produit précisément quand un tiers est lent, c'est-à-dire au pire moment.
Correctif proposé — un verrou consultatif Postgres autour de l'enrichissement d'un foyer (pg_advisory_xact_lock(hashtext(household_id))), qui rend la question sans objet quel que soit le nombre d'appelants.
F6 — rattraperCritairFoyer ne tourne qu'au quiz · faible
Il n'est appelé que depuis POST /api/quiz. Un véhicule non classé au quiz (année inconnue, modèle absent du catalogue) et dont le foyer n'a pas donné d'adresse ne sera jamais reclassé, même quand le référentiel RDW s'enrichit — enrichirZfeFoyer, qui recalcule aussi Crit'Air, exige une adresse géolocalisée.
Correctif proposé — l'appeler également depuis enrichirRisquesFoyer, qui tourne pour tous les foyers chaque semaine, adresse ou non.
F7 — Webhook Stripe : deux fragilités · faible
subscription.items.data[0].current_period_end(webhook/route.ts:33et:80) lève siitemsest vide. L'exception rend un 500, et Stripe rejoue l'événement en boucle pendant des jours.customer.subscription.updatedmappe tout ce qui n'est niactivenicanceledvers"expired"— doncpast_due(impayé temporaire, que Stripe retente pendant plusieurs jours) ettrialingcoupent la veille immédiatement. Un foyer qui paie voit son calendrier se reverrouiller sur un incident de carte.
Correctif proposé — lecture défensive de items.data[0], et conserver active sur past_due et trialing ; ne basculer qu'à unpaid et canceled.
F8 — L'écran contrats promet un rattrapage qui n'existe plus · faible
Le texte d'aide de l'écran 7 dit : « Seulement ceux que tu as déjà. Les autres, on te les proposera au bon moment — rien n'est obligatoire ici. » (quiz-client.tsx:1149).
C'est faux depuis D-007, qui a retiré l'opt-in post-quiz : rien ne sera jamais proposé. Le foyer qui passe son tour en confiance perd la déclaration pour de bon.
Correctif proposé — corriger la copie, et regarder si le récap annuel n'est pas le bon endroit pour rouvrir la déclaration sans redite (cf. pilier-contrats.md § 3.1). La seconde partie révise D-007 et demande une décision, pas seulement du code.
F9 — Le funnel peut être silencieusement vide · faible
POST /api/event avale toute exception dans un catch {} muet. L'intention est juste (« le funnel ne doit jamais casser le parcours ») mais la conséquence l'est moins : si les insertions échouent, l'étoile polaire du § 13.1 mesure zéro sans que personne ne le sache.
Correctif proposé — garder le catch, y journaliser l'erreur.
3. Le quiz face à l'état réel des développements
La question posée était : la recherche par marque/modèle ne relie pas au code moteur — ne devrait-on pas demander la puissance P.2 pour faire le lien ? La réponse est oui, et le trou est plus précisément un trou de câblage, pas de conception.
3.1 Ce qui se passe aujourd'hui, voie par voie
| Voie d'entrée | Ce que le foyer donne | Ce que le moteur en tire |
|---|---|---|
| Champ K (+ D.2 si connus) | le numéro de réception | réception exacte → énergie → P.2 proposé en liste fermée → 1 à 3 codes moteur → distribution affirmée si unanimité |
| Marque / modèle | « Peugeot 308 » + énergie + année tapée | modèle canonique → tous les moteurs jamais homologués sur ce modèle → unanimité impossible → silence |
| Rien | — | rien, et c'est conforme (§ 4.3) |
Le champ P.2 n'est affiché que si decl.pick.reception existe (quiz-client.tsx:1045), et puissanceRetenue() rend undefined sans réception (quiz-client.tsx:276). La voie déclarative ne peut donc structurellement jamais fournir P.2.
Or codesEnLice (distribution-moteurs.ts:219) sait parfaitement s'en servir sans réception : le filtre puissance_kw_max::numeric = puissance s'applique après le filtre marque/modèle exactement comme après le filtre réception. La donnée est disponible, la requête sait la lire, seule l'interface ne la demande pas.
3.2 Pourquoi ça compte plus que ça n'en a l'air
Le champ K n'est connu de personne de tête : il faut sortir la carte grise. La voie marque/modèle est donc, selon toute vraisemblance, la voie majoritaire — et c'est celle qui ne produit rien sur la seule échéance véhicule qu'aucun tableau de bord n'affiche, celle qui justifie D-017 à elle seule.
Aggravant, sur la même voie : parLibelle rend toujours annee: null. L'année tapée par le foyer sert bien à dater la courroie (repereVehicule, windows.ts:78) mais ne restreint jamais les moteurs en lice — alors que vehicules_reference.dateReception le permettrait. Sur un modèle vendu quinze ans, c'est ce qui sépare « douze moteurs candidats » de « trois ».
3.3 Correctif proposé — trois pas, du plus rentable au plus coûteux
- Étendre
/api/vehicule/taxonomied'un mode?marque=&modele=&energie=qui renvoie la même cartemotorisationsque la voie champ K, agrégée sur toutes les réceptions du couple. Le composant P.2 existe déjà : la conditiondecl.pick.reception && …devientmotorisationChoisie && …. - Filtrer par l'année déclarée dans
codesEnLice(borne surdateReception, avec une marge de ± 2 ans pour absorber l'écart entre homologation d'un type et mise en circulation d'un exemplaire). - Nommer la motorisation plutôt que ses kW quand le référentiel sait le faire (« 1.5 dCi — 4 cylindres, 1 461 cm³ ») : plus lisible qu'un nombre, et toujours une liste fermée (§ 4.1).
À mesurer avant d'implémenter, pas après : sur la voie déclarative, une même valeur de P.2 peut désigner deux moteurs différents sur deux réceptions différentes du même modèle. La règle d'unanimité d'accorderDistribution protège de l'erreur — elle produira du silence, jamais du faux — mais elle peut aussi rendre l'ajout inutile. La mesure à refaire est exactement celle de D-017 (les 15 modèles dominants), appliquée cette fois au couple marque/modèle/énergie/P.2/année. Si le gain n'est pas là, la question ne se pose pas : une question de plus au quiz qui ne débloque rien est une régression au regard de la règle d'or.
3.4 Trois défauts mineurs relevés sur le même écran
pickVehiculene remet pasmotorisationsà zéro. Sans conséquence aujourd'hui (l'affichage etpuissanceRetenue()sont tous deux gardés pardecl.pick.reception), mais le point 1 ci-dessus lève cette garde : ça deviendrait un bug le jour même, en proposant les motorisations de la voiture précédente.- Le champ K trouvé peut se perdre en silence. Quand l'identification réussit, l'écran affiche aussi la recherche marque/modèle, pré-remplie. Y taper un caractère appelle
searchVehicule, qui remetpickànull— l'identification exacte est perdue sans que rien ne le signale, et le véhicule retombe enprovenance: "declaratif". - La vignette Crit'Air est une réponse globale. Le chip écrit
vignette_critairsur tous les véhicules du foyer (quiz-client.tsx:906). Un foyer à deux voitures dont une seule a sa vignette ne peut pas le dire, et recevra la fiche pour les deux ou pour aucune.
4. Code mort
Aucun fichier orphelin. Quatre symboles réellement morts, et une série d'exports inutilement publics.
Morts — à retirer :
| Fichier | Symbole | Note |
|---|---|---|
src/components/ui/primitives.tsx |
Card, Badge, BadgeTone, PilierDot |
jamais importés ; le reste du fichier (Band, Container, Eyebrow, SectionTitle, Lede, IconTile) sert |
src/knowledge/schema.ts |
Knowledge (const + type) |
vestige ; le loader travaille sur DeadlineTemplate[] |
src/knowledge/critair.ts |
LIBELLE_CRITAIR |
aucun consommateur, y compris dans les tests |
src/jobs/notifications.ts |
simulerRappelFoyer |
outil de mise au point sans appelant ni script npm — soit lui donner un npm run rappels:simuler, soit le retirer |
Exports publics sans consommateur externe (utilisés seulement dans leur propre fichier) : isApplicable, buildDesiredState, envoyerRappelFoyer, estEnergie, RAYON_ALERTE_KM, DELAI_DECLARATION_JOURS, CHAUFFAGE_CONNU. Sans gravité — mais chacun est une promesse de stabilité qu'on ne s'est pas engagé à tenir.
Faux positifs écartés : register (contrat d'instrumentation Next.js), contentType (métadonnée d'image Next.js), account / verification (tables better-auth, consommées par import * as schema), et l'ensemble des types exportés consommés par inférence.
5. Exploitation — les écarts au cadrage
Ce sont les manques les plus lourds, parce qu'ils ne se voient pas tant que rien ne casse.
E1 — Pas de CI · élevé
.github/ n'existe pas. Le § 27 fait des tests du moteur de réconciliation et de la validation Zod des fiches la priorité absolue de la CI ; aujourd'hui rien ne les exécute automatiquement. La garantie « une fiche invalide casse le build, jamais la prod » repose entièrement sur la discipline de lancer les commandes à la main.
Correctif proposé — un workflow unique : npm ci → typecheck → vitest run → knowledge:check, sur push et sur PR.
E2 — Pas de migrations · élevé
Seul drizzle-kit push est câblé (npm run db:push), et aucun dossier drizzle/ n'est versionné. Le § 27 prévoit « migrations Drizzle jouées au déploiement ». push compare le schéma au vivant et applique le diff : sur une base de production, il peut supprimer une colonne sans avertissement. Le schéma actuel a déjà connu des retraits de colonnes (carrosserie, cylindree) — la mécanique est donc réellement sollicitée.
Correctif proposé — drizzle-kit generate, dossier drizzle/ versionné, migrate au démarrage du conteneur.
E3 — Pas de sauvegarde · élevé
Le § 24.2 appelle la sauvegarde chiffrée quotidienne vers un stockage objet français « LA condition » de l'hébergement local, et pose la restauration testée comme non négociable. Rien dans le dépôt ne la met en œuvre : docker-compose.yml monte /srv/user-data/fokan et s'arrête là.
C'est le seul risque que le cadrage qualifie d'inacceptable, et c'est le seul des cinq mitigations du § 24.2 qui ne soit pas commencé.
Correctif proposé — un service pg_dump quotidien, chiffré (age ou gpg), poussé vers Scaleway Object Storage, plus une procédure de restauration écrite et exécutée une fois pour de vrai.
E4 — Monitoring à moitié fait · moyen
Les heartbeats existent (events.name = cron_*, écrits par les quatre files) — c'est la moitié difficile. Personne ne les lit : ni supervision externe, ni alerte, ni healthcheck sur le conteneur app (la base en a un, l'app non). Le scénario nommé au § 24.2 point 3 — « scheduler planté depuis 5 jours » — reste entièrement ouvert.
E5 — Divers · faible
- Pas de lint : ni script, ni configuration ESLint, alors que le § 27 le cite.
Dockerfile:npm installau lieu denpm ci— le build ne reproduit pas le lockfile.marked.parse()est rendu viadangerouslySetInnerHTMLsans assainissement. Le contenu est le nôtre et validé en CI, donc ce n'est pas une faille aujourd'hui ; ça le deviendrait le jour où une page de connaissance serait rédigée par quelqu'un d'autre.
6. Cohérence de la base de connaissance
- 29 attributs référencés par le DSL, 28 réellement posés par le code. Le seul orphelin est
demenagement_recent(cf. F4). - 8 stratégies nommées déclarées au schéma, 8 utilisées, aucune orpheline dans un sens ni dans l'autre.
- 8 types de contrats dans
CONTRACT_TYPES, 8 fiches*-echeancecorrespondantes. Aucun contrat déclarable sans fiche, aucune fiche sans contrat déclarable. - Une dérive de rangement à arbitrer : les cinq fiches de
contrats.yaml(emprunteur, mutuelle, box, mobile, salle de sport) sont classéespilier: papiers. Le § 5.6 dit qu'elles sont « rattachées au foyer » — mais il n'existe pas de pilier « foyer », les piliers sont quatre et gravés. Le choix est donc pragmatique et défendable ; il fait simplement du pilier Papiers un fourre-tout dont l'intitulé ne prépare pas le foyer à y trouver son forfait mobile. À trancher : assumer et le dire dans la copie du hub, ou déplacer ce qui peut l'être (le forfait mobile et la box ne sont pas des papiers).
7. Ce que cet audit ne couvre pas
- Le contenu factuel des 66 fiches (règles, fréquences, montants) n'a pas été revérifié source par source : chacune porte son
source:et a été sourcée à sa rédaction. Une revue de fraîcheur annuelle reste à planifier — c'est le risque n° 7 du cadrage (coût éditorial permanent). - La délivrabilité réelle (SPF/DKIM/DMARC, réputation) : le domaine fokan n'existe pas encore, le montage est décidé (D-014) mais non exécuté.
- La configuration Caddy de l'hôte, hors dépôt — ce qui laisse S6 partiellement indéterminé.
8. Ordre de traitement proposé
| # | Sujet | Pourquoi d'abord |
|---|---|---|
| 1 | F1 — le mail de « garder le lien » | Une promesse non tenue à un foyer, sur le seul geste de confiance qu'on lui demande |
| 2 | E3 — sauvegardes | Le seul risque que le cadrage qualifie d'inacceptable |
| 3 | E1 + E2 — CI et migrations | Tout le reste devient plus sûr à changer |
| 4 | S3 + S5 — réclamation de foyer, consentement | Deux effets de bord au mauvais endroit, corrigés ensemble dans le webhook |
| 5 | S1 + S2 — limitation de débit | Protège l'accès aux API d'État et la réputation d'envoi |
| 6 | F3 — sourcing des intervalles de courroie | Ce qui fait exister la promesse du pilier véhicules |
| 7 | F2 / § 3 — la voie déclarative du quiz | À mesurer d'abord, implémenter ensuite |
| 8 | S4, S6, S7, F5–F9, § 4 | Le reste, par lots |
9. Documents produits ou mis à jour avec cet audit
Créés — les trois piliers qui n'avaient pas de document de référence, alors que maison et véhicules en avaient un :
- pilier-papiers.md — inclut le constat que cinq des sept fiches du pilier ont une fenêtre libre, donc ne déclenchent jamais de mail, et le seul 5e pilier sérieusement candidat (Enfance & scolarité).
- pilier-animaux.md — le seul pilier sans aucune dépendance externe ; enrichissements identifiés (I-CAD, chiens catégorisés, voyage/rage).
- pilier-contrats.md — explicitement pas un pilier (§ 5.6) ; socle juridique des fenêtres de résiliation, trou ouvert par D-007, état de l'affiliation.
Créé depuis : pilier-scolarite.md — le cinquième pilier, ouvert par D-019 le même jour. C'est précisément l'extension que pilier-papiers.md § 5.3 jugeait « le seul 5e pilier sérieusement candidat ».
Un enrichissement ressort deux fois : le calendrier scolaire (data.education.gouv.fr, source vérifiée, non testée) sert le passeport enfant, la garde des animaux, le voyage avec un animal et toute la saisonnalité « avant les vacances ». Un adapter, deux piliers.
Mis à jour :
- phase-0/quiz.md — réécrit. Il décrivait encore la saisie de plaque, l'API SIV, le prix mensuel et l'ics gratuit ; l'ordre même des écrans était faux (animaux et contrats inversés).
- phase-0/landing.md et phase-0/etude-api-plaque.md — avertissements en tête, contenu conservé pour la trace du raisonnement.
- pilier-vehicules.md — § 7.1 (le référentiel ne se limite plus à la famille EB), § 7.2 (
peutNotifier = 0, dit sans détour), § 8 (quatre points ouverts ajoutés). - ../README.md — prix, périmètre gratuit, état réel par brique, index des documents.
Non modifiés volontairement : le cadrage et le journal des décisions. Plusieurs constats de cet audit appellent des décisions (rouvrir la déclaration des contrats, arbitrer le rangement dans les piliers, un éventuel 5e pilier, echeance_mois en connue ou estimee) — elles se prennent, elles ne se constatent pas.