fokan/docs/audit-2026-07-28.md
Gautier Stefanini 6870e027c0 Rejouer le quiz effaçait des contrats et un délai de travaux encore ouvert
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>
2026-08-07 13:37:33 +00:00

40 KiB
Raw Permalink Blame History

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 conteneur fokan-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.ymlnpm citypechecklintvitestknowledge:checkbuild
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_recent mort) : confirmé différé le 28/07 — noté dans pilier-vehicules.md et pilier-animaux.md comme 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 type est fait, et c'est bien la moitié de ce que cette ligne annonçait. Les véhicules restent remplacés : reception_numero est 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 :

  1. un tiers peut horodater un consentement qui n'est pas le sien ;
  2. 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

  1. subscription.items.data[0].current_period_end (webhook/route.ts:33 et :80) lève si items est vide. L'exception rend un 500, et Stripe rejoue l'événement en boucle pendant des jours.
  2. customer.subscription.updated mappe tout ce qui n'est ni active ni canceled vers "expired" — donc past_due (impayé temporaire, que Stripe retente pendant plusieurs jours) et trialing coupent 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

  1. Étendre /api/vehicule/taxonomie d'un mode ?marque=&modele=&energie= qui renvoie la même carte motorisations que la voie champ K, agrégée sur toutes les réceptions du couple. Le composant P.2 existe déjà : la condition decl.pick.reception && … devient motorisationChoisie && ….
  2. Filtrer par l'année déclarée dans codesEnLice (borne sur dateReception, avec une marge de ± 2 ans pour absorber l'écart entre homologation d'un type et mise en circulation d'un exemplaire).
  3. 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

  • pickVehicule ne remet pas motorisations à zéro. Sans conséquence aujourd'hui (l'affichage et puissanceRetenue() sont tous deux gardés par decl.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 remet pick à null — l'identification exacte est perdue sans que rien ne le signale, et le véhicule retombe en provenance: "declaratif".
  • La vignette Crit'Air est une réponse globale. Le chip écrit vignette_critair sur 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 citypecheckvitest runknowledge: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 install au lieu de npm ci — le build ne reproduit pas le lockfile.
  • marked.parse() est rendu via dangerouslySetInnerHTML sans 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 *-echeance correspondantes. 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ées pilier: 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, F5F9, § 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.mdexplicitement 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.