fokan/docs/decisions.md
Gautier Stefanini c33c1caeb3 Corriger sept erreurs factuelles et logiques dans les guides, relevées par audit
Contresens (DPE, ZFE), erreur de RFR (N-1 → N-2), règle de vote copropriété
inexacte, montant de bourse incohérent avec son propre tableau, et deux
divergences chiffrées entre un guide et son template — dont l'une corrigée
côté template (chauffe-eau) car le tableau détaillé du guide faisait
autorité. docs/decisions.md et docs/pilier-scolarite.md alignés sur le
montant corrigé de la bourse de lycée.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-09 06:35:03 +00:00

376 KiB
Raw Permalink Blame History

Journal des décisions post-cadrage

D-067 — 8 août 2026 · La responsabilité se définit, elle ne s'exclut pas — et un compte gratuit ne devient jamais payant tout seul

Contexte : demande du porteur de projet de se protéger d'un conseil mal suivi ou d'un conseil qui aurait pu engendrer des dégâts, et de réserver l'évolution du service — conditions modifiables à tout moment, passage au payant possible pour les accès déjà ouverts.

1. Le périmètre plutôt que l'exclusion — c'est ce qui décide si la clause tient

Une clause qui supprime ou réduit le droit à réparation d'un consommateur figure sur la liste noire de l'art. R212-1, 6° du Code de la consommation : irréfragablement présumée abusive, donc réputée non écrite. « fokan ne peut être tenu responsable des dommages » est donc une clause qui saute — et qui, en sautant, emporte souvent la crédibilité de l'article entier.

Une clause qui définit ce que le service promet ne retire aucun droit et n'entre pas dans ce champ. L'article 2 « Responsabilité » est donc écrit ainsi, en quatre temps : un service d'information et non de conseil (ni juridique, ni technique, ni fiscal, ni médical, ni vétérinaire) ; une obligation de moyens, jamais de résultat ; l'utilisateur seul décideur, tenu de vérifier auprès de l'autorité ou du professionnel compétent avant tout acte ; et une échéance absente qui ne décharge de rien, l'obligation existant indépendamment du service.

Ce dernier point est celui qui manquait le plus. Le risque n'est pas d'abord « un conseil mal suivi » mais un silence — une fiche qui ne s'est pas appliquée, une source indisponible, un barème périmé qui cesse de filtrer (D-023). Un texte qui ne parlerait que de l'erreur laisserait découvert le cas le plus probable.

L'article se terminait par un paragraphe de soupape — « ces stipulations ne limitent en rien la responsabilité de l'éditeur en cas de dommage corporel, de faute lourde ou dolosive… ». Il ne s'y trouve plus, et l'article n'en est pas affaibli, parce que ce qui le rendait nécessaire a disparu avec lui (8 août 2026, à la demande du porteur de projet, gêné de voir l'atteinte à la personne nommée dans un service de dates d'échéances).

Deux clauses de limitation subsistaient, et c'est à elles seules que la soupape servait : « l'éditeur ne répond pas des conséquences… » et, en gratuit, « n'ouvre droit à aucune indemnité ». Elles ont été retournées en non-promesses :

Forme écartée Forme retenue Pourquoi
« l'éditeur ne répond pas de X » « le service ne garantit pas X » La première réduit le droit à réparation (R212-1, 6° — liste noire), la seconde définit l'obligation. Portée utile identique.
« n'ouvre droit à aucune indemnité » « aucune somme n'ayant été versée, ne donne lieu à aucun remboursement » La première écarte une réparation, la seconde constate qu'il n'y a rien à rendre.

Il ne reste donc aucune clause de limitation dans les trois pages légales, et plus rien à sauver par un rappel de ce qu'elle ne limite pas. Un test tient l'invariant sur les formes de rédaction, pas sur un mot : ne peut être tenu responsable, ne répond pas de, décline toute responsabilité, aucune indemnité, dans la limite du montant sont interdites sur les trois pages et dans les deux modes (legal.test.tsx). C'est par une phrase ajoutée de bonne foi que le problème reviendrait, pas par une réécriture assumée.

L'exposition réelle est inchangée, et il faut le dire clairement : elle ne dépendait d'aucune de ces phrases. Une clause limitant la réparation d'un dommage corporel est nulle d'ordre public quoi qu'on rédige — aucun texte n'achète cette protection. Ce qui protège est la chaîne de causalité, attaquée par les quatre paragraphes de périmètre : pas de conseil, obligation de moyens, utilisateur seul décideur tenu de vérifier auprès d'un professionnel avant tout acte, et le silence du service qui ne décharge de rien. Le seul transfert de risque qui existe pour le résiduel n'est pas contractuel mais assurantiel.

En gratuit, la qualification de « professionnel » est douteuse et ce régime ne joue peut-être pas. Le texte est écrit au standard le plus strict quand même : ça ne coûte rien, et le même article sert alors les deux modes sans divergence.

Il devient un article autonome, placé juste après l'objet. Il vivait en deuxième paragraphe d'un article intitulé « Objet du service », où personne ne le trouvait — ni un lecteur, ni un juge, qui cherchent l'un comme l'autre un article portant son nom.

2. Les CGU ne couvrent pas la surface la plus exposée : les guides

Des conditions générales n'engagent que ceux qui les ont acceptées. En mode gratuit il existe bien un point d'acceptation réel — le mur porte « en créant ton accès, tu acceptes les conditions générales d'utilisation ». Mais les 78 guides sont publics, indexés, et deux se lisent en entier sans compte (D-042). Quelqu'un qui arrive de la recherche, lit, agit et provoque un dégât n'a jamais rien accepté : c'est le scénario exact que la demande visait, et les CGU ne le couvrent pas du tout. Second trou, plus étroit : la personne lésée peut ne pas être le titulaire du compte — un conjoint, un locataire, un voisin —, et la responsabilité est alors extracontractuelle (art. 1240).

Une phrase au pied de chaque guide, à côté de la référence à la source, et rien d'autre. Pas de bandeau, pas de modale, rien en tête d'article : c'est là que le lecteur cherche déjà d'où vient l'information, donc là où « et voilà ce que ça ne garantit pas » se lit sans friction. Un avertissement répété partout devient du décor, et on cesse de le voir à l'endroit où il compte.

3. Modifier sans préavis est possible — et le préavis protège d'abord l'éditeur

La demande était « sans préavis, c'est gratuit, j'ai le droit ». Sur l'arrêt du service, la fermeture d'un accès et la fin de la gratuité : oui, sans réserve — aucune durée déterminée, aucune contrepartie versée, aucun droit acquis à un service gratuit.

Sur la modification des conditions, la réponse est plus subtile et va dans le sens inverse de l'intuition : des conditions générales n'engagent que si elles ont été acceptées (art. 1119 du Code civil), donc une version changée en silence n'est pas opposable à qui ne l'a jamais vue. Sans préavis, l'éditeur se retrouverait à invoquer un texte que l'utilisateur peut dire n'avoir jamais accepté. La construction retenue donne « à tout moment » sans délai chiffré tout en restant opposable : la version applicable est celle publiée sur la page au moment de l'utilisation, sa date de mise à jour est affichée, et la poursuite de l'usage vaut acceptation.

4. Ce qui n'est PAS écrit, et pourquoi

La demande incluait qu'un compte gratuit puisse devenir payant « sans que rien ne puisse y être opposable ». Cette clause n'existe pas dans le texte, et aucune rédaction ne la rendrait valable : l'art. L121-12 du Code de la consommation interdit la fourniture d'un service assortie d'une demande de paiement sans commande préalable. Une clause nulle ne protège rien et signale au lecteur qu'on a essayé.

La liberté commerciale visée est pourtant entière sans elle : rendre le service payant pour l'avenir, y compris pour les accès déjà ouverts, et fermer l'accès de qui ne souscrit pas, sans indemnité. Ce qui reste impossible — débiter quelqu'un qui n'a rien accepté — l'est de toute façon en pratique, aucun moyen de paiement n'étant conservé.

Sequencing à retenir : le jour où le service devient payant, il faut le SIREN. Encaisser sans immatriculation rend les conditions inopposables (art. L8221-3 du Code du travail). Le code le gère seul — la bascule en payant fait revenir les neuf mentions et l'encart d'avertissement.

5. Les articles se numérotent tout seuls

Ils étaient écrits à la main, avec des {gratuit ? "6" : "7"} de part et d'autre du branchement. Tenable à neuf articles, intenable dès qu'on en insère deux au milieu : chaque numéro postérieur devait être décalé dans les deux modes, et un oubli produit deux articles portant le même chiffre — une référence croisée qui ne désigne plus rien dans un document contractuel. Un compteur déclaré dans le composant (jamais au module, qu'un second rendu ferait repartir de neuf) remplace les quinze littéraux ; le test de numérotation reste, comme filet.

D-066 — 8 août 2026 · Le gratuit n'a pas d'entreprise derrière : plus d'identité, plus de compteur, un seul mail

Révise D-065, livré le matin même, sur trois points relevés à l'usage.

1. Un service édité sans structure ne peut pas afficher une identité qu'il n'a pas

Les trois pages légales réclamaient neuf mentions en payant, cinq en gratuit — identité, siège, responsable de la publication, hébergeur, contact — et affichaient « MIAW » comme éditeur. Or il n'y a pas d'entreprise : ni SIREN, ni siège, ni raison sociale. Chacun de ces champs restait donc en « à compléter », avec un encart rouge en tête des trois pages, indéfiniment.

En gratuit, il ne reste qu'une mention réclamée : l'adresse de contact. Tout le reste disparaît — pas mis à blanc, retiré. Le bloc « Éditeur du site » dit ce que le service est (« un service personnel, édité et exploité à titre non professionnel, sans structure commerciale : rien n'y est vendu, aucune publicité n'y est diffusée et aucune donnée n'y est revendue »), l'hébergement dit où il tourne sans nommer d'hébergeur tiers puisqu'il n'y en a pas, et « MIAW » cède la place à « fokan » dans les trois documents.

Le contact, lui, n'est pas négociable, et ce n'est pas une identité d'entreprise — c'est une boîte. L'art. 13 du RGPD impose de dire au lecteur où exercer ses droits ; une politique de confidentialité sans destinataire ne vaut rien, et c'est aussi par là qu'arrivent les signalements d'une règle inexacte, qui profitent à tous les foyers. contact@fokan.fr existe déjà, donc l'encart rouge ne s'affiche plus.

Ce qui est assumé et doit être écrit ici plutôt que maquillé : l'art. 6 III-2 LCEN, qui ouvre l'anonymat aux éditeurs non professionnels, le subordonne à ce qu'un hébergeur tiers détienne l'identité — condition qu'un service auto-hébergé (§ 22) ne remplit pas. Le produit cesse donc de réclamer ces champs à un écran, ce qui est la décision de l'éditeur ; il ne prétend pas pour autant que leur absence est sans conséquence, et le commentaire de mentionsManquantes le dit en toutes lettres. Le jour où le mode repasse en payant, les neuf mentions redeviennent obligatoires et l'encart rouge revient de lui-même — c'est le même code, avec l'autre branche.

2. Le mur ne compte plus les places, et la file cesse d'être un mérite

Le mur annonçait « il reste 100 accès à ouvrir » puis « 100 accès ouverts à ce jour ». Les deux sont retirés. Le premier fabrique une urgence là où le produit s'interdit tout compte à rebours (§ 4.5) ; le second publie la taille du service à qui passe. Le seul chiffre qu'un visiteur voit désormais est son propre rang, une fois inscrit — une information qui le concerne, pas une métrique de l'entreprise.

Le refus se dit autrement : plus « les accès sont complets », mais « on est victimes de notre succès », suivi de la raison (« la veille interroge une quinzaine de sources publiques par foyer, et on ouvre les accès par vagues pour continuer à le faire correctement ») et du geste (« prendre ma place dans la file »). C'est la même information, mais elle explique au lieu de constater — et « ta place est prise » remplace « tu es sur la liste », qui rangeait le visiteur au lieu de le servir.

3. Un seul mail, quel que soit le mode et quel que soit l'état de la file

Le lien magique portait six contextes depuis D-065. Il en porte deux : connexion et invitation. activation (« le paiement est passé »), inscription, attente et place-liberee sont tous retombés sur connexion.

Ce n'est pas une simplification cosmétique, c'est la seule façon d'écrire un message qui reste vrai dans les quatre situations où il part. « Ton calendrier est déverrouillé » ment à qui attend une place. « Ta place s'est libérée » suppose que le lecteur se souvienne d'avoir patienté, et transforme une bonne nouvelle en rappel qu'il a fait la queue. Le lien, lui, mène au même endroit dans tous les cas — son foyer, qui dira son état mieux qu'un objet de message. Le texte ne promet donc qu'une chose : « Ce lien t'ouvre ton foyer directement, sans mot de passe. » Il ne parle ni de prix, ni d'abonnement, ni de gratuité, ni de place ; la bascule de mode ne le touche pas.

Effet de bord heureux sur la sécurité, et déplacement de la cible d'attaque. invitation devient le seul contexte qui AFFIRME quelque chose (« untel t'invite »), donc le seul que le sceau interne ait à protéger : un message générique n'a plus rien à usurper. Mais l'attaque de l'audit du 28/07 (S2) n'a pas disparu, elle a changé de cible — urlFoyer est rendu en lien cliquable, et un metadata non scellé qui le poserait ferait partir depuis noreply@fokan.fr, domaine aligné SPF/DKIM/DMARC, un message contenant le lien de l'attaquant. lireMetadata le jetait déjà ; un test le grave désormais, à la place de celui qui gardait « activation ».

Ce qu'on perd, et qui est assumé : le ton d'accueil du premier accès. Un foyer qui vient de s'inscrire reçoit « Ton lien de connexion fokan » plutôt qu'un mot de bienvenue. C'est le prix d'un message qui ne peut jamais démentir ce que la page affiche — et l'audit mail § 1 refusait déjà un gabarit de plus pour un chemin que le premier couvre.

D-065 — 8 août 2026 · Le mode d'accès devient une variable : gratuit ou payant, sans casser l'existant

Contexte : décision du porteur de projet de passer fokan en accès gratuit, avec un plafond de comptes et une liste d'attente au-delà. La consigne est explicite — on ne case pas l'existant, on rend modulaire : le payant doit rester atteignable d'un mot dans un fichier d'environnement, au cas où le modèle y revienne.

1. Une variable, deux modes, et une porte qui ne connaît ni l'un ni l'autre

FOKAN_MODE vaut gratuit ou payant, défaut payant. Le défaut n'est pas une commodité : une variable absente ou mal orthographiée en production doit laisser le produit dans l'état qu'on lui connaît, jamais ouvrir l'accès complet à tout le monde sur une faute de frappe. Seul le mot exact bascule ; GRATUIT, free, 1 et gratui restent payants, et un test le grave.

La pièce qui rend la bascule bon marché était déjà là : getSubscription().isActive est la seule porte de tout le produit — hub, flux .ics, gestes, rappels, catalogue, rejeu du quiz, quinze appelants. Elle n'a pas changé. Un accès gratuit est un subscription_status = "active" sans date de fin, et !periodEnd le rendait déjà actif indéfiniment. Ce que le mode change est ce qu'on raconte à qui n'a pas franchi la porte — jamais ce qu'on lui ouvre.

Trois colonnes s'ajoutent à households, et pas une table : acces_mode (gratuit | payant), acces_le, attente_depuis. La première est celle sans laquelle rien ne marche — sans elle, le plafond de places gratuites compterait les foyers payants historiques et fermerait les inscriptions au profit de gens qui ont payé. La migration les rétro-remplit sur les foyers qui portent un identifiant d'abonnement Stripe : on écrit la vérité qu'on connaît pendant qu'on la connaît encore.

Le mode se lit côté serveur uniquement (lib/mode-serveur.ts) et descend en props jusqu'aux composants client. Ce n'est pas de la discipline gratuite : process.env.FOKAN_MODE n'existe pas dans le bundle navigateur (Next n'injecte que NEXT_PUBLIC_*), un import depuis un composant "use client" répondrait donc « payant » quoi qu'il arrive, et la page se contredirait entre son rendu serveur et son hydratation — en silence. Le vocabulaire partagé vit dans lib/mode.ts, pur et isomorphe ; les décomptes de base dans lib/places.ts, pour que les trois pages légales n'aient pas à traîner un pool Postgres pour afficher un titre.

2. La liste d'attente n'existe pas

C'est la conception, pas une pirouette : il n'y a ni table, ni file, ni rang stocké. Un foyer sans place porte subscription_status = "attente" au lieu de "active", et le passage quotidien de 4 h bascule les plus anciens quand il reste de la place. La seule chose ajoutée au produit est une valeur de statut et une colonne de date — et c'est ce qui rend la bascule inverse gratuite : repasser en payant ne demande de supprimer aucun objet, seulement de cesser d'admettre.

Ce que « compte latent » veut dire, précisément : le compte existe pour de bon (il est dans user, il est membre du foyer, il peut se connecter, son quiz a tourné, ses échéances sont calculées et continuent de se recalculer chaque nuit) ; ce qui n'est pas ouvert, c'est le déverrouillage. Un foyer en attente est exactement dans l'état d'un foyer gratuit d'avant — l'aperçu, rien de plus — à ceci près qu'il n'a plus rien à faire pour en sortir.

Le rang ne se stocke pas, il se recalcule. C'est la règle déjà appliquée au quota de rejeux (D-060) et aux conditions de ressources (D-051) : on stocke ce que le foyer a fait, jamais ce qu'on en conclut. Un rang en base serait un verdict à réécrire à chaque admission et à chaque suppression de compte, et qui mentirait entre-temps.

Relever la limite ne demande rien d'autre que de relever la limite. FOKAN_PLACES_GRATUITES est relue à chaque exécution du cron ; passer de 100 à 150 se traduit par cinquante admissions le lendemain matin, chacune avec son mail. Pas de commande à taper, pas d'écran d'administration à construire. Trois choix expliqués :

  • La décision se prend à l'écriture, pas à l'affichage. Décider à la lecture donnerait la place à celui qui recharge sa page le plus souvent.
  • L'admission est quotidienne et non immédiate, parce que la seule chose qui bouge est le plafond, et qu'il bouge quand un humain édite un fichier. Un passage par jour est à l'échelle de cet événement-là.
  • 0 est un plafond légitime, pas un synonyme de « vide » : il ferme les inscriptions et met tout le monde en attente — le geste d'un lancement qu'on prépare. Un plafond abaissé sous le nombre d'accès déjà ouverts ne ferme personne (invariant n° 3) et ne rend jamais un nombre de places négatif ; un test tient ce cas, parce qu'un restantes: -12 passé en limit() ferait planter le passage quotidien.

3. Le full reveal quitte le paiement pour l'inscription — mêmes chemins, sans Stripe

POST /api/activer est le pendant exact de /api/checkout : même écran, même position dans le parcours, même geste. Ce qui change est la contrepartie — une adresse au lieu d'une carte — et ce qui disparaît avec elle : le prix, la case de renonciation au droit de rétractation (pas de contrat à distance à titre onéreux, donc rien à quoi renoncer), et le passage par un tiers.

L'adresse redevient obligatoire, et ce n'est pas un retour au mur e-mail de D-005. Ce mur-là se dressait avant toute contrepartie ; celui-ci vient après que le foyer a vu son calendrier. Et l'adresse est le compte : sans elle, ni destinataire de rappel, ni moyen de retrouver son foyer, ni place à tenir dans une file. En payant, c'est Stripe qui la collecte au même endroit du parcours — on ne demande rien de plus, on le demande soi-même.

Aucune session n'est ouverte sur une adresse simplement tapée. Le déverrouillage suppose une session (D-017 : le lien seul ne suffit plus une fois l'accès ouvert), et connecter quelqu'un sur une adresse non prouvée donnerait le foyer à quiconque saisirait celle d'un autre. Le lien magique est ce qui prouve la boîte ; c'est un aller-retour, pas une friction gratuite. La route est limitée à cinq appels par quart d'heure et par IP — elle crée un compte et fait partir un mail vers une adresse fournie par l'appelant, c'est-à-dire qu'elle serait sans cela un relais d'envoi depuis un domaine aligné SPF/DKIM/DMARC.

Le rattachement du compte au foyer, vingt lignes qui vivaient dans le webhook Stripe (D-049), est extrait dans lib/compte-foyer.ts et partagé par les deux portes : deux copies auraient divergé au premier correctif, sur le chemin dont l'enjeu est qu'un foyer ait un destinataire de rappel. Une seule différence subsiste, et elle est passée en argument : emailVerified vaut true côté Stripe (qui vient d'envoyer un reçu à cette adresse) et false côté formulaire — marquer vérifiée une adresse tapée ferait dire au produit qu'il a constaté quelque chose qu'il n'a pas constaté.

Le mode gratuit ferme /api/checkout au serveur, pas seulement à l'écran. Un mur qui ne propose plus de payer ne suffit pas : la route reste appelable, et une clé Stripe encore présente dans l'environnement — le cas normal d'une bascule qu'on veut pouvoir défaire — la ferait aboutir. On encaisserait alors 19,99 € pour un accès offert.

/api/stripe/portal n'est PAS fermé, et c'est une obligation. Un abonnement souscrit en ligne doit se résilier en ligne, en trois étapes au plus, depuis une fonctionnalité accessible en permanence (art. L215-1-1 du Code de la consommation ; 15 000 € d'amende administrative au manquement). Fermer la porte de sortie parce qu'on a fermé la porte d'entrée enfermerait précisément ceux qui ont payé.

4. Trois contextes de mail, pas trois mails

Le lien magique en portait déjà trois (connexion, invitation, activation) ; il en porte six. inscription (l'accès s'ouvre), attente (la place est prise, l'accès non), place-liberee (le passage quotidien a admis le foyer). Ce n'est donc pas un sixième message au sens de l'audit mail : c'est la même mécanique, avec sa table de messages, et les nouveaux contextes sont soumis au même sceau interne que les autres — sans quoi n'importe qui pourrait faire partir depuis noreply@fokan.fr un « ta place s'est libérée » avec un lien de connexion qui fonctionne.

Deux tests gardent le piège le plus probable : attente est le seul de la famille à ne pas annoncer un accès ouvert, et un copier-coller depuis inscription produirait un message intitulé « Ton foyer est ouvert » suivi d'un lien menant à un aperçu verrouillé — ce qui se lit comme une panne, pas comme une file d'attente. Le second vérifie qu'aucun contexte hors activation ne parle de paiement.

place-liberee est le seul point où la liste d'attente parle à l'extérieur, et il est indispensable : sans lui, un compte admis six semaines plus tard ne l'apprendrait qu'en revenant de lui-même sur un site qu'il a peut-être oublié — soit exactement le contraire de la promesse du produit.

5. Les pages légales : ce qui tombe avec la vente, et ce qui n'en dépendait pas

/cgv garde son URL et change de titre. Elle est liée depuis le pied de page, depuis le mur, depuis d'anciens mails et probablement depuis des signets ; déplacer un document contractuel à chaque bascule casserait ces liens dans les deux sens. C'est le titre qui nomme un document, pas son chemin.

Cinq mentions ne tiennent qu'au commerce, et les afficher comme manquantes sur un service gratuit serait faux au sens strict — pas seulement inutile. mentionsManquantes() prend donc le mode :

Mention Pourquoi elle tombe en gratuit
SIREN art. 6 III-1 LCEN — exigé de l'éditeur professionnel
Téléphone art. L221-5 et R111-1, 4° — régissent la vente à distance
Médiateur de la consommation art. L616-1 — litiges nés d'un contrat à titre onéreux ; l'adhésion est de surcroît payante, la réclamer reviendrait à faire payer la gratuité
Régime de TVA il n'y a pas de prix à qualifier
Garantie légale de conformité art. L224-25-1 — contrats conclus « contre un prix » ou contre des données fournies en contrepartie ; le second cas ne joue pas, l'article 2 des CGU l'engageant en toutes lettres

Ce qui reste dû dans les deux modes : identité, siège, responsable de la publication, contact, hébergeur. Le régime allégé du III-2 LCEN ne joue pas — il suppose qu'un hébergeur tiers détienne l'identité, alors que le service est auto-hébergé (§ 22).

Deux articles apparaissent en gratuit, et aucun n'est décoratif. Gratuité : un service gratuit qui ne dit pas comment il se finance laisse le lecteur conclure « c'est moi le produit » — on répond par les engagements vérifiables que la politique de confidentialité tient déjà (aucune publicité, aucune revente, aucun traceur), et par la clause qu'un passage au payant ne serait jamais rétroactif. Nombre d'accès et liste d'attente : elle décrit une limitation que l'utilisateur subit, et la taire dans un document contractuel serait le silence qu'on reproche aux autres.

La politique de confidentialité perd Stripe quatre fois — sous-traitant, destinataire, transfert hors UE, durée de conservation comptable. Déclarer un sous-traitant qui ne traite rien n'est pas prudent, c'est inexact, et l'art. 13 impose l'exactitude. Les foyers ayant payé avant la bascule existent : le texte les nomme au passé plutôt que de faire comme s'ils n'avaient jamais existé.

Un défaut sans rapport corrigé en passant : les mentions légales renvoyaient encore les sources vers « la page d'accueil », où D-064 les a retirées la veille.

6. Le vocabulaire, et ce qu'on a refusé d'y mettre

lib/mode.ts porte le vocabulaire qui se répète mot pour mot d'un écran à l'autre — contrepartie, bénéficiaire, CTA, prix, nom des conditions — plus deux formules composées (reserveAux, ceQuiLOuvre). Les phrases entières dont la structure change restent dans leur composant, en ternaire : une table de chaînes qui grossit finit par rendre illisible ce que la page raconte. On mutualise le vocabulaire, jamais le discours.

Deux messages ont été neutralisés plutôt que dupliqués : le quota de rejeux disait « cinq mises à jour par année d'abonnement » et « le compteur repart à la prochaine période d'abonnement ». Il dit « par an » et « à la prochaine période », vrai dans les deux modes — six points d'appel évités, dont un composant client, pour deux phrases.

Une subtilité qui a failli coûter cher : debutHistorique a été séparée de debutPeriodeCouverte. La borne basse de l'historique d'un accès gratuit est sa date d'ouverture, mais le quota de rejeux se compte contre la même fonction — lui donner une borne fixe aurait condamné tout inscrit gratuit à cinq mises à jour à vie, exactement le défaut que le repli en année glissante de D-060 avait été écrit pour éviter.

Le prix a une seule source. EDITEUR.prixAnnuelTtc (lib/legal.ts) le tient pour que CGV et encart ne divergent pas ; MOTS.payant.prix le recopiait, et un test tient désormais l'accord des deux.

D-064 — 8 août 2026 · La page d'accueil cesse de publier ses sources, et deux sections cessent de se répéter

Contexte : six retouches demandées par le porteur de projet sur la page d'accueil — vocabulaire, masquage des sources, une phrase à retirer, une nuance à ajouter, et « il y a des redondances entre Ce qu'on va chercher pour toi et Ce que personne ne retient ». Révise le § 5 de D-033, qui avait au contraire réaligné et enrichi la liste des sources.

Le détail des sources quitte la landing. Le bloc « d'où vient ce qu'on sait » nommait douze jeux de données, chacun avec son hôte, son lien et ce qu'il apporte — c'est-à-dire le plan de montage du produit, offert à qui voudrait le recopier. D-033 avait poussé dans l'autre sens (« nommés et vérifiables ») pour rendre contrôlable la promesse du bandeau ; l'arbitrage est renversé, et le composant landing/sources.tsx est supprimé plutôt que commenté. La section reste, réduite à la méthode : qu'une source officielle ne dispense pas de relire chez la collectivité, et que chaque relevé porte sa date. Ce qui est perdu est réel — un visiteur ne peut plus vérifier la promesse depuis la page publique — mais les sources continuent d'être nommées là où elles engagent : chaque guide affiche sa référence (template.source) et chaque carte la source de sa couche. On ne cache pas d'où vient une règle à qui la lit ; on cesse d'en publier l'inventaire à qui ne fait que passer.

« Un service gratuit se paie toujours — en publicité, ou en revente de données. Ici, tu es le client, pas le produit. » est retirée. L'en-tête de sources.tsx en faisait la seule phrase de posture économique restant sur la landing depuis que le prix l'a quittée (D-028), et l'argument avait du poids : sans elle, le caractère payant se découvrirait après dix questions, ce qui se vit comme un péage caché. Vérification faite, il en reste deux mentions, toutes deux au moment du geste — « sans compte, sans carte bancaire » sous le bouton du hero, et « s'affichent tout de suite, sans payer » dans le CTA final. La posture disparaît, l'information demeure. La deuxième colonne du bloc, qui serait restée vide, dit désormais la clause d'effacement (D-023) en langage de visiteur : un barème périmé cesse de filtrer plutôt que de filtrer faux, et dans le doute l'échéance s'affiche.

Deux mots changent partout sur la page. « Obligation » devient « échéance » — c'est le mot du produit, et « 78 obligations suivies » lisait comme un reproche là où le compteur dit un volume (§ 4.5) ; les deux descriptions SEO/OpenGraph suivent. « Abonnement » devient « inscription » sous la carte des périmètres. Une occurrence reste hors page d'accueil, sur l'écran de fabrication du quiz (src/engine/fabrication.ts) : elle n'était pas dans le périmètre demandé et attend un arbitrage sur l'ensemble du produit.

« Rien à cocher, rien à tenir à jour » devient « … si tu ne le souhaites pas ». Ce n'est pas un adoucissement : la phrase est devenue fausse le 6 août. Depuis D-055 le foyer peut cocher — « c'est fait », sourdine, responsable — et la carte « pas une to-do list » promettait donc l'absence d'une chose que le produit venait de livrer. Le refus tient toujours (rien n'est exigé, et une échéance ignorée glisse en silence), il est simplement dit juste.

Les deux sections se marchaient dessus sur trois piliers. Le bloc « Ce qu'on va chercher pour toi » et le bloc « Ce que personne ne retient » énuméraient les mêmes échéances à quelques centimètres d'écart : contrôle technique voiture et deux-roues, rappels, courroie, Crit'Air et ZFE dans les deux ; bourses, dossier social étudiant, inscriptions, affectation, Parcoursup et calendrier d'académie dans les deux ; l'arrêt de l'ADSL dans les deux. Le lecteur y voit une redite, pas deux propos. La cause est un glissement de rôle : le premier bloc, créé pour dire le mécanisme, avait fini par dresser l'inventaire du second. Les rôles sont séparés — l'inventaire par domaine reste au second, le premier ne répond plus qu'à « comment le savez-vous ? ». Ses quatre cartes deviennent Une adresse suffit / Une ligne de la carte grise suffit / Une classe suffit / Et ce qui bouge ensuite, sans plus aucune liste d'échéances, et le titre de la section devient « Ce que tu donnes, et ce qu'on en déduit » — sinon il annonçait encore l'inventaire qu'il ne fait plus. Une sur-promesse au passage : « on la situe au mètre près » devient « au point précis », le géocodage rendant un point d'adresse, pas une mesure.

Vérification : npm run typecheck, npm run lint (0 erreur, 2 avertissements préexistants), npm run knowledge:check (78 fiches, 5 barèmes à jour) et npm test (905 tests, inchangés — aucune logique, seulement de la restitution) passent. Image fokan-app reconstruite et redéployée (docker compose up -d --build app) : conteneur healthy, /api/health à 200, et la page servie ne contient plus ni « Géorisques » ni « service gratuit », mais bien « Ce que tu donnes, et ce qu'on en déduit », « échéances » au compteur et « l'inscription montre le tracé exact ». Le build se fait dans l'image, jamais sur l'hôte : le npm run build qui compte est celui de l'étape builder du Dockerfile, et .next figure au .dockerignore — le .next/ traînant sur l'hôte (1 118 fichiers appartenant à root, séquelle d'un ancien build privilégié) n'entre donc dans aucune image et ne prouve rien. Il n'y a rien à y réparer, seulement à ne pas s'en servir.

D-063 — 7 août 2026 · Un rejeu de quiz ne remplace pas un foyer, il lui apporte des nouvelles

Contexte : question du porteur de projet — « quand un abonné rejoue le quiz, on capte bien le déménagement et les nouveaux travaux ? on reprend les anciennes infos ? idem pour les contrats ? ». Trois réponses, dont une seule était bonne. Révise D-018 (le rejeu) et D-059 (les travaux).

Ce qu'un rejeu détruisait sans le dire. F8 avait tranché : les assets à exemplaires multiples sont remplacés, faute de correspondance fiable entre « l'ancienne deuxième voiture » et « la nouvelle deuxième voiture » d'une liste sans identifiant. L'argument est juste pour un véhicule, il ne l'était pas pour un contrat — qui porte un type pris dans une énumération fermée, c'est-à-dire une identité, pas une ressemblance à évaluer. On payait donc l'état utilisateur de la seule saisie opt-in du produit (§ 4.1) pour un appariement qui ne demandait aucune heuristique. Les contrats sont désormais appariés sur leur type ; ce qui disparaît du quiz est supprimé, ce qui apparaît est créé. Véhicules, personnes et animaux restent remplacés, et l'écran continue de le dire avant validation.

Une fiche morte depuis sa publication. carte-grise-changement-adresse attendait un attribut demenagement_recent que personne n'écrivait — la chaîne n'apparaissait qu'une fois dans tout le dépôt, dans l'applicabilité de la fiche elle-même. Un mois pour mettre la carte grise à jour, 135 € ensuite, et aucun foyer ne pouvait la voir. Le rejeu est pourtant le seul endroit du produit où un déménagement se constate : il tient l'ancienne adresse et la nouvelle dans la même fonction. On compare, on pose le mois — jamais un booléen, qui resterait vrai à vie et rejouerait exactement la faute que D-059 venait de corriger sur la piscine. Trois abstentions valent mieux qu'une conclusion : première adresse renseignée, adresse retirée, identifiants absents des deux côtés → on ne conclut rien. Donner son adresse pour la première fois n'est pas déménager.

Le formulaire est un message, le logement est la mémoire. La question des travaux revient à vide à chaque rejeu : la ramener cochée ferait répondre « piscine, mars » à « des travaux terminés depuis ta dernière mise à jour ? » huit mois plus tard, sans que personne ne s'en aperçoive. Mais remettre à zéro l'asset effacerait un délai encore ouvert — travaux déclarés en juin, rejeu en août pour une véranda, et l'obligation de juin s'évanouissait avec son sujet. Les déclarations s'accumulent donc sur le logement et sont dédupliquées, pendant que le formulaire repart neuf. Elles s'oublient au bout de treize mois : le délai est de 90 jours, une déclaration d'il y a plus d'un an ne peut plus rien ouvrir, et sans borne la liste grossirait à chaque passage.

Un mois par chantier, donc une échéance par mois. La forme précédente ne gardait qu'un travaux_mois pour tout le logement : deux constructions finies à trois mois d'écart n'avaient qu'une date limite, celle de la dernière déclarée. Le sujet devient le mois, plusieurs sujets coexistent, et deux chantiers du même mois restent une seule échéance — c'est une visite au service des impôts, pas deux. Seuls les quatre types qui créent de la surface y entrent : changer sa chaudière n'ouvre aucun délai foncier, et lui en annoncer un serait une date fausse tirée d'une information vraie. La liste des types vit dans src/engine/travaux.ts ; comme le DSL d'applicabilité sait dire contains_any sur des chaînes mais pas fouiller des objets, la fiche garde sa propre liste en YAML et un test tient l'accord des deux.

Ce que la migration protège. Aucune colonne ne bouge — attributes est du jsonb, c'est sa forme qui change. Sans reprise, le logement déjà déclaré en production aurait perdu sa date au premier passage de réconciliation : sujet vide, échéance datée archivée, échéance sans date à la place. Le foyer n'a rien fait ; sa déclaration ne doit pas se dégrader parce qu'on a changé d'écriture.

Vérifié sur une copie de la production, contre l'image réelle : la reprise convertit la déclaration existante sans y toucher (sa clôture tient, et son libellé reste celui qu'elle portait — une ligne close est hors moteur, l'invariant n° 3 se voit à l'œil nu ici) ; un rejeu avec adresse changée, véranda de juillet, une box retirée et une salle de sport ajoutée rend deux déclarations foncières vivantes, la carte grise réveillée, les cinq contrats conservés avec leur identifiant, le sixième supprimé, le septième créé ; un second rejeu à l'identique ne bouge plus rien. L'écran a été conduit dans un navigateur réel : zéro select avant de cocher, un par travaux ensuite, deux mois distincts tenus, décocher n'emporte que sa ligne, aucune erreur de console. 905 tests au vert.

Une conséquence assumée, et une limite connue. Le déménagement est reposé sur des véhicules recréés à chaque rejeu : un « c'est fait » sur la carte grise ne survit donc pas à un rejeu ultérieur, comme tout l'état des véhicules depuis F8. Et la rétention du déménagement est de quatre mois quand celle des travaux est de treize — même rapport au délai (un mois, 90 jours) : assez large pour qu'un rejeu rapproché ne perde pas l'obligation, assez court pour qu'une ligne close depuis un an ne traîne pas. Passé ce terme la fiche cesse de s'appliquer ; elle n'a jamais eu vocation à tenir le compte de ce qui n'a pas été fait (§ 4.5).

D-062 — 7 août 2026 · Le mois des travaux se choisit dans une liste, il ne s'écrit plus

Contexte : demande du porteur de projet — normaliser « Terminés vers quel mois ? », et retirer l'aide « Une construction neuve se déclare aux impôts dans les 90 jours — c'est ce qui ouvre l'exonération de taxe foncière » sous la question des travaux (D-059).

Ce que le champ libre risquait, et qui dépasse le champ. <input type="month"> rend AAAA-MM quand le navigateur connaît ce type de champ ; ceux qui ne le connaissent pas affichent un champ texte ordinaire, où « mars 2026 » s'écrit aussi bien. La conséquence n'est pas locale : windows.ts et sujets.ts testent ^\d{4}-\d{2}$ et se contenteraient de ne rien dater, mais le Zod de POST /api/quiz valide le même motif et refuse alors tout le quiz en bloc. Une réponse facultative pouvait faire perdre le passage entier.

Une liste des 13 derniers mois, mois courant inclus, plus « Je ne sais pas ». Il n'y a plus de valeur à valider, seulement des valeurs à choisir — et le mois arrive dans la forme exacte où l'échéance le lira, sans nettoyage en aval. Aucun mois à venir : des travaux qui ne sont pas finis n'ouvrent aucun délai, et une date future donnerait une limite fausse plutôt qu'aucune limite.

Le mois déjà déclaré figure toujours dans la liste, même trop ancien pour elle. Un rejeu (D-018) ramène des réponses vieilles de plusieurs mois ; sans ce rattrapage, la liste s'afficherait vide et valider effacerait une réponse que personne n'a retirée — l'invariant n° 3 vaut aussi pour ce qui est vieux.

Vérifié dans un navigateur contre une image de test isolée : balise <select>, 14 options, « Juin 2026 » écrit 2026-06, « Je ne sais pas » efface le champ au lieu d'y laisser une chaîne vide, zéro erreur console. 7 tests sur la construction de la liste (passage d'année, format relu par le moteur, mois ancien conservé), 865 tests au vert.

D-061 — 7 août 2026 · Des réponses écrites hier ne doivent pas faire tomber l'écran d'aujourd'hui

Contexte : « le quiz plante après la question 3 ». Reproduit en trois minutes dans un navigateur réel contre la production : question 3 → Continuerécran blanc, Cannot read properties of undefined (reading 'includes').

La cause. Les réponses reviennent à l'écran par deux chemins — le brouillon local d'un quiz interrompu (sessionStorage) et le rejeu d'un foyer, dont les réponses dorment en base depuis des mois (D-018) — et les deux fusionnaient par simple étalement : { ...ANSWERS_VIDES, ...brut }. Or étaler remplace logement en entier. Un enregistrement d'avant D-059 (la veille) n'a pas de travaux_recents ; l'écran des équipements appelle .includes dessus ; React démonte tout l'arbre.

Ce n'était pas un cas de bord. Les quatre foyers de production ont un quiz_answers.logement sans travaux_recents — vérifié en base : les quatre auraient eu l'écran blanc en rejouant leur quiz, c'est-à-dire au premier usage de ce qu'on venait de livrer la veille. Et tout visiteur qui avait un quiz en cours au moment du déploiement le voyait aussi, sans avoir rien fait.

La parade est structurelle, pas ponctuelle. Combler travaux_recents aurait repoussé le même écran blanc au prochain champ ajouté. Les formes du quiz sortent donc dans src/app/quiz/answers.ts, avec une seule porte pour les réponses venues d'ailleurs : normaliserAnswers, qui fusionne champ par champ et dont le type de retour est un Answers complet — un champ ajouté demain ne compile pas tant qu'il n'a pas sa valeur de repli. Le contrat tient en une phrase : tout champ absent reprend son défaut, aucun champ présent n'est réécrit. Un logement d'avant D-059 revient donc sans travaux déclarés, ce qui est exactement la vérité de ce que son foyer a répondu — on complète, on n'invente pas.

Ce que la leçon coûte de plus : la même famille de défaut existait déjà et avait déjà été traitée une fois, à la main, pour les enfants d'avant D-019 (normaliserEnfants). Un cas particulier corrigé n'est pas une règle établie — c'est le signal qu'il en manquait une.

Vérifié : 6 tests sur la normalisation (chaque champ-tableau éprouvé absent, pas seulement celui qui est tombé), 858 tests au vert, et le parcours rejoué dans un navigateur réel contre le conteneur de production — le même brouillon d'avant D-059 traverse maintenant les questions 3 à 8 sans une erreur de console, en gardant les réponses qu'il portait.

D-060 — 7 août 2026 · Le rejeu du quiz se compte par abonnement, plus par mois

Contexte : demande du porteur de projet — cinq rejeux pendant la durée de l'abonnement, reconduits avec lui, au lieu d'un par mois. Révise le garde-fou de D-018 (audit F8).

Ce que le garde-fou mensuel comptait vraiment. Il ne comptait pas des rejeux, il comptait le temps écoulé depuis le dernier : un délai de carence de 30 jours. Or son objet, lui, est bien un volume — chaque rejeu remplace les assets multiples, purge leurs échéances et relance une réconciliation complète (D-018). La carence rate donc les deux bouts : elle refuse le second passage à qui déménage la veille d'acheter une voiture — deux corrections légitimes, à un jour d'intervalle, dont la seconde attend un mois —, et elle n'a jamais borné le total, qui restait de douze par an. Un budget par période sert le même but sans punir la simultanéité : cinq passages d'affilée le même jour sont permis, le sixième attend la reconduction.

Ce qu'on stocke est la liste des dates, jamais le verdict. households.quiz_rejoue_le (un horodatage) devient quiz_rejeux (timestamp[]), et le quota se recompte à chaque lecture contre la période payée en cours. Conséquence directe, et c'est la raison du choix : la reconduction annuelle relève le quota sans qu'aucun code ne tourne à cette date — pas de compteur à remettre à zéro, pas de tâche planifiée, pas de colonne « période de référence » qui pourrait diverger de Stripe. C'est la règle de D-051 appliquée ici : on garde ce que le foyer a fait, jamais ce qu'on en conclut. La borne basse est debutPeriodeCouverte, la même fonction qui borne déjà l'historique du hub — une seule définition de « la période en cours » dans le produit.

Le repli, pour les foyers sans période connue. subscription_period_start n'est renseigné que depuis le 05/08/2026. Compter tous les rejeux depuis toujours condamnerait le plus ancien des abonnés à cinq mises à jour à vie ; on borne donc à une année glissante — la durée réelle de la seule formule (§ 10.1) — et le quota repart un an après le plus vieux rejeu retenu. On ne prononce jamais une date de facturation qu'on ne connaît pas.

Aucune promesse de reprise qu'on ne tienne pas : un abonnement résilié en fin de période (cancel_at_period_end) garde ses rejeux restants, mais son écran ne dit plus « le compteur repart le … » — il dit « à la prochaine période d'abonnement ». Même exigence que pour l'annonce du renouvellement dans subscription_cancel_at_period_end : le produit ne relance pas, il n'a pas non plus le droit d'annoncer un prélèvement qui n'aura pas lieu.

Le reste à consommer n'est annoncé qu'à partir du premier rejeu. « Il te reste 5 mises à jour » affiché à quelqu'un qui n'a rien fait est un compteur à surveiller, donc de la comptabilité morale (§ 4.5) ; « il te reste 2 mises à jour d'ici le 12/09/2027 » est un fait dont il a besoin avant de cliquer. Le refus, lui, est annoncé avant le clic sur les trois écrans qui mènent au rejeu (mon espace, la page du quiz, le préremplissage) — envoyer quelqu'un sur un formulaire qui refuse est l'inverse de la règle d'or.

L'écriture est un array_append en SQL, pas un lire-modifier-écrire : deux rejeux concurrents en perdraient un, et un rejeu non compté est exactement le trou que le quota ferme.

Migration en deux temps (0022, 0023) : la colonne est ajoutée et la dernière date connue reprise dans la liste avant que l'ancienne ne tombe. Les rejeux antérieurs à celui-là n'ont jamais été conservés — la liste démarre donc au plus bas, ce qui sur-inclut d'au plus un rejeu réel plutôt que de rendre cinq passages à qui vient d'en faire un.

Vérifié : 9 tests sur le calcul pur (src/engine/quiz-quota.ts, sans base ni session — donc importable aussi par l'écran client qui affiche le refus), dont les cinq rejeux du même jour, la période précédente qui ne pèse plus, la borne exacte du début de période, et le silence sur la date de reprise d'un abonnement résilié. tsc --noEmit propre, 852 tests au vert.

D-059 — 7 août 2026 · Les fiches sans date se coupent en trois, et « avoir une piscine » cesse d'ouvrir un délai

Contexte : question du porteur de projet sur les 15 fiches durablement sans date — « il faut dissocier les savoirs latents des risques adressables une seule fois ». Le découpage retenu ne se fait pas sur la nature de la fiche mais sur ce qui la fait revenir, parce que c'est en ces termes que le moteur raisonne depuis D-058. Et l'identité d'une échéance étant fiche × asset × sujet, les trois familles tombent exactement sur ces trois pièces.

① Fait une fois, fait à vie — 5 fiches. recensement-jdc, plomb-crep, amiante-etat, critair-vignette, pprt-travaux-prescrits. Les descriptions le disent d'elles-mêmes (« fait une fois et négatif, il vaut à vie »). Aucun changement : clore + fenêtre fixe donne déjà le bon comportement. Et quand l'occasion est un objet neuf — une seconde voiture pour la vignette — c'est l'assetId qui change, donc une ligne neuve, sans rien à écrire.

② Fait pour cette fois, un événement du foyer la ramène. Traitée ici pour declaration-fonciere-90j, la seule dont le déclencheur était réellement faux.

③ Un savoir qui ne se fait pas — 5 fiches, et le geste disparaît. zfe-restriction-actuelle, decence-energetique, inondation-gestes, courroie-humide-prise-en-charge, chaine-aac-dv5-prise-en-charge. On ne « fait » pas un état du monde. Plutôt que de renommer le bouton, on le retire : comportement_fait gagne la valeur aucun, et l'écran n'affiche aucun geste.

iode-pastilles bascule en ③, délibérément et contre sa nature. Retirer les comprimés EST un acte. Mais les boîtes périment, et la recherche du jour a montré qu'aucune source publique ne donne le calendrier des campagnes ASNR — le seul flux disponible est celui des avis d'incidents (reglementation-controle.asnr.fr/rss/avis-incidents-INB, vivant, 50 items, mars→août 2026), écarté pour trois raisons mesurées : aucune clé de rattachement (1 item sur 50 porte un n° INB, 0 un code INSEE, le nom du site est en prose libre), aucun niveau INES sur 49 items sur 50, et un contenu qui n'est pas un signal de danger public mais des non-conformités d'exploitation, une dizaine par mois. Laisser un foyer clore la fiche lui ferait croire l'affaire réglée jusqu'à une péremption que rien ne peut lui rappeler. La ligne reste affichée : ça coûte une ligne, là où se taire coûterait la boîte au moment où elle sert.

Le champ devient obligatoire. comportement_fait était optional() alors que les 78 fiches le déclarent. Une omission sur une fiche neuve aurait fait disparaître son geste en silence ; elle fait maintenant échouer la CI. Un test garde l'autre bout — la liste exacte des six sans geste, pour qu'un aucun posé par distraction se voie.

Un second interrupteur retiré au passage : « pas de rappel par mail » s'affichait sur les 14 échéances non datées de production, qui ne peuvent envoyer aucun mail (AVANCE_JOURS.libre vaut null). Un interrupteur qui n'éteint rien est pire qu'un interrupteur absent.

Le défaut de fond, trouvé en instruisant la famille ②. declaration-fonciere-90j se déclenchait sur equipements contains piscine — c'est-à-dire posséder une piscine, pas venir d'en construire une. Un foyer qui a la sienne depuis dix ans lisait « 90 jours pour déclarer » à vie, sans date, donc sans qu'aucun mail ne puisse partir. Le déclencheur n'était pas approximatif, il était faux.

Correctif : une déclaration de travaux au quiz, sur l'écran des équipements — pas d'écran de plus. Six puces (piscine, abri, véranda, extension, isolation, chauffage) et un mois qui n'apparaît qu'une fois quelque chose de coché : on ne demande jamais une date pour rien. Nouvelle stratégie declaration_travaux, fenêtre = 1er du mois + 90 jours, confiance connue, et AVANCE_PAR_STRATEGIE.declaration_travaux = 90 pour que le rappel parte à l'ouverture du délai et non à sa fin — même dessin que la CatNat, pour la même raison : un mail qui annonce « 90 jours » alors qu'il en reste trois est une insulte polie.

Le 1er du mois plutôt que le jour réel : ça avance la limite d'au plus trente jours, donc on prévient trop tôt plutôt que trop tard. C'est le seul sens d'erreur acceptable sur un délai dont le dépassement coûte l'exonération de taxe foncière, puis une amende.

Le sujet est le mois, et c'est ce qui donne son sens au geste : sans lui, un foyer qui déclare sa piscine en mars, coche « c'est fait », puis déclare une véranda en septembre retrouverait la même ligne close — la clôture de mars s'appliquerait à l'obligation de septembre. Avec le mois dans l'identité, la seconde déclaration est une échéance neuve qui n'hérite de rien (D-058). Un seul sujet à la fois : le quiz est un instantané, pas un journal, et c'est ce qui rend la question tenable en deux tapes.

Mesuré de bout en bout sur la chaîne quiz → assets → état désiré :

Déclaration Résultat
piscine possédée, aucun travaux la fiche ne s'applique plus — le défaut corrigé
travaux piscine, juin 2026 avant le 30/08/2026 · connue · sujet 2026-06
travaux véranda, sans mois dans les 90 jours après la fin des travaux · libre
isolation seule ne s'applique pas — aucune surface créée
nouveaux travaux, septembre avant le 30/11/2026 · sujet 2026-09ligne neuve

Écran vérifié au navigateur : le champ de mois est absent tant que rien n'est coché, borné à 2026-08 (pas de futur), aucune erreur console, aucun débordement.

Non fait, et pourquoi : carnet-information-logement ne bouge pas. Il s'applique déjà à tout propriétaire, indépendamment de tous travaux — lui ajouter un sujet sur la déclaration produirait une seconde carte pour la même obligation. Sa mise à jour à chaque chantier est une nuance que le texte de la fiche porte, pas une affaire de moteur.

843 tests.

D-058 — 7 août 2026 · « C'est fait » vaut pour un rendez-vous, pas pour tous ceux qui suivent

Contexte : question du porteur de projet sur les papiers d'identité — « que se passe-t-il si on coche fait et qu'un client est encore là dans plusieurs années ? ». La réponse était : la fiche ne revenait jamais.

1. Le défaut, et son étendue.

clore écrivait clos_le, clos_le mettait l'échéance dans horsMoteur, et horsMoteur la sortait du moteur définitivement — plus recréée, plus mise à jour, plus archivée, sa date figée jusque dans l'étagère. Un foyer qui vérifie ses titres en mars 2027 ne les revoyait ni en 2028 ni en 2035, alors que la fiche demande précisément de recommencer chaque printemps. Le geste veut dire « j'ai vérifié », pas « j'ai renouvelé » : on effaçait une vigilance annuelle sur la foi d'un contrôle ponctuel.

Neuf fiches clairement touchées, pas seulement les papiers : les quatre titres d'identité, garde-vacances, voyage-animal-rage, declaration-revenus, bourse-college, bourse-lycee. Une dixième plus discrètement (dpe-fin-validite). Les autres clore étaient justes — catnat, restriction-eau, fermeture-cuivre portent un sujet ou une adresse, et chaque nouvel événement crée sa propre ligne.

Le pire est que le commentaire du code affirmait l'inverse de ce que le code faisait : « elle y reviendra à la fenêtre suivante — rien ne la gèle au-delà du cycle en cours ». Écrit la veille, en même temps que la ligne qui la gelait. Un défaut nommé dans le code n'est pas un défaut corrigé — c'est exactement la leçon de D-037, à un jour d'intervalle.

2. Une seule règle, qui produit les deux comportements sans qu'aucune fiche ait à les déclarer.

On enregistre la fenêtre visée par la clôture (clos_fenetre, une signature date|libellé), et horsMoteur compare cette signature à la fenêtre que le moteur veut poser. Tant qu'elles coïncident, on ne touche à rien ; dès qu'elles diffèrent, la ligne revient.

  • Une vérification de papiers se recalcule chaque printemps sur les vacances de l'académie : sa fenêtre bouge, elle revient.
  • Un retrait de comprimés d'iode a une fenêtre libre dont le libellé ne bouge jamais : elle reste close pour toujours, ce qui est exactement ce qu'on veut d'une chose qui ne se fait qu'une fois.

Aucun nouveau comportement_fait à déclarer, aucune fiche à éditer, et la distinction « une fois / récurrent » cesse d'être une donnée à tenir à jour : elle découle du calcul de fenêtre, qui est déjà la seule source de vérité sur les dates.

La confiance est exclue de la signature : une fenêtre qui passe de estimee à connue sans changer de jour désigne le même rendez-vous, et rouvrir sur ce seul motif ferait réapparaître une échéance que le foyer vient de traiter. Une ligne close avant cette décision (aucune en production) est relue comme définitive, faute de savoir sur quelle fenêtre elle l'a été.

Un seul endroit lève la clôture : la branche qui applique toUpdateWindow. Une ligne close n'y arrive que si horsMoteur l'a laissée passer, c'est-à-dire si sa fenêtre a changé — la réouverture est donc une conséquence du calcul, jamais une décision séparée qui pourrait diverger.

3. Les quatre titres d'identité passent en yearly (cni-validite v4, passeport-adulte v4, permis-conduire-validite v3 ; passeport-enfant l'était déjà). Trois d'entre eux se déclaraient once alors que leur stratégie verification_papiers recalcule déjà une fenêtre par an. Même défaut de bicéphalie que argile-fissures la veille (D-057), avec une conséquence de plus : le filtre « ce qui revient » du calendrier (D-041) les rangeait du mauvais côté.

4. L'étagère cesse de ressembler à un cimetière. Chaque ligne dit si elle reviendra — sans annoncer de date, parce que c'est le moteur qui la calculera le moment venu et qu'une date écrite ici serait une seconde implémentation du § 20.4.

Vérifié en base, sur une copie de la production, deux scénarios joués jusqu'au bout : passeport-enfant marqué fait sur mars 2027 reste clos au passage du lendemain puis revient au calendrier quand la fenêtre de l'année suivante est calculée ; amiante-etat marqué fait reste clos après cinq réconciliations. 838 tests, dont un qui fait tourner dix ans de passages sur une fiche à fenêtre fixe.

Deux fausses pistes, notées parce qu'elles coûtent du temps à qui recommencera : simuler « l'année d'après » en ne déplaçant que la signature, ou en imposant à une fiche once une fenêtre datée, fabrique des états que le produit ne peut pas atteindre — et fait conclure à des défauts qui n'existent pas. La signature et la fenêtre stockée bougent toujours ensemble, parce que la seule fonction qui écrit l'une écrit l'autre.

D-057 — 7 août 2026 · argile-fissures passe en annuel : la fiche contredisait sa propre page

Contexte : question du porteur de projet en testant les gestes — « argile-fissures ne devrait pas être annuelle ? ». Elle était déclarée freq: once + comportement_fait: clore, donc sans champ de date et sortie du calendrier au premier « c'est fait ».

La réponse était écrite dans la fiche elle-même, au dernier paragraphe de sa page publique :

« Refaire la série l'année suivante, à la même période, transforme des photos en historique — et un historique, dans une expertise, pèse beaucoup plus qu'un souvenir. »

C'est le fond du sujet, pas un détail de rédaction : une photo unique documente un état, une série documente une évolution, et c'est l'évolution que l'expert cherche à dater pour rattacher les désordres à l'épisode reconnu. Déclarée once, la fiche disparaissait du calendrier après le geste qui n'avait produit que la moitié de sa valeur — et le foyer n'avait plus rien pour lui rappeler la série de l'été suivant.

C'est un défaut de bicéphalie (invariant n° 6) d'un genre qu'on n'avait pas encore rencontré : les deux têtes existaient, ne se contredisaient sur aucun fait, mais la partie moteur ne tirait pas la conséquence de ce que la partie éditoriale affirmait. knowledge:check ne peut rien y voir — il vérifie que les contenus sont présents et valides, pas qu'ils disent la même chose que la récurrence.

Décision : freq: yearly (saison inchangée, septembre), comportement_fait: reprogrammer_cycle, version 2. Elle gagne ainsi le champ de date, et la série repart de la date réellement donnée plutôt que du mois de la fiche — ce qui est exactement ce que « refaire à la même période » demande.

Balayage des 30 autres fiches once : dix ressortaient sur une recherche de mentions de répétition, toutes des faux positifs après lecture. Elles se répartissent en deux familles qu'il faut savoir distinguer :

  • le renouvellement d'un objet (CNI, passeport, permis, DPE, état amiante) — chaque titre ou diagnostic se refait une fois, et la fiche suivante naîtra de la nouvelle date de validité, pas d'un cycle ;
  • la périodicité de l'administration, pas celle du foyer — « le ministère publie chaque année une note de service » (inscription-lycee) décrit le calendrier d'Affelnet, alors que le foyer inscrit son enfant une fois.

argile-fissures était donc le seul cas réel : la seule fiche dont la page demande au foyer de recommencer.

Ce qui ne se rattrape pas tout seul : une échéance déjà close sous l'ancien comportement reste close après le passage en yearly — c'est l'invariant n° 3, et il est juste. Elle se récupère par « remettre dans le calendrier » depuis l'étagère. Aucune ligne concernée en production.

D-056 — 7 août 2026 · La sourdine coupe le mail, pas la connaissance ; et « c'est fait » devient annulable

Contexte : quatre remarques du porteur de projet sur les cartes d'échéance, deux jours après la livraison de la boucle d'état (D-055). Deux sont de la mise en page, deux touchent au dessin du produit — et l'une d'elles révise D-055 sur un point qui remonte jusqu'à l'invariant n° 3.

1. « Ne pas me le rappeler » ne retire plus du calendrier.

D-055 faisait de la sourdine un retrait complet : plus de mail, et plus de carte. C'est trop, et l'argument qui le montre était déjà écrit — c'est celui de D-045 pour l'opt-out global : « ton calendrier reste complet, on cesse simplement de t'écrire ». Deux niveaux de silence qui se comporteraient différemment seraient incohérents, et le plus fin des deux était le plus brutal.

Ce qu'il en coûtait, concrètement : une obligation légale dont on ne veut pas les mails disparaissait de l'écran. Ce n'est pas la taire, c'est la faire oublier — et le calendrier est précisément ce qui est vendu.

Conséquence non évidente, et c'est elle qui compte : muted sort de horsMoteur dans le moteur de réconciliation. Une échéance qu'on continue d'afficher doit continuer d'être recalculée, sinon sa date se fige et vieillit à l'écran — soit exactement le défaut inverse de celui qu'on cherchait à éviter. L'invariant n° 3 tient toujours (le choix du foyer survit à toute évolution de la base) ; il a simplement cessé de vouloir dire « gèle ». clos_le reste seul à sortir une échéance du moteur.

Trois prédicats là où il y en avait deux, et ils ne filtrent plus au même endroit : auCalendrier() (ni clos ni archivé), notifiable() (le calendrier moins la sourdine — le seul endroit où mute_le filtre), passeVisible() pour l'historique. Le libellé suit : « Pas de rappel par mail », qui dit ce que ça fait, là où « ne plus me le rappeler » laissait croire à une disparition. Et la carte montre son état : un état invisible se lit comme un bouton sans effet.

2. « C'est fait » s'annule depuis la carte — le manque le plus net de D-055.

Le porteur de projet demandait que la carte bascule dans l'étagère après un « c'est fait ». C'est refusé pour les 45 fiches reprogrammer_cycle : un ramonage fait en septembre est dû en septembre suivant, la carte ne disparaît pas, elle change de date. La faire basculer ferait rétrécir le calendrier à mesure qu'on s'en sert, c'est-à-dire un tableau de complétion — ce que le § 4.5 refuse. Pour les 33 fiches clore, c'était déjà le comportement livré.

Mais la demande visait un vrai trou : sur une fiche cyclique, un « c'est fait » posé par erreur poussait l'échéance d'un cycle entier sans aucun retour possible. L'étagère ne recevait que les fiches closes, donc « reprendre » n'était jamais proposé aux cycliques. D'où annuler_fait, sur la carte : « tu as dit : fait en août 2026 · annuler ». La phrase rend au foyer ce qu'il a déclaré, jamais un état qu'on lui attribue.

reprendre cesse au passage de rallumer les mails : tant que la sourdine sortait du calendrier, les deux allaient ensemble ; depuis qu'elles sont indépendantes, remettre une fiche au calendrier n'a aucune raison de défaire un réglage pris séparément. L'étagère, elle, ne contient plus que des fiches closes — et s'appelle donc « ce que tu as marqué comme fait ».

3. Un défaut réel, trouvé par la vérification à l'écran et pas autrement.

La route normalisait la date reçue à midi — pour ne pas basculer d'un jour au changement d'heure — puis comparait ce midi à Date.now() pour refuser le futur. Résultat : tout « c'est fait » posé avant 12 h UTC, soit avant 14 h à Paris, était rejeté « date invalide ». Le geste le plus courant du produit, cassé la moitié de chaque journée.

Rien ne pouvait le montrer ailleurs : les 823 tests passaient, tsc et le build aussi, et la vérification de la veille avait tourné à 19 h. Il a fallu relancer le navigateur un matin. La règle vit désormais dans le module pur (dateApprisePossible), compare des jours et non des instants, lit ce jour dans le fuseau du foyer (Europe/Paris, celui des crons) et porte six tests dont un calé à 08:06.

4. Deux corrections de mise en page.

Le bouton « Guide » occupait une troisième colonne du flex : il réservait sa largeur sur toute la hauteur de la carte, donc rognait chaque ligne, y compris celles qui passaient bien en dessous de lui. Il flotte désormais dans le bloc de texte — mesuré : 654 px de texte sous le bouton pour une carte de 655 px, contre 82 % de la largeur avant.

L'enjeu quittait la carte en bas, en capitales, à côté d'une pastille qui disait déjà la même chose. Il passe sur la pastille, en title + sr-only. Le survol seul aurait été le défaut corrigé deux jours plus tôt sur l'axe de la frise (« ne se lisait qu'au survol, ce qui exclut tout écran tactile ») : au doigt il reste la forme de l'icône, distincte par enjeu, jamais la couleur seule. Le nom du domaine reste, mais seulement en tri à plat, où la ligne a perdu son en-tête de section.

Une entorse assumée à l'invariant n° 4. annuler_fait rouvre l'occurrence que le geste avait close, au lieu d'en appendre une neuve. Une occurrence enregistre un rendez-vous du foyer, pas un clic : laisser un fait dans l'histoire pour une déclaration qu'il vient de retirer ferait dire à l'historique une chose qu'il sait fausse, et en appendre une seconde lui en ferait dire deux. On ne rouvre que la dernière, et seulement si elle porte bien le statut qu'on annule — passé un roulement, l'ancienne appartient au passé pour de bon.

Correctif du même jour, signalé à l'usage : l'accusé de geste remplaçait les boutons « jusqu'au prochain rendu du serveur » — sauf qu'aucun rendu ne le retirait. router.refresh() renvoie bien de nouvelles props, mais l'état local d'un composant client survit au rafraîchissement : le message restait, les boutons ne revenaient pas, et un foyer qui coupait les mails ne pouvait plus les rallumer sans recharger la page. Les boutons sont désormais toujours affichés et le message vit à côté d'eux, effacé non par une minuterie mais par le changement d'état lu sur le serveur — c'est-à-dire au moment précis où la carte se met à porter l'information elle-même (« tu as dit… », « pas de mail pour celle-ci »). Une minuterie aurait effacé la seule réponse à un geste dont le rendu aurait tardé.

Vérifié en navigateur, desktop et mobile, sur une copie de la base de production : le texte reprend toute la largeur sous le bouton (654/655 px en 1280, 252/256 en 390) ; la sourdine laisse la carte, affiche « pas de mail pour celle-ci » et propose la reprise ; « c'est fait » validé le jour même fait passer octobre 2026 → août 2027, et « annuler » ramène à octobre 2026 ; une fiche clore quitte le calendrier et atterrit dans l'étagère. Aucune erreur console, aucun débordement horizontal. 829 tests.

D-055 — 6 août 2026 · La boucle du foyer existe : « c'est fait » devient une date, et la date devient une échéance

Contexte : chantier n° 1 de D-054, c'est-à-dire le § 7.1 de l'audit mail et le § 1 de l'audit du 30/07 — le seul item que les trois audits classaient en tête et que personne n'avait ouvert. Le diagnostic tenait en une ligne : quatre gestes promis (fait, muet, responsable, date apprise), quatre colonnes lues partout, écrites nulle part.

1. La base de connaissance savait déjà — c'est la découverte qui a dicté le dessin.

comportement_fait est déclaré dans le schéma des fiches depuis l'origine, et renseigné sur les 78 : 45 reprogrammer_cycle, 33 clore. Aucune ligne de code ne le lisait. Autrement dit, la question « que veut dire c'est fait sur CETTE fiche ? » avait déjà sa réponse, fiche par fiche, rédigée par qui les a écrites ; il manquait quelqu'un pour la lui poser. Il n'y a donc eu aucune règle produit à inventer, et c'est ce qui explique qu'un chantier annoncé comme le plus lourd tienne en deux modules purs.

2. Une date apprise recale un CYCLE ; elle ne déplace jamais une date qu'un tiers décide.

C'est la seule règle vraiment nouvelle, et elle est une allowlist dont le défaut est le refus (STRATEGIES_ANCRABLES). Le calcul générique, le contrôle technique et la courroie l'acceptent ; declaration_revenus, campagne_bourse, verification_papiers, restriction_eau, catnat_declaration la refusent. La campagne de bourses ouvre le 1er septembre que le foyer ait déposé son dossier en août ou en octobre — y appliquer l'ancre ferait dire une date fausse à partir d'une information vraie, et ferait passer la confiance à connue au moment précis où elle cesse d'être fondée.

Deux stratégies ont leur propre arithmétique, et il fallait qu'elles l'aient : la fiche du CT voiture déclare biennial quand celle des deux-roues déclare custom/3, si bien qu'un cycle déduit de recurrence marcherait sur l'une par coïncidence et sur l'autre par chance. Le CT passe donc par PERIODICITE_CT, qui porte la règle du Code de la route. Et la courroie gagne au passage ce qui lui manquait depuis D-017 : un remplacement déclaré remet le compteur à zéro, là où la fenêtre se calait jusqu'ici sur l'âge du véhicule — le meilleur repère possible pour une courroie d'origine, le pire pour une courroie déjà changée, qui s'entendait dire « échéance dépassée depuis 2018 » sur un remplacement de l'an dernier.

3. On ne repousse pas une fenêtre dépassée, et le libellé ne compte pas les années.

Un foyer qui déclare un entretien annuel fait en 2021 est dû aujourd'hui, pas en 2027 : faire rouler le cycle jusqu'à dépasser now rendrait une date d'apparence saine à partir d'un aveu de retard, c'est-à-dire ferait taire la fiche à l'instant où elle a le plus à dire. Même arbitrage que courroieWindow, pour la même raison. Le libellé, lui, dit « dès que possible — la dernière fois, c'était en mars 2021 » : un fait que le foyer a lui-même fourni, jamais un décompte, jamais un « en retard de 5 ans » (§ 4.5).

Écarté délibérément : recaler l'ancre sur la saison de la fiche. Un ramonage fait en novembre revient en novembre, pas en septembre. C'est moins malin et c'est le but — snapper sur la saison répondrait au foyer une date qu'il n'a pas donnée, et raccourcirait son cycle à dix mois sans le dire.

4. « Fait » et « muet » sont deux états distincts qui produisent le même effet moteur — et les fondre aurait été la faute.

reconcile.ts les traite à l'identique (horsMoteur), mais la base les sépare (clos_le, mute_le). L'écran, lui, ne peut pas les confondre : « tu as dit que c'était fait » et « tu as dit que ça ne t'intéressait pas » ne se reprennent pas avec la même phrase, et une colonne unique aurait rendu impossible de savoir laquelle des deux a sorti une ligne du calendrier. Corollaire assumé : « muet » n'écrit AUCUNE date. Le confondre avec « fait » inscrirait dans l'histoire du foyer un entretien qui n'a peut-être jamais eu lieu, et le ressortirait un an plus tard comme une échéance datée.

Les quatre colonnes d'état sont des horodatages, pas des drapeaux (même raison qu'à rappels_coupes_le, D-045). muted a été remplacé et non doublé : deux colonnes pour un fait finissent toujours par diverger le jour où un correctif n'en met qu'une à jour. Il n'a jamais porté que des false (vérifié : 0 ligne), et 0019 transfère quand même — une migration ne se décide pas sur l'état d'une base.

5. Un invariant que la timeline supposait partout et que personne ne garantissait.

« Une échéance vivante a exactement une occurrence ouverte » tenait tant que seule la réconciliation écrivait dans occurrences. « C'est fait » la ferme sans en rouvrir — et candidatsFoyer joint la dernière occurrence quel que soit son statut, donc aurait accroché le rappel suivant à une clé d'outbox déjà consommée, c'est-à-dire à un mail qui ne serait jamais parti. D'où assurerOccurrenceOuverte, idempotente. Elle couvre aussi le cas que la réconciliation ne peut pas voir : « reprendre » remet dans le moteur une échéance dont la fenêtre stockée est déjà la bonne — rien à mettre à jour, donc rien qui rouvre.

6. Le geste porte sur le GROUPE, jamais sur la ligne. Le hub regroupe ce qui dit exactement la même chose (D-020) : « Assurance scolaire · Lina, Tom, Jo » est une carte pour trois lignes en base. N'en marquer qu'une laisserait deux échéances identiques à l'écran et un foyer persuadé d'avoir répondu — avec le rappel qui repart. Les identifiants se cumulent donc tous, y compris ceux dont le libellé d'asset était déjà présent (deux véhicules homonymes font une ligne et restent deux échéances).

7. Le geste ne calcule jamais sa propre date. Il écrit l'ancre, puis rend la main à runReconciliation. Un geste qui calculerait lui-même sa prochaine fenêtre serait une seconde implémentation du § 20.4 — celle qui diverge au premier changement de fiche (invariant n° 2). C'est aussi ce qui rend le résultat identique qu'on passe par l'écran ou par le passage de 4 h.

8. Ce que l'écran refuse de faire. Aucune coche, aucun vert, aucun compteur, aucune félicitation : une ligne marquée faite change de date et s'en va. La seule récompense est factuelle — la prochaine date, calculée par le serveur (accuseGeste, testé contre /bravo|félicit|✅|\d+ sur \d+/). Et « ce que tu as mis de côté » est replié, sans compteur en évidence, jamais rendu à un visiteur : sans cette étagère, « état utilisateur inviolable » se lirait « irréversible », et un tap malheureux n'aurait d'autre recours qu'un rejeu complet du quiz.

9. Mesuré de bout en bout, sur une copie de la base de production, dans un navigateur réel :

Fiche Geste Avant Après
ramonage c'est fait, aujourd'hui septembre 2026 · estimee août 2027 · connue
detecteurs-fumee c'était en mars 2021 octobre 2026 · estimee dès que possible — la dernière fois, c'était en mars 2021 · connue
controle-technique fait le 12/03/2026 avant juillet 2027 · estimee avant mars 2028 · connue
gouttieres-nettoyage c'était le 18/04/2026 octobre 2026 avril 2027
passeport-enfant (clore) c'est fait au calendrier close, aucune date stockée

Et les trois propriétés qui comptent plus que les cinq lignes ci-dessus : deux réconciliations complètes d'affilée ne touchent aucun état utilisateur (sourdine, clôture, ancre et fenêtres identiques au hash près), la seconde ne crée rien (idempotence), et « reprendre » rend à une échéance close une occurrence ouverte — 0 avant, 1 après.

Ce qui reste vrai et ne change pas : AVANCE_JOURS.libre ne notifie toujours jamais, et les fiches sans mécanisme de date le restent tant que le foyer ne dit rien. Ce qui change est qu'il peut le dire — les 7 fiches que l'audit du 30/07 § 1.3 (b) recensait comme bloquées faute d'ancre sont couvertes par un test nominatif qui échouera si l'une d'elles cesse d'être ancrable.

Le mail de rappel reprend une ligne, une seule. D-048 avait retiré la promesse des trois gestes en notant « ils reviendront le jour où ils existeront, pas avant ». Le pied dit maintenant « Déjà fait ? Dis-le depuis ton foyer — la prochaine date se recale sur la tienne ». Vers le foyer, jamais par URL-capacité comme la coupure des rappels : couper est sans danger — au pire on se tait — là où « c'est fait » écrit une date qui décide de la prochaine échéance. Un antispam qui scanne les URL ferait taire un rappel ; le même accident sur « c'est fait » décalerait une obligation légale d'un an, en silence.

Mot de passe (D-054 § 2, livré avec) : section « Mon compte » dans l'espace, POST /api/compte/mot-de-passe, appuyée sur auth.api.setPasswordserverOnly chez better-auth, donc joignable de nulle part ailleurs, ce qui est exactement la propriété recherchée. On ne crée jamais de compte ici, seulement un moyen d'entrer dans un compte qui existe : aucune inscription publique n'ouvre, et le lien magique est le chemin de secours, ce qui dispense du sendResetPassword et du sixième gabarit de mail. Vérifié en navigateur : la branche « J'ai un mot de passe » de /connexion, inatteignable et sans issue depuis le 22/07, connecte et redirige.

Fichiers : src/engine/gestes.ts + gestes.test.ts (nouveaux, purs), src/engine/gestes-db.ts et src/engine/etat-utilisateur.ts (nouveaux), src/engine/windows.ts (ancre, cycleMois, fenetreDepuisAncre, accepteAncre), src/engine/reconcile.ts (clos, horsMoteur), src/engine/reconcile-db.ts (ancres lues avant l'état désiré, assurerOccurrenceOuverte), src/engine/hub.ts (deadlineIds, ancrable, responsable), src/engine/historique.ts, src/jobs/notifications.ts, src/db/schema.ts + drizzle/0019/0020, src/app/api/foyer/[id]/echeance/route.ts et src/app/api/compte/mot-de-passe/route.ts (nouvelles), src/app/foyer/[id]/gestes.tsx et rangees.tsx (nouveaux), calendrier.tsx, page.tsx, src/app/mon-espace/compte.tsx et page.tsx, src/lib/auth.ts, src/emails/rappel.tsx.

D-054 — 6 août 2026 · Six arbitrages sur l'audit d'état : la boucle du foyer passe devant, le mot de passe se crée au lieu de se couper

Contexte : audit complet du 06/08/2026, exécuté (783 tests, tsc, eslint, knowledge:check, base de production et site public interrogés). Il ne trouve aucun défaut du socle — il trouve quatre dettes ouvertes et deux décisions produit en attente, dont certaines traînent depuis le 28/07. Cette entrée les tranche toutes les six d'un coup. Elle ne décrit donc pas un travail livré, mais l'ordre dans lequel il le sera ; ce qui est fait est consigné à mesure dans les entrées suivantes.

1. La boucle d'état utilisateur passe en tête, et une mesure nouvelle explique pourquoi.

Le diagnostic du 30/07 disait « chaque mail promet trois gestes qui n'existent pas ». D-048 a réglé la moitié du problème par la copie honnête — les mails ne promettent plus. Ce que l'audit ajoute est une mesure prise en base, pas dans le code : sur les 69 échéances de production, aucune n'est en confiance connue (56 estimee, 13 libre). Or AVANCE_JOURS.connue = 21 et AVANCE_JOURS.estimee = 0.

Autrement dit, la moitié du planificateur de rappels n'a jamais tourné et ne peut pas tourner : le chemin des 21 jours d'avance attend une date que rien au monde ne peut lui donner, puisque la seule source possible serait un geste du foyer. Ce n'est pas une fonctionnalité manquante parmi d'autres, c'est l'entrée manquante d'un moteur déjà écrit, déjà testé, déjà déployé. C'est ce qui la fait passer devant les sauvegardes elles-mêmes.

2. Le mot de passe se complète, il ne se coupe pas — l'audit mail recommandait l'inverse.

§ 6.4 de l'audit mail recommandait de désactiver emailAndPassword, au motif que la branche « J'ai un mot de passe » de /connexion est inatteignable et sans issue, et que la base comptait alors 0 utilisateur — donc que couper était gratuit. Le raisonnement était juste, son arbitrage est écarté : ce qui manquait n'était pas une raison de retirer la branche, c'était l'écran qui la rend atteignable.

Une section « Mon compte » dans l'espace, à côté de « Mon foyer », là où vit déjà « Connecté comme X · Se déconnecter ». Le lien magique reste le chemin par défaut et mis en avant (§ 25 du cadrage) ; le mot de passe devient ce qu'il aurait toujours dû être — une commodité pour qui en veut un, créée depuis une session déjà prouvée, jamais un second chemin d'entrée à défendre. Cet ordre-là a une conséquence qui n'est pas cosmétique : on ne crée un mot de passe que connecté, donc aucun formulaire d'inscription publique n'ouvre, et /sign-up/email reste ce qu'il est aujourd'hui — une route que le produit n'emprunte pas.

3. Les sauvegardes (E3) sortent du périmètre de fokan. Différées le 28/07 (« on verra plus tard »), elles le sont à nouveau, mais pour une raison qui a changé de nature : le sujet sera traité à l'échelle du serveur miaw entier, pas projet par projet. Une sauvegarde de la seule base fokan écrite ici serait un rouage de plus à maintenir et à faire diverger de celle des ~25 autres conteneurs de la machine. Le risque reste celui que le § 24.2 du cadrage qualifie d'inacceptable, il est assumé et daté, et il ne se referme pas dans ce dépôt.

4. La purge RGPD n'est pas due avant la fin de la première année d'abonnement non renouvelé. L'avertissement de confidentialite/page.tsx disait « à implémenter avant l'ouverture du paiement réel ». Il est trop strict, et l'audit le confirme sans le dire : Stripe est en clés sk_test, aucun encaissement réel n'a eu lieu, et surtout aucune donnée n'atteindra sa durée de conservation avant qu'un premier abonnement ait vécu un an puis cessé d'être renouvelé. C'est la date à retenir, et elle est très postérieure à la mise en service.

Ce qui ne change pas : la durée annoncée reste due (art. 5.1.e), la suppression sur demande reste tenue à la main, et l'avertissement du fichier reste — corrigé sur son échéance, pas retiré. Une purge écrite trop tôt est une purge qu'on n'aura jamais vue s'exécuter sur des données réelles le jour où elle compte.

5. Les 31 fiches muettes (§ 6.3 de l'audit mail) attendent la boucle, pas une documentation. La recommandation était « documenter et assumer maintenant, laisser § 7.1 le résoudre ». Puisque § 7.1 — la boucle d'état — est justement ce qui est entrepris (point 1), documenter l'état transitoire coûterait une section par pilier à réécrire aussitôt. La documentation se fera sur l'état d'arrivée. Reste vrai et non traité : l'option « donner une saison par défaut » reste interdite, elle achèterait des notifications contre la règle d'or.

6. Le sourcing des intervalles de courroie attend aussi. Toujours le seul travail qui fasse parler le pilier véhicules, toujours hors code, toujours 4 lignes sur 34 portant « aucune source officielle publique » dans leur propre champ source — ce qui est la bonne façon de ne pas mentir en attendant.

Un défaut d'exploitation relevé au passage, corrigé par le porteur de projet le jour même : l'image de production datait de 13:45 UTC pour un dernier commit à 15:38 — elle tournait donc sans D-053 § 7 ter, c'est-à-dire en appelant docling sans ocr_engine, donc avec easyocr et ses 7 295 Mio de VRAM croissants sur une carte partagée. Reconstruite. La leçon est celle du commentaire de D-053 : un paramètre qui vit dans l'appel plutôt que dans le service est à un déploiement manqué de redevenir le défaut.

D-053 — 6 août 2026 · Le champ K se photographie : OCR local, deux champs lus, rien de conservé

Contexte : le champ K est la clé la plus précieuse du pilier véhicules (identification exacte, gratuite, sans API SIV payante — D-004), et la plus pénible à fournir : e2*2001/116*0362*00, dix-huit caractères à astérisques sur un clavier de téléphone. La règle d'or dit qu'une interaction doit supprimer plus de travail qu'elle n'en coûte ; deux tapes valent mieux que dix-huit caractères.

Décision : lecture par photo ou PDF, via docling déjà installé sur la machine (ai-net). Un seul contrôle pour l'ordinateur et le téléphone, <input type="file" accept="image/*,application/pdf"> sans l'attribut capture — contre-intuitif mais mesuré : capture force l'appareil photo sur Android et retire l'accès à la galerie, alors que sans lui iOS comme Android ouvrent une feuille « Prendre une photo / Photothèque / Parcourir ». Aucune détection d'agent utilisateur.

1. L'OCR propose, le référentiel dispose — et c'est ce qui rend la fonctionnalité compatible avec le § 4.1.

Ouvrir une caméra dans un quiz qui n'a « aucune saisie libre » aurait dû être une contradiction. Ça n'en est pas une, parce que K et P.2 sont les deux seuls champs de la carte grise qui soient auto-validants : K passe par identifierParReception, qui compare à l'identique aux 31 546 réceptions du référentiel ; P.2 est confronté aux puissances réellement homologuées pour cette réception. Une lecture fausse ne devient jamais une réponse — elle ne trouve rien, et l'écran retombe sur la saisie manuelle, qui n'a jamais cessé d'être disponible pendant la lecture.

Ce n'est pas une précaution théorique. Mesuré sur une photo dégradée : le champ P.1 s'est lu 828 au lieu de 898. Le même glissement d'un chiffre sur P.2 écarterait silencieusement TOUTES les motorisations (D-017) — c'est-à-dire ferait taire le produit sans cause visible. Vérifié de bout en bout : une P.2 lue 67 sur une réception qui n'homologue que 55/66/70 est jetée, le véhicule restant identifié.

2. Le parseur s'écrit d'après un relevé, et le relevé a démenti deux fois l'intuition.

Relevé sur les 31 546 réceptions distinctes du référentiel : toujours exactement trois astérisques, code pays e + 1-2 chiffres, numéro à 4-5 chiffres, révision à 2 chiffres. Deux choses n'étaient pas devinables :

  • Trois directives commencent par des LETTRESKS07/46, KS18/858, NKS, soit 1 247 réceptions qu'un motif du genre e\d+\*\d aurait ratées en silence. NKS n'a même pas de barre oblique.
  • Le champ K se reconnaît à sa forme, jamais à son étiquette. Mesuré : les étiquettes sont l'élément le plus fragile de la page (P.1 lu Pl, S.1 lu 5.1, X.1 lu X.l, I lu #), alors que les valeurs passent. Un parseur accroché à l'étiquette K casse donc en premier là où on croyait tenir le document. À l'inverse P.2 n'a pas le choix — « 66 » tout seul ne veut rien dire —, et c'est précisément pourquoi sa valeur est confrontée au référentiel.

3. Deux défauts trouvés par les tests, pas par la relecture.

  • Borner la capture du nombre (\d{1,4}) au lieu de borner la valeur tronquait 12000 en 1200, qui repassait sous le plafond de 3 000 kW : une lecture manifestement aberrante devenait une puissance parfaitement plausible.
  • Le test de bout en bout contre une réception RÉELLE a échoué là où l'exemple inventé passait : e11*2007/46*0052*01 se lit el1*.... Le défaut ne pouvait pas apparaître avec e2, et il touche les huit codes pays à deux chiffres. La réparation l1 n'est pas une entorse à « ne réparer aucune coquille qu'on ne saurait deviner juste » : la bonne question n'est pas « quelle est la lecture la plus probable ? » mais « la position autorise-t-elle autre chose ? ». Sur les segments purement numériques, non — donc un l n'y est pas ambigu, il est impossible. Le segment de la directive, lui, porte de vraies lettres et n'est pas touché.

4. « Rien n'est conservé » est une propriété du code, pas une intention — et trois réglages de docling l'ont rendue vraie.

L'audit du service a montré que l'endpoint synchrone passe par la même file que l'asynchrone : son résultat — donc le nom, l'adresse, le VIN et la plaque — restait 300 s en mémoire après la réponse (result_removal_delay). Passé à 0. Au passage, sync_poll_interval valait 2 (toute réponse arrondie à 2 s près, corrigé à 1) et max_file_size/max_num_pages valaient sys.maxsize, c'est-à-dire aucune borne (16 Mio / 8 pages). Côté fokan : aucune écriture disque, aucune journalisation du texte OCR même en cas d'erreur, et la réponse ne porte que les deux champs — les 28 autres ne quittent jamais le processus. La page de confidentialité est amendée en conséquence : « aucun document » devenait faux, elle dit maintenant « aucun document conservé » et décrit l'exception transitoire.

5. Le GPU change la nature du geste. localai ayant libéré la carte, DOCLING_DEVICE=cuda : 18-22 s → 1 s (PDF à texte natif), 2 s (image 1600 px), 4 s (photo brute). Conséquence non évidente, qui inverse une conclusion prise sur CPU : la réduction côté client à 1600 px fait maintenant gagner la moitié du temps (elle n'en gagnait aucun sur CPU, où docling normalisait la page avant de travailler). Elle gagne aussi 55 % du poids et efface l'EXIF, donc les coordonnées GPS. imageOrientation: "from-image" est obligatoire : sans lui, une photo prise en portrait produirait un texte couché à 90°, soit un OCR muet sur une photo pourtant parfaite.

6. Le PDF est admis, et c'est le meilleur cas. Le certificat provisoire de l'ANTS arrive à texte natif : 1 s, et surtout aucune corruption d'étiquette là où l'OCR abîme tout autour. Un seul réglage couvre les trois entrées — do_ocr=true avec force_ocr au défaut fait lire nativement ce qui est natif et n'applique l'OCR qu'au bitmap. page_range=[1,1] borne un PDF déposé par erreur (vérifié : honoré).

7. Une seule conversion à la fois, et un plafond de 10/heure. Trois conversions simultanées ont occupé 11 444 Mio des 12 288 de la carte. C'est la seule route dont l'abus dégrade un service partagé avec le reste de la machine, pas seulement fokan — d'où avecVerrou("docling", …) en plus du plafond.

7 ter. Le moteur OCR pèse plus lourd que tout le reste — et le plus léger lit le mieux. easyocr (le défaut) occupait 7 295 Mio et continuait de croître (4 053 après cinq conversions, 7 295 après dix) ; rapidocr tient 881 Mio, stables sur douze conversions, 1 017 sous trois requêtes simultanées. Sept fois moins, et sans compromis : sur les deux documents où les deux moteurs divergent, rapidocr gagne les deux fois — il lit e11*2007/46*0052*01 là où easyocr rend el1*..., et il retrouve l'étiquette P.2 sur la photo la plus dégradée, où easyocr ne rendait qu'un P 66 inexploitable. Latence comparable. Le réflexe « le modèle le plus lourd lit le mieux » est faux ici, et il aurait coûté toute la carte graphique pour une lecture moins bonne.

Ce choix vit dans l'appel, pas dans le service, et ce n'est pas un choix de facilité. docling n'a pas de réglage DOCLING_SERVE_OCR_ENGINE — contrairement à RESULT_REMOVAL_DELAY, SYNC_POLL_INTERVAL ou ENG_LOC_NUM_WORKERS, le moteur par défaut est câblé en dur dans le modèle Pydantic de la requête (docling_serve/datamodel/convert.py), pas lu depuis l'environnement. Le forcer au niveau du service exigerait de patcher ce fichier, donc de passer d'une image tirée telle quelle (:latest) à une image construite — un patch qui casserait silencieusement à la prochaine mise à jour de l'image, sur un service partagé avec d'autres projets de la machine (/home/miaw/docling, hors du dépôt fokan, non versionné). ocr_engine=rapidocr est donc passé explicitement dans chaque appel de src/app/api/vehicule/carte-grise/route.ts, et le compose de docling porte un commentaire à ce sujet : tout futur appelant qui omettrait ce paramètre récupérera easyocr et son empreinte de 7 Go+, sans qu'aucun réglage ne l'en protège.

7 bis. Et la cause de ce pic était un réglage de docling, pas la charge. eng_loc_num_workers vaut 2 par défaut : deux conversions tournent en parallèle, chacune avec son propre jeu de modèles sur le GPU. Pire, l'allocateur de PyTorch ne rend jamais au pilote ce qu'il a réservé — la carte est donc restée à 94 % pleine en étant inactive (11 515 Mio occupés, 0 % d'utilisation), donc inutilisable par localai, longtemps après la fin des conversions. Passé à 1 : 4 053 Mio, stables même sous trois requêtes simultanées, soit 7,5 Go rendus. Le second worker ne servait de toute façon à rien ici, puisque fokan sérialise déjà ses appels de son côté — on payait 7 Go pour un parallélisme que personne ne demandait. La leçon dépasse ce service : un défaut de configuration peut ne se voir qu'à l'arrêt, quand plus rien ne tourne et que la ressource reste prise.

Aucun taux de réussite n'est affiché (décision du porteur de projet) : les mesures portent sur un certificat synthétique, et une promesse chiffrée sur une photo qu'on n'a pas encore vue n'engage personne. L'écran dit ce que la lecture fait, avant le geste — jamais ce qu'elle a des chances de réussir.

Ce qui NE change pas : aucune migration, aucune colonne. reception_numero, puissance_kw et source: "carte_grise" existent depuis D-017 et désignent exactement ce que ce chemin produit. La saisie manuelle du champ K et la recherche par marque/modèle sont intactes.

D-052 — 6 août 2026 · Les trois pages légales passent en revue de mise en ligne : l'identité sort du code, trois manquements de fond sont corrigés

Contexte : demandé par le porteur de projet, qui prépare la mise en ligne avec le paiement ouvert dès le premier jour, sans SIRET. Les trois pages (/mentions-legales, /cgv, /confidentialite) existaient et étaient bien construites — un module unique (src/lib/legal.ts), un composant Valeur qui affiche « à compléter » en jaune plutôt que d'inventer — mais sept champs étaient vides et trois obligations de fond manquaient.

Réserve consignée, et écartée par le porteur de projet : encaisser sans immatriculation rend les CGV inopposables et relève de l'art. L8221-3 du Code du travail (travail dissimulé). L'immatriculation micro-entreprise est gratuite et prend une à deux semaines au guichet unique de l'INPI. La décision de lancer sans a été prise en connaissance de cause ; le code ne la bloque pas, il la rend visible.

1. L'identité de l'éditeur sort du code et passe par le .env. Le nom, le domicile et le téléphone d'un éditeur personne physique sont des données personnelles : les écrire dans legal.ts les mettrait dans l'historique git, c'est-à-dire nulle part d'où on puisse les retirer. Quinze clés LEGAL_* documentées dans .env.example.

Conséquence non évidente : les trois pages passent en force-dynamic. L'étape npm run build du Dockerfile ne monte pas le .env ; prérendues, ces pages figeraient un « à compléter » que remplir le fichier n'effacerait jamais. C'est le genre de défaut qui ne se voit qu'en production, sur la seule page où un blanc se paie.

2. Trois manquements de fond, dont un que le produit savait déjà mieux faire :

  • Résiliation en trois clics (art. L215-1-1, en vigueur depuis le 1er juin 2023). Les CGV annonçaient une résiliation « en écrivant à » l'éditeur, alors que le portail de facturation Stripe est branché dans l'espace abonné depuis toujours (src/app/mon-espace/abonnement.tsx). Le texte décrivait donc moins que ce que le produit fait, et moins que ce que la loi impose — amende administrative jusqu'à 15 000 € pour une personne physique (art. L241-3-1). ⚠️ Le bouton ne suffit pas : la résiliation doit être activée dans la configuration du portail Stripe, sinon l'article devient faux.
  • Plateforme européenne RLL : elle n'existe plus. Le règlement (UE) 2024/3228 du 19 décembre 2024 a abrogé le règlement 524/2013 ; les dépôts ont cessé le 20 mars 2025, la plateforme a fermé le 20 juillet 2025. Renvoyer un consommateur en litige vers un service fermé lui fait perdre le délai pendant lequel il cherche la porte. Mention retirée, médiateur conservé (art. L616-1, adhésion payante encore à souscrire).
  • Garantie légale de conformité des services numériques (art. L224-25-12 et s., applicables depuis le 1er janvier 2022) : entièrement absente. Ces garanties sont d'ordre public — ne pas les mentionner ne les écarte pas, cela prive seulement l'abonné de l'information à laquelle il a droit.

3. La politique de confidentialité ne déclarait pas le compteur de lectures (D-042). C'est l'omission la plus coûteuse, et elle vient du même biais que celles de la connaissance : la page affirmait « aucune adresse IP conservée en base », ce qui est vrai au pied de la lettre et trompeur sur le fond. lectures_guides stocke un HMAC salé et tronqué de l'IP — une donnée personnelle pseudonymisée, pas anonyme, donc à déclarer (art. 13 RGPD). Ajoutés dans la foulée : les transferts hors UE (Stripe, maison mère américaine), l'absence de DPO et son motif, l'absence de décision automatisée, l'adresse postale de la CNIL, et une date de dernière mise à jour sur chacune des trois pages.

Ce qui reste ouvert, et qui n'est pas rédactionnel : les durées de conservation annoncées ne sont tenues que pour une seule donnée. purgerLecturesGuides (90 jours) tourne au passage de 4 h ; rien ne purge les foyers dormants, les leads et le journal d'envois. Il n'existe pas de rédaction qui rattrape ça — annoncer une durée sans la tenir manque à l'art. 5.1.e, ne pas l'annoncer manque à l'art. 13. Le job est à écrire avant l'ouverture du paiement réel.

D-051 — 5 août 2026 · Le quiz demande une tranche de revenus ; elle retire des fiches, elle n'affirme aucun droit (précise D-019 et l'audit du 30/07)

Contexte : demandé par le porteur de projet. Quatre fiches vivaient déjà sous plafond de ressources — bourse-college, bourse-lycee, dse-bourse-superieur, cheque-energie-guichet — et aucune ne l'appliquait. Un foyer à 80 000 € de revenu fiscal recevait chaque septembre un rappel de bourse de collège ; le chèque énergie, dont le plafond exclut la grande majorité des ménages, s'affichait à tout le monde. L'instruction complète est dans audit-ressources-2026-08-05.md.

1. La ligne qui change, et celle qui ne change pas

Quatre fichiers portaient la même phrase — pilier-papiers § 5.3, pilier-scolarite § 5, l'en-tête de knowledge/templates/scolarite.yaml, audit-2026-07-30 § 5 : « signaler la fenêtre de demande est de la veille ; estimer l'éligibilité serait du conseil », avec son corollaire « fokan ne connaît aucun revenu et n'a aucune intention d'en connaître ».

Le corollaire tombe, la ligne tient, et la formulation qui les sépare est la décision elle-même :

On ne simule pas des droits, on filtre des échéances. La tranche de revenus est un attribut d'applicabilité comme chauffage: gaz — elle décide si une fiche s'applique, jamais ce que le foyer touchera. Elle ne peut que retirer du bruit.

Ce n'est pas un habillage. Il n'existe aucun verdict « éligible » dans le moteur : src/knowledge/ressources.ts n'exporte que estExclu. Aucun montant estimé, aucun total, aucune liste de « tu as droit à » — ce serait le conseil financier du § 2.3 et la comptabilité morale du § 4.5, tous deux intacts.

Deux constats appuyaient déjà ce sens. D-048 l'annonçait en retirant 24 promesses de gestes inexistants : « elles reviendront […] quand un test d'éligibilité rendra la question inutile (chèque énergie) ». Et l'audit du 30/07 refusait un onglet « Aides & droits » parce que les plafonds étaient inconnus — connaître la tranche retire l'argument sans rouvrir l'onglet.

Le test qui borne définitivement l'ambition est celui que le produit s'applique déjà à lui-même (pilier-scolarite § 3) : une fiche qui répète ce qu'un établissement dit déjà ne supprime aucun travail. mesdroitssociaux.gouv.fr simule 58 prestations, gratuitement et mieux que ce qu'on écrirait. Ce que personne ne fait, c'est dire que c'est maintenant, sans qu'on ait rien demandé. Un simulateur attend qu'on vienne à lui ; c'est exactement ce que le produit refuse d'exiger.

2. Le fait technique qui commande tout : une réponse, plusieurs dénominateurs

Les aides sous condition de ressources n'utilisent ni la même mesure du revenu, ni la même année, ni le même diviseur :

Aide Notion Année Diviseur
Bourses de collège et de lycée RFR N1 table par enfants à charge
Chèque énergie RFR N2 unités de consommation (1 · +0,5 · +0,3)
ARS revenu net catégoriel N2 table par enfants
Leasing social RFR N2 parts fiscales
MaPrimeRénov' / allocations familiales RFR / net catégoriel N2 (ne ferment aucune porte — voir § 3)

Une seule question suffit malgré tout, pour deux raisons mesurées. Les diviseurs se calculent entièrement à partir de ce que le quiz sait déjà (adultes, enfants). Et RFR et revenu net catégoriel coïncident pour le foyer salarié ordinaire — les deux valent « salaires 10 % » tant qu'il n'y a ni revenu foncier, ni BIC/BNC, ni pension alimentaire versée.

C'est aussi ce qui rend les bornes nécessairement dynamiques, et pas décorativement : 30 000 € sont sous le plafond pour une famille de cinq et au-dessus pour une personne seule, sur la même aide.

Vérifié et fermé avant de se résigner à poser la question (doctrine de D-027) : l'API Particulier et l'API Impôt particulier rendraient le RFR et le quotient familial exacts, mais sont réservées aux administrations et aux organismes chargés d'une mission de service public — fokan n'entre dans aucune case. OpenFisca-France reste un excellent contrôle croisé de barèmes, jamais un moteur appelé à l'exécution : Python, AGPL, instance publique sans engagement, et l'invariant n° 5 exige un moteur déterministe et testable chez nous.

3. Deux règles ramènent vingt seuils à quatre pastilles

Pour 2 adultes + 2 enfants, les seuils bruts font dix valeurs, et plus de vingt avec les échelons internes. Aucune version « macroscopique » ne tient à ce compte-là. Deux règles, empruntées au projet lui-même :

Qualifier plutôt que filtrerD-034 § 2, écrite pour le champ K des rappels automobiles, s'applique mot pour mot : un seuil qui ne fait varier qu'un montant ne produit pas de point de coupure. MaPrimeRénov' n'est pas fermée aux revenus supérieurs, elle est moins généreuse ; les allocations familiales sont modulées, pas supprimées. Les échelons internes des bourses tombent de même : le produit dit quand demander, jamais combien on touchera.

Fusionner les grappes — trois seuils tenaient dans 977 € (4,3 %), deux autres dans 38 €. Une bande de doute vaut mieux qu'une coupure de plus.

Résultat mesuré : 3 pastilles pour une personne seule, 4 pour un couple avec deux ou trois enfants, et les bornes montent avec la famille.

Point qui n'allait pas de soi : la fusion est purement cosmétique. estExclu teste toujours le plafond réel, jamais la borne affichée — réduire le nombre de pastilles ne peut donc jamais rendre un verdict faux, seulement laisser une fiche visible là où une coupure plus fine l'aurait écartée. C'est la direction d'erreur retenue partout dans ce module.

4. Le net mensuel, et l'arrondi qui rattrape la conversion

« Quel est ton revenu fiscal de référence ? » est une question mémoire, et de la pire espèce : elle envoie chercher un document. Le § 4.2 l'interdit, et le produit s'y tient partout ailleurs — même le champ K de la carte grise est facultatif et annoncé comme tel. « Ce qui tombe sur le compte chaque mois », tout le monde sait y répondre.

On convertit donc les seuils, jamais les réponses : net mensuel → net imposable → ×12 → 10 % → RFR, soit un facteur de l'ordre de 11,3. Ce n'est pas une constante de la loi, c'est une moyenne, et c'est le maillon le plus faible du dispositif — faux d'un facteur, pas d'un pourcentage, pour un indépendant au micro-BIC (34 %, 50 % ou 71 % d'abattement).

La parade est un seul caractère de code, et c'est le sens de l'arrondi : la borne affichée se place légèrement au-dessus du plafond réel. Un foyer qui navigue autour du seuil tombe dans la tranche du dessous et voit la fiche. Sur-inclure coûte une ligne dans un mail ; exclure à tort coûte l'aide.

Même arbitrage sur l'unité du foyer, où l'on choisit délibérément l'hypothèse fausse : deux adultes ne forment pas forcément un foyer fiscal, et l'hypothèse « concubins » est nettement plus généreuse rapportée au total du ménage. On retient le maximum des deux — on n'écarte qu'un foyer que même l'hypothèse la plus favorable ne fait pas passer.

Ce qui ne se rattrape pas et vit désormais dans la copie : le foyer répond avec ses revenus d'aujourd'hui, les barèmes regardent N1 ou N2. Le foyer dont les revenus viennent de chuter est exactement celui qui devient éligible et l'ignore.

5. Où ça se branche — et pourquoi pas dans le DSL

Le when du DSL ne peut pas exprimer cette condition, et ce n'est pas une limite à contourner. bourse-college s'applique sur un asset personne ; son plafond dépend du nombre d'enfants du foyer entier. Un asset ne connaît pas ses frères. L'y forcer supposerait de recopier la composition sur chaque personne — figer dans la couche Instance une donnée qui appartient à la Connaissance (invariant n° 4).

D'où un champ propre, conditions_ressources: { bareme: <id> }, évalué dans applicableDeadlines à partir d'un contexte lu sur les assets eux-mêmes. Conséquence directement testée : un enfant ajouté relève le plafond et fait revenir la fiche, sans rejeu du quiz.

Et le corollaire qui compte : le verdict n'est jamais stocké. Ce qu'on garde est la tranche déclarée ; l'exclusion se recalcule à chaque réconciliation, sinon une mise à jour de barème ne rattraperait jamais les foyers existants (invariant n° 2). Ce qu'on stocke, ce sont les bornes réellement affichées, jamais un identifiant de tranche : un barème révisé déplacerait les bornes, et un identifiant relu sur les nouvelles ferait dire au foyer quelque chose qu'il n'a jamais dit — même raisonnement que les tranches d'âge d'avant D-019.

6. Le garde-fou qui compte plus que le reste

Un barème périmé cesse de filtrer, il ne filtre jamais faux. Les plafonds sont refixés chaque année par arrêté (22 décembre 2025 pour les prestations familiales, 19 décembre 2025 pour les plafonds Anah, 26 mars 2026 pour les bourses du supérieur). Un barème oublié écarterait silencieusement des foyers devenus éligibles — un silence sur une aide, c'est-à-dire exactement le mal qu'on soigne.

Chaque barème porte donc valideJusquAu. Passée cette date, estExclu répond faux, la fiche se réaffiche à tout le monde, et elle ne propose plus de coupure au quiz. C'est la clause d'effacement automatique de D-023 appliquée ici. La CI échoue sur un barème périmé (npm run knowledge:check), et le loader casse le build sur un identifiant de barème inconnu — une faute de frappe ferait sinon cesser le filtrage en silence.

7. Ce qui entre dans la base, et ce qui n'y entre pas

Le critère n'est pas « est-ce une aide ? » mais « y a-t-il une date qu'on peut rater ? ». Un droit sans fenêtre ni démarche n'est pas une échéance, c'est une ligne de plus à lire — donc l'onglet que l'audit du 30/07 a refusé.

Filtres posés (4) : bourse-college, bourse-lycee, cheque-energie-guichet, et la nouvelle fiche ARS.

Fiches écrites (2) :

  • ars-declaration-scolarite — le point resté ouvert au § 6 de pilier-scolarite, et les faits le tranchent dans l'autre sens que la catégorie : ce n'est pas le dossier social CAF qu'écarte pilier-papiers § 4.4, c'est une déclaration à date fixe. À partir de 16 ans, la Caf ne peut plus savoir seule si l'enfant est scolarisé ; sans déclaration en juillet, elle ne verse rien — 466,02 €. Elle se règle sur l'âge et non sur la classe (un redoublant de 17 ans en 1re la doit autant qu'un élève de terminale), d'où le nouvel attribut age_fin_annee, recalculé chaque semaine comme le reste de l'état scolaire.
  • leasing-social-electrique — voiture électrique à moins de 200 €/mois, RFR par part ≤ 16 880 €, contrats à partir du 16 juillet 2026. Sur le foyer et non sur le véhicule : une fiche par voiture possédée aurait produit trois rappels identiques. Une condition reste intestable et le reste — plus de 10 km de trajet domicile-travail, ou plus de 8 000 km/an à titre professionnel : la fiche le dit en clair et laisse le foyer trancher, comme la vignette Crit'Air.

Écarté, et pour un motif à chaque fois : allocations familiales, complément familial, allocation de base Paje (automatiques, aucune fenêtre — ce serait l'onglet « tu as droit à ») ; prime à la naissance (vraie échéance, mais suppose de connaître une grossesse) ; prime d'activité et RSA (frontière sociale, et mesdroitssociaux le fait déjà) ; complémentaire santé solidaire (refus santé) ; cantine à 1 €, FSL, tarifs sociaux de l'eau et des transports (locaux, donc non universels) ; MaPrimeRénov' (qualifie, ne ferme rien).

Le Pass'Sport n'a pas été écrit, et c'est délibéré. Il cochait tout — 70 €, fenêtre du 1er septembre au 31 décembre, éligibilité entièrement dérivable de l'ARS. Mais la saison 2026-2027 n'était pas annoncée au 5 août 2026, seulement « prochainement lancée ». Une fiche qui affirmerait une fenêtre non publiée serait exactement la faute que le projet se reproche ailleurs. À reprendre dès la publication.

Le DSE garde sa fiche sans filtre : son barème à points de charge est d'un autre ordre de complexité que les cinq autres, et il s'instruit séparément.

8. Ce que ça coûte, et qui le paie

Un foyer qui ne répond pas voit deux échéances de plus qu'avant — les deux fiches nouvelles, dont les plafonds sont justement les plus fermés. La règle est uniforme et assumée : l'absence de réponse ne retire jamais rien, comme pour papiers_anciens et internet. Répondre rend le hub plus silencieux ; ne pas répondre le rend un peu plus bavard. Le déséquilibre est voulu — un droit tu à qui n'a pas répondu serait exactement le non-recours qu'on prétend combattre.

Fichiers : src/knowledge/ressources.ts (+ 22 tests), src/engine/applicability.ts (+ 9 tests), src/knowledge/schema.ts, src/knowledge/loader.ts, src/knowledge/scolarite.ts, src/engine/quiz-to-assets.ts, src/lib/declarations.ts, src/app/quiz/quiz-client.tsx (écran 6, TOTAL_SCREENS 9 → 10 — aucun impact sur le plan de fabrication, le calcul est local et n'appelle rien), scripts/validate-knowledge.ts, src/app/confidentialite/page.tsx, deux fiches et deux pages publiques. 78 fiches, 747 tests.


D-050 — 5 août 2026 · L'axe de la frise se lit sans survol : l'année devient un bandeau, les mois changent de densité (révise D-046)

Contexte : demandé par le porteur de projet, sur la même frise que D-046 — révélation de fin de quiz et page foyer, qui sont le même écran. L'année glissante posait deux questions auxquelles l'axe ne répondait qu'en infobulle, c'est-à-dire jamais au doigt : un title n'existe pas au tactile. De quelle année parle cette colonne, et quel mois porte cette initiale ? « J » ne distingue ni janvier de juin ni juin de juillet. La dernière phrase de D-046 — « l'infobulle nomme le mois et son année » — était donc la bonne intention adressée au mauvais canal.

Ce que la mesure impose

Relevé au navigateur, Inter 600, plutôt que supposé : une étiquette lisible pèse 29 px (« sept. », « janv. »), une année 28 px, quand une colonne n'offre que 17 px sur un écran de 320 px et 20 px sur un 360 px. Deux conséquences qu'aucun réglage ne contourne — resserrer les gouttières gagne 2 px, et réduire le corps répond à un problème de lisibilité en rapetissant le texte :

  • douze étiquettes ne rentrent pas sur un téléphone ;
  • une année ne rentre dans aucune colonne, sur aucun téléphone.

D'où deux réponses de nature différente.

1. L'année est un bandeau, pas une colonne. Une année glissante ne traverse qu'une frontière : au plus deux tranches, donc au plus deux étiquettes, qui tiennent à toute largeur (segmentsAnnees). Un filet vertical marque la bascule dans la zone des pastilles, là où l'œil est — et il désigne la colonne de janvier sans qu'elle ait à se nommer.

Chaque année est alignée à gauche de sa tranche, c'est-à-dire posée sur la colonne où elle commence. L'alignement à droite tenait dans tous les cas de figure, y compris la tranche d'une seule colonne, mais plaçait « 2027 » onze colonnes après la bascule qu'il annonce : il se lisait comme l'étiquette du dernier mois. Le cas limite (départ en février ou en décembre) déborde désormais vers l'intérieur de la marge de la carte, où rien ne le coupe et rien ne l'attend.

2. Les mois passent en densité variable — trois paliers : noms complets au-delà de 1024 px de place, abréviations françaises au-delà de 448 px, une colonne sur deux en dessous, ancrée sur la colonne 0 déjà surlignée pour qu'une colonne muette se déduise d'un pas et jamais de six.

Une colonne muette n'affiche rien, pas même un point de repère. La première version en posait un, et il entrait en collision : une étiquette de 29 px déborde largement d'une colonne de 17 px et venait toucher le repère planté au centre de sa voisine, à 21 px de là. Le nuage de points au-dessus donne déjà la grille.

Les paliers sont des @container, jamais des sm:

Le point qui ne se devine pas, et qui vaut au-delà de cette frise : le même composant vit dans deux largeurs très différentes à viewport identique — ~1064 px sur la page du foyer, ~454 px dans la colonne du hero de la landing (lg:grid-cols-[1.05fr_0.95fr]). Un palier accroché à la taille de l'écran ferait déborder la landing pour servir le foyer. La règle porte sur la place réellement disponible, pas sur le téléphone qu'on tient.

Le title de l'<abbr> devient un sr-only porté par les douze colonnes, y compris les muettes : une colonne sans étiquette reste une colonne qui a un nom.

Fichiers : src/components/timeline-annee.tsx (+ test — les deux tranches d'une colonne, le départ en janvier sans bascule, et la confrontation, sur 12 mois de départ × 12 colonnes, entre l'année qu'annonce le bandeau et celle que la colonne calcule elle-même).


D-049 — 5 août 2026 · Le compte naît du paiement, plus du clic dans le quart d'heure

Contexte : le mail d'activation portait un lien magique valable 15 minutes et à usage unique. Ce lien n'était pas un raccourci — il était la seule chose au monde capable de faire exister le compte de l'acheteur. better-auth ne crée l'utilisateur qu'à la vérification du lien ; resolveMembership (src/lib/membership.ts) n'inscrit le premier membre que s'il y a une session ; destinatairesFoyer ne connaît que les appartenances. Toute la chaîne pendait donc à un clic.

Ce que ça coûtait, concrètement : un mail lu le soir, un lien ouvert deux fois, un antispam qui le déclenche à vide en le scannant — et un foyer payé 19,99 € restait sans compte, sans appartenance, donc sans aucun destinataire de rappel, définitivement. /mon-espace liste les foyers par appartenance : l'acheteur n'en avait aucune, il n'avait donc rien à retrouver. Le produit encaissait et ne livrait rien, sans que personne n'ait rien fait de mal.

Ce cas n'est pas marginal : le mail d'activation n'est envoyé que dans le cas 2 du webhook (personne n'était connecté avant de payer). Dans le cas 1, le compte existe déjà et aucun mail ne part. Autrement dit, le seul message d'activation qui existe était précisément celui dont l'expiration était fatale.

Décision : le webhook Stripe crée le compte et l'appartenance lui-même, à réception de checkout.session.completed, depuis l'adresse collectée par Stripe Checkout. On passe par auth.$contextinternalAdapter.createUser plutôt que par un insert maison : c'est lui qui fabrique l'identifiant, joue les hooks et respecte le schéma — exactement ce que le plugin magic-link appelle de son côté. Le compte est marqué vérifié parce que Stripe vient d'envoyer un reçu à cette adresse.

Le lien magique part toujours : un tap reste un tap. Il n'est simplement plus une bouée — son expiration ne coûte plus qu'un aller-retour par /connexion, ce que le mail dit désormais en toutes lettres.

Contrepartie assumée : Stripe ne prouve pas la possession de la boîte. Quelqu'un qui paierait 19,99 € en donnant l'adresse d'un tiers rattacherait un foyer au compte de ce tiers — un ajout indésirable, jamais une prise de contrôle, et facturé à celui qui le tente. En face, l'ancien comportement perdait un client payant sur un simple délai de lecture. Le déséquilibre entre les deux risques n'est pas discutable.

Autre correction, même journée : plus aucun fond de page dans les mails (amende D-048). L'aplat crème faisait une tache là où le client est blanc et un cadre clair là où il est sombre. Il ne reste que la carte et sa bordure — qui dit la même chose et reste juste sur n'importe quel fond, y compris ceux qu'on ne connaît pas. Le pied de page se distingue par un filet, plus par une teinte.

Outillage : npm run mails:echantillon -- <adresse> ajoute désormais, aux six messages types, le rappel réel des foyers de cette adresse — vraies échéances, vrai moteur. apercuRappelFoyer met de côté l'outbox et le budget mensuel (deux règles qui évitent de réémettre, et ne changent pas une ligne de ce qui serait écrit) ; si rien n'est encore dû, l'aperçu est daté du jour où la première échéance entrera dans sa fenêtre, et l'objet le dit. --essai écrit sur le disque au lieu d'envoyer.

D-048 — 5 août 2026 · Un seul châssis pour les mails ; les fiches cessent de promettre des gestes qui n'existent pas

Contexte : relecture de bout en bout des messages réellement reçus. Six défauts, dont trois qu'aucun test ne pouvait voir parce qu'ils n'apparaissent que dans un client mail.

1. Le fond ne tenait pas, et c'est un mécanisme, pas un goût. Le crème était posé sur <body> — que Gmail supprime en réécrivant le message. La carte blanche se retrouvait donc collée au fond du client : blanc sur blanc chez les uns, bloc éblouissant sur fond noir chez les autres. Le fond vit désormais sur une <table> pleine largeur, seule couche qu'aucun client ne retire, et la carte porte une bordure qui la définit même si le fond saute quand même.

Le mode sombre est déclaré (color-scheme, supported-color-schemes) plutôt que subi : sans cette déclaration, Gmail et Apple Mail inversent les couleurs claires au jugé, et le vert de marque en ressort délavé. Avec elle, ils appliquent nos règles — doublées de sélecteurs [data-ogsc] pour Outlook.com, qui ne connaît pas prefers-color-scheme.

Un piège qui ne se voit qu'à l'écran : la marge intérieure était posée sur les <table>. En modèle de bordures fusionnées — celui de tout mail, et celui qu'Outlook applique de toute façon — la spécification CSS elle-même ignore la marge d'un élément table. Tout le message se collait aux bords. La marge se porte sur le <td>, jamais ailleurs. Vérifié au navigateur, clair et sombre, 380 px et 700 px.

2. Le logo entre dans les mails, en image et non en texte. Un bandeau encre en tête de chaque message, avec le lockup (le F sert d'initiale à « okan », géométrie reprise au pixel près de src/components/logo.tsx, mot converti en tracés — aucune fonte à charger). Le bandeau est sombre dans les deux modes : c'est ce qui donne au logo une seule version à exister, juste partout. Un logo encre sur carte blanche redevient invisible chez le premier client qui bascule la carte en sombre sans lire nos règles.

Deux conséquences hors des gabarits : public/ n'existait pas, et output: "standalone" ne le trace pas — sans la ligne ajoutée au Dockerfile, le logo aurait répondu 404 chez tous les destinataires, panne invisible en développement. Et /email/* est mis en cache un an : c'est le seul fichier du produit que des mandataires d'images tiers rappellent une fois par destinataire et par message.

3. Les fiches promettaient un geste qui n'existe pas — 24 fois. « Si c'est déjà fait, dis-le-moi et je me tais », « Fait ? Dis-le-moi et à l'an prochain », « Dis-moi sa date d'expiration et je gère le tempo » : aucun de ces gestes n'est implémenté (cf. l'avertissement du CLAUDE.md et audit-2026-07-30 § 1). Le pied de page avait déjà été purgé de la même promesse ; les fiches, elles, la répétaient dans le corps du message. Retirées, sans remplacement : elles reviendront d'elles-mêmes le jour où l'état utilisateur s'écrira, ou quand un test d'éligibilité rendra la question inutile (chèque énergie). Aucune fiche n'y perd d'information — la phrase était toujours la dernière, et toujours conversationnelle.

4. Le bloc d'accompagnement du rappel groupé disparaît. « Rien d'urgent, rien à faire tout de suite — juste ce qui arrive, pour que tu n'aies pas à y penser toi-même » n'ajoutait rien aux échéances listées dessous, et sa dernière proposition se démentait au moment où on la lisait : ce message est précisément celui qui demande au foyer d'y penser. Le titre, puis les échéances.

5. Le rappel renvoie au sujet, pas seulement au hub. Chaque échéance dont la fiche a une page publique porte un « Lire le guide → ». La bicéphalie de l'invariant n° 6 ne servait jusqu'ici que le SEO, alors que c'est au destinataire d'un rappel que l'explication entière manque le plus. Aucun lien fabriqué pour la symétrie : la fiche sans page n'en affiche pas.

6. Le lien d'activation tient en un bloc. Le bouton et l'adresse durable du foyer formaient deux étages séparés par trois lignes d'explication, pour deux liens qui mènent au même écran. Une ligne suffit sous le bouton.

Ce qui n'a PAS changé, et pourquoi : le bouton du mail d'activation reste le lien magique, pas l'URL permanente. Ce message n'est envoyé que dans le cas 2 du webhook Stripe — personne n'était connecté avant de payer — et dans ce cas précis c'est la vérification du lien magique qui crée le compte ; resolveMembership n'inscrit ensuite le premier membre que s'il y a une session (src/lib/membership.ts). Un bouton pointant l'URL permanente laisserait donc un foyer payé sans compte, sans appartenance, et par conséquent sans aucun destinataire de rappeldestinatairesFoyer lit les appartenances. Le rendre permanent suppose d'abord de créer le compte et l'appartenance dans le webhook, à partir de l'e-mail que Stripe a déjà vérifié ; c'est un chantier distinct, pas un réglage de gabarit.

Icône expéditeur (BIMI) : public/bimi/fokan.svg est publié au format SVG Tiny PS conforme (carré, fond plein, <title>, aucun script). Il ne suffit pas : Gmail, Yahoo et Apple Mail n'affichent le logo qu'avec un VMC (certificat de marque vérifiée, ~1 000 €/an, adossé à une marque déposée), et BIMI exige au préalable un DMARC en application (p=quarantine ou p=reject, aujourd'hui à vérifier côté DNS). L'enregistrement default._bimi.fokan.fr TXT "v=BIMI1; l=https://…/bimi/fokan.svg;" peut être posé dès maintenant — il ne coûte rien et sera honoré par les clients qui n'exigent pas de VMC — mais l'avatar Gmail restera l'initiale tant que le certificat n'est pas acheté. À arbitrer, pas à supposer résolu.

Outillage : npm run mails:echantillon rend un exemplaire de chacun des six messages (le lien magique comptant pour trois selon son contexte) depuis les vraies fiches — sur disque pour relecture, ou envoyés à une boîte réelle. C'est le seul moyen de voir ce qu'un client mail fait d'un HTML qui passe tous les tests.

D-047 — 5 août 2026 · Les scripts d'installation npm sont coupés par défaut ; le .env sort de l'image

Contexte : ver de chaîne d'approvisionnement « ChainDrop » publié sur npm le 4 août 2026 vers 09:35 UTC. Point d'entrée keyv, cacheable, flat-cache, file-entry-cache, puis plus de 1 300 versions en une matinée par auto-propagation : un hook preinstall vole les jetons npm, GitHub, AWS, les clés SSH et les chaînes de connexion, et republie avec les jetons volés.

fokan n'a pas été touché, et il faut dire pourquoi, parce que ce n'est pas de la robustesse : node_modules datait du 29/07 et package-lock.json du 29/07 également, six jours avant la fenêtre. npm ci en CI comme dans les deux étages du Dockerfile a fait le reste — la résolution vient du lock, pas des plages. Les trois paquets concernés sont pourtant bien là, en transitif sous ESLint : file-entry-cache@8.0.0flat-cache@4.0.1keyv@4.5.4, chacun tiré par un ^. Un seul npm install ce jour-là aurait suffi.

Ce qui change :

  1. ignore-scripts=true dans .npmrc. C'est la parade au vecteur lui-même : sans script d'installation, un paquet compromis est du code inerte tant que rien ne l'importe — et keyv n'est importé par rien ici, il dort au fond de l'arbre d'ESLint. Le coût est borné et mesuré : 7 paquets sur 687 ont un script d'installation, dont un seul en production. esbuild (×4 versions), unrs-resolver et fsevents (optionnel, macOS) sont en dev ; sharp est en prod, optionnel. Les trois utiles sont relancés nommément par npm rebuild — dans la CI (esbuild pour tsx et vitest, unrs-resolver pour ESLint) et dans le Dockerfile (les deux mêmes au build, sharp à l'étage des dépendances de production). On fait confiance à trois paquets désignés au lieu de 687 par défaut.

  2. .dockerignore, qui n'existait pas. Le COPY . . du builder emportait le .env de développement dans une couche de l'image — gitignoré, donc bien présent sur le disque, et lisible par n'importe quel script de build : exactement ce que le payload du 04/08 allait chercher. Il écrasait au passage le node_modules fraîchement installé par le npm ci de la ligne précédente, avec celui de l'hôte.

  3. permissions: contents: read sur la CI, dont le jeton avait jusqu'ici les droits par défaut du dépôt, et actions épinglées au SHA (actions/checkout, actions/setup-node) : un tag est mutable, c'est le ^ de npm sous un autre nom.

Ce qui n'a délibérément pas été fait : aucune rotation de secret. Rien n'a été installé après le 29/07 sur cette machine ni sur les autres projets (video-studio 07/07, kankwa/frontend 30/05, tous deux sans ces paquets ; openclaw.bak en liste deux dans son lock mais n'a aucun node_modules), aucun preinstall n'existe dans l'arbre, aucun fichier de node_modules n'est postérieur au 04/08.

Ce qui n'aurait servi à rien, et qu'il vaut mieux savoir avant la prochaine fois : les versions malveillantes portaient une provenance GitHub Actions valide — l'attaquant poussait sur main puis publiait une release. La provenance atteste le dépôt d'origine, jamais le contenu ; npm audit signatures aurait répondu que tout allait bien. Le 2FA mainteneur non plus : le ver republiait avec des jetons d'automatisation volés.

Le point faible qui reste, et qu'aucun fichier ne couvre : npm install (y compris npm i <paquet>) re-résout les ^ et peut remonter sur une version empoisonnée le jour où elle est en ligne. La règle est temporelle, et c'est la seule qui aurait protégé quelle que soit la technique : on n'installe pas une version publiée il y a moins de sept joursnpm install --before=$(date -d '7 days ago' -I) en attendant une politique outillée. Le ver a été repéré dans l'heure ; sept jours de quarantaine le rendent inoffensif par construction. Corollaire de discipline : le diff de package-lock.json se lit, ligne à ligne, sur tout ajout de dépendance.

D-046 — 5 août 2026 · La frise des douze mois glisse au lieu de suivre l'année civile

Contexte : demandé par le porteur de projet pour la frise de fin de quiz et de la page foyer — qui sont le même écran, le quiz atterrissant sur /foyer/[id].

Le docstring du composant annonçait déjà « les douze mois à venir » quand le code rangeait les pastilles par index de mois calendaire, de janvier à décembre. Deux défauts en découlaient, et le second ne se voyait pas :

  • La moitié de la promesse était hors champ. Un foyer qui ouvrait sa page en août voyait sept colonnes derrière lui, où il ne se passera plus rien, et perdait de vue les mois de début d'année suivante. Mesuré sur le foyer témoin : 12 échéances datées, dont 7 tombent après décembre.
  • Deux années se superposaient en silence. Septembre 2026 et septembre 2027 tombaient dans la même colonne. Sur un foyer dont l'échéance la plus lointaine est à douze mois — c'est-à-dire la totalité d'entre eux, l'horizon étant naturellement borné par un cycle de récurrence (cf. D-045 § 1) — la collision était structurelle.

La colonne 0 est désormais le mois en cours, la 11 le onzième mois à venir, et le rangement se fait sur une différence de dates réelle (decalageMois, exportée pour que la page foyer et l'aperçu de la landing partagent la même règle). Le calcul passe par (année × 12 + mois) et jamais par un écart en millisecondes : « le mois prochain » n'a pas de durée fixe, et 30 jours enjambent un mois ou deux selon le jour où on les mesure.

Deux effets de bord traités dans le même mouvement, dont le premier aurait menti :

  • « Sans date fixée » ne compte plus que ce qui n'a pas de date. Le calcul était toutes.length - points.length, juste tant que la frise couvrait toute l'année civile, faux dès qu'elle glisse : une échéance datée au-delà de douze mois en sort, et se serait retrouvée annoncée « sans date fixée » alors qu'elle en a une. Il repart de la fenêtre elle-même.
  • Le repère « on est ici » redevient lisible. Les colonnes prenaient la hauteur de leur contenu (items-end) ; le mois courant étant désormais toujours en première position, et souvent vide, son fond se réduisait à un trait gris de quelques pixels qui se lisait comme un défaut d'affichage. Les colonnes montent maintenant toute la hauteur, les pastilles restant collées en bas.

L'infobulle nomme le mois et son année : sur une année glissante, deux colonnes peuvent porter « J » sans être ni le même mois ni la même année.

Fichiers : src/components/timeline-annee.tsx (+ test), src/app/foyer/[id]/page.tsx, src/app/page.tsx (l'illustration du hero devient une distribution de décalages — elle dit « ton année à partir d'aujourd'hui » quelle que soit la date de la visite, au lieu d'un janvier vide en plein août).


D-045 — 5 août 2026 · Le silence devient un réglage, et la timeline retient enfin le passé

Contexte : trois questions du porteur de projet sur ce que l'abonnement couvre et ce qu'il donne à voir — la durée, les échéances échues qui « disparaissent », et la visibilité au-delà du terme — plus un défaut d'écran signalé sur l'encart de « Mon espace ». L'audit qui suit a établi que deux de ces trois questions portaient sur le même défaut de fond, et que la troisième n'en était pas un.

1. Il n'y a pas de « période de couverture », et il ne faut pas en fabriquer une

L'abonnement est un mode: "subscription" Stripe : subscription_period_end est une date de prélèvement, pas une date d'expiration. Borner le calendrier dessus aurait produit un écran qui rétrécit à mesure que l'année avance — 12 mois visibles au premier jour, deux au trois-centième — c'est-à-dire un décompte avant renouvellement, exactement la comptabilité morale que le § 4.5 refuse. Et ça ne protégerait rien : le calendrier est déjà un horizon glissant d'un an (mesuré : 0 échéance au-delà de 12 mois, la plus lointaine au 01/08/2027), parce qu'une ligne deadlines ne porte qu'une fenêtre — la prochaine. Comme 15 des 25 échéances du foyer témoin sont annuelles et retombent au même mois, le calendrier du 364ᵉ jour dit la même chose que celui du 1ᵉʳ : qui veut recopier n'a aucune raison d'attendre la fin de son abonnement.

Ce qui protège le renouvellement n'est donc pas le masquage, mais ce qui ne se copie pas — les référentiels rafraîchis en continu et les fiches publiées après coup — et le flux .ics, qui répond 403 dès que l'abonnement tombe et n'a aucun RRULE : une photo figée, jamais une série qui roule toute seule.

2. Le vrai défaut : la timeline n'appendait rien

runReconciliation n'insérait une occurrence qu'à la création de l'échéance, puis faisait rouler la fenêtre en écrasant deadlines sur place. Mesuré : 50 échéances, 50 occurrences, chacune figée à la première fenêtre calculée. L'invariant n° 4 annonçait une « timeline append-only » qui n'avait jamais rien appendu, et le § 10.2 vendait un « historique des occurrences » qui n'existait pas.

Une occurrence est désormais close (glisse, close_le horodaté) quand sa fenêtre est passée et qu'une nouvelle prend sa place. Trois conditions cumulatives déclenchent la clôture — fenêtre sortante datée, passée, et différente de l'entrante — sans quoi un bump de version de fiche, ou simplement la réconciliation du lendemain, insérerait une occurrence par jour. Vérifié en base : deux passages consécutifs laissent exactement deux occurrences.

Deux nuances qui ne se devinent pas. L'occurrence ouverte suit la fenêtre de son échéance tant qu'elle est a_venir — c'est l'état courant, pas de l'histoire ; elle n'est gelée qu'à sa clôture. Et occurrences.fenetre_label est recopié depuis l'échéance, parce que le libellé ne vivait que sur deadlines où il est écrasé à chaque roulement : sans lui l'historique n'aurait qu'une date nue, là où le libellé porte ce qui était à l'écran (« avant le 12/09/2026 »).

3. Ce que l'historique montre, et ce qu'il se refuse à dire

Un horizon de plus dans les filtres existants (D-041) — « Déjà passé » — borné au début de la période d'abonnement en cours (subscription_period_start, désormais recopié de Stripe ; repli à period_end moins un an pour les foyers antérieurs). Il n'entre pas dans « Tout » : la vue par défaut reste rigoureusement celle d'avant, et aucun compteur de la page ne bouge.

Chaque ligne échue porte sa fenêtre et, s'il en est parti un, le jour de l'envoi du rappel — lu dans notifications_log, qui était jusqu'ici la seule mémoire du passé du foyer et n'était affichée nulle part. Elle ne porte ni « non fait », ni rouge, ni décompte. C'est impératif tant que le geste « c'est fait » n'existe pas (§ 7.1 de l'audit mail) : tout le passé est alors mécaniquement glisse, et l'afficher tel quel reprocherait une inaction qu'on n'a aucun moyen de constater.

Un drapeau range une ligne, jamais sa date. Une courroie de distribution annonce « échéance dépassée depuis mars 2024 » : sa fenêtre est passée mais c'est un constat en cours. La ranger sur la date l'aurait sortie de la vue par défaut, c'est-à-dire fait disparaître du calendrier une échéance qui n'a jamais été honorée.

4. L'encart : un état et un réglage, plus jamais confondus

« Rappels par mail activés » était rendu à la position du bouton dans un justify-between — l'œil y cherchait une action et trouvait un état, lequel n'était même pas un état mais une constante affichée : aucun réglage n'existait derrière, et rien nulle part ne permettait de couper les rappels (audit mail § 4.7, niveau élevé).

Deux lignes désormais. La première dit le prix et la reconduction ; la seconde porte l'interrupteur. La phrase qui compte le plus est « ton calendrier reste complet » : sans elle, un interrupteur sous une ligne d'abonnement se lit comme une résiliation, et qui voulait le silence annulerait ce pour quoi il paie.

L'opt-out est au niveau du foyer (households.rappels_coupes_le, horodatage et non booléen — il se journalise tout seul), c'est le niveau 2 de l'audit § 6.1 ; le par-membre deviendra naturel le jour où les foyers à deux membres existeront. La règle est une fonction séparée de abonnementOuvert : payer et vouloir le silence sont deux questions, et les fondre aurait rendu impossible de savoir laquelle a fait taire un foyer. Elle est posée deux fois, comme la précédente — dans le where du passage collectif pour filtrer en masse, à l'unité dans envoyerRappelFoyer que les scripts appellent directement.

Le geste existe sans session, par URL-capacité HMAC dérivée de BETTER_AUTH_SECRET : un destinataire qui veut cesser de recevoir n'a pas à se connecter d'abord, et le client de messagerie qui appelle l'endpoint one-click (RFC 8058) n'a évidemment pas de cookie. Le jeton ne sait que couper — rallumer exige une session, faute de quoi un tiers détenant un vieux lien pourrait relancer des envois. D'où les deux en-têtes List-Unsubscribe / List-Unsubscribe-Post qu'exigent Gmail et Yahoo depuis 2024, et le pied de page du rappel qui cesse de promettre « marquer comme fait, changer de responsable » — deux gestes qui n'existent toujours pas (arbitrage § 6.2 de l'audit, tranché ici en faveur de la copie honnête).

5. Deux corrections de vérité

cancel_at_period_end est désormais lu dans le webhook. Stripe laisse status: "active" sur une résiliation programmée — le service est dû jusqu'au terme, ce qui est juste — mais l'écran continuait d'annoncer « Renouvellement le 31/07/2027 » à quelqu'un qui venait précisément de dire l'inverse. Et le portail de facturation Stripe s'ouvre depuis l'encart : un écran qui annonce un prélèvement doit dire où on l'arrête. Il est accessible à tout membre, pas au seul payeur — le § 6.3 pose des membres égaux, et réserver la facturation au payeur laisserait un foyer sans recours le jour où celui-ci s'en va.

Non fait, délibérément : le récap annuel et le mail de fin d'abonnement (le 5ᵉ message manquant de l'audit § 1) restent en attente d'arbitrage. Le niveau 1 ci-dessus est ce qui les rendra chiffrables.

Fichiers : src/db/schema.ts + drizzle/0018_* (quatre colonnes : rappels_coupes_le, subscription_period_start, subscription_cancel_at_period_end, occurrences.fenetre_label/close_le), src/engine/reconcile-db.ts (clôture et roulement), src/engine/historique.ts (nouveau), src/lib/rappels.ts + test (nouveau), src/lib/subscription.ts, src/app/api/rappels/desabonner/route.ts et src/app/rappels/page.tsx (nouveaux), src/app/api/stripe/portal/route.ts (nouveau), src/app/mon-espace/abonnement.tsx (nouveau) et page.tsx, src/app/api/stripe/webhook/route.ts, src/jobs/notifications.ts, src/adapters/mail.ts, src/emails/rappel.tsx, src/app/foyer/[id]/ (filtres, calendrier, page).


D-044 — 5 août 2026 · Le trait du logo devient l'initiale de « okan » ; le point disparaît (révise D-043)

Contexte : signalé par le porteur de projet en relisant le lockup nav/pied de page posé par D-043 — le trait du logo n'est pas une icône à côté du mot « fokan », c'est déjà un F (le toit protège, le jambage porte le pied). Autant le faire tenir lieu d'initiale plutôt que de le répéter à côté d'un « fokan » tapé en entier.

Calibrage mesuré, pas à l'œil

Un lockup [F]+« okan » exige que le pied du F touche la ligne de base du texte et que l'espacement se lise comme un seul mot — deux réglages qu'un aperçu à petite échelle ne permet pas de juger. Les deux ont été calés par rendu sur <canvas> et lecture de pixels (getImageData), pas par itération visuelle :

  • Alignement vertical : translateY(0.02em) sur le SVG — l'écart réel entre le pied du F et la ligne de base mesurait 0,6 px avant correction, contre les 0,13em ajoutés à l'aveugle dans le premier jet (qui faisait au contraire déborder le F sous la ligne).
  • Resserrement horizontal : margin-left: -0.55em sur le texte. La vraie limite n'est pas la marge visuelle apparente (largement dominée par un espace involontaire du HTML, un simple retour à la ligne entre le SVG et le texte) mais l'encre du F dans la bande verticale précise qu'occupe le « o » — mesurée à 169px sur une boîte de 435, très en retrait du bord de la boîte.

Le point retiré

Avec le point, la limite de resserrement devenait le jambage du « k » (qui occupe la même bande verticale que le point) plutôt que le jambage du F — mesuré à 5-6px de la marge réelle avant contact. Plutôt que garder cette marge de sécurité, le point est retiré : le trait seul se suffit comme signature, et sa disparition lève la contrainte tout en simplifiant le mark (plus aucun usage de var(--accent) dans le logo lui-même — la sauge reste réservée à l'accent produit).

Fichiers : src/components/logo.tsx (composant réécrit en lockup unique, entièrement en em, aria-label="fokan" porté par le conteneur puisque rien dans le DOM n'épelle plus le nom sans lui), src/components/nav.tsx et src/components/footer.tsx (un seul <Logo /> remplace l'icône + le texte « fokan »), src/app/icon.svg et src/app/opengraph-image.tsx (point retiré du mark, pour cohérence — ces deux contextes gardent icône + texte séparés, converger vers le lockup y exigerait de charger Newsreader dans Satori, hors périmètre ici).


D-043 — 5 août 2026 · Un logo, une palette sauge & encre, et Newsreader remplace Fraunces

Contexte : le porteur de projet fournit un logo (deux traits + un point, en encre #1E2A2B et sauge #6E8B7B — exactement les deux teintes ci-dessous) et une nouvelle palette, « Vert sauge & encre ». Elle remplace la palette vert/sable posée au socle de design (D-005). Un second signalement, séparé — « je n'aime pas la forme du ⟨f⟩ » — porte sur Fraunces, qui jusqu'ici portait logo, titres et chiffres clés.

1. La palette

--vert-* et --sable-* deviennent --sauge-* et --encre-* dans globals.css (échelles à 11 paliers, mode clair et sombre) ; --ambre-* est reformulé autour d'un « ambre doux » #D19A3E. Le pilier logement reprend la sauge de marque, comme le vert avant lui (§ 19.3 : le vert/sauge de marque reste libre, réservé au point du logement — seul repère de sa couleur sur la carte).

Un seul écart à la palette fournie, et il est mesuré, pas esthétique : #6E8B7B en texte blanc de bouton tombe à 3,7:1, sous le seuil AA (4,5:1). --accent prend --sauge-600 (#617a6c, huit points de clarté plus bas, 4,7:1) ; #6E8B7B reste employé tel quel ailleurs (icônes, bordures, --sauge-500). Les emails (React Email) et la vignette Open Graph (Satori) ne lisent aucune variable CSS ; leurs couleurs y sont donc dupliquées en dur, comme elles l'étaient déjà pour vert/sable.

2. La police

Comparatif à cinq (Fraunces, Newsreader, Lora, Source Serif 4, Piazzolla) rendu dans le vrai contexte du site — lockup du logo, titre de la home, chiffre de widget — et non en abstrait : c'est la forme d'une lettre précise qui posait problème, pas une police en général. Newsreader l'emporte : même mécanique que Fraunces (un axe optique opsz qui affine le dessin selon la taille, remplacement direct dans lib/fonts.ts), un ⟨f⟩ sobre sans le crochet haut de Fraunces, et la même chaleur littéraire sans sa théâtralité — plus proche du majordome que du manifeste (§ 4.6).

Fichiers : src/app/globals.css (palette), src/lib/fonts.ts (police), src/components/logo.tsx (nouveau, colore par var(--ink)/var(--accent) — ne connaît pas plus le mode sombre que le reste des composants), src/app/icon.svg (favicon), src/app/opengraph-image.tsx et les trois emails (src/emails/*.tsx, couleurs dupliquées en dur pour la raison ci-dessus).


D-042 — 31 juillet 2026 · Deux guides entiers par visiteur, puis l'aperçu — et le crawler reçoit tout

Contexte : /guide réservait déjà son catalogue aux abonnés (§ 10.2), mais chaque fiche prise isolément était lisible en entier, indéfiniment. Le rail « à lire aussi » de la colonne de droite propose trois guides du même pilier sur chaque page : il EST la table des matières que le catalogue verrouillé prétendait protéger. Les 76 guides se parcouraient de proche en proche sans rien payer, et sans même avoir à chercher.

La règle

Deux lectures entières par visiteur, puis un aperçu d'environ 30 % du texte, un dégradé et le mur.

Deux et non zéro : le canal n° 1 du produit est le SEO longue traîne (§ 11.2). Quelqu'un qui arrive de Google sur « le ramonage est-il obligatoire » doit obtenir sa réponse — sinon il repart, et Google l'apprend. Deux et non dix : au-delà, on n'a plus vendu un abonnement, on a donné la base de connaissance.

Le compteur porte sur le VISITEUR et non sur des guides désignés : n'importe quelle fiche reste une porte d'entrée viable, ce qu'une liste blanche de deux slugs aurait interdit aux 74 autres.

Trois choses qui ne se devinent pas

1. Aucune IP n'est stockée. lectures_guides.visiteur est un HMAC-SHA-256 de l'adresse, tronqué à 128 bits, salé par GUIDE_METER_SECRET (à défaut BETTER_AUTH_SECRET). C'est la seule forme sous laquelle une IP touche la base. Une purge à 90 jours voyage avec la réconciliation de 4 h — un compteur est une friction commerciale, pas un historique de lecture.

Deux limites payées en connaissance de cause, et il faut les nommer parce qu'elles frappent des gens qui n'y sont pour rien : derrière un CGNAT d'opérateur mobile ou un réseau d'entreprise, des milliers de lecteurs partagent une adresse et donc un quota de deux — le troisième voit l'aperçu sans avoir rien lu. Symétriquement, un bail DHCP renouvelé remet le compteur à zéro. Le mécanisme freine la lecture en rafale, il ne la rend pas impossible ; c'est une friction commerciale, jamais une frontière de sécurité. Le jour où le premier défaut se voit dans les chiffres, la parade est d'ajouter un cookie PAR-DESSUS — le plus permissif des deux gagne — et non de remplacer l'un par l'autre.

2. Le crawler reçoit le texte entier, et le balisage le déclare. isAccessibleForFree: false + hasPart avec cssSelector: ".guide-paywall" : c'est la « flexible sampling » que Google documente et autorise explicitement pour le contenu payant. Sans ce traitement, les 74 guides tronqués ne seraient indexés que sur leur amorce — le paywall détruirait le canal d'acquisition qu'il est censé monétiser. Ce qui distingue ce montage du cloaking, qui lui est sanctionné, c'est exactement cette déclaration ; elle ne varie pas selon le visiteur, parce qu'elle décrit la politique d'accès du DOCUMENT et non ce qu'un lecteur a obtenu aujourd'hui. La détection se fait sur le User-Agent, sans DNS inverse : usurpable en une ligne de curl, et c'est assumé — voir le point précédent sur ce qui est protégé.

3. On coupe le markdown au jeton, jamais le HTML au caractère. Tronquer une chaîne HTML rendue revient tôt ou tard à couper dans un <strong> ou entre <ul> et son premier <li> : le navigateur referme les balises à sa façon et avale le reste de la page, rail de contexte et pied compris. marked.lexer rend des blocs de premier niveau ; on en garde un nombre entier, donc le HTML est valide par construction.

Le piège, à l'intérieur du piège : marked résout les liens de référence ([texte][cle] avec la définition en bas de page) au moment du lexer, pas du parser. Reparser la seule amorce — même en lui recollant la table des liens après coup — arrive trop tard, et le lien se rend en texte brut entre crochets au milieu du paragraphe. La seule coupe correcte tranche des jetons déjà tokenisés dans le contexte du document complet. Écrit après l'avoir constaté sur un test qui échouait.

Le texte coupé n'est jamais envoyé au navigateur. Le dégradé signale la coupe, il ne la produit pas : un voile posé sur un article complet se lève d'un clic dans l'inspecteur.

Sens d'erreur choisi : base injoignable → le guide s'ouvre. Perdre quelques lectures gratuites est réversible ; perdre l'indexation d'une fiche pendant une panne ne l'est pas.

Vérifié le 31/07/2026 sur une base jetable : lectures 1 et 2 entières, lecture 3 tronquée (4 sections <h2> pour le crawler, 2 pour le lecteur mural) ; retour sur un guide déjà lu → entier, même quota épuisé ; autre IP → entier ; Googlebot sur l'IP épuisée → entier, avec isAccessibleForFree:false et cssSelector:".guide-paywall" dans le JSON-LD.

Le défaut trouvé en production, dans la minute qui a suivi le déploiement

Un préchargement de next/link consommait une lecture libre. Un prefetch exécute le rendu serveur complet de la page — donc le compteur — pour un guide que personne n'a ouvert. Le rail « à lire aussi » en propose trois par fiche : deux lectures libres pouvaient partir en survolant une page, et le mur tombait sur le premier guide réellement lu. Constaté sur la table lectures_guides moins d'une minute après le redémarrage, sur un visiteur qui n'était pas nous : deux guides de scolarité enregistrés à cinq secondes d'intervalle.

C'est le pire échec possible pour ce mécanisme — pire que la sur-inclusion CGNAT, parce qu'il est de notre fait et qu'il frappe tout le monde. La parade tient en deux temps : prefetch={false} sur les quatre liens vers /guide/<slug> (index, rail, ligne du hub, popup de carte), et un filet côté serveur qui répond sans consommer quand l'en-tête Next-Router-Prefetch est posé — pour qu'un lien ajouté demain ne rouvre pas le trou en silence. Une navigation cliente ordinaire porte RSC: 1 mais pas cet en-tête : c'est une vraie lecture, elle compte.

Le filet répond selon le quota courant sans le consommer, et non par l'aperçu : la charge utile préchargée est ce que le routeur affichera à la navigation, et servir un mur là reviendrait à le montrer à qui avait encore des lectures.

Une promesse a dû être réécrite. L'index des guides affirmait « chaque guide reste trouvable individuellement — par une recherche, par un lien ». Ce n'est plus vrai. Une promesse qui survit à la règle qu'elle décrivait est pire qu'un silence : elle apprend au lecteur qu'on lui raconte n'importe quoi, au moment précis où on lui demande de payer.


D-041 — 31 juillet 2026 · Le calendrier du foyer se filtre et se trie, sans jamais rien compter moralement

Contexte : le hub listait les échéances groupées par pilier, dans un ordre fixe, sans aucune prise. Sur un foyer chargé — logement, deux véhicules, trois enfants, deux animaux —, « qu'est-ce qui tombe avant la rentrée ? » se répond en parcourant cinq sections.

Ce qui change

Quatre axes de filtrage (domaine, enjeu, échéance, récurrence) et trois tris (par domaine, par date, par enjeu le plus contraignant d'abord), dans un composant client — l'état vit dans la page, aucun aller-retour serveur.

Le défaut est neutre : aucun filtre actif, tri par domaine, c'est-à-dire exactement le rendu qui existait avant. Un foyer qui ne touche à rien voit la page qu'il voyait hier. La barre de filtres est une sortie, jamais un péage d'entrée — c'est la condition pour que « je ne fais rien » reste parfaitement servi (§ 4.5).

Aucune comptabilité morale n'entre par cette porte. C'est le seul endroit du produit où le foyer réduit volontairement ce qu'il voit, et c'est précisément pourquoi il n'y a ni « à traiter », ni retard, ni rouge : les chiffres des puces sont des volumes, comme la légende de la frise. L'état vide dit que la combinaison ne ramène rien — jamais « bravo, tout est à jour ».

Quatre décisions de détail qui ont chacune une raison

Les horizons sont à choix unique. Deux mois ⊂ six mois ⊂ douze : proposer de cocher les deux aurait donné deux cases pour un seul résultat. « Sans date fixée » sort de l'emboîtement — c'est l'autre bout, et un horizon n'avale jamais une échéance non datée. « Je ne sais pas » est une réponse de première classe : une fiche sans fenêtre placée sous « dans les 2 mois » répondrait à une question qu'elle ne sait pas trancher. Un test le verrouille sur les trois horizons.

Les comptes des puces sont des facettes, pas des totaux. Chaque puce compte ce que donnerait son activation, les autres axes restant tels quels. Sans ça, une puce annoncerait « Animaux (3) » alors que la cocher, avec un filtre d'enjeu déjà posé, ramènerait zéro — un compte que le lecteur ne peut pas vérifier à l'écran, c'est-à-dire exactement ce que le ton du produit refuse depuis D-020.

Trois paniers de récurrence, pas six. Le schéma en connaît six ; la question que se pose un foyer n'en distingue que trois — ça revient tous les ans, ça revient de loin en loin, c'est une fois pour toutes. Relevé sur les fiches : 40 régulières, 7 espacées, 29 ponctuelles. Six cases en auraient laissé trois à un ou deux éléments. La ligne affiche en revanche la fréquence exacte (« tous les 4 ans »), et se tait sur les ponctuelles : « une seule fois » sur 29 lignes n'apprend rien.

En tri à plat, chaque ligne porte son domaine. Les en-têtes de section ont disparu, la pastille de couleur seule ne suffit pas (a11y) — même règle que la légende de la frise.

Deux frontières techniques déplacées

  • identiteIcs a quitté hub.ts. Ce dernier importe Drizzle et node:fs (chargeur de fiches) ; le calendrier filtrable est un composant client et a besoin de cette clé pour ses key React. L'importer depuis hub.ts aurait tiré la base dans le bundle du navigateur. Le calcul vit désormais dans engine/identite.ts, pur, et hub.ts le réexporte — aucun appelant historique n'a changé.
  • Rappels traverse la frontière en children. C'est un composant serveur asynchrone (il résout les véhicules et lit la base des rappels constructeur) : il est rendu par la page et passé en prop, jamais importé par le composant client, qui ne saurait pas l'attendre.

HubDeadline porte une nouvelle propriété recurrence, recopiée du template. Elle vient de la fiche et non de la ligne en base : la clé de grouperEcheances commence par templateId, un groupe ne peut donc pas en mélanger deux.

Le calendrier verrouillé ne change pas : on ne filtre pas des cartes qui ne portent qu'un compte, et une barre de filtres au-dessus de cinq cadenas serait une promesse de détail qu'on ne tient pas.


D-040 — 31 juillet 2026 · Le pilier « maison » devient le pilier « logement » (révise l'invariant n° 7)

Contexte : une maison n'est pas un appartement ; un logement, si. Le pilier couvrait depuis l'origine les deux — la moitié de ses 37 fiches (AG de copropriété, DPE, décence énergétique, VMC, fermeture du cuivre) parlent d'appartements autant que de maisons — mais le mot « Maison » s'affichait à un locataire d'immeuble sur le hub, sur la landing, dans le fil d'Ariane de chaque guide, dans la légende de la frise et dans le HUD du quiz.

Le renommage est complet : énumération Pilier, ordre d'affichage, PILIER_META, variables CSS (--pilier-logement, --color-logement), fiches (knowledge/templates/logement.yaml), document de référence (docs/pilier-logement.md) et toutes les mentions en prose.

Une homonymie voulue, à ne pas défaire

logement désigne désormais trois choses distinctes, dans trois espaces de noms qui ne se croisent jamais. Les confondre est l'erreur que la prochaine relecture cherchera à « corriger », d'où cette entrée :

  1. Le pilier logement — un rangement d'affichage, cinq valeurs, aucune table.
  2. L'asset_type logement — ce sur quoi une fiche s'applique. Il existait déjà avant ce renommage. Une fiche du pilier logement peut porter sur l'asset foyer (taxe d'habitation), et une fiche du pilier papiers peut porter sur l'asset logement.
  3. QuizAnswers.logement.type — le bâti, qui vaut "maison" ou "appartement". Celui-là ne change pas : c'est la seule occurrence de « maison » qui reste dans le code, et elle est juste. Les libellés d'asset « La maison » / « L'appartement » en dérivent.

Aucune migration. Le pilier n'est jamais persisté : il est lu du template à chaque construction du hub. Les identifiants de fiches, eux, ne bougent pas — l'invariant n° 1 (« IDs de templates stables à vie ») est intact, ramonage-obligatoire-frequence reste ramonage-obligatoire-frequence et aucune URL de guide ne change.


D-039 — 31 juillet 2026 · L'outbox nomme son destinataire, et l'avance de rappel cesse d'être une affaire de confiance seule (révise les §§ 2, 6 et 7 de D-013)

Contexte : audit de bout en bout du service mail, demandé le 30/07/2026 et consigné dans docs/audit-mail-2026-07-30.md — cinq messages peuvent partir, pour trois gabarits. Quatorze défauts relevés ; dix corrigés le 31/07 (« lot A »), quatre laissés en décision produit ouverte (§ 6 de l'audit). Le SMTP est configuré et actif depuis D-014, ce qui change la nature de ces défauts : ils ne sont plus théoriques.

Trois des correctifs ne réparent pas un écart au dessin de D-013 — ils changent ce dessin. D'où cette entrée.

Ce qui change

1. metadata n'est plus un canal de confiance. POST /sign-in/magic-link accepte un champ metadata que better-auth déclare dans son schéma public et transmet tel quel à sendMagicLink, d'où le contexte du message était lu. Sans compte ni session, un appel forgé faisait donc partir depuis noreply@fokan.fr — domaine aligné SPF/DKIM/DMARC — un message intitulé « Ton foyer est activé sur fokan » affirmant « Le paiement est passé », ou une invitation dont le titre affichait une adresse choisie par l'attaquant. C'est le trou que l'audit du 28/07 avait fermé sur /api/invitations (S2), contourné une route plus loin.

Un sceau dérivé de BETTER_AUTH_SECRET (src/lib/lien-magique.ts) distingue désormais l'appel interne de l'appel réseau ; tout écart retombe sur « connexion », le seul contexte qui n'affirme rien. La règle générale vaut au-delà du mail : un champ que le framework expose au client n'est pas un canal de confiance, même quand seul notre code l'écrit aujourd'hui.

2. L'avance de rappel peut dépendre de la stratégie, et non plus de la seule confiance (révise le § 2 de D-013). catnatWindow pose fenetreDue = publication au Journal officiel + 30 jours. Traitée comme n'importe quelle fenêtre connue, elle partait 21 jours avant cette limite, c'est-à-dire 9 jours après la publication — un mail annonçant « 30 jours pour déclarer » quand il en restait 21. C'est le gaspillage exact que la veille quotidienne de D-012 a été bâtie pour éviter : son commentaire s'inquiète qu'un passage hebdomadaire consomme « jusqu'à un quart » du délai, quand le planificateur en consommait 30 %, tous les jours, sans que rien ne le signale.

AVANCE_PAR_STRATEGIE porte l'exception, nommée et unique. Trois semaines restent le bon réglage pour un contrôle technique ou une échéance ZFE : ce n'est pas la sémantique de connue qui était fausse, c'est la constante qui est inadaptée à une fenêtre légale d'un mois. Une avance de stratégie ne ressuscite jamais une fenêtre libre.

3. L'outbox porte le destinataire dans sa clé (révise le § 6 de D-013, et lève le troisième « sacrifié en connaissance de cause »). La boucle d'envoi marquait envoye dès qu'un membre avait été servi, et journalisait par occurrence. Sur un foyer à deux : la première adresse passe, la seconde rebondit, la porte d'idempotence se referme, et le second membre ne reçoit jamais ce rappel — ni le lendemain, ni jamais, puisque seul envoye fait taire l'outbox. Le § 6.3 promet pourtant à chaque membre ses propres rappels, « sans jamais passer par quelqu'un d'autre ».

La clé devient (occurrence, canal, fenêtre notifiée, destinataire), et envoyerRappelFoyer calcule un plan par membre : si une adresse a échoué hier, lui seul redevient candidat aujourd'hui. Dans le cas courant les plans sont identiques et le comportement est celui d'avant.

Deux précisions qui ne se devinent pas :

  • c'est le user_id qui est stocké, jamais l'adresse — la minimisation du § 8 tient, l'adresse se relit chez l'utilisateur ;
  • l'envoi_id reste unique par foyer et par jour. En donner un par destinataire aurait divisé le budget mensuel d'un foyer par son nombre de membres, puisque envoisCeMois compte des envoi_id distincts.

Les lignes antérieures, sans destinataire, valent « parti à tout le foyer » et font taire l'échéance pour tous — c'est ce qu'elles voulaient dire quand elles ont été écrites, et les relire autrement aurait fait repartir d'anciens rappels à tout le monde.

4. Une troisième porte, à l'unité (complète le § 7 de D-013). envoyerRappelFoyer est exporté et appelé directement par les scripts de mise au point : la seule vérification d'abonnement vivait dans le where du passage collectif, que ces appels ne traversent jamais. abonnementOuvert() — pure, testée — est désormais posée aussi à l'entrée par foyer. La duplication avec le SQL est délibérée : le SQL filtre en masse, la fonction vérifie à l'unité.

5. Un mail d'activation qui survit à son lien. Le lien magique expire en 15 minutes. Dans le cas 2 du webhook Stripe — personne n'était connecté avant de payer —, ce mail était la seule chose qui portait l'adresse du foyer : resolveMembership n'inscrit le premier membre qu'à la visite de /foyer/<id>. Passé le quart d'heure, un foyer payé 19,99 € devenait inatteignable, /mon-espace listant les foyers par appartenance et l'acheteur n'en ayant précisément aucune. L'URL du foyer voyage désormais dans le message ; le lien magique reste le chemin rapide, il n'est plus le chemin unique.

6. Une invitation se renvoie. Le jeton d'invitation vit 7 jours, le lien magique 15 minutes, et le garde-fou « une invitation est déjà en cours » (audit S2) refusait tout second envoi : ni l'invité ni l'invitant ne pouvaient avancer pendant une semaine. Un renvoi est désormais possible sur l'invitation pendante, avec son jeton d'origine, sous un plafond propre — 5 par adresse et par jour, distinct du plafond de création qui ne compte que des lignes en base.

7. Hygiène d'envoi : multipart/alternative sur les trois gabarits (un seul arbre React rendu deux fois, les parties ne peuvent pas diverger) ; Reply-To vers SMTP_USER à défaut de REPLY_TO_EMAIL, parce que noreply@ n'est pas une boîte ; délais de garde SMTP, sans lesquels une connexion qui pend bloquait le passage entier jusqu'à l'expiration pg-boss de 30 min ; message_id enfin écrit dans la colonne qui l'attendait, seul lien possible entre un envoi et un rebond ; un plafond applicatif de 10/h/IP sur la demande de lien magique, indépendant de NODE_ENV — celui de better-auth ne s'active qu'en production.

Deux défauts trouvés en vérifiant, pas en relisant

  • L'avance CatNat posée à zéro faisait l'inverse du correctif. L'avance se compte depuis fenetreDue, qui est la fin du délai : zéro aurait fait partir le mail le dernier jour des 30. La bonne valeur est le délai lui-même, 30. Un test croise la constante avec DELAI_DECLARATION_JOURS de adapters/catnat.ts — que le moteur pur ne peut pas importer sans cesser d'être pur — pour que la duplication ne dérive pas.
  • Aucun gabarit mail ne pouvait être testé. tsconfig.json laisse jsx: "preserve" puisque Next.js transforme en production ; vitest n'a pas ce relais et tout rendu échouait sur « React is not defined ». C'est la raison pour laquelle les trois gabarits n'avaient jamais eu un seul test : l'outil ne le permettait pas, et personne ne l'avait constaté. esbuild: { jsx: "automatic" } dans vitest.config.ts.

Ce qui reste ouvert, et pourquoi ce n'est pas ici

Quatre décisions produit, détaillées avec leurs options et une recommandation en § 6 de l'audit : le périmètre du désabonnement (§ 6.1 — exigence Gmail/Yahoo depuis 2024, RFC 8058), les gestes que chaque mail promet sans qu'aucun écran ne les tienne (§ 6.2), les 31 fiches sur 76 dont le contenu mail ne peut jamais partir (§ 6.3), et le mot de passe activé sans aucun chemin pour en obtenir un (§ 6.4). Elles ne sont pas tranchées : elles n'ont donc pas leur place dans ce journal, et y viendront quand elles le seront.

Vérification

npm test : 645 tests, dont 31 ajoutés par ce lot — 10 sur le sceau du lien magique (dont le scénario d'attaque exact, contexte forgé et sceau volé à une autre configuration), 10 sur les trois gabarits, 7 sur les portes du passage de rappels, 4 sur l'avance CatNat. tsc --noEmit et next build passent, sans avertissement de lint nouveau. Migration 0016_right_jamie_braddock générée, jouée et vérifiée en base : colonne destinataire_id, index unique reconstruit sur quatre colonnes, clé étrangère vers user.

Le chemin d'envoi lui-même n'a pas été rejoué de bout en bout contre un SMTP : la base de développement est vide (0 foyer, 0 membre, 0 ligne d'outbox), contrairement à la vérification de D-013 qui disposait de données réelles.

D-038 — 30 juillet 2026 · Une zone garde tous ses anneaux, plus seulement le plus étendu

Contexte : D-037 § 4 signalait ce défaut sans le corriger, l'ayant jugé « d'affichage seulement ». Consigne du porteur de projet : « il faut que la carte soit exhaustive ». Elle tranche juste — une carte qui omet un secteur réglementé ne se trompe pas d'un pixel, elle ment par omission à qui y habite.

1. Le défaut, partout le même

Quatre fonctions distinctes réduisaient un MultiPolygon à son anneau le plus étendu, avec le même commentaire rassurant : « les autres sont des enclaves ou des secteurs annexes ». Mesuré, c'est faux.

source mesure
Base Nationale des ZFE 41 polygones jetés sur 9 zones. Toulouse en publie 8, dont 7 entre 3,7 et 6,8 km du principal ; Montpellier en a un à 13 km
Zones d'alerte SANDRE 6 zones sur 14 échantillonnées ont plusieurs parties, dont une de 867 sommets
Contour de commune (API Découpage) une commune n'est pas tenue d'être d'un seul tenant — îles, exclaves

Le stockage passe donc de contour (un anneau) à contours (un tableau d'anneaux) sur zfe_zones et zones_secheresse, et contoursExterieurs remplace les quatre réductions.

2. Ce que ça change au-delà du dessin

Trois conséquences, et seule la première était visible :

  • La carte dessine les secteurs annexes. 35 zones ZFE portent désormais 76 anneaux au lieu de 35.
  • enveloppe englobe tous les anneaux. C'est le point qu'il ne fallait pas manquer : le rayon sert au test de proximité (distance(centre) rayon ≤ marge), et calculé sur le seul anneau principal il écartait un logement situé dans un secteur annexe — avant même que la question du dessin se pose.
  • croiserZfe répond « dedans » dès qu'un anneau contient le point. Les secteurs annexes d'une ZFE sont réglementés au même titre que le principal.

3. Ce qu'on continue d'ignorer, et pourquoi

Les anneaux intérieurs — les trous. Les omettre sur-inclut : on peut répondre « dedans » à qui habite une enclave exclue du périmètre. C'est le sens d'erreur assumé du projet, celui qui coûte le moins cher face au silence, et il est désormais écrit dans le code plutôt que subi.

PPRT et inondation gardent un anneau par ligne, et c'est correct pour elles : leurs couches Géorisques ne publient que des Polygon — vérifié sur 2 853 features relevées autour de six villes (2 356 sur PPRT_PERIMETRE_RISQIND, 497 sur PPRN_PERIMETRE_INOND), zéro MultiPolygon — et leur unité de stockage est déjà le fragment (D-037), pas la procédure. 188 de ces features portent des trous, qu'on ignore comme partout ailleurs.

4. Migrations et rattrapage

0014 retire contour, 0015 ajoute contours : ce n'est pas un renommage, la forme de la valeur change, et l'ancienne colonne ne contient rien de convertible puisque les polygones manquants n'y ont jamais été écrits. Les deux tables sont des référentiels publics mutualisés, sans donnée personnelle.

Le rattrapage a été fait sans attendre les crons : npm run zfe:sync — script créé au passage, il n'existait que pour les compléments — et scripts/veille-foyer.ts --tous pour les zones d'alerte.

D-037 — 30 juillet 2026 · Un PPRT est publié en morceaux ; on en gardait un sur trois

Contexte : question du porteur de projet après le correctif de tolérance de D-036 § 4 — « on récupère donc désormais tous les points d'intérêt attendus, pas de biais ? les autres sources sont OK aussi ? ». La réponse à la première question était non.

1. Le défaut : la clé était la procédure, pas le morceau

moissonnerPprt dédupliquait sur id_gaspar — l'identifiant de la procédure — et gardait le premier morceau rendu par le WFS. Or PPRT_PERIMETRE_RISQIND publie une feature par fragment, avec son propre id_sup.

Mesuré sur trente sites industriels, 67 procédures PPRT distinctes :

fragments publiés 221
fragments conservés 67
fragments jetés 154, soit 69,7 % de la géométrie
procédures fragmentées 11 sur 67 (16,4 %)

Une procédure sur six est fragmentée, et celles qui le sont le sont beaucoup : 78 morceaux pour la plate-forme Saint-Avold Nord à Carling, 48 pour SPLRL Hauconcourt, 18 pour Arkema Villers-Saint-Paul.

Le cas qui dit le coût : PPRT Lacq Mont a deux morceaux, l'un de rayon 0,57 km, l'autre de 1,76 km, centres distants de 3,1 km. On gardait le premier arrivé. Un foyer situé dans l'autre s'entendait répondre qu'il n'était concerné par rien — alors que dedans, les travaux de protection prescrits sont financés à 90 %.

2. La correction était déjà écrite ailleurs

pprn_inondation porte cette clé depuis sa création, et le commentaire qui la documente signalait explicitement la divergence : « Deux différences avec pprt_perimetres […] La clé est le FRAGMENT, pas la procédure. » La leçon avait été tirée sur la couche écrite après, jamais reportée sur celle écrite avant. Un défaut nommé dans le code n'est pas un défaut corrigé.

La table passe donc sur id_sup (unique), id_gaspar devenant un index simple ; l'élément de carte est identifié par le fragment. La migration 0013 vide la table : ses lignes sont précisément celles que le défaut produisait, sans id_sup pour les nommer, et leur inventer une clé les ferait cohabiter pour toujours avec les vrais fragments. Elle se réalimente au quiz pour les nouveaux foyers et au passage du dimanche pour les autres — scripts/remoissonner-pprt.ts a fait le rattrapage immédiat sur les 4 points distincts du parc.

3. Pourquoi il n'y a aucun filtre de distance à l'ingestion

L'inondation coupe ses fragments au-delà de GARDE_KM. On ne fait pas la même chose ici, et c'est délibéré : la distance disponible est celle du sommet le plus proche, pas du polygone. Un fragment assez vaste pour contenir toute l'emprise a tous ses sommets au-delà — le cas est réel, un périmètre marseillais de 61 247 sommets a son plus proche à 98 m d'un point pourtant intérieur. Filtrer là-dessus rejetterait exactement le périmètre qui contient le logement : le défaut qu'on corrige, replacé un cran plus loin.

Reste un plafond de 200 fragments appliqué après tri par distance, garde-fou de volume : si l'on coupe, on coupe les plus lointains, et celui qui contient le logement est en tête.

4. Les autres sources, passées à la même grille

  • Inondation : conforme — clé de fragment, tolérance 2 m, garde alignée sur l'affichage.
  • Nucléaire : conforme — recopie nationale, purge sur déclaration, tous les champs publiés lus depuis D-024. 85 features, toutes des points.
  • Rapport Géorisques à l'adresse : conforme — statut lu à l'adresse et jamais à la commune, débroussaillement filtré sur le code commune.
  • ZFE et sécheresse : un défaut réel, mais d'affichage seulement, et non corrigé ici. anneauExterieur / contourExterieur ne gardent que l'anneau le plus étendu d'un MultiPolygon. Sur la Base Nationale des ZFE, 41 polygones sont écartés sur 9 zones (Toulouse en publie 8, Montpellier en a un à 13 km du centre du principal) ; sur un échantillon SANDRE de 14 zones d'alerte, 6 ont plusieurs parties, dont une de 867 sommets. Aucune règle n'en dépend : la restriction Crit'Air se décide sur la proximité à 20 km, pas sur l'appartenance, et zfe_dedans n'est lu par aucune fiche ; l'appartenance à une zone de sécheresse vient de VigiEau, pas de notre tracé. La carte, elle, ne dessine pas ces secteurs. À traiter pour lui-même, avec le stockage multi-anneaux que ElementCarte.anneaux accepte déjà.
  • Les trous sont ignorés partout : anneauExterieur ne rend que l'anneau extérieur (11 features sur 114 en ont autour de Marseille). Conséquence : sur-inclusion — on peut répondre « dedans » à qui est dans une enclave. C'est le sens d'erreur assumé du projet, à l'inverse du silence.

D-036 — 30 juillet 2026 · Les relevés d'adresse partent ensemble ; l'emprise PPRT se déduit de la carte

Suite de D-035, qui a rendu l'attente visible. Restait à la raccourcir.

Contexte : question du porteur de projet — « est-ce qu'on peut résoudre l'ensemble en multithread plutôt qu'en séquentiel ? », puis accord sur la proposition avec une consigne : « prends une marge pour PPRT pour être sûr de bien prendre les zones nécessaires ».

1. Pas du multithread : de la concurrence

Node est mono-thread, et rien ici n'occupe le processeur — tout attend le réseau. Des worker threads ajouteraient de la complexité pour zéro gain. Ce qu'il fallait est de la concurrence, que le langage fait nativement.

Ce qui l'empêchait n'était pas le modèle d'exécution mais le motif d'écriture : trois étapes font un lire-modifier-écrire sur la même ligne assets.attributes, et les lancer ensemble perd les clés de la plus lente — l'audit F5 avait déjà corrigé une course à cet endroit avec un mutex. La décomposition qui marche : paralléliser les lectures, sérialiser les écritures.

Trois relevés partent donc ensemble, parce qu'ils n'ont rien à s'échanger — le rapport Géorisques écrit assets, les deux moissonnages écrivent leurs propres tables : risquesLogement, moissonnerPprt, moissonnerInondation. La veille et la distribution restent en série, elles écrivent assets. Leur coût réseau est de toute façon négligeable (CatNat 34 ms, VigiEau 55 ms).

On les rejoint dans l'ordre du plan de fabrication, pas dans celui où elles finissent : attendre une promesse déjà résolue ne coûte rien, et l'écran coche à l'index du nombre d'événements reçus (D-035). Chaque promesse porte son propre catch, sans quoi un moissonnage en échec ferait rejeter le groupe et perdrait un relevé de risques abouti.

Mesuré sur la même adresse marseillaise, quiz réel de bout en bout : 16,5 s → 7,8 s.

2. L'emprise PPRT était un carré en degrés, pas en kilomètres

Le moissonnage interrogeait un carré de 0,08° de demi-côté sur les deux axes. Un degré de latitude vaut 111 km partout ; un degré de longitude rétrécit avec la latitude. Ce carré couvrait donc, à Marseille, 8,9 km en latitude pour 6,5 km en longitude — et l'écart se creuse vers le nord : 5,6 km à Dunkerque, pour une marge d'affichage de 5 km.

Le côté court était donc à peine au-dessus de ce que la couche prétend montrer, pendant que le côté long payait de la bande passante pour rien. L'emprise se déduit maintenant de MARGE_VOISINAGE_KM axe par axe, avec MARGE_SECURITE_KM = 2 par-dessus — soit 7 km de couverture uniforme, partout.

La marge de sécurité n'est pas une précaution de principe. En théorie la marge d'affichage suffirait, le filtre bbox du WFS testant l'intersection avec la géométrie. Trois raisons de s'en donner deux de plus, et aucune ne se rattrape après coup : le rayon_km sur lequel la couche décide est calculé sur notre contour simplifié et non sur celui de la source ; la sémantique exacte du filtre spatial d'un MapServer n'est pas contractuelle (celui-ci ignore déjà silencieusement les filtres d'attribut) ; et un périmètre manqué au moissonnage ne se voit pas — il n'apparaît simplement jamais sur la carte.

La constante d'affichage a quitté la couche pour le moissonnage, et la couche l'importe — même sens que GARDE_KM pour l'inondation (D-031) : c'est le moissonnage qui se déduit de l'affichage, jamais l'inverse.

Ce que ça n'est pas : un gain de vitesse. À Marseille la nouvelle emprise fait 85 % de la surface de l'ancienne, et à Lille 97 %. C'est une correction de couverture.

3. Vérification de la couverture, contre la règle d'affichage

788 périmètres relevés dans un rayon de 28 km autour de l'adresse de test, chacun évalué comme la couche l'évalue (distance(centre) rayon ≤ 5 km) :

périmètres affichables 131
perdus par la nouvelle emprise 0
gagnés par la nouvelle emprise 0

Le décompte du quiz passe de 8 à 7 périmètres stockés, et l'écart est entièrement dans la bande 79 km — au-delà de ce que la carte montre.

4. Ce que la question « points ou contours ? » a mis au jour

Le porteur de projet a demandé si les PPRT étaient des points ou des contours, et si l'on testait bien l'intersection dans la marge plutôt que l'inclusion. Trois réponses, dont la dernière a valu un correctif :

  • Ce sont toujours des contours : 788 features relevées, 788 polygones, aucun point — jusqu'à 101 524 sommets ;
  • le filtre bbox du WFS teste bien l'intersection, vérifié plutôt que supposé sur ce serveur qui ignore déjà les filtres d'attribut en silence : un carré de 22 m au cœur d'un polygone de 61 247 sommets, dont le sommet le plus proche est à 98 m, le renvoie. Un périmètre qui contient le logement ne peut donc pas être manqué, quelle que soit l'emprise. Côté affichage, la couche teste distance(centre) rayon ≤ 5 km, c'est-à-dire un cercle englobant : il sur-inclut par construction, et c'est le contour réel qui tranche ensuite le ton (concerne ou voisin) ;
  • mais le contour stocké était simplifié à 20 m, là où la couche inondation était descendue à 2 m en D-031 pour cette raison exacte. Ce contour n'est pas seulement dessiné : c'est lui qu'on interroge pour répondre « suis-je dedans ? », et dedans un PPRT les travaux prescrits sont financés à 90 %. Mesuré sur les 8 PPRT réels de l'échantillon : sur 897 points pris le long de leurs bords, 19,2 % changeaient de réponse entre les deux tolérances. Le prix d'une réponse qui bascule n'est pas un pixel, c'est une aide qu'on ne mentionne pas. La tolérance passe à 2 m ; le coût est de 180 sommets qui en deviennent 593.

Vérification : npm run typecheck, npm run lint (0 erreur, 2 avertissements préexistants) et npm test (606 tests, 5 de plus sur empriseDeg, dont un qui tient la couverture minimale de Marseille à Dunkerque) passent. Quiz réel rejoué : plan à 0,16 s, déclaré à 0,18 s, calendrier à 0,23 s, véhicules à 0,23 s, risques à 7,6 s, périmètres à 7,6 s, ZFE à 7,7 s, veille à 7,8 s, fin à 7,8 s. Foyer de test supprimé.


D-035 — 30 juillet 2026 · L'écran de fabrication dure le temps du travail ; le plafond de 10 s tombe

Révise le plafond posé à la Phase 1 et relevé à la Phase 2.

Contexte : un test du porteur de projet sur une adresse de Marseille — « certains périmètres réglementaires se sont rajoutés plus tard quand je suis revenu sur reprendre mon foyer. C'est normal ? ». Oui, et c'était un défaut.

1. Ce que le plafond faisait vraiment

POST /api/quiz attendait le relevé d'adresse 10 s au plus, puis rendait la main et confiait le reste à pg-boss. Le raisonnement d'origine est bon : la règle d'or ne supporte pas un onboarding suspendu à la disponibilité d'un tiers.

Ce qu'il ne dit pas, c'est à quoi on rend la main. Pas à un écran d'attente : à un aperçu qui se présente comme fini. Mesuré sur le foyer du test (créé à 20:39:25) : périmètre PPRT écrit à +19 s, fragments d'inondation à +21 s — deux couches de carte apparues à la visite suivante, sans que rien ne l'ait annoncé.

Les autres couches ne bougeaient pas, et c'est la ligne de partage : ZFE, sécheresse et nucléaire sont recopiés en base avant le quiz, donc là au premier rendu. Les périmètres PPRT et inondation n'ont aucun identifiant à demander — Géorisques ignore silencieusement les filtres d'attribut, seul le filtre spatial fonctionne — donc on moissonne par emprise autour du point, foyer par foyer. Ce sont les deux étapes les plus lentes du passage, et une adresse urbaine dense en rend beaucoup.

2. L'arbitrage : on regarde l'attente au lieu de la couper

Décision du porteur de projet, prise contre l'avis initial : l'écran de fabrication reste actif tant que le travail dure, et trace chaque étape au fil de l'eau jusqu'au révélé complet. Une attente qu'on regarde avancer n'est pas une attente suspendue — c'est même le seul moment où le foyer voit ce qu'on fait pour lui, et l'écran était déjà construit pour ça (piloté par le flux, jamais par un minuteur).

Le passage d'enrichissement rendait tout d'un bloc à son retour ; il rend maintenant compte pendant qu'il travaille, par un SuiviEnrichissement facultatif — la file pg-boss n'en passe pas, seul le quiz en a l'usage. Quatre étapes remontent : risques, perimetres, zfe, veille.

Le moissonnage des périmètres devient une étape à lui seul. Il vivait dans « relevé des risques », ce qui donnait dix secondes d'écran muet pour la partie la plus longue du passage. Il annonce ce qu'il a relevé autour, jamais ce qui concerne le foyer — « 8 périmètres relevés autour de ton adresse » est de l'inventaire ; dire lesquels le touchent serait le mode d'emploi que D-005 réserve à l'abonnement, et c'est la carte qui répond à « suis-je dedans ».

3. Deux pièges de synchronisation

  • L'ordre du plan est celui du travail, pas celui de la lecture. L'écran coche la ligne à l'index du nombre d'événements reçus : veille était annoncée avant zfe alors que le passage les traverse dans l'autre sens, ce qui aurait fait tourner le curseur sur la mauvaise ligne et accroché le détail d'une étape à une autre. veille passe après zfe, et un test tient l'ordre.
  • Une étape annoncée que personne ne coche fait tourner l'écran sans fin. D'où un filet : ce que le passage n'a pas franchi est coché avec « on finit ça de notre côté », et la liste des étapes à couvrir est tirée du plan lui-même plutôt qu'écrite à la main en face — deux listes finiraient par diverger, l'étape ZFE n'existant que s'il y a un véhicule.

4. Ce qui reste borné

Un garde-fou demeure, à 90 s et pour la panne, pas pour l'attente : chaque appel sortant porte déjà son AbortSignal.timeout, mais c'est leur somme qu'il faut borner, sans quoi une accumulation de lenteurs tiendrait la requête plusieurs minutes. Au-delà, on retombe sur le comportement d'avant — la file prend le relais.

L'écran ne promet plus « quelques secondes, pas davantage » : c'était vrai à 6 s et faux dès qu'on l'a laissé durer. Une promesse de durée qu'on ne tient pas coûte plus cher que pas de promesse du tout, et la liste dit déjà où l'on en est.

Vérification : quiz réel rejoué sur l'adresse du test. Flux horodaté — plan à 0 s, déclaré à 0,2 s, calendrier à 0,3 s, véhicules à 0,4 s, risques à 8,2 s, périmètres à 16,3 s (« 8 périmètres relevés autour de ton adresse »), ZFE à 16,4 s, veille à 16,4 s, fin à 16,5 s. Le hub rendu juste après affiche 9 périmètres dont les zones inondables, celles-là mêmes qui manquaient au premier rendu. npm run typecheck, npm run lint (0 erreur, 2 avertissements préexistants), npm run knowledge:check (76 fiches) et npm test (601 tests, 3 de plus sur l'ordre du plan) passent. Foyer de test supprimé.


D-034 — 30 juillet 2026 · Le champ K qualifie les rappels ; il ne les filtre pas

Contexte : observation du porteur de projet — « je vois sur les fiches RappelConso que les informations complémentaires semblent ressembler au numéro K de la carte grise ». Elle est juste, et elle vaut mieux que le VIN écarté en D-031 § 3.

1. Ce que la source publie, mesuré

informations_complementaires est renseigné sur 97,8 % des fiches automobiles, et porte une réception CE lisible sur 94,6 % (1 573 fiches sur 1 662, 3 703 couples base-extensions ingérés). C'est le format exact du champ K : e2*2007/46*0532*19. Couverture par marque : Mercedes-Benz, Opel, VW et Toyota à 100 %, Peugeot et Renault à 97 %, BMW à 91 %.

C'est la seule donnée de la source qui désigne une version. rappels_modeles ne désigne qu'un modèle, et une « 308 » couvre 103 réceptions de 2007 à 2026.

2. Pourquoi elle qualifie au lieu de filtrer

Le gain de précision est spectaculaire — sur un 3008, 33 campagnes au titre du modèle, médiane de 1 par réception, et 43 % des réceptions sans aucune campagne. Filtrer là-dessus était donc tentant, et c'est exactement ce qu'il ne faut pas faire.

On sait mesurer la précision de ce rattachement, pas son exhaustivité. Une fiche qui omettrait une réception concernée, ou l'écrirait d'une façon que le parseur rate (5,4 % n'en publient aucune de lisible), produirait un silence sur un rappel de sécurité. La sur-inclusion actuelle est au moins annoncée comme telle au foyer ; un faux négatif, lui, ne se voit pas.

Les campagnes citant le champ K s'ajoutent donc à celles du modèle, et se distinguent : pastille d'accent sur la frise, mention « dont N sur ta version », et une phrase qui dit laquelle des deux affirmations on est en train de faire. L'union sert aussi dans l'autre sens — 39 % des fiches n'accrochent aucun modèle du référentiel, et redeviennent visibles dès que leur réception colle. Vérifié sur un foyer réel : une C5 Aircross passe de 18 campagnes à 23, dont 12 sur sa version.

3. Le parseur, écrit d'après un relevé et non d'après le format

Les notations réellement employées ne ressemblent pas au format théorique. Relevé exhaustif sur les 1 626 valeurs non vides, et ce que le parseur en fait :

Forme Occurrences Lecture retenue
*05-*15, *05-15, *05 - 15 928 tirets plage sur l'extension
*03 to *07, *03 à *07 127 + 44 plage
*02 and *03, et 46 + 6 énumération — les valeurs ne se suivent pas toujours
, *34, *35 86 valeurs énumération à base implicite
e1*2007/46*1783*, base seule, *?? 1 060 jetons toutes les extensions
*00-*99 11 toutes — Jaguar Land Rover écrit « n'importe laquelle » ainsi
¤ 12 valeurs séparateur d'enregistrements

Trois refus explicites, chacun tenu par un test :

  • , 308 n'est pas une extension 308, c'est une Peugeot. La source mêle noms de modèles et réceptions dans le même champ. C'est l'astérisque qui distingue une continuation d'un libellé, et 86 valeurs en dépendent ;
  • un tiret entre deux bases différentes n'est pas une plage. e1*2007/46*0172*06 - e1*2007/46*0215*08 - e1*2007/46*0215*09 - … est une énumération BMW : les numéros de réception ne se suivent pas par famille de véhicule, et l'intervalle inventerait des centaines de rattachements. Sept valeurs sont concernées, dont une plage écrite à l'envers, qui suffit à prouver que le tiret sépare ;
  • aucune coquille n'est réparée. e2*2207/46*0092*08 (2207 pour 2007) et ee*2007/46*0019*27 ne se rattachent à rien. Seule tolérance : l'astérisque à la place du slash de la directive, où la réception visée ne fait aucun doute. Le numéro est stocké en entier, ce qui absorbe seul le 0405 / 405 de la source sans deviner un format de padding.

4. Ce que ça ne change pas

Le VIN reste hors sujet, pour la raison de D-031 § 3 : ce sont des suites de production, pas des intervalles lexicographiques. Le quiz ne demande rien de plus — le champ K y était déjà, et il servait déjà à identifier le modèle. Il est simplement conservé après la résolution au lieu d'être jeté.

5. Deux blocs plutôt qu'une frise annotée (affiné le même jour, après lecture à l'écran)

La première version mêlait les deux groupes sur une seule frise, chaque campagne de la version portant une mention « Ta version ». Vu en vrai, la hiérarchie ne se lisait pas : une pastille sur deux répétait la même étiquette, et le foyer devait la chercher point par point pour savoir ce qui le concernait vraiment.

Deux blocs, donc, et l'ordre porte le sens :

  • ce qui cite le champ K passe devant, déplié, avec le repère de l'année du véhicule et un en-tête qui le dit une fois pour toutes (« 12 campagnes visent ta version »). L'étiquette par pastille disparaît — dans un bloc où toutes le sont, elle n'apprenait plus rien ;
  • le reste passe dessous, replié derrière un <details> natif — pas une ligne de JavaScript, ouvrable au clavier et trouvable par la recherche du navigateur. Replié, jamais retiré : ces campagnes peuvent concerner le foyer, et une fiche qui omet sa réception ne prouve rien (§ 2).

Le libellé de ce second bloc a d'abord dit « sans que la fiche précise la version », ce qui est faux : 1 573 fiches sur 1 662 citent bien des réceptions — elles en visent simplement d'autres que la sienne. Il dit maintenant ce qui est vrai dans les deux cas de figure, « la fiche n'y cite pas le numéro de ta carte grise », et une ligne sous le repli donne la lecture dominante sans jamais rassurer à tort : le plus souvent d'autres versions du modèle, mais une fiche peut aussi n'indiquer aucun numéro, ou oublier le sien. C'est exactement la raison pour laquelle ces campagnes restent affichées, et le dire évite qu'un foyer lise le repli comme un classement sans appel.

Le repli est conditionnel : sans champ K, ou quand aucune fiche ne cite la réception, tout reste déplié comme avant. Enfermer la totalité des rappels d'un foyer derrière un clic serait l'inverse exact du service rendu.

Le défilement horizontal se voyait déjà mal ; avec deux frises, il ne se voyait plus du tout. Une frise de vingt campagnes déborde toujours de la largeur du hub, et rien ne le disait : la barre de défilement n'apparaît qu'au survol sur macOS, jamais sur iOS, et personne n'essaie de faire glisser ce qui ressemble à une image. Un foyer pouvait voir cinq campagnes sur vingt-trois et croire que c'était tout.

FriseDefilante (client, le seul morceau de la frise qui ait besoin du navigateur) ajoute donc une flèche à chaque bord utile. Elles disparaissent aux extrémités : une flèche qui ne fait rien apprend à ne plus cliquer. La zone est focusable au clavier (WCAG 2.1.1) — obligatoire pour une région défilante, et d'autant plus ici que la frise bridée ne contient aucun lien qui y amènerait le focus. Vérifié au pilotage du navigateur : au chargement, flèche droite seule ; en fin de course, flèche gauche seule.

Un voile en dégradé a accompagné ces flèches, puis a été retiré — l'aller-retour vaut d'être noté, parce que l'idée est bonne en théorie et mauvaise ici. Il a d'abord eu deux défauts, tous deux aux bords : un liseré de 4 px de contenu parfaitement net à chaque extrémité (la marge négative -mx-1 était portée par la zone défilante, qui dépassait donc son parent — mesuré : zone 41→844, parent 45→840 ; la marge appartient depuis au conteneur, et c'est un correctif à garder, les boutons y sont ancrés aussi), puis un fondu qui commençait dès le bord et rendait le texte à demi effacé. Corrigés l'un et l'autre, restait le vrai problème : sur la frise détaillée, le voile traverse des paragraphes entiers, et un texte estompé se lit encore — donc on le lit, donc on butte dessus. Une coupe franche au bord dit déjà « ça continue », et la flèche dit qu'on peut y aller. Le bouton porte son propre fond opaque et son ombre, ce dont le dégradé se chargeait.

L'élément visé se pose 44 px après le bord et non dessus : le bouton fait 32 px et vit à 2 px du bord, si bien qu'un alignement au bord posait chaque campagne exactement sous la flèche — qui masquait donc précisément ce qu'elle venait de faire apparaître. Le premier élément fait exception et retombe à 0 : au tout début il n'y a pas de flèche gauche, donc rien à dégager, et l'espace serait perdu. Mesuré après chaque clic : bord droit de la flèche à 34 px, texte à 44 px.

Enfin, une flèche avance d'un élément, jamais d'une fraction de la largeur visible. Une frise n'est pas une page : ses éléments ne font pas la même largeur (le repère du véhicule, une pastille bridée et une campagne détaillée vont du simple au triple) et ils ne sont pas régulièrement espacés, puisque l'écart entre deux points est proportionnel au temps qui les sépare. Un défilement au pourcentage tombait donc n'importe où — tantôt trois campagnes d'un coup, tantôt une moitié de texte, et jamais deux fois pareil. Les arrêts sont lus sur le DOM, jamais recopiés depuis les classes : la gouttière vient de items[0].offsetLeft, ce qui interdit qu'elle diverge du px-3 d'en face. Vérifié au navigateur sur une frise de treize éléments : 0 → 170 → 321 → 425 → 554 → 692 à droite, et le chemin inverse à gauche.

Chaque pastille porte enfin sa date de publication au lieu de l'année affichée aux seuls changements d'année. Cette économie supposait que la position sur l'axe suffisait à situer un point — vrai pour lire une tendance, faux pour identifier une campagne, qui se retrouve par sa date sur la fiche officielle et chez le garage. La date est formatée par découpage de la chaîne AAAA-MM-JJ, sans passer par Date : la source publie une date, pas un instant, et un aller-retour UTC ferait reculer d'un jour toute campagne du 1er du mois sous un fuseau négatif.

Vérification : npm run typecheck, npm run lint (0 erreur, 2 avertissements préexistants), npm run knowledge:check (76 fiches) et npm test (598 tests, 17 de plus, tous sur le parseur et la clé) passent. Sync réelle : 1 662 fiches, 3 703 réceptions, 89 fiches sans réception lisible. Chaîne complète rejouée sur un foyer temporaire (Peugeot 208, e2*2007/46*0639*07) : 23 campagnes dont 22 sur la version, là où le modèle seul en donnait 2. Foyer de test et attributs empruntés supprimés, égalité avec la sauvegarde vérifiée.


D-033 — 30 juillet 2026 · Le front rattrape les cinq chantiers, et le mur de paiement chiffre le manque

Suite de D-031 et D-032 : le lot livré avait changé ce que le produit sait faire, pas ce que les pages publiques en disent.

Contexte : demandé par le porteur de projet — « mets à jour le front dont le landing avec les nouveautés ajoutées ; vérifie qu'il n'y a pas de redite et qu'on présente bien l'étendue de l'offre ».

1. Un halo manquait, et le commentaire jurait le contraire

L'illustration de la carte, sur la landing, dessinait ses halos depuis une liste de quatre entrées indexées à la main, quand le registre en comptait cinq depuis la couche inondation. La légende, elle, se déduit bien du registre : cinq pastilles listées, quatre halos dessinés. L'en-tête du fichier promettait pourtant qu'« ajouter les zones inondables au registre les fera apparaître ici sans qu'on y revienne ».

C'est exactement l'écart que la légende du hero avait connu (14 annoncées pour 13 dessinées), et il se détecte aussi mal : personne ne recompte des cercles sur une illustration. Le dessin est donc redevenu une projection du registre — une position composée à la main tant qu'il y en a, puis un repli déterministe en angle d'or au-delà. Le repli est moins joli ; il a le seul mérite qui compte, celui de ne rien laisser tomber.

2. Trois redites retirées, jamais au prix d'une information

  • « 9 questions » était dit trois fois sur la même page — le hero, l'accroche de « ce qu'on va chercher », l'étape 1 de « comment ça marche ». Il ne l'est plus que deux fois, aux deux extrémités de la page ;
  • l'étape 2 de « comment ça marche » relistait les mécanismes que le bloc précédent venait de détailler. Elle dit maintenant ce qu'elle seule peut dire : que le relevé est visible pendant qu'il tourne, source par source ;
  • « aucun document stocké » figurait au bandeau de garanties et, dix centimètres plus bas, dans la carte « pas un coffre-fort ». La carte ne répète plus la promesse technique : elle dit ce qu'elle change pour le foyer — rien à classer, rien à retrouver le jour où ça compte.

3. Ce que la landing ne vendait pas

Trois postes de l'offre existaient dans le produit et dans le mur de paiement, mais nulle part sur la page qui doit donner envie de faire le quiz : le catalogue de guides (une page par fiche, garantie par la CI — le nombre de guides est donc celui des obligations suivies, par construction), le foyer partagé, et le fait que le bilan gratuit ne se limite pas à un compteur. Le CTA final le dit maintenant en clair : le compte, le rythme de l'année et les premières échéances s'affichent sans payer.

Les nouveautés du lot sont entrées là où elles ont un sens plutôt qu'en liste : la fin de l'ADSL et les zones inondables dans le relevé d'adresse, le contrôle technique des deux-roues et le défaut de rappel décrit en clair dans le bloc véhicules, le dossier social étudiant en scolarité, la rage avant une frontière en animaux.

4. Le mur de paiement dit un nombre, plus une formule

« Tu viens d'avoir un aperçu de ce qu'on offre » n'apprenait rien à quelqu'un qui vient de voir son aperçu. La phrase est remplacée par les deux nombres du foyer : combien d'échéances restent masquées, et combien d'entre elles relèvent d'une obligation légale. Ce sont les mêmes agrégats que ceux déjà affichés pilier par pilier, redits d'une phrase — de l'inventaire, jamais un titre ni une date (§ 10.2).

Le compte des masquées se calcule en retirant l'échantillon révélé par son identité .ics, pas par un total 3 : l'échantillon en compte trois quand il y a trois échéances datées, et le raccourci mentirait au foyer qui n'en a que deux.

5. Sources et chiffres réalignés

Le bloc « d'où vient ce qu'on sait » annonçait encore que RDW fournissait les rappels — faux depuis D-032, et vérifiable en un clic par n'importe quel visiteur. RDW y est désormais cantonné à ce qu'il fait vraiment (modèles et motorisations, à partir du champ K) ; RappelConso et le fichier de fermeture du réseau cuivre s'ajoutent, et Géorisques mentionne les zones inondables et les arrêtés de catastrophe naturelle qu'il servait déjà en silence. Le README annonçait par ailleurs 590 tests pour 581 réels.

Vérification : npm run typecheck, npm run lint (0 erreur, 2 avertissements préexistants), npm run knowledge:check (76 fiches) et npm test (581 tests, inchangés — aucune logique pure nouvelle, seulement de la restitution) passent.


D-032 — 30 juillet 2026 · Le registre néerlandais des rappels sort ; RappelConso reste seul

Révise le § 3 de D-031, pris le même jour.

Contexte : demandé par le porteur de projet — « je ne veux plus de RDW pour les rappels, seuls les rappels véhicule France persistent ». D-031 avait conclu, mesures à l'appui, qu'il fallait garder les deux registres ; l'arbitrage tranche autrement, et il est appliqué en entier.

1. Ce que ça coûte, redit sans l'atténuer

La mesure de D-031 § 3 ne change pas — elle devient simplement le prix payé plutôt qu'un argument :

Sur les 24 modèles les plus répandus du parc français RDW (retiré) RappelConso
campagnes recensées 586 242
antériorité 2010 2018
défaut décrit en clair non — 19 catégories fermées oui, en français
gravité évaluée par l'autorité oui non

Un foyer voit donc environ deux fois moins de campagnes, et plus rien avant 2018. En base, 5 246 campagnes et 10 560 rattachements ont été supprimés ; il en reste 1 662 et 1 909.

Ce qu'on gagne n'est pas du volume, c'est de la cohérence : une source française pour un parc français, un défaut qu'on lit au lieu de le deviner, et un seul chemin d'ingestion au lieu de deux registres qu'on s'interdisait de rapprocher — avec la note qu'il fallait afficher au foyer pour expliquer qu'une même campagne pouvait apparaître deux fois. Cette note disparaît avec le second registre.

2. Un retrait, pas une désactivation

Ce qui n'existait que pour RDW est parti avec lui, plutôt que de rester nul à vie :

  • le code : les trois jeux Socrata de rappels, les tables de traduction des 19 catégories, 5 dangers et 3 gravités néerlandais, et syncRappels côté RDW. src/knowledge/rappels-fr.ts a été fondu dans src/knowledge/rappels.ts, qui redevient le module unique — ingestion et restitution — comme avant l'ajout du second registre ;
  • le schéma : source, categorie, gravite, producteur, defaut_source, reparation_source, nb_vehicules, info_tel sur rappels, et source sur rappels_modeles. Les index d'unicité reviennent à (reference) et (reference, marque, modele) ;
  • l'écran : la pastille de la frise ne se colore plus par gravité (elle était la seule chose que l'autorité néerlandaise évaluait), et apercuRappels disparaît — ses deux appelants ne lisaient que le total, que rappels.length donne directement.

La migration porte deux DELETE écrits à la main, avant le DDL généré, et c'est le point qui méritait de l'attention : sans eux, les 5 246 campagnes RDW auraient survécu à la suppression de la colonne source. Devenues indistinguables, elles se seraient affichées avec un motif nul — donc sous le libellé « Défaut non précisé par la source » — jusqu'au passage hebdomadaire suivant, soit jusqu'à six jours de campagnes qu'on venait précisément de décider de ne plus servir.

3. Ce que le repli dit désormais

titreDuRappel retombait sur la catégorie néerlandaise quand aucun motif n'était publié. Ce repli n'a plus d'objet : il annonce maintenant « Défaut non précisé par la source ». C'est le cas d'une fiche sur 1 662, et dire qu'on ne sait pas vaut mieux qu'inventer une nature de défaut.

4. Un piège attrapé par les tests pendant la fusion

En repliant rappels-fr.ts dans rappels.ts, le marqueur qui protège les marques à séparateur — « mercedes-benz », « can-am », « alfa romeo » — est passé d'un caractère de contrôle à un marqueur entouré d'espaces. Or le découpage trim() chaque morceau : dès que la marque insécable est seule dans le champ, le marqueur perd ses espaces, devient méconnaissable, et la fonction rend « 0 » comme nom de marque. Aucune erreur, aucun rattachement — les 167 fiches Mercedes-Benz et les 24 Opel/Vauxhall seraient devenues invisibles en silence.

Les tests de marquesDeLaFiche l'ont arrêté avant la base : vérifié après correction, les compteurs sont identiques à ceux d'avant la fusion (1 909 rattachements, 248 pour Mercedes-Benz). Le marqueur retenu est @@ — il survit à trim(), ne peut apparaître dans aucun nom de marque, et laisse le fichier source lisible : un octet nul en fait un binaire pour git, grep et la moitié des éditeurs.


D-031 — 30 juillet 2026 · Cinq chantiers de l'audit sont ouverts ; une source française entre sans en chasser une autre

Applique l'audit du 30 juillet (§ 8), dans l'ordre retenu par le porteur de projet : voyage-animal-rage, la fermeture du réseau cuivre, les rappels français, la couche inondation, le contrôle technique des deux-roues et le dossier social étudiant.

Contexte : arbitrage du porteur de projet à partir du classement de l'audit. Deux consignes ont infléchi l'implémentation — « il faudra sûrement demander si ADSL ou fibre dans le quiz », et « utilise K et P.2 en priorité, on demande le VIN en plus si nécessaire ; évalue la possibilité de réduire les infos minimales ». Les gestes du foyer (§ 1 de l'audit), la purge RGPD et le récap annuel restent ouverts : ils n'ont pas été retenus dans ce lot.

1. La question qu'on a mesurée avant de la poser : faut-il le VIN ?

Non, et l'écart n'est pas serré. Mesuré sur les 1 662 fiches automobiles du registre français :

Ce qu'on espérait du VIN Ce que la source publie réellement
identifier l'exemplaire, pas le modèle 71 fiches sur 1 662 (4 %) mentionnent un numéro de série
tester l'appartenance à une plage les plages sont des suites de production — « de vf3cuhmrphy179596 à vf3cuhmrpky054432 » — dont les caractères diffèrent sur plusieurs positions à la fois

Ce dernier point tranche seul : ce n'est pas un intervalle lexicographique. Comparer deux chaînes pour décider si un VIN est « entre les deux » rendrait une réponse fausse, sur le sujet précis où un faux positif coûte le plus cher. Dix-sept caractères à recopier, pour couvrir 4 % d'un seul mécanisme, de façon non fiable : la règle d'or n'a pas eu besoin d'aide pour trancher.

2. Ce que « réduire les infos minimales » a donné, mécanisme par mécanisme

La question était bonne, et la réponse est qu'il n'y a rien à retirer et rien à ajouter — ce qui n'était pas évident avant de le vérifier :

Mécanisme Ce qu'il exige réellement Le champ K y sert-il ?
Rappels (deux registres) marque + modèle, l'année en garde-fou non — la voie déclarative suffit
Crit'Air énergie + année non — il ne fait qu'affiner la norme Euro
Moteur / courroie le champ K, et P.2 pour départager oui, et lui seul

Autrement dit, le socle marque, modèle, année, énergie — déjà demandé, déjà en listes fermées — porte deux mécanismes sur trois pour tous les foyers. Le champ K et le P.2 restent ce qu'ils étaient : un bonus facultatif qui ne débloque que la distribution. C'est exactement le partage que D-017 avait posé, et la mesure le confirme au lieu de le supposer.

3. Le registre français entre — et ne remplace pas le néerlandais

Révisé le jour même par D-032 : le porteur de projet a tranché pour la source française seule. Le raisonnement ci-dessous reste celui qui a été tenu, et sa mesure reste le prix du retrait — elle est reprise telle quelle en D-032 § 1.

L'instruction était d'adapter les rappels sur la source française. La mesure a montré que la remplacer aurait coûté cher, et le compromis retenu est donc plus large que l'instruction :

Sur les 24 modèles les plus répandus du parc français RDW (néerlandais) RappelConso (français)
campagnes recensées 586 242
antériorité 2010 2018
défaut décrit en clair non — 19 catégories fermées oui, en français
gravité évaluée par l'autorité oui non

L'un a le volume et l'antériorité, l'autre a les mots. Basculer aurait supprimé 60 % des campagnes d'un foyer pour gagner une phrase ; on garde donc les deux, et ce qui change à l'écran est le titre de chaque campagne : « les écrous de roue peuvent se détacher pendant que le véhicule est en mouvement » remplace « Essieux, roues et pneus » quand le registre français couvre la même voiture.

Aucun rapprochement entre les deux registres. Ils ne partagent aucun identifiant, et fusionner sur (marque, modèle, date) produirait des fusions fausses en silence — la faute que D-004 § 3 proscrit. Une campagne peut donc figurer deux fois, et la page le dit plutôt que de laisser croire à un double comptage accidentel.

Trois vérifications qui ne se devinaient pas, toutes faites sur les données réelles :

  • le champ marque_produit porte 148 orthographes pour une cinquantaine de constructeurs (vw, volkswagen (vw), peugot, citroen, ds, opel, peugeot) — d'où une table d'alias tenue à la main en git, même règle que les noms de ZFE ;
  • les rappels de véhicules ne passent pas par le circuit consommation : conduites_a_tenir, nature_juridique et date_de_fin_de_procedure sont vides à 100 % sur l'automobile, alors qu'ils sont remplis sur l'alimentaire. On ne bâtit rien dessus ;
  • la purge de chaque registre est désormais bornée à sa provenance. Sans cela, les deux tournant dans le même cron, le second aurait vidé le premier chaque dimanche, sans erreur — même précaution que D-023 § 4 pour les ZFE complémentaires.

Ce qui n'entre pas : les 1 138 rappels « bébés-enfants » et les 498 « maison-habitat ». On ne connaît aucun produit du foyer, et les croiser sur « il y a des enfants ici » serait le bruit que le § 4.4 refuse. C'est le premier usage sérieux de la bannette (Phase 2).

4. Le cuivre : une source qu'on avait déclarée inaccessible, et qui ne l'était pas

pilier-logement § 2.6 portait « non testé — pas d'API propre ». C'était faux, et un seul appel le montrait : le jeu fermeture-reseau-cuivre est ouvert, indexé par code INSEE — la clé qu'on possède déjà — et pèse 760 ko en cinq secondes hors géométries. Il est donc recopié en entier chaque semaine plutôt qu'interrogé par foyer : la date de coupure d'une commune est en base avant que le quiz ne la demande, ce qui rend le révélé complet à sa date sans un appel sortant dans l'onboarding (D-027).

Deux pièges relevés sur les 35 305 lignes :

  • la source publie une ligne par couple (commune, code postal) — Toulouse en compte six. Vérifié : sur les 34 916 codes INSEE distincts, aucun ne porte deux dates différentes ni un mélange date/absence. Le code INSEE est donc une clé sûre, et le déduplicateur existe pour le jour où il cesserait de l'être ;
  • les villes à arrondissements sont publiées par arrondissement (75104, 69382) — exactement ce que la BAN renvoie. La jointure tombe juste sans traitement particulier.

Seule la fermeture technique crée une échéance. La fermeture commerciale, plus précoce de douze à vingt-quatre mois, n'oblige à rien : on garde son service, on ne peut simplement plus commander de ligne cuivre. La faire déclencher aurait prévenu jusqu'à deux ans avant la coupure. Elle reste dite dans le libellé, là où elle informe sans presser.

Et la question au quiz est assumée : « la fibre, l'ADSL, ou je ne sais pas ». C'est la seule chose du logement qu'aucune source publique ne sache dire — l'Arcep publie l'éligibilité d'une adresse, jamais l'abonnement en cours, et c'est justement le logement raccordable resté en ADSL qu'il faut prévenir. « Je ne sais pas » est servi comme « concerné » : le dire à un foyer fibré coûte une ligne dans un mail, se taire auprès d'un foyer en ADSL peut lui coûter sa téléalarme.

5. La couche inondation, et deux réglages corrigés par la mesure

C'est l'application la plus nette du critère de D-022 : dans la même rue, un côté est dans le zonage et l'autre pas, et cette ligne décide si des travaux peuvent être prescrits et si l'information est obligatoire à la vente. La fiche inondation-gestes existait depuis le 25 juillet, muette et sans tracé.

Contrairement à la couche PPRT, la source est propre : PPRN_PERIMETRE_INOND ne rend que le risque inondation et que des procédures approuvées — vérifié sur quatre agglomérations. On refiltre quand même : une propreté observée n'est pas un contrat.

Deux réglages ont été corrigés après avoir été mesurés, et c'est ce qui vaut d'être noté :

  • La simplification à l'ingestion était dix fois trop grossière. À la tolérance des PPRT (~20 m), les fragments d'un plan urbain — souvent au parcellaire — tombaient à cinq points de moyenne. Or ce contour n'est pas seulement dessiné : c'est lui qu'on interroge pour répondre « suis-je dedans ? ». Un quadrilatère à la place d'un contour, c'est une réponse qui bascule pour les logements proches du bord, c'est-à-dire précisément ceux qu'il fallait servir.
  • La garde de proximité à 500 m ratait Nîmes de 52 mètres. Le fragment le plus proche du centre d'une ville entièrement construite autour de ses cadereaux est à 552 m. Le seuil est passé à un kilomètre, et il est désormais le même que la marge d'affichage de la couche : ce qu'on moissonne est exactement ce qu'on sait dessiner. Deux nombres différents auraient laissé la carte prête à montrer des zones que la base n'aurait jamais eues — un manque invisible, donc jamais corrigé.

Ce qui est sacrifié, et dit à l'écran : on ne montre pas le plan entier, qui peut courir sur trente kilomètres de vallée pour 200 000 points, mais les zones autour du logement. La frontière qui décide est la locale.

6. Les deux-roues entrent par la règle, pas par le référentiel

Le contrôle technique des deux-roues est obligatoire depuis le 15 avril 2024 : premier passage dans les six mois précédant le 5ᵉ anniversaire, puis tous les 3 ans — contre 4 puis 2 pour une voiture. C'est à deux nombres près la mécanique déjà écrite : la périodicité est devenue un paramètre de ct_vehicule, aucune stratégie nouvelle.

Le référentiel, lui, reste M1. RDW couvre pourtant la catégorie L (plus de 100 000 lignes, vérifié) — mais l'y étendre demande une synchronisation multi-catégories, une recherche de modèles et un barème Crit'Air propre aux deux-roues. Le contrôle technique n'a besoin d'aucun des trois : l'année suffit. Un deux-roues se déclare donc en une question, plutôt que par une recherche de modèle qui ne rendrait jamais rien.

Aucun Crit'Air n'est estimé pour eux, et c'est un refus explicite : leur barème n'est pas celui des voitures, et appliquer la table existante produirait une classe plausible et fausse — celle sur laquelle un foyer se fierait pour entrer dans une ZFE.

7. Deux fiches qui ne coûtaient qu'un peu d'écriture

  • voyage-animal-rage — le déclencheur manquait depuis le premier jour, il existait depuis D-019. La stratégie est distincte de reservation_vacances bien qu'elle porte la même avance : une stratégie nomme une règle (les 21 jours de validité du vaccin antirabique), pas un délai. Les réutiliser l'une pour l'autre aurait affiché « le temps de trouver une garde » devant une échéance de vaccin — la faute que D-019 a corrigée en renommant passeport_enfant. L'égalité des deux avances est en revanche voulue : elles tombent le même jour, donc dans le même mail.
  • dse-bourse-superieur — la bourse du supérieur et le logement CROUS passent par un dossier unique, distinct de Parcoursup et déposé au printemps de la terminale. Une famille qui a terminé Parcoursup croit avoir tout fait ; le dépôt reste possible jusqu'au 31 décembre mais hors priorité, donc en pratique sans logement.

D-030 — 30 juillet 2026 · La carte d'un foyer s'ouvre seule ; celle d'un guide attend toujours le clic

Révise le § 4 de D-022 (rien ne part au chargement d'une page).

Contexte : demandé par le porteur de projet — « fais en sorte que la carte, bridée ou depuis l'espace, soit dépliée par défaut ».

D-022 § 4 posait une règle unique pour toutes les cartes, et la justifiait par trois raisons. Elles ne valent pas au même endroit :

Fiche publique Page d'un foyer
Vie privée — l'IP et la zone regardée partent chez l'IGN subi : le lecteur vient lire une règle, pas voir sa commune demandé : il vient de donner son adresse pour qu'on regarde autour
SEO — 72 pages prégénérées qui vivent de leur poids oui sans objet, la page est en noindex
Une version que le robot lit oui, le tableau sans objet

Sur la page d'un foyer, les trois raisons tombent ensemble, et il reste un clic entre quelqu'un et le résultat de son propre quiz — c'est-à-dire un péage, ce que la règle d'or refuse. La carte s'y monte donc au rendu, bridée comme déverrouillée : le bridage de D-026 est structurel, il ne dépend pas du moment du chargement.

Ce que ça coûte, dit à l'endroit où ça se passe : la provenance du fond quittait l'invite au clic, qui ne s'affiche plus. Elle est reprise sous la carte — « fond de carte © IGN, chargé par ton navigateur ; nos relevés, eux, sont faits par nos serveurs » — et la politique de confidentialité distingue désormais les deux régimes au lieu d'affirmer que rien ne part sans geste.

Vérifié au navigateur : sur un foyer, aucun bouton, Leaflet monté, 20 tuiles demandées, 22 objets tracés ; sur /guide/zfe-vehicule-non-autorise, le bouton est là, Leaflet n'est pas monté, zéro tuile.


D-029 — 30 juillet 2026 · À niveau égal, on retient la zone d'eau qu'on sait tracer — et le quiz montre sa déclaration se constituer

Corrige la réserve du § 4 de D-027 (le SANDRE ne publie que des zones SUP et SOU).

Contexte : le porteur de projet signale qu'à Gif-sur-Yvette « les restrictions d'eau ne semblent plus détectées ». Vérification faite à l'adresse de test : deux constats sans rapport l'un avec l'autre, dont un seul est un défaut.

1. Ce qui n'est pas un défaut : la vigilance ne parle pas

VigiEau publie trois zones à cette adresse, toutes en vigilance (arrêté 2026-272 du 17/07/2026, valable jusqu'au 31/10/2026). L'attribut est bien écrit sur le logement — vérifié en base, restriction_eau_niveau = vigilance, relevé du jour. Mais la vigilance ne déclenche ni fiche (in: [alerte, alerte_renforcee, crise]) ni ligne sur l'écran de fabrication : c'est une sensibilisation, elle n'oblige à rien, et l'annoncer serait du bruit. Rien à corriger, et aucun lien avec les changements de la veille.

2. Le défaut : l'ordre de la réponse décidait de ce qu'on savait montrer

Les trois zones étant au même niveau, zoneLaPlusStricte gardait la première de la réponse — dont l'ordre n'est garanti par rien. Deux quiz à la même adresse retenaient donc deux zones différentes, et l'une des deux est muette :

Zone idSandre Nom publié
2172960 (SUP) 2165 « Yvette-Bièvre zone interconnectée agglomération parisienne »
2172957 (AEP) aucun 2026-nnn_ziap_aep — un code de fichier, que nomLisible supprime
2172961 (SOU) 2177 « Yvette-Bièvre zone interconnectée… »

Retenir l'AEP coûtait le nom et le contour : sans idSandre, le moissonnage au quiz (D-027) n'a aucune clé à joindre, et la carte n'affichait rien alors que la fiche annonçait la zone. Constaté en base : deux foyers de test portent restriction_eau_zone_id = 2172957, dont aucun contour n'existe.

À niveau égal, on préfère la zone traçable, puis nommée. Le niveau étant identique, ce choix ne change rien à ce qui s'impose au foyer — il ne change que ce qu'on est capable de lui montrer.

Le niveau reste souverain : une eau potable en alerte renforcée s'impose toujours à une rivière en alerte, même sans contour disponible. Ce qui oblige ne se négocie pas contre ce qu'on sait dessiner. Et quand aucune zone du niveau retenu n'est traçable, le filet hebdomadaire garde son utilité : il lit le jeu national par identifiant VigiEau, sans avoir besoin de l'idSandre — c'est exactement la réserve que D-027 avait refusé de retirer, et elle vient de servir.

Effet de bord : la régression apparente vient de là. Avant D-027, le contour d'une zone AEP arrivait par le passage hebdomadaire ; depuis, le chemin du jour même ne sait pas la traiter. Le départage referme l'écart au lieu d'attendre la nuit.

3. Le quiz montre la déclaration se constituer

La colonne libérée par l'élargissement du quiz (D-028) reçoit le panneau « Ce que tu as déclaré » de la page du foyer, alimenté au fil des questions. Il ne fait pas que remplir un vide : une réponse mal comprise — un chauffage coché de travers, une année de naissance à côté — se voyait jusqu'ici seulement au révélé.

Deux garde-fous : le rendu est partagé avec la page du foyer (components/declarations-panneau), donc la même réponse s'y relit à l'identique ; et le panneau n'affiche que ce qui a été demandé, pas ce qui est en mémoire — le formulaire part avec deux adultes par défaut, et les annoncer avant la question ferait dire au panneau une chose que personne n'a déclarée. Les seuils sont ceux du HUD, pour que le compte d'éléments et la relecture parlent du même foyer.


D-028 — 30 juillet 2026 · Le prix quitte la landing, qui cesse de décrire le produit de la semaine dernière

Révise le § 22 du cadrage (Phase 0 : « landing + prix affiché à 19,99 €/an ») et précise le § 10.2 (révélation graduée).

Contexte : audit du front demandé par le porteur de projet — « on a beaucoup enrichi depuis le début, les éléments présentés ne sont peut-être plus adéquats », avec deux consignes fermes : retirer de la landing le prix et le bloc « Pourquoi ce n'est pas gratuit », et challenger l'aperçu pour maximiser la conversion sans rien dévoiler de plus.

1. L'écart mesuré : la landing vendait un pense-bête

Relevé fiche par fiche contre le code et la base de connaissance, le 30/07/2026 :

Ce que le produit fait Ce que la landing en disait
Carte des périmètres réglementaires — 4 couches (D-022 → D-027) rien, ni mention ni visuel
Relevé de l'adresse : Géorisques, arrêtés et restrictions d'eau en cours, DPE, plomb/amiante par année de construction « obligations réglementaires… », en fin d'énumération
Champ K → motorisation → rappels constructeur, courroie, Crit'Air, durcissements ZFE datés « contrôle technique »
Académie, calendrier scolaire, bourses, affectation, Parcoursup, recensement « bourse de collège »
Agenda .ics vivant, foyer partagé, enrichissement continu rien

Le hero décrivait quatre piliers sur cinq et aucun des mécanismes qui justifient le prix. Trois blocs entrent donc : ce qu'on va chercher pour toi (le mécanisme, jamais le mode d'emploi), la carte et d'où vient ce qu'on sait — les neuf jeux de données publics nommés, ce qui rend enfin vérifiable la phrase « vérifié aux sources officielles » qui était affirmée nue depuis le premier jour.

2. Pourquoi le prix descend après le quiz

Le cadrage gravait l'inverse (Phase 0 : prix affiché sur la landing) et l'arbitrage est assumé : on ne demande pas à quelqu'un de juger un tarif avant d'avoir rien reçu. L'aperçu post-quiz est le premier moment où la question « est-ce que ça vaut 19,99 € ? » a un objet — le foyer a son compteur, sa frise, trois échéances en clair et ses halos sous les yeux.

Une seule phrase de posture reste sur la landing, sans chiffre et sans geste d'achat : « un service gratuit se paie toujours — en publicité, ou en revente de données ; ici, tu es le client, pas le produit ». Sans elle, un visiteur découvrirait le caractère payant du service après neuf questions, ce qui se vit comme un péage caché. Avec un prix, on lui demanderait de trancher trop tôt. L'argumentaire complet, lui, déménage dans l'encart de paiement, où il répond à une question que le lecteur vient de se poser lui-même.

3. L'illustration de la carte est dessinée, et c'est une conséquence de D-022

Aucune vraie carte ne peut vivre sur la landing : rien ne part au chargement d'une page (D-022 § 4), ni Leaflet ni une tuile IGN. L'illustration est donc un SVG en ligne — aucune requête, aucune donnée — et elle montre exactement ce que l'aperçu gratuit montre, des halos sans frontière (D-026 § 4). Elle ne promet ainsi rien qu'un visiteur ne verrait pas à l'écran suivant, et ses teintes viennent du registre des couches : ajouter les zones inondables les y fera apparaître sans qu'on rouvre le fichier.

4. Ce que l'aperçu gagne, sans rien dévoiler de plus

Ajout Pourquoi c'est de l'inventaire, pas le mode d'emploi
Agrégats par pilier verrouillé — « dont 6 obligations légales · 3 dans les deux mois » On ne remonte pas d'un compte à laquelle des six. Un « 16 échéances verrouillées » ne disait pas si l'on protégeait un ramonage ou une amende
Les familles de la carte, nommées D-026 § 2 les range explicitement dans ce qui se montre, et l'écran de fabrication les a déjà dites en clair. La phrase précédente était plus vague que le droit qu'on s'était donné
Barre de rappel sur mobile, après un écran de défilement, effacée dès que l'encart entre dans le champ Le manque s'installe d'abord ; dix écrans séparaient le compteur du seul geste possible
Trois postes vendus par le § 10.2 et absents de l'encart : agenda vivant, foyer partagé, nombre de guides Un poste qu'on ne nomme pas est un poste qu'on ne vend pas

Le bridage structurel reste intact : ElementApercu ne porte toujours que quatre champs, et l'aperçu ne reçoit ni titre ni contour. Vérifié dans le HTML servi à un foyer réel — les halos arrivent avec un identifiant haché, une classe de taille et une ancre, rien d'autre.

5. Les pages légales entrent, et elles disent ce qui manque

Un service payant sans mentions légales, sans CGV et sans politique de confidentialité n'était pas défendable. Les trois pages existent, avec deux partis pris :

  • Ce que le code ne peut pas connaître ne s'invente pas. Identité juridique, hébergeur déclaré, médiateur de la consommation : les champs vides s'affichent en jaune sur la page publique (src/lib/legal.ts, A_COMPLETER). Une mention légale fausse est une faute, une mention manquante est un chantier — et un chantier visible ne s'oublie pas.
  • Les CGV décrivent le code, pas l'intention. L'abonnement est un mode: "subscription" Stripe, donc à reconduction automatique : écrire « sans reconduction » aurait été faux. La renonciation au droit de rétractation reprend au mot près le texte de la case déjà collectée au paiement (§ 10.7) — deux formulations du même engagement, c'est un litige.

6. Deux dettes que cet audit a mises au jour

Elles ne sont pas comblées ici, et elles bloquent l'ouverture du paiement réel :

  1. Aucune purge de conservation n'existe. La politique de confidentialité déclare des durées (foyer dormant à douze mois, journal d'envois à deux ans) parce que le RGPD impose de les déclarer — aucun job ne les applique. La suppression se fait sur demande, à la main.
  2. Le récap annuel n'est pas implémenté. Il est promis par l'encart de paiement et par « Mon espace », et l'article L215-1 du Code de la consommation en fait une obligation : informer l'abonné entre trois mois et un mois avant la reconduction. Aucun job ne l'envoie.

Le texte des CGV reste à faire valider par un professionnel du droit avant tout encaissement (§ 10.7, § 28).


D-027 — 29 juillet 2026 · Le révélé du quiz est complet à sa date ; les passages quotidiens ne finissent pas le travail du jour

Précise D-009 (l'écran de fabrication comme moment où le travail se fait) et le § 4 de D-022.

Contexte : relevé par le porteur de projet sur une adresse réelle à Tressin (59) — restriction d'eau annoncée dans la fiche, rien sur la carte. Diagnostic : le foyer avait été créé à 19 h 14, après les passages du jour.

1. La règle qu'on avait enfreinte sans la formuler

Ce que le quiz révèle doit être complet à la date du quiz. Les passages quotidiens veillent sur ce qui évolue ensuite ; ils ne servent pas à terminer ce qui aurait dû l'être le jour même.

Les contours de zones d'alerte ne s'ingéraient qu'au cron — hebdomadaire, avec un rattrapage à 3 h. Un foyer créé entre deux passages voyait donc sa restriction d'eau écrite dans sa fiche et absente de sa carte pendant quelques heures. Petit défaut, mais il touche exactement le moment où l'on juge le produit.

2. Pourquoi on ne pouvait pas simplement « aller chercher le fichier »

Le jeu national des zones sous arrêté pèse 399 Mo, sans compression, et couvre tout le pays. On en retenait une ligne : 0,03 % de ce qu'on télécharge. Rien de tel n'entre dans un onboarding borné à deux minutes.

Le cache journalier, envisagé, a été écarté : il n'aide qu'à partir du deuxième quiz du jour, impose 399 Mo de rotation quotidienne sur disque, et oblige à relire tout le fichier à chaque extraction faute d'index. Il optimise la répétition alors que le problème est le gaspillage.

3. Ce que la recherche a trouvé, et qu'on n'avait pas vu

Le SANDRE publie les zones d'alerte sécheresse en WFS — 3 851 zones, France et outre-mer — et il en sert une seule à la demande :

Jeu national SANDRE, une zone
Poids 399 Mo 334 ko
Temps plusieurs minutes 0,7 s
Filtre d'attribut sans objet honoré

Ce dernier point mérite d'être noté, parce qu'il est l'inverse exact du piège de Géorisques : là-bas CQL_FILTER est ignoré en silence et une requête filtrée répond 200 avec des données non filtrées (D-022). Ici le filtre OGC fonctionne. Deux MapServer, deux comportements — on ne peut rien supposer, il faut essayer.

La clé de jointure existait déjà sous nos yeux : VigiEau rend idSandre dans la réponse qu'on lit au moment du quiz. Tressin donne idSandre: 1072, qui est le gid SANDRE de sa zone.

Elle n'est pas stockée sur le logement : c'est une clé de moissonnage, utile une seconde puis inutile, et l'inscrire reviendrait à indexer un détail d'implémentation d'une source tierce (§ 8).

4. Ce qui est gardé en filet, et pourquoi

Le passage hebdomadaire à 399 Mo reste en place, inchangé. Deux réserves le justifient, toutes deux vérifiées sur un seul cas :

  • idSandre pourrait manquer sur certaines zones ;
  • le SANDRE ne publie que des zones SUP et SOU — une zone « eau potable » de VigiEau pointe alors sur la géométrie de sa zone superficielle. À Tressin, les zones 2160393 (SUP) et 2160402 (AEP) renvoient toutes deux vers le SANDRE 1072, ce qui est cohérent, mais reste une équivalence déclarée par VigiEau.

Le jour où la jointure sera confirmée sur des foyers réels, les 399 Mo pourront tomber — et avec eux le lecteur JSON en flux, le rattrapage quotidien et le segment le plus lourd du cron. On ne l'a pas fait aujourd'hui : retirer un filet sur la foi d'une seule observation, c'est la faute que ce journal documente déjà trois fois.

Effet de bord bienvenu : VigiEau nomme ses zones (« Bassins versants de la Marque et de la Deûle ») là où le jeu national publie des codes de fichier (2026-nnn_ziap_aep) que nomLisible devait supprimer. Les zones moissonnées au quiz arrivent donc nommées.


D-026 — 29 juillet 2026 · L'aperçu de la carte cesse de cacher ce qu'il a déjà envoyé (corrige le § 5 de D-022)

Contexte : signalé par le porteur de projet — « la carte à la fin du quiz montre des choses sans bridage, et semble incomplète. Elle doit montrer tout ce qui est détecté, mais sans en donner les informations. »

1. Le bridage était une apparence

D-022 § 5 promettait « l'ancre seule ». L'implémentation uniformisait le ton et vidait le détail — côté affichage. Vérifié dans le HTML servi à un foyer non abonné le 29/07/2026 :

Ce qui partait quand même Comment on le lisait
« ZFE — Métropole du Grand Paris », « Périmètre d'intervention — Saclay » Sans outil : l'info-bulle était liée à chaque tracé quel que soit l'abonnement. Un survol suffisait.
Les quatre contours exacts Dans la page, 134 ko
L'identifiant zfe:Métropole du Grand Paris|48.859,2.347|9.6 Le nom, le centre et le rayon, dans la clé de rendu
Sources, liens d'arrêté, slugs de fiches Idem

Cacher n'est pas brider. Un mur qu'on contourne au survol n'est pas un mur.

2. Ce qui se paie, et ce qui ne se paie pas

Le tri ne s'invente pas, il découle de D-022 § 1 : une couche se justifie quand la frontière EST la règle. Donc la frontière est la valeur, et le reste est de l'inventaire — que D-005 autorise à montrer.

Montré Pourquoi Retenu Pourquoi
Combien d'objets, de quelles familles L'écran de fabrication l'annonce déjà en clair (resumerRisques) Le tracé C'est la réponse à « suis-je dedans ? »
L'ancre, à peu près Le centre d'un périmètre public ; que le CEA soit à Saclay n'est pas un secret Le nom de chaque périmètre Il permet d'aller chercher la règle ailleurs
Une classe de taille (petit/moyen/grand) Rend la carte lisible L'étendue en kilomètres Avec un centre et un rayon, on reconstruit le périmètre

3. Un type, pas un drapeau

ElementApercu ne porte que quatre champs — identifiant opaque, couche, classe de taille, ancre. Le bridage devient structurel : l'appelant n'a physiquement pas de titre ni de contour à fournir, donc rien de tel ne peut fuir. Un if (abonné) de plus dans le rendu aurait reproduit le défaut d'origine à la première distraction.

L'identifiant est haché, et c'est le piège qu'on n'a vu qu'au second passage : nos clés sont lisibles à dessein, et celle d'une ZFE portait le rayon. Réduire l'objet ne sert à rien si la clé le reconstitue.

4. Le flou est le message

Un halo à bord dégradé, dimensionné en pixels et non au sol : il ne grandit pas au zoom, donc on ne peut en tirer aucune échelle, et il se lit immédiatement comme un repère plutôt qu'une zone. La carte se cadre sur l'ensemble de ce qu'elle a trouvé — c'est la constellation entière qui donne envie de l'ouvrir, là où le cadrage à zoom fixe laissait la moitié des objets hors champ (d'où l'impression d'inachevé).

Et l'aperçu dit ce qu'il ne dit pas : « ce qu'on ne montre pas ici, c'est le tracé exact et de quel côté tu te trouves ». Un flou sans explication se lit comme une carte cassée ; un flou dont on donne la raison se lit comme une porte. C'est la même règle que partout ailleurs dans le produit — on ne fait pas mystère du mystère.

Note de mise en œuvre : le flou n'est pas un filter: blur(). Chrome rogne la région d'un filtre CSS à la boîte englobante du tracé élargie d'un dixième, ce qui rendait des hexagones — un flou dont on voit les côtés est le pire des deux mondes. C'est un dégradé radial SVG, posé par couche.


D-025 — 29 juillet 2026 · La couleur dit la couche, l'épaisseur dit le foyer (révise le § 2 de D-022)

Contexte : signalé par le porteur de projet — « les contours ZFE sont trop fins par rapport aux autres », « il y a trop de couleurs sur la carte actuelle, et ça réduit la lecture des contours ».

1. Deux dimensions confondues en une

D-022 § 2 posait trois tons — trait plein, pointillé, trait fin gris — et faisait porter au ton la couleur autant que l'épaisseur. Le défaut ne s'est vu qu'une fois quatre couches à l'écran : une carte de fiche n'a aucun foyer, donc tous ses tracés sont « voisins », donc tous étaient gris et épais d'un pixel — deux fois plus fins que les cercles de la couche d'à côté. Une différence d'échelle se lisait comme une différence d'importance.

La couleur dit à quelle couche appartient un tracé. L'épaisseur dit son rapport au foyer. Jamais l'inverse, jamais les deux mélangées.

Une teinte par couche, déclarée dans la couche elle-même (CoucheCarte.couleur) et non dans le rendu — c'est toujours de la configuration (D-022 § 3). Elles vivent en variables CSS avec leur variante sombre, comme tout le reste : aucun composant du produit ne connaît le mode sombre, et la carte ne fait pas exception. Le vert de marque reste libre, réservé au seul point du logement, qui devient ainsi le seul repère vert de la carte.

L'écart d'épaisseur demeure — ce qui concerne le foyer se voit d'abord — mais resserré de 1 → 2,5 px à 2 → 3,5 px, et le voisinage gagne un remplissage : sans lui, une grande zone se réduit à un fil sur une carte de France.

2. Le fond de carte se tait

Le Plan IGN porte ses propres couleurs — vert des espaces naturels, orange des grands axes, bleu de l'eau — et sous quatre couches elles-mêmes colorées, elles disputent l'attention à la seule information de la carte. L'IGN ne publie aucune couche neutre (vérifié dans ses capacités WMTS : Plan v2, Scan, ortho, rien de gris).

On désature donc au rendu plutôt que de changer de fournisseur, et c'est un choix, pas un pis-aller : garder l'IGN, c'est ne rouvrir aucune des questions de vie privée réglées par D-022 § 4 et ne dépendre d'aucun tiers de plus. En mode sombre, une inversion évite qu'un plan clair désaturé ne forme un rectangle blanc au milieu de la page.

3. La légende dit les deux choses

Le panneau de couches, appelé par un bouton posé sur la carte, porte désormais un échantillon de couleur par couche et un rappel de ce que dit l'épaisseur. Ce second bloc n'apparaît que là où il a un sens : sur une carte de fiche, sans foyer, les trois tons seraient tous « à côté de chez toi ».


D-024 — 29 juillet 2026 · Un périmètre nucléaire n'est pas un rayon unique, et un PPI n'est pas un droit à l'iode (corrige D-022 et le § 4 de pilier-logement)

Contexte : question du porteur de projet — « les 20 km ne s'appliquent qu'aux centrales ? Saclay et Fontenay-aux-Roses sont liés au CEA, il semble que le PPI soit différent dans ce cas (2,5 km ?) ». La réponse est oui, et la source la publiait dans des champs qu'on ne lisait pas.

1. Ce que Géorisques publie, et qu'on ignorait

Cinq champs par installation : ppi, risque_iode, rayon_ppi, regle_georisques, site. Leur lecture change trois choses d'un coup.

Ce qu'on faisait Ce que la source dit
Étendue 20 km partout 20 km autour des 18 centrales EDF en service ; 10 km à Marcoule ; 5 km à La Hague et Cadarache ; 2,5 km au CEA Saclay ; 2 km à Fontenay-aux-Roses ; 600 m à Romans-sur-Isère
Existence un cercle sur les 85 installations 56 sur 85 n'ont aucun PPI — irradiateurs industriels, accélérateurs, sites démantelés
Iode « comprimés gratuits » partout Marcoule, La Hague, Saclay, Fontenay-aux-Roses ont un PPI sans distribution préventive
Regroupement 42 sites déduits par proximité à 5 km Le champ site est publié — il nomme les sites comme les arrêtés (« La Hague », « Saint-Alban »), ne dépend d'aucun seuil, et réunit ITER et Cadarache que 1,4 km séparaient

2. Ce n'était pas un défaut d'affichage

Géorisques répond « Risque Concerne » pour le nucléaire au pied du CEA de Fontenay-aux-Roses comme à Golfech.

L'échéance iode-pastilles se déclenchait sur cette seule réponse. Un foyer francilien recevait donc un mail lui annonçant des comprimés gratuits en pharmacie qui n'existent pas pour lui. Un rappel faux coûte la confiance aussi sûrement qu'un rappel manquant — c'est le même défaut, symétrique, que le faux négatif corrigé à Paris et Grenoble (D-023).

La condition passe à un nouvel attribut risque_iode, qui exige deux choses : la réponse de Géorisques à l'adresse — qui fait autorité sur l'appartenance, parce qu'elle applique les listes de communes préfectorales — et le drapeau iode du site dont l'adresse relève. La géométrie ne décide jamais seule ; elle désigne seulement de quel périmètre il s'agit. risque_nucleaire reste écrit, pour ce qu'il dit vraiment : l'adresse est dans un PPI.

3. Ce que la carte n'a plus le droit de laisser deviner

Deux mentions désormais obligatoires sur chaque site, parce qu'un cercle sur une carte se lit spontanément de travers :

  • « Un périmètre de préparation à un accident, pas une zone exposée en permanence. » Rien ne s'y applique tant que le préfet ne déclenche rien. Le guide le dit aussi, en tête.
  • Aucun cercle là où la source n'en publie pas. Cinq sites (Brest, l'Île Longue, Cherbourg, Toulon, l'ILL de Grenoble) n'ont qu'une liste de communes : une pastille dit « il y a quelque chose ici », un cercle inventé aurait dit « la limite passe là ».

La couche change de nom en conséquence — iode devient nucleaire — parce qu'un cercle de 2 km autour de Fontenay-aux-Roses n'est pas un périmètre d'iode, et que le lien vers le guide des comprimés ne s'affiche plus que là où il y a des comprimés.


D-023 — 29 juillet 2026 · Le jeu national des ZFE a des trous ; fokan les comble sans jamais s'y substituer

Précise D-011 (croisement Crit'Air/ZFE) et la règle de tri de D-004.

Contexte : en construisant la carte des ZFE (D-022), une vérification agglomération par agglomération contre mieuxrespirerenville.gouv.fr puis contre chaque collectivité.

1. L'état relevé le 29/07/2026

Constat Ampleur
Le portail d'État recense 25 agglomérations sous ZFE la Base Nationale n'en publie que 19
Six manquent entièrement Nice, Lille, Caen, Le Havre, Pau, Nîmes — un foyer lillois ne voyait aucune ZFE, ni carte ni rappel
Trois des dix-neuf portent une règle fausse Paris (Crit'Air 4 depuis 2021 publié, Crit'Air 3 depuis 2025 appliqué) · Grenoble (seule la zone utilitaires/poids lourds de 2019 est publiée, aucun seuil voiture) · Nancy (Crit'Air 5 annoncé pour 2025 sans dire qu'il ne vise que les poids lourds — les voitures en 2028)

Les deux premiers sont des faux négatifs silencieux : un Crit'Air 3 francilien ne recevait aucun rappel et sa carte lui disait qu'il n'était pas concerné. Le troisième est un faux positif, qui coûte la confiance de la même façon.

2. La règle qui gouverne tous ces comblements

La géométrie peut venir d'ailleurs. La règle, jamais.

Un tracé approché situe ; il ne décide de rien. Une classe Crit'Air et une date, elles, déclenchent un rappel — elles sont donc relues chez la collectivité, à la main, avec la date de cette lecture. Le Havre en est la démonstration : sa relation OpenStreetMap porte access:note = crit'air>=5, quand son arrêté ARRT-20240221 ne vise que les véhicules non classés. Suivre l'étiquette aurait produit des rappels à tort.

Application, par ordre de qualité de la source :

Agglomération Géométrie Pourquoi
Nice Point d'Accès National Publiée au schéma exact de la BNZFE : le parseur existant la lit sans rien changer
Nîmes Limites communales officielles La Ville écrit que le périmètre est le territoire communal — la donnée exacte existait, décalquer sa carte JPEG aurait été absurde
Lille, Caen, Le Havre, Pau OpenStreetMap, relations recousues Aucune autre source publique ; annoncé comme indicatif à l'écran

3. Deux clauses d'effacement, et c'est le point

Ces fichiers sont faits pour se vider tout seuls. Aucun n'est un référentiel : chacun est une dette éditoriale qui doit disparaître.

  • zfe-corrections.ts s'effacera quand la Base Nationale rattrapera son retard. Chaque entrée porte verifieLe, sa source et son motif — une correction sans trace de ce qui a été lu, et quand, est invérifiable donc indéfendable.
  • zfe-complements.ts s'efface automatiquement : chaque zone déclare les SIREN sous lesquels une publication officielle apparaîtrait (relevés sur l'API Découpage administratif, pas devinés), plus un filet de proximité à 20 km — parce que rien ne garantit qu'une source s'y tienne, Annecy publiant ses zones sous « 1 » à « 4 ». L'officielle prime toujours ; sans cette règle, la carte aurait affiché deux périmètres pour la même agglomération et le moteur aurait compté deux fois.

4. Une purge qui ne confond plus l'absence et l'abrogation

Défaut introduit puis corrigé dans la même journée, et qui mérite d'être noté parce qu'il est contre-intuitif : la purge effaçait « ce qui n'a pas été revu au dernier passage ». C'est la bonne règle pour un jeu qu'on recopie en entier — un site déclassé disparaît sans intervention. C'est la mauvaise ici : Overpass répond 504 une fois sur trois, et une indisponibilité n'est pas une abrogation. Pau a disparu puis est revenue ; les zones auraient clignoté d'une semaine à l'autre.

La purge se fait donc sur la déclaration et non sur la date de passage : on ne supprime que ce qu'on ne déclare plus. Un tracé d'hier vaut mieux que pas de tracé. Vérifié à l'usage le jour même — deux appels Overpass en échec, aucune zone perdue.


D-022 — 29 juillet 2026 · Une carte entre dans le produit, et une couche devient de la configuration

Précise le § 6.1 du cadrage (le hub comme surface connectée) et le § 8 (minimisation).

Contexte : demandé par le porteur de projet, à partir d'une question plus étroite — « peut-on mettre une carte des ZFE sur la fiche guide ? ». L'évaluation a montré que la fiche n'était pas le bon point de départ : c'est le foyer qui a des périmètres autour de lui, et la carte d'une fiche n'en est qu'une instance sans adresse.

1. Le critère qui décide qu'une donnée mérite la carte

Une couche se justifie quand la frontière EST la règle — quand « suis-je dedans ? » change ce qu'il faut faire.

C'est ce critère, et lui seul, qui a trié ce que fokan sait de géographique sur un foyer :

Retenu Pourquoi Écarté Pourquoi
ZFE La classe refusée dépend du périmètre Radon Potentiel par commune : colorier sa commune n'apprend rien qu'on ne sache
Zones d'alerte sécheresse Elles suivent les bassins et les nappes, jamais les communes Arrêtés CatNat Un événement daté sur une commune, pas une zone
PPRT Dedans, les travaux prescrits sont financés à 90 % Loi Montagne 34 départements : une phrase suffit
Périmètres d'iode Dedans, les comprimés sont gratuits en pharmacie Argiles (RGA) Un aplat continu sur tout le territoire n'est plus une frontière, c'est un fond

Corrigé le jour même par D-024 : la couche « iode » devient « nucléaire ». Il n'y a pas un rayon mais un par site, et un plan particulier d'intervention n'implique pas la distribution de comprimés — les deux étaient confondus ici.

Les véhicules ne sont pas une couche mais le filtre qui donne son ton à la couche ZFE : un asset qualifie une couche, il n'en est jamais une.

2. Trois tons, jamais une palette

La constitution refuse le rouge, le retard et la comptabilité morale. Une carte qui colorierait douze niveaux de gravité redeviendrait un tableau de bord à tenir. Elle ne dit donc que trois choses : ce qui te concerne (trait plein), ce qui te concernera à une date connue (pointillé), ce qui existe à côté (trait fin gris). Rien à cocher, aucun compteur, aucune complétion.

Révisé le jour même par D-025 : les trois tons restent, mais ils ne portent plus la couleur — seulement l'épaisseur et le remplissage. La couleur dit désormais la couche.

3. Une couche est de la configuration

Un contrat ElementCarte, un registre, et un rendu qui ne sait rien des ZFE ni des centrales. Ajouter les zones inondables sera un fichier dans src/carte/couches/ et une ligne dans le registre — même règle que « un pilier est un asset_type, pas une table » (invariant n° 7). Le calcul du ton reste pur et testé, et consomme le moteur existant : classeInterdite sert la carte comme elle sert les échéances, si bien qu'un rappel et un tracé ne peuvent pas dire deux choses différentes du même véhicule.

4. Le fond de carte n'est chargé qu'au clic

Les tuiles viennent de la Géoplateforme de l'IGN — établissement public français, service ouvert sans clé, même famille que Géorisques, la BAN et VigiEau qu'on appelle déjà. Mais elles sont demandées par le navigateur : l'IP du visiteur y arrive, et les coordonnées de tuile trahissent la zone regardée.

D'où la règle : rien ne part au chargement d'une page, ni Leaflet, ni une tuile. Sur une fiche publique ce serait subi ; après un clic, c'est demandé. Le rendu statique des 72 guides est préservé, et le texte reste ce qu'un robot lit.

Complété par D-025 : les tuiles IGN sont désaturées au rendu. L'IGN ne publiant aucune couche neutre, c'était le seul moyen de rendre le fond silencieux sans ajouter un fournisseur, donc sans rouvrir ce paragraphe.

5. L'aperçu montre où, jamais quoi

Un foyer non abonné voit son ancre et des contours gris ; le clic n'ouvre rien. C'est la révélation graduée du § 10.2 appliquée à la carte : l'inventaire, pas le mode d'emploi.

Corrigé le jour même par D-026 : ce paragraphe décrivait une intention que l'implémentation ne tenait pas. Elle masquait à l'affichage une donnée qu'elle envoyait quand même — titres, contours exacts, et une info-bulle qui les rendait lisibles au survol. L'aperçu ne reçoit désormais plus la donnée du tout, et montre des halos sans frontière.

Ce que les sources ont imposé, et qui ne se devine pas

  • Le nom des ZFE n'existe pas. La Base Nationale ne renseigne nom que sur 2 zones sur 35. Une table écrite à la main (SIREN d'EPCI → agglomération) vit donc en git : choisir « Eurométropole de Strasbourg » plutôt que « Strasbourg » est un jugement, pas un pipeline (règle de tri de D-004). Les 24 périmètres relevés sont couverts et testés.
  • La couche PPRT de Géorisques n'est pas une couche PPRT. PPRT_PERIMETRE_RISQIND contient 80 000 périmètres de tous les types de PPR ; autour de Feyzin, deux procédures sur dix seulement sont des PPRT. Pire, le serveur ignore silencieusement CQL_FILTER et rejette le filtre OGC : une requête filtrée par attribut répond 200 avec des données non filtrées. Le filtrage se fait donc chez nous, en croisant id_gaspar avec la liste GASPAR ; seul le filtre spatial fonctionne, d'où un moissonnage par petite emprise autour des adresses connues.
  • L'API et le WFS de Géorisques partagent un hôte, mais pas une passerelle. Un enrichissement qui vient d'appeler l'API garde une connexion ouverte vers georisques.gouv.fr et la réutilise pour le WFS, qui répond alors 404 no Route matched — quand la même URL répond 200 sur une connexion neuve. Le WFS est donc appelé sur www., pour séparer les pools.
  • outputFormat=application/json est refusé par ce MapServer, qui l'annonce pourtant dans ses capacités. C'est geojson.
  • Les géométries de zones sécheresse n'existent qu'en un seul fichier de 418 Mo. L'API VigiEau n'en sert aucune, les publications par DDT sont fragmentées, le serveur ne compresse pas et l'archive zip pèse 7,9 Go. On lit donc les 418 Mo en flux, sans jamais les charger, et on ne retient que les zones dont un foyer dépend — quelques lignes, pas 3 463. La jointure se fait sur l'identifiant VigiEau, qu'on a commencé à relever sur le logement pour l'occasion. (Démenti par D-027 : le SANDRE en sert une à la demande, 334 ko contre le même fichier — que D-027 chiffre à 399 Mo, la même taille comptée en mébioctets.)
  • Géorisques publie une ligne par installation nucléaire, pas par site. La Hague en compte huit, Marcoule six, à quelques centaines de mètres : les 85 installations sont regroupées en 42 sites par la distance, jamais par la commune — un site est à cheval sur deux communes plus souvent que deux sites n'en partagent une. (Corrigé par D-024 : la source publie un champ site, qu'il suffisait de lire — le regroupement par proximité aurait fondu Flamanville et La Hague, deux exploitants et deux rayons différents.)

Ce qui est sacrifié, en connaissance de cause

  • Le segment sécheresse est de loin le plus lourd du passage hebdomadaire : 418 Mo par semaine pour quelques contours. Il est en dernier, ne part que si un foyer est concerné, et son échec ne coûte qu'une couche. (D-027 lui a retiré son rôle principal : il ne reste qu'un filet, appelé à disparaître.)
  • Les PPRT n'apparaissent qu'après un premier passage d'enrichissement. Le moissonnage est spatial et partiel par construction : un foyer créé aujourd'hui verra ses périmètres au prochain passage, pas avant.
  • Aucun itinéraire, aucune géolocalisation navigateur. « Ma voiture peut-elle aller à Lyon » est un service de navigation, hors périmètre ; et demander une position alors qu'on connaît l'adresse serait une régression.
  • Les contours sont simplifiés (~20 m à l'échelle du foyer, ~500 m à celle de la France) : la carte situe, l'arrêté fait foi. Sans cela, la seule couche ZFE pèserait 890 ko pour 35 zones dont la plupart tiennent dans sept pixels.

Le cadrage reste le document de référence ; ce journal trace les décisions qui le révisent ou le précisent.

D-021 — 28 juillet 2026 · « Mon foyer » devient « Mon espace », et un compte ne porte qu'un foyer

Révise le § 6.1 du cadrage (le hub comme unique surface connectée) et précise le § 6.3 (comptes et appartenance).

Contexte : demandé par le porteur de projet. L'espace abonné se réduisait à /mes-foyers, une liste de liens anonymes — chaque foyer s'y intitulait « Voir ce foyer », sans adresse ni compteur, y compris quand il n'y en avait qu'un. Tout le reste du produit y était invisible : le flux .ics existait côté serveur depuis l'audit S4 et n'était proposé sur aucune page, l'état de l'abonnement n'était lisible nulle part, et « connecté comme » vivait au bas de la page du foyer, c'est-à-dire à un endroit qui décrit la session dans une page qui décrit un logement.

1. Un sommaire, jamais un dashboard

/mon-espace remplace /mes-foyers (redirection 308 conservée : l'ancienne adresse est la cible des liens magiques de connexion, et un lien magique perdu l'est définitivement — il est à usage unique).

Ce que la page fait : nommer le foyer par son adresse, dire combien d'échéances il porte et laquelle arrive en premier, puis ouvrir ce à quoi le compte donne accès — les guides, le calendrier .ics, les déclarations, l'abonnement. Ce qu'elle ne fait pas, et ne doit jamais faire : afficher une complétion, une tâche en attente, un badge à faire disparaître. La constitution produit refuse le dashboard à tenir ; « Mon espace » est un sommaire, on y vient pour trouver quelque chose, pas pour l'entretenir.

2. Un compte, un foyer

Rien n'empêchait un utilisateur connecté de refaire le quiz. Il repartait avec un second foyer, vide d'abonnement, pendant que le premier — celui qui porte son abonnement, ses échéances muettes, ses dates apprises — restait derrière lui sans qu'aucun écran ne le dise. Deux dégâts silencieux : l'état utilisateur du premier foyer devenait inatteignable en pratique (l'invariant n° 3 le protège de la réconciliation, pas de l'oubli), et l'abonnement, rattaché au foyer (§ 21), n'aurait pas suivi.

La règle est appliquée à trois profondeurs : refus serveur dans POST /api/quiz (409, avec l'identifiant du foyer existant pour pouvoir y conduire), écran d'accueil sur /quiz avant la première question — jamais après neuf —, et disparition du CTA « Faire le quiz » de la nav pour qui est connecté. La mise à jour des déclarations passe donc exclusivement par le rejeu (D-018, audit F8) : le même quiz, sur le même foyer.

Le parcours anonyme est intact : sans session, le quiz crée un foyer comme avant. C'est la promesse du § 4 (ni compte ni carte bancaire pour voir son résultat), et elle n'est pas touchée.

Le pluriel reste possible en lecture — une invitation à rejoindre un second foyer, un compte antérieur à cette règle. On affiche ce qu'il y a ; on ne propose simplement jamais d'en créer un de plus.

3. La page du foyer : l'adresse, la pleine largeur, et la déclaration qui l'a produite

  • « Ton foyer » disparaît de l'en-tête. Le libellé ne disait rien que la page ne dise déjà, et il reléguait au rang de sous-titre la seule chose qui identifie vraiment ce foyer parmi les autres — son adresse, qui lui sert désormais de nom ici comme dans « Mon espace ».
  • La page passe en pleine largeur (wide, comme les guides — elle était seule en max-w-3xl). Le calendrier reste pour autant dans une colonne de largeur de lecture : une ligne d'échéance de 1 100 px ne se lit pas. La largeur gagnée va à la frise annuelle et à une colonne de contexte.
  • « Ce que tu as déclaré » occupe cette colonne : logement, véhicules, personnes, animaux, contrats, relus en phrases courtes depuis les assets (src/lib/declarations.ts, pur et testé). C'est ce qui rend le calendrier vérifiable — un ramonage n'a de sens qu'à côté du « chauffage bois » qui l'a fait naître, et un foyer qui a changé de chaudière ne peut le découvrir qu'en relisant sa propre déclaration. Le rejeu du quiz est à un clic de là, où il a enfin un motif.

Exposition, en connaissance de cause : l'aperçu gratuit reste accessible à qui a le lien (§ 10.2, sans quoi personne ne verrait le résultat de son propre quiz), et ce panneau y ajoute le chauffage, les véhicules et les années de naissance des enfants à ce que la page montrait déjà — l'adresse et trois échéances en clair. L'URL reste un UUID non devinable, /foyer/ est exclu des robots, et le calendrier complet d'un foyer payant continue d'exiger une session (D-017). Si cette ligne doit bouger, elle se conditionne à unlocked en un seul endroit, foyer/[id]/declarations.tsx.

4. La page de guide suit — ajouté le 29/07/2026

Le § 3 disait que la page du foyer passait en pleine largeur « comme les guides ». C'était vrai de l'index /guide, faux de la page d'un guide, restée seule en max-w-2xl : la dernière colonne étroite du site, et de loin la plus visitée puisque c'est la porte d'entrée du canal SEO (§ 11.2).

Elle passe en wide avec le même partage que le foyer : le texte garde une largeur de lecture — un guide dont les lignes font 1 100 px ne se lit pas —, et la largeur gagnée devient un rail de contexte collant. « À lire aussi » et la ligne de référence y montent depuis le bas de page, où seul un lecteur arrivé au bout les voyait. C'est le maillage interne du § 11.2 qui y gagne le plus : sortir vers un guide voisin ne demande plus de faire défiler tout l'article.

Sous lg, le rail repasse simplement à la suite du texte — l'ordre de lecture est le même qu'avant.

Ce qui est sacrifié, en connaissance de cause

  • Le CTA « Faire le quiz » quitte la nav pour les comptes connectés. Un compte sans foyer le retrouve en pleine page dans « Mon espace » ; pour tous les autres, ce bouton menait à un écran de refus.
  • Le flux .ics est maintenant découvrable, donc il circulera. Le jeton reste une capacité dédiée et révocable (audit S4), fabriquée au clic seulement — un foyer qui ne s'en sert pas n'en porte pas —, et la page le dit : ce lien ouvre le calendrier sans mot de passe.
  • Aucune révocation de jeton .ics n'est offerte dans cette version : l'endpoint n'existe pas. À ajouter le jour où quelqu'un le demandera, pas avant.
  • La nav met un instant à savoir qu'on est connecté. SiteNav lit la session côté client (authClient.useSession()), donc le HTML servi est toujours celui d'un visiteur anonyme : un compte connecté voit « Me connecter » et « Faire le quiz » le temps que la requête de session revienne, y compris sur « Mon espace ». C'est le fonctionnement d'avant cette décision, pas une régression — mais elle en étend la portée, puisque le CTA quiz rejoint désormais les éléments concernés. La corriger imposerait de lire la session dans layout.tsx, donc headers(), donc la fin du rendu statique des 72 pages de guides : le prix est sans commune mesure avec la gêne.

Vérification

npm run typecheck, npm run lint (0 erreur, 3 avertissements préexistants) et npm test (375 tests, 10 de plus, tous sur resumerDeclarations) passent. next build compile — 90 pages.

Rendu vérifié en build de production sur la base réelle : la page d'un foyer non abonné sort en max-w-6xl sans plus aucune occurrence de « Ton foyer » ni de « Connecté comme », l'adresse tient l'en-tête, et le panneau relit « Maison · Propriétaire · Chauffage électrique, bois · Jardin ou haies, piscine, chauffe-eau électrique · Construite entre 1949 et 1997 · CITROEN C5 AIRCROSS — hybride, Crit'Air 1 · 2 adultes · Enfant 1 — né·e en 2013, en 5e… ». Les enfants sont remis dans l'ordre de leurs libellés : la base les rendait 2, 3, 1. /mes-foyers redirige bien en 308 vers /mon-espace, /mon-espace en 307 vers /connexion sans session.

Vérifié en session le 29/07/2026, sur le conteneur de production et la base réelle, avec un compte jetable rattaché tour à tour à deux foyers de test (compte, adhésion et jeton .ics supprimés ensuite, base rendue à son état d'origine) :

État Ce que la page rend
Compte sans foyer Le seul texte de la page est l'invitation à faire le quiz — aucune tuile, aucune section vide
Foyer non abonné « Aperçu — la veille n'est pas encore activée », catalogue de guides annoncé comme fermé, tuile agenda remplacée par « Activer la veille », pas de rejeu proposé
Foyer abonné « La prochaine — Assurance scolaire · août 2026 », « Parcourir le catalogue », lien .ics, « Rejouer le quiz », « Renouvellement le 28/07/2027 »
Foyer sans adresse Le compteur sert de nom (corrigé le 29/07 : la carte répétait « Mon foyer » sous un titre de section identique)

Les portes tiennent : POST /api/quiz en session répond 409 avec l'identifiant du foyer existant, sans rien créer en base ; /quiz en session affiche l'écran « Ton foyer existe déjà » à la place de la première question ; /guide sert le mur (5 piliers comptés, 72 guides annoncés) à l'anonyme et les 72 entrées à l'abonné ; GET /api/foyer/[id]/ics fabrique le jeton au premier appel et /calendrier/<token> rend 19 événements, 19 UID distincts (D-020). La page du foyer sort bien en max-w-6xl, l'adresse en en-tête, et « Ce que tu as déclaré » relit logement, véhicule et les trois enfants dans l'ordre de leurs libellés.

Reste non vérifié : l'état de la nav après hydratation (« Guides » et « Mon espace » à la place des deux CTA), qui demanderait un navigateur — aucun n'est installé sur l'hôte. Le rendu serveur, lui, est celui d'un visiteur anonyme par construction (voir ci-dessus).

D-020 — 28 juillet 2026 · Une échéance répétée à l'identique devient une seule ligne, sur les trois surfaces (précise D-019)

Précise le § 10.2 du cadrage (révélation graduée, le hub) et le § 20.5 (la politique du silence).

Contexte : remonté sur un foyer de test à trois enfants, sorti du quiz avec trois cartes « Assurance scolaire · août 2026 » rigoureusement identiques, au libellé de l'asset près. Le défaut n'est pas né avec le pilier scolarité — il existait pour deux adultes (carte d'identité, passeport, permis, soit six cartes pour trois choses à faire) et pour deux animaux — mais D-019 l'a rendu criant, parce qu'un foyer a plus souvent trois enfants que trois voitures.

C'est un échec direct de la règle d'or : trois fois le même travail de lecture pour une seule action à mener.

1. La règle : ne fusionner que ce qu'un lecteur ne pourrait pas distinguer

La clé de regroupement est stricte — fiche, sujet, date, libellé de fenêtre, niveau de confiance. Deux véhicules dont les contrôles techniques tombent à des dates différentes restent deux cartes : ce sont deux rendez-vous, et les confondre ferait rater le second. Deux arrêtés de catastrophe naturelle sur la même commune restent deux lignes, comme le voulait déjà D-012.

Ne sont fusionnées que des lignes strictement indiscernables à l'écran. La carte nomme alors les personnes concernées (« Enfant 1, Enfant 2, Enfant 3 ») plutôt que d'afficher un compte : « 3 enfants » économiserait deux mots et coûterait la seule chose qui compte, savoir de qui on parle.

2. Une carte vaut une échéance — arbitré par le porteur de projet

Chaque échéance garde en base sa ligne, son état « muet » et son responsable propres : l'invariant n° 3 n'est pas touché. Ce que le regroupement change, en revanche, c'est ce qui est compté.

Deux réponses se défendaient, et le choix n'appartenait pas au code :

  • compter les échéances réelles (trois passeports à refaire, ce sont trois documents, trois rendez-vous en mairie, trois timbres fiscaux) ;
  • compter les cartes (ce que le foyer voit à l'écran).

L'implémentation initiale avait retenu la première, au motif qu'elle ne déflate pas la promesse du produit. Le porteur de projet a tranché pour la seconde, et la raison est meilleure que la mienne : afficher « 29 » au-dessus de 25 cartes crée un écart que rien n'explique à l'écran, et qu'un lecteur ne peut lever qu'en recomptant les prénoms sur chaque carte. Un compteur invérifiable d'un coup d'œil se lit comme un chiffre gonflé — exactement ce que le ton du produit refuse. Le total, les en-têtes de pilier, la légende et les pastilles de la timeline comptent donc tous la carte.

Le prix est assumé et connu : sur les 33 fiches susceptibles de se regrouper, 31 décrivent de vraies obligations multiples — trois passeports, deux bourses (deux dossiers distincts), deux cartes d'identité. Elles comptent désormais pour une. Seules assurance-scolaire (un contrat couvre souvent toute la fratrie) et garde-vacances (une seule réservation) sont réellement des actions uniques. Le compteur sous-estime donc, mais il sous-estime de façon vérifiable, ce qui est le contraire d'un chiffre gonflé.

3. Le mail était le cas le plus grave, et il n'avait pas été signalé

Le budget d'envoi plafonne à trois échéances par mail (§ 20.5). Un foyer de trois enfants recevait donc un message intitulé « 3 choses ce mois-ci » qui n'en disait qu'une, répétée trois fois — et qui, ce faisant, évinçait du même envoi les deux autres échéances réellement distinctes du mois. Le regroupement a lieu avant la sélection, si bien que le plafond compte désormais des choses à faire.

L'outbox, elle, journalise toujours une ligne par occurrence et non par ligne de mail : n'en garder qu'une aurait fait repartir les deux autres dès le lendemain, dans un message identique.

4. Un défaut du flux .ics, trouvé en passant, et plus coûteux que celui-ci

UID:${templateId}-${sujet}-${foyerId} ne portait pas l'asset. Conséquence : deux véhicules dont les contrôles techniques tombent à des dates différentes produisaient deux événements de même identifiant, dont tout client de calendrier ne garde qu'un. Une des deux dates disparaissait purement et simplement de l'agenda, sans erreur et sans trace.

L'UID porte désormais les assets concernés. La fenêtre en reste délibérément absente : une échéance annuelle glisse, et un UID qui glisserait avec elle ferait recréer l'événement chaque année en laissant l'ancien traîner.

Ce qui est sacrifié, en connaissance de cause

  • Le compteur sous-estime les obligations réellement multiples (§ 2). Trois passeports à refaire s'affichent comme une échéance. C'est le prix, choisi, d'un chiffre que le lecteur peut vérifier lui-même.
  • Deux assets homonymes ne se distinguent plus. quizToAssets nomme un véhicule « PEUGEOT 208 » : deux exemplaires identiques du même modèle et de même année portent le même libellé ET la même fenêtre, et fusionnent en une ligne qui ne les nomme qu'une fois. C'était déjà le cas à l'écran avant ce changement, faute de moyen de les distinguer.
  • Au-delà de trois assets, la liste est tronquée (« Enfant 1, Enfant 2 et 2 autres ») : une ligne qui déborde ne se lit plus. Le compte reste visible, ce qui permet de vérifier qu'on n'a oublié personne.
  • Rien n'a changé côté « fait » ni « mute » — ces gestes n'existent pas encore dans le hub. Le jour où ils arriveront, il faudra trancher si mettre en sourdine une ligne regroupée vaut pour tous ses assets ou pour un seul. La question est ouverte, elle n'est pas urgente.

Vérification

npm run typecheck, npm run lint (0 erreur, 3 avertissements préexistants) et npm test (365 tests, 17 de plus — 11 sur le regroupement du hub et l'UID .ics, 4 sur celui du mail) passent. next build compile.

Cohérence des compteurs vérifiée sur les trois foyers réels de la base, avec les calculs exacts de la page : total = cartes affichées, total = somme des piliers, total = pastilles de la timeline + « sans date », légende = en-têtes de pilier. Les quatre invariants tiennent partout. Exemple relevé : un foyer annonce « 25 échéances sous contrôle » et affiche exactement 25 cartes, dont un passeport-enfant qui nomme trois enfants et une assurance-scolaire qui en nomme trois. Le lecteur peut recompter.

Éprouvé en base réelle sur un foyer de test à Nantes, deux adultes et trois enfants (nés en 2023, 2011 et 2008) : 25 échéances réelles rendues en 19 cartes. Le passeport enfant passe de trois cartes à une, les trois titres d'identité des adultes de six à trois, l'assurance scolaire de deux à une. Les bourses de collège et de lycée restent deux cartes distinctes — deux fiches différentes, deux dossiers différents. La section « ce qui arrive en premier » montre enfin trois choses distinctes. Flux .ics : 17 événements, 17 UID distincts. Foyer de test supprimé.

D-019 — 28 juillet 2026 · Le calendrier scolaire entre, l'âge des enfants cède la place à leur année de naissance, et un cinquième pilier s'ouvre

Révise le § 5.1 du cadrage (les quatre piliers gravés), le § 5.3 de docs/pilier-papiers.md, le § 4.1 de docs/pilier-animaux.md et docs/phase-0/quiz.md (écran 5).

Contexte : trois chantiers ouverts se sont révélés être le même. La vérification saisonnière des titres d'identité (pilier papiers § 5.1, « le meilleur rapport effet/effort du pilier »), le branchement du calendrier scolaire (« l'enrichissement à plus fort effet de levier des trois piliers non-maison ») et le cinquième pilier Enfance & scolarité butaient tous sur la même absence : fokan n'a jamais su quand un foyer part en vacances, ni où en est un enfant dans sa scolarité.

1. Le grain du calendrier scolaire est l'académie, et il n'existe rien de plus fin

La question posée était « au grain adresse ? ». La réponse est non, et ce n'est pas une limite de notre implémentation : les dates de vacances sont fixées par arrêté ministériel à la maille académique, la source (fr-en-calendrier-scolaire, data.education.gouv.fr) les publie ainsi, et il n'existe aucun découpage inférieur à aller chercher. Le département suffit donc — et nous l'avons déjà.

La correspondance département → académie est totale et univoque, vérifiée plutôt que supposée : les 107 départements de l'annuaire de l'éducation nationale remontent exactement une académie chacun, sans ambiguïté. Elle est dérivée de la source, jamais tapée (npm run academies:generate), même règle que les codes moteur de D-017 — mais stockée en git et non en base, contrairement aux dates de vacances : le découpage académique bouge une fois par décennie (fusion Caen + Rouen en 2020), et une panne de source qui viderait la table couperait tout le pilier.

Trois pièges de la source, tous vérifiés sur les données réelles :

  • L'horodatage est en UTC, la date qui compte est celle de Paris. La Toussaint 2026 est publiée à 2026-10-16T22:00:00+00:00, c'est-à-dire le 17 octobre à minuit heure de Paris. Lire la date brute aurait fait commencer toutes les vacances la veille de leur premier jour — un jour d'écart qui ne saute pas aux yeux dans un libellé de fenêtre.
  • Certaines lignes visent les enseignants, dont les vacances d'été ne sont pas celles des élèves (relevé sur Paris 2025-2026 : élèves jusqu'au 31 août, enseignants jusqu'au 30). Une famille se cale sur l'élève.
  • Deux académies ne portent pas le même nom dans deux jeux du même ministère : l'annuaire écrit « La Réunion » et « Polynésie Française », le calendrier « Réunion » et « Polynésie ». Sans alias, un foyer réunionnais n'aurait jamais eu de dates — en silence.

Ce qui n'est pas encore publié l'est dit : au 28/07/2026, l'année 2026-2027 ne couvre que les 24 académies métropolitaines. La Corse et l'outre-mer relèvent d'arrêtés distincts, postérieurs. Un foyer concerné n'a donc pas d'ancre pour cette année-là, et les fiches retombent sur leur saison générique — absence de donnée, jamais donnée fausse.

2. Quatre fiches muettes depuis leur création se mettent à parler

Le pilier papiers relevait que cinq de ses sept fiches ont une fenêtre libre, donc que AVANCE_JOURS.libre === null, donc que estDu rend toujours false : cni-validite, passeport-adulte et permis-conduire-validite n'ont jamais envoyé un seul mail depuis leur écriture. Elles affichaient sans jamais prévenir.

Elles se calent désormais, comme passeport-enfant, dix-sept semaines avant les vacances d'été réelles de l'académie du foyer — le haut de la fourchette de délai d'un titre (3 mois, rendez-vous en mairie compris), plus la marge nécessaire pour sortir les titres, lire les dates et décrocher le rendez-vous.

Corrigé le 28/07/2026, sur remontée : la première version prenait treize semaines, c'est-à-dire le seul délai de fabrication, sans marge — ce qui plaçait le rappel début avril pour un départ début juillet. Or la page publique de la fiche écrit noir sur blanc : « pour partir en juillet, la demande se fait en mars, pas en mai. » Une fiche qui contredit sa propre page se trompe forcément d'un côté, et c'était du côté qui coûte un voyage. Le libellé de la fenêtre a été refait dans la foulée : il annonçait « 13 semaines avant les vacances d'été » à côté d'une description parlant de la règle des 6 mois de validité restante — deux nombres côte à côte qui ne répondent pas à la même question (quand regarder, contre quoi vérifier sur la date lue), et qui se lisaient comme deux réponses concurrentes. Il nomme désormais ce que le délai couvre — « le temps de refaire un titre » — plutôt que l'écart arithmétique qui l'a produit. La stratégie passeport_enfant disparaît de l'énumération : son nom désignait une fiche là où une stratégie doit désigner une règle, et elle déclenchait en mars, en dur, c'est-à-dire sur la moyenne nationale d'un calendrier qui est académique.

Le permis de conduire rejoint le lot délibérément, alors qu'il n'a rien de saisonnier : les trois titres se vérifient d'un seul geste, en sortant la même pochette, et un rappel qui en groupe trois supprime plus de travail que trois rappels étalés. Le budget d'envoi de D-013 (trois échéances par mail) fait le reste.

garde-vacances (pilier animaux) passe sur la même mécanique avec une avance de huit semaines — une pension n'est pas une préfecture. C'est la promesse du § 4.1 du document pilier, tenue : un adapter, trois piliers servis.

3. L'année de naissance remplace la tranche d'âge — une saisie de moins, une précision de plus

C'est le changement de fond, et il était inévitable. Chacune des quatre tranches du quiz enjambe un pivot scolaire : « 6-11 » sépare le CM2 de la 6e, « 12-15 » la 3e de la 2de. Or la bourse de collège et celle de lycée sont deux droits différents, dont les montants vont du simple au triple. Aucune tranche ne pouvait les départager.

Exactement le défaut relevé en D-010 sur la tranche ADEME « 1989-2000 », qui enjambait le pivot plomb du 1er juillet 1997 — et la réponse est la même : demander la seule donnée qui tranche.

La scolarité française se règle sur l'année civile de naissance, pas sur l'âge au jour près : l'instruction est obligatoire « dès la rentrée scolaire de l'année civile où l'enfant atteint l'âge de 3 ans » (art. L113-1 du Code de l'éducation), et tout s'enchaîne d'un cran par an ensuite. L'année de naissance suffit donc à connaître le niveau exact — et elle n'est pas une question mémoire (§ 4.2) : un parent la donne sans réfléchir. Elle reste une liste fermée, jamais une saisie libre (§ 4.1).

La tranche d'âge n'a pas disparu, elle a changé de statut : de réponse déclarée à valeur dérivée, comme epoque_construction l'est de l'année de construction. Il le fallait — recensement-jdc s'applique sur tranche: 16-17 et son identifiant est stable à vie (invariant n° 1).

Le niveau se périme, il est donc recalculé, jamais figé. Un enfant en 3e cette année est en 2de l'an prochain sans que personne ne le déclare. src/jobs/enrichissement-scolarite.ts repose niveau_scolaire, cycle_scolaire, transition_scolaire et tranche à chaque passage hebdomadaire — figés à la création du foyer, ils auraient envoyé une bourse de collège à une famille de lycéen, silencieusement, et d'autant plus longtemps que le foyer est ancien.

Le pivot de l'année scolaire est le 1er août et non le 1er septembre : un enfant qui a fini le CM2 en juillet est « entrant en 6e » tout l'été, et les campagnes de bourse ouvrent le 1er septembre — basculer plus tard leur aurait fait viser le niveau de l'an dernier pendant toute la campagne.

transition_scolaire est distinct du niveau, et c'est indispensable : une inscription se fait toujours l'année d'avant. Un enfant s'inscrit en maternelle au printemps précédant sa première rentrée, alors qu'il n'est scolarisé nulle part et n'a donc aucun niveau ; les vœux d'affectation au lycée se saisissent en mai, alors qu'il est en 3e.

4. Le cinquième pilier, et la ligne qui le sépare de Papiers

Le cadrage grave quatre piliers au § 5.1 ; le pilier papiers jugeait Enfance & scolarité « le seul 5e pilier sérieusement candidat » et demandait « une décision de cadrage explicite ». Elle est prise.

Il ne coûte ni table ni type d'asset — un pilier est de la configuration, jamais du code (invariant n° 7), et ses six fiches s'appliquent sur l'asset personne qui existe déjà. Ce qui changeait vraiment, et justifiait une décision plutôt qu'un commit, c'est le rangement offert au foyer : personne ne cherche « bourse de collège » dans un onglet nommé « Papiers ».

La ligne de partage est celle de l'origine de l'obligation, pas celle de l'âge. Ce qui découle de la scolarité entre dans le nouveau pilier ; ce qui découle de l'état civil reste dans Papiers, même pour un enfant. passeport-enfant et recensement-jdc ne bougent donc pas : un passeport ne cesse pas d'être un papier parce que son titulaire a douze ans.

Six fiches, toutes sourcées à des sources primaires :

Fiche Déclencheur Fenêtre Ce qu'on risque
bourse-college cycle collège 1er sept → 15 oct jusqu'à 525 €/an, perdus pour l'année
bourse-lycee cycle lycée 1er sept → 15 oct jusqu'à ~1 071 €/an
parcoursup-voeux terminale mars un vœu non confirmé n'est pas examiné
inscription-maternelle transition entrée maternelle mars le choix de l'école, la cantine, le périscolaire
inscription-lycee transition entrée lycée juin le bénéfice de l'affectation obtenue
assurance-scolaire tout enfant scolarisé août cantine et sorties refusées

Deux d'entre elles portent une information que la source dit et que personne ne relaie :

  • Les bourses ont un piège symétrique. Un accord donné une fois à l'inscription fait étudier le droit automatiquement, pour toute la scolarité — et si cet accord n'a pas été donné, aucune relance ne partira jamais. Rien, nulle part, ne dit à une famille dans quel cas elle se trouve.
  • Parcoursup a deux dates, et c'est la seconde qui élimine. Tout le monde retient celle des vœux (mi-mars) ; celle qui coûte une année est la confirmation (début avril). Un vœu formulé mais non confirmé n'est pas transmis, n'est pas examiné, et rien ne signale sa disparition. La fiche est calée sur mars, le seul mois qui couvre les deux.

Et une porte l'information qui fait économiser de l'argent plutôt que d'en signaler la perte : la responsabilité civile de l'assurance scolaire est très souvent déjà comprise dans le contrat multirisque habitation (service-public.gouv.fr F1871). Beaucoup de familles la paient deux fois.

Aucune de ces fiches ne prétend dire si un foyer est éligible. Signaler la fenêtre d'une demande est de la veille ; estimer un droit serait du conseil — la ligne que le pilier papiers avait fixée est tenue.

5. Une duplication trouvée en chemin, qui aurait fait un 500

src/engine/hub.ts recopiait sa propre liste des piliers. L'ajout d'un cinquième y aurait produit un parPilier[pilier].push sur undefinedune erreur 500 sur le hub dès la première fiche du nouveau pilier, pour tout foyer concerné. La liste vit désormais dans src/knowledge/schema.ts, à côté de l'énumération, et lib/pilier-meta la réexporte plutôt que de la redéclarer.

Ce qui est sacrifié, en connaissance de cause

  • Les foyers d'avant D-019 n'ont pas de pilier scolarité, et rien ne l'invente pour eux. Leurs réponses ne portent qu'une tranche d'âge ; en déduire une année de naissance reviendrait à fabriquer une donnée qu'ils n'ont jamais donnée. Elles restent lues telles quelles au rejeu (D-018), l'écran redemande simplement l'année. Le silence, jamais la supposition — mais c'est une population qui n'aura rien tant qu'elle ne rejoue pas.
  • Le niveau rendu est le niveau NOMINAL : ni redoublement, ni saut de classe, ni scolarité adaptée. Les fiches disent donc toujours « si ton enfant est en 3e », jamais « ton enfant est en 3e » — même réserve que le classement Crit'Air déduit (D-011).
  • Six semaines par an, une fiche d'inscription peut annoncer la mauvaise année. Entre la fin de sa saison (juin) et le pivot du 1er août, nextSeasonStart fait rouler la fenêtre sur l'année suivante alors que la transition est encore active — l'échéance s'archive d'elle-même au 1er août. Corriger cela demanderait de changer la sémantique générique des saisons, qui gouverne toutes les fiches du produit : le coût est sans rapport avec l'écart.
  • Une fiche peut encore contredire sa page publique sans que rien ne le signale. Le décalage ci-dessus a été trouvé par lecture humaine, pas par un test : rien ne rapproche automatiquement une fenêtre calculée du texte du guide qui la commente. C'est un angle mort qui vaudra pour toute stratégie future.
  • recensement-jdc reste en fenêtre libre, alors que l'année de naissance donne désormais l'année des 16 ans. Il manque le mois, et une fenêtre calée sur une année entière serait un faux intervalle. À reprendre si un jour la date de naissance complète existe.
  • La piste des délais de rendez-vous ANTS est close, et c'est une réponse. Le § 4.2 du pilier papiers demandait de tester l'existence d'un point d'accès programmable : vérifié, rendezvouspasseport.ants.gouv.fr renvoie la même coquille HTML sur toute URL, y compris inventée. C'est une application web, pas une API. « On n'ira pas gratter un site de l'État » — la piste s'arrête là, comme le document l'avait prévu.
  • Aucun test automatisé sur enrichissement-scolarite.ts lui-même : c'est une frontière I/O, et sa logique pure est testée séparément — 41 tests nouveaux sur scolarite.ts (20), academies.ts (10) et l'adapter (11), plus 10 sur les nouvelles stratégies de fenêtre et 4 sur la lecture des deux formes de réponses du quiz. La chaîne complète a en revanche été éprouvée en base réelle (ci-dessous).

Vérification

npm run typecheck, npm run lint (0 erreur, 3 avertissements préexistants), npm run knowledge:check (72 fiches, dont 6 en scolarité) et npm test (343 tests, 55 de plus — 364 après les correctifs de D-020 et du libellé) passent. next build génère les 72 pages de guides.

Synchronisation réelle du calendrier : 342 périodes sur deux années scolaires (2025-2026 et 2026-2027), zéro manquante, en une poignée de secondes.

Chaîne complète éprouvée sur un foyer de test à Nantes (académie de Nantes, zone B, vacances d'été au 3 juillet 2027 — soit le lendemain de la date publiée en UTC, ce qui valide la conversion) avec trois enfants nés en 2023, 2011 et 2008 : les six fiches du pilier se déclenchent chacune sur le bon enfant, cni-validite / passeport-adulte / permis-conduire-validite sont datées en mars 2027 (17 semaines avant), garde-vacances en mai 2027 (8 semaines avant), et la réconciliation rejouée ne crée, ne modifie ni n'archive rien — idempotence du § 20.4 vérifiée avec les nouveaux attributs. Foyer de test supprimé.

D-018 — 28 juillet 2026 · Le quiz devient rejouable ; la réclamation d'un foyer ne dépend plus que du paiement

Contexte : un audit complet du dépôt (code, base de connaissance, exploitation) a relevé neuf défauts de sécurité, neuf défauts fonctionnels et cinq écarts d'exploitation. La quasi-totalité a été corrigée le jour même ; ce qui suit ne retient que ce qui révise ou précise le cadrage — le détail technique complet reste dans le document d'audit, qui n'est pas dupliqué ici.

1. Le quiz devient la référence unique pour mettre à jour un foyer

Jusqu'ici, un foyer se déclarait une fois pour toutes : rien ne permettait de corriger un véhicule vendu, un animal arrivé, un contrat oublié, sans repasser par un nouveau quiz — donc un nouveau foyer, une nouvelle veille, un doublon. Un abonné peut désormais rejouer son quiz (/quiz?rejouer=<id>), réponses précédentes préremplies et modifiables, avec un garde-fou d'un rejeu par mois pour qu'un rejeu répété ne devienne pas un vecteur d'abus.

Ce n'est pas neutre pour l'invariant n° 3 (état utilisateur inviolable) : un compromis a dû être tranché. Les assets à instance unique (foyer, logement) sont mis à jour en place, même id — leurs échéances, qui couvrent la quasi-totalité du pilier logement, gardent leur état (muet, responsable, historique). Les assets à instances multiples (véhicules, personnes, animaux, contrats) sont remplacés entièrement : il n'existe aucune 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 produit une correspondance fausse en silence — le même raisonnement qui gouverne déjà accorderDistribution (D-017 § 5 : « aucune correspondance approchée »). Un remplacement franc, annoncé à l'écran avant validation, a été préféré à une correspondance devinée. Point à réexaminer si l'usage montre que la perte d'état sur ces échéances-là gêne les foyers qui rejouent — une correspondance par clé naturelle (reception_numero pour un véhicule, type pour un contrat) resterait possible plus tard, non faite ici par prudence.

Comble au passage le trou ouvert par D-007 (l'opt-in post-quiz des contrats retiré « en connaissance de cause ») : un contrat devenu pertinent après le premier quiz (nouveau véhicule, nouvel animal) se déclare de nouveau via un rejeu, sans réintroduire le rattrapage post-quiz que D-007 avait explicitement fermé.

2. La réclamation d'un foyer ne se produit plus qu'au paiement

Un défaut de sécurité réel a été corrigé : resolveMembership faisait du premier visiteur authentifié d'un foyer sans membre son propriétaire, y compris pour un foyer jamais payé — un lien de quiz partagé avant paiement suffisait à en perdre la propriété, définitivement, sans que l'auteur ne le sache. La condition exige désormais aussi subscriptionStatus: "active". La réclamation elle-même se fait dans le webhook Stripe (checkout.session.completed) quand une session existait déjà avant le paiement — jamais plus comme effet de bord d'un simple rendu de page.

Le consentement de renonciation au droit de rétractation (§ 10.7) est, pour la même raison, désormais horodaté uniquement dans ce même webhook, avec l'identifiant de la session Stripe comme preuve croisée — jamais avant, jamais par une requête non authentifiée.

3. Le flux .ics devient une capacité, pas un UUID

Le flux .ics vivant (§ 10.2, avantage payant) répondait à quiconque connaissait l'UUID du foyer — un identifiant qui circule dans chaque lien de partage, chaque mail. Il est désormais servi par un jeton dédié et révocable (GET /calendrier/[token], généré à la demande par un membre abonné authentifié), l'ancienne route répond 404.

4. Le rangement des contrats dans les piliers est tranché

Le § 5.6 laissait ouvert le rattachement des cinq contrats sans pilier naturel (emprunteur, mutuelle, box, mobile, salle de sport), rangés par défaut dans Papiers. Décision : ils y restent. Aucune fiche déplacée, aucun changement de copie du hub pour l'instant.

5. Les migrations remplacent le push manuel

Le § 27 prévoyait des « migrations Drizzle jouées au déploiement » ; seul drizzle-kit push (qui compare le schéma au vivant et peut supprimer une colonne sans avertissement) était câblé. La base vivante — créée par push, jamais par une migration — a été baselinée (migration 0000 marquée appliquée sans être rejouée, son SQL décrivant un schéma qui existait déjà), puis une vraie migration 0001 (les colonnes ajoutées ci-dessus) a été générée et jouée. npm run db:migrate tourne désormais automatiquement au démarrage du conteneur ; db:push reste un raccourci de développement, plus une pratique de déploiement.

Ce qui reste ouvert, sciemment

  • Les sauvegardes (§ 24.2, seul risque que le cadrage qualifie d'inacceptable) — différées à la demande du porteur de projet, pas oubliées.
  • demenagement_recent — le seul attribut mort du DSL (relevé aussi en D-004/pilier véhicules) — reste sans code qui le pose ; débloqué le jour où l'adresse du logement devient modifiable dans le hub.
  • Le sourcing des intervalles de courroie (D-017 § 9) — confirmé comme un travail éditorial, pas un correctif de code.

D-017 — 26 juillet 2026 · L'entretien véhicule se réduit à la distribution, et change de clé : le code moteur (annule le référentiel d'entretien de D-004)

Contexte : le point laissé en suspens depuis D-004 était « où trouver les intervalles d'entretien constructeur ». La table vehicules_entretien existait, validée et testée, avec un importateur — et zéro ligne, zéro appelant. L'audit devait trouver la source manquante. Il a d'abord trouvé autre chose.

1. Le blocage n'était pas la donnée

Même pleine, la table n'aurait produit aucune échéance datée. computeWindow renvoyait start: null pour ses deux stratégies, et src/jobs/notifications.ts fait if (!r.fenetreStart) continue. Mesuré en base : 12 revision-constructeur et 3 courroie-distribution, toutes en confiance libre, aucune datée, aucun mail jamais parti. Et comme libre n'envoie rien, personne ne tapait « fait », donc aucune date n'était apprise : boucle fermée. Le référentiel n'aurait acheté qu'un libellé plus précis dans le hub.

2. Ce qu'aucune source ne donne, et ce qui l'explique

  • RMI (règlement UE 2018/858 art. 61) : payant par marque (Renault ASOS relevé à 26 €/jour, 339 €/mois, 3 521 €/an ; Stellantis et erWin sur le même modèle), redistribution interdite par les CGU. Inexploitable pour un référentiel.
  • Notices constructeur publiques : testé. Peugeot publie librement ses guides (public.servicebox.peugeot.com, HTTP 200) ; le guide du 208 passé à l'extraction donne 674 ko de texte propre et une seule occurrence de « périodicité », qui renvoie ailleurs : « se reporter au plan d'entretien du constructeur », « plan d'entretien fourni avec le véhicule ». Le carnet est papier ou réseau. Renault et Toyota : même schéma.
  • Agrégateurs (chaine-courroie, RTA, odopass, Autodoc) : bases propriétaires protégées par le droit sui generis. Contre-vérification, jamais source.

3. La raison de fond : le tableau de bord fait déjà le travail

La même notice l'écrit : « la valeur indiquée est déterminée en fonction du kilométrage parcouru et du temps écoulé depuis la dernière révision ». La voiture connaît les deux bornes, la date réelle du dernier entretien, et sur les diesels récents la dilution d'huile. fokan n'a pas le compteur. Sur la révision, il ne pouvait qu'être moins bon qu'une source que l'utilisateur a sous les yeux — et se tromper visiblement là abîme la confiance sur les échéances où fokan est seul à savoir. C'est un échec au test de la règle d'or : l'interaction coûterait plus de travail qu'elle n'en supprime.

Le même argument, retourné, désigne ce qui reste : aucun véhicule n'a de voyant de courroie de distribution. C'est l'unique angle mort du combiné, et celui qui coûte un moteur.

4. Le référentiel change de clé — RDW publie le code moteur

Le jeu TGK Aandrijving Uitvoering (4by9-ammk), jamais exploité, publie motorcode, cilinderinhoud et aantalcilinders : 6,1 M de lignes, 0,1 % sans code, 18 086 codes distincts. Ce sont les vrais codes constructeur (HN05, K9K E6, ALH, 1NR-FE) — le vocabulaire de toute la documentation technique. C'est la bonne clé : courroie ou chaîne est une propriété du moteur, pas du modèle. La clé (marque, modèle, énergie) faisait cohabiter dans une même entrée un EP6 à chaîne et un DV6 à courroie.

vehicules_entretien (vide) est remplacée par moteurs_distribution, clé motorcode_norm, sans vidange. Nouvelle table vehicules_motorisations pour l'ingestion. La cible n'est plus ~12 900 triplets mais quelques centaines de codes, sourcés à la demande : npm run distribution:queue liste les codes qu'un véhicule porte réellement et que le référentiel ignore.

Bug corrigé au passage : sync-vehicules.ts lisait cilinderinhoudbovengrens dans le jeu Energiebron, où la colonne n'existe pas (vérifié sur l'API). vehicules_reference.cylindree était NULL sur les 2 908 933 lignes, sans une erreur. Colonne supprimée, la cylindrée est là où la source la met.

5. Le champ K ne suffit pas à nommer un moteur — le champ P.2 le fait presque

Mesuré sur les 15 modèles dominants du parc (~3 800 réceptions) :

identification 1 code moteur 1 famille moteur
K seul 7 % 10 %
K + énergie (l'état antérieur) 23 % 38 %
K + énergie + P.2 (puissance kW) 72 % 86 %
K + D.2 (variante + version) 98 % 99,8 %

P.2 est retenu : un nombre, imprimé sur toutes les cartes grises, facultatif, proposé seulement une fois le champ K reconnu. D.2 doublerait la saisie de deux codes alphanumériques pour 14 points de plus.

Et il se propose, il ne se saisit pas. Le quiz n'a aucune saisie libre (§ 4.1) ; un champ numérique libre y ouvrait de surcroît une erreur invisible — la confusion kW / chevaux. 181 au lieu de 133 est un nombre plausible qui n'existe sur aucune version : le filtre écarte alors toutes les motorisations et le produit se tait, sans trace, tandis que l'utilisateur croit avoir aidé. identifierParReception rend donc les puissances homologuées par énergie, et deux cas sur trois ne posent aucune question : 35,6 % des couples (réception, énergie) n'ont qu'une seule puissance possible — on l'affiche — et 38,9 % désignent déjà un seul moteur, ce qui rend la question sans objet. Quand elle est posée, elle tient en ≤ 4 pastilles dans 83,2 % des cas. Les décimales sont conservées : une 208 essence porte 121.00 et 121.40, et la valeur étant choisie dans une liste issue du référentiel, elle s'y compare à l'identique.

Règle de silence : on n'affirme que ce sur quoi tous les codes en lice s'accordent, et un seul candidat non documenté suffit à faire taire (« aucune correspondance approchée », D-004 § 3). Beaucoup moins exigeant qu'il n'y paraît : deux moteurs différents s'accordent souvent sur « courroie », qui est la seule chose à dire.

6. Ce qui change dans les fiches

  • revision-constructeur v4 : ne date plus rien, perd sa stratégie (retirée de l'enum), et porte ce que la voiture ne dit pas — les factures conditionnent la garantie et se réclament à la revente. document_attendu conservé.
  • courroie-distribution v3 : fenêtre datée sur la borne en années, ancrée au 1er juillet de l'année du véhicule (même convention que ctWindow), confiance estimee — donc notifiante. Une échéance déjà dépassée reste à sa date passée : c'est un constat, pas un retard. Les hybrides y entrent : energie in [essence, diesel] les excluait, alors qu'ils ont un moteur thermique — 9 des 12 véhicules en base, et le cas le plus coûteux du parc.
  • courroie-humide-prise-en-charge (nouvelle) : applicabilité sur l'attribut distribution_humide, jamais sur un code moteur écrit en dur — même séparation que les fiches ZFE (D-011). Toutes les données viennent du document officiel Stellantis « PureTech Terms and Conditions » du 11/05/2026 et du communiqué du 16/01/2025 : couverture 10 ans / 180 000 km (la presse a relayé 175 000, chiffre de mars 2024), moteurs 1.0/1.2 atmosphériques de juin 2012 à juin 2022 et 1.2 turbo d'avril 2014 à décembre 2025, entretien conforme par n'importe quel professionnel, trois dernières factures demandées, tolérance 3 mois / 3 000 km, diagnostic et réparation par le réseau agréé, réclamation en ligne pour les frais engagés entre le 01/01/2022 et le 31/12/2024.

7. Le moteur de fenêtres redevient plus pur qu'avant

computeWindow ne reçoit plus de référentiel en argument : ce que le code moteur a appris est posé sur l'asset par src/jobs/enrichissement-distribution.ts, comme les attributs ZFE. Une seule frontière I/O pour tout le pilier au lieu d'une par référentiel, et buildDesiredState ne charge plus rien.

8. Deux pièges de la source, tous deux vérifiés plutôt que supposés

  • elektromotorindicator ne dit pas ce qu'il semble dire. Il vaut « J » sur la ligne thermique d'un hybride : il décrit le groupe motopropulseur, pas la ligne. S'en servir pour écarter l'électrique aurait fait taire tous les hybrides — exactement ce que cette décision cherche à corriger. Il est en outre absent de 4,6 M de lignes. Le discriminant retenu est l'absence de cylindres : une motorisation purement électrique ne porte ni cylindres, ni cylindrée, ni type de carburant, seulement un code (ZK01).
  • L'énergie doit se lire aussi sur l'asset, pas seulement sur la résolution : la 208 existe en essence, hybride et électrique sous un seul numéro de réception, et sans ce repli une 208 électrique héritait de la courroie des versions thermiques.

9. Amorçage, et ce qui reste

npm run distribution:seed écrit la famille EB (PureTech 1.0 et 1.2) — 26 orthographes de code, dérivées de la source par cylindrée et nombre de cylindres puis restreintes à la nomenclature PSA, jamais tapées à la main. La source publie d'ailleurs elle-même l'équivalence (EB2ADTS (HN05)), ce qui la prouve. Type et caractère humide en confiance haute : ils sont établis par le constructeur.

Aucun intervalle de remplacement n'est écrit, et c'est délibéré : le document officiel ne le donne pas, et les sources secondaires se contredisent (175 000 contre 180 000 km). La contrainte de schéma qui l'aurait exigé a été retirée — « courroie, intervalle non documenté » est déjà une information utile, une borne inventée pour satisfaire un schéma serait le faux intervalle que D-004 proscrit. Tant qu'aucun intervalle n'est sourcé, la fenêtre de la courroie reste libre : la machinerie de datation est en place et testée, elle attend la donnée.

Synchronisation faite : vehicules_motorisations porte 3 158 182 lignes et 5 991 codes moteur distincts sur la catégorie M1, en 49 minutes, sans purge. Les 18 086 codes annoncés par la source couvraient aussi camions, autocars et tracteurs : la cible de sourcing est trois fois plus petite qu'estimée.

La file de sourcing triait faux, et c'est corrigé. Elle classait par nombre de configurations du catalogue — ce qui mesure à quel point un constructeur a décliné une version, pas combien de voitures roulent. Elle proposait donc un moteur Iveco avant le 1.5 dCi : ses 25 360 configurations sont à 24 125 des camping-cars, un par carrossier. Nouveau classement à trois niveaux : foyers porteurs du code (restreints par l'énergie déclarée, sinon un hybride crédite le diesel de sa réception), puis parc réellement immatriculé, puis catalogue hors ≥ 3 t.

Le parc réel vient d'une nouvelle table parc_immatricule, alimentée par le registre RDW des véhicules immatriculés (m9d7-ebf2, 16,8 M de lignes) — jeu qu'on utilisait déjà pour traduire les codes marque, sans avoir vu qu'il portait aussi les champs D.2 : le rapprochement se fait donc au niveau de la version, qui ne porte qu'un moteur. 315 354 versions, une requête agrégée côté Socrata, quelques secondes, dans le passage hebdomadaire existant.

Biais assumé et écrit dans la sortie de la commande : c'est le parc néerlandais. Ni K9K ni DV6 n'apparaissent dans les vingt premiers, alors qu'ils dominent le parc français. Le classement écarte le bruit, il ne désigne pas la France — le jugement reste nécessaire.

Troisième colonne morte trouvée et retirée : carrosserie, car codecarrosserie n'existe dans aucun des cinq jeux TGK. Les 83 champs déclarés dans CHAMPS_TYPES ont depuis été confrontés aux schémas publiés par Socrata — c'était le dernier.

Reste à faire : sourcer les intervalles et le type de distribution des familles dominantes du parc français (Renault K9K / K4M / D4F / H4B / H5F, PSA DV6 / DV5 / EP6, VW EA211 / EA288, Toyota). C'est le seul travail qui débloque la fenêtre datée, et c'est du sourcing, pas du code.

D-016 — 26 juillet 2026 · La frise des rappels constructeur devient proportionnée ; l'année d'un véhicule ne se devine plus (précise D-004, D-011)

Précise le § 10.2 du cadrage (révélation graduée) et complète D-004 (rappels constructeur, résolution véhicule) et D-011 (classement Crit'Air).

Contexte : partie d'une remontée sur l'affichage des rappels constructeur sur un foyer de test (Citroën C5 Aircross), deux défauts distincts sont apparus, l'un visuel, l'autre de fond :

  1. la liste des rappels était plafonnée à 5 lignes affichées (slice(0, 5)) alors que la requête elle-même plafonnait déjà à 20 (.limit(20)) — un foyer avec plus de campagnes n'en voyait jamais le vrai total ;
  2. en creusant pourquoi le champ K identifiait le modèle comme « Essence » alors que la voiture est hybride, la cause s'est révélée plus large qu'un bug d'affichage : l'attribut annee d'un véhicule pouvait être une valeur tapée librement par le foyer, jamais distinguée d'une éventuelle date d'achat, et cette même valeur non fiable alimentait à la fois le classement Crit'Air (D-011) et le filtrage temporel des rappels (D-004).

Ce qui change

1. L'année d'un véhicule n'est plus qu'une donnée du champ K, jamais une saisie. src/knowledge/vehicules-resolution.tsVehiculeResolu.annee ne vient plus de attributs.annee (ce que le foyer a tapé) mais de min(date_reception) de la réception RDW exacte, et vaut null dès que le véhicule n'est identifié que par marque/modèle saisis, sans champ K. Le quiz suit : le champ « Année » devient un texte confirmé, non éditable, quand le champ K a identifié le véhicule (quiz-client.tsx) ; sans champ K, il reste éditable mais renommé « Année de mise en circulation » pour lever l'ambiguïté avec une date d'achat — un concept volontairement pas introduit, faute d'un seul endroit du produit qui en aurait l'usage.

2. Le classement Crit'Air suit la même hiérarchie de confiance. Nouveau src/jobs/enrichissement-critair.ts, appelé juste après la création des assets et avant la réconciliation : champ K > date de mise en circulation déclarée > année la plus ancienne connue du modèle dans le référentiel RDW (vehicules_modeles.annee_min, déjà calculée à la synchronisation) quand ni l'un ni l'autre n'existe. Hypothèse délibérément pessimiste — la version la plus polluante plutôt qu'une classe favorable non garantie — même logique de prudence que « un faux positif coûte plus cher qu'un silence » (D-004).

3. La liste de rappels devient une frise chronologique horizontale, proportionnée au temps réel. Toutes les campagnes datées s'affichent (plus de plafond arbitraire), espacées selon leur date réelle (marge cumulée, ~56 px/an, plancher de 20 px) plutôt que sur une grille régulière — deux campagnes à trois semaines d'écart restent visuellement proches, deux campagnes à cinq ans d'écart s'étalent. Un repère vert (couleur de marque, jamais une alerte) situe l'année du véhicule sur le même axe. Icône unique — une clé, jamais un triangle ou un bouclier — pour rester cohérent avec le ton du fichier (« un rappel n'est pas une faute, c'est un courrier constructeur qui s'est perdu », § 4.5) : une réparation disponible, pas un danger à fuir.

4. Bridé et abonné partagent la même frise. Seul le niveau de détail change : le visiteur non abonné voit les mêmes positions et le même repère véhicule, avec des pastilles neutres et la seule année ; l'abonné voit en plus la catégorie, la gravité, le danger identifié et le lien constructeur. Avant, le mode bridé n'affichait qu'un paragraphe générique — la frise prouve désormais concrètement qu'on a regardé SA voiture, sans jamais révéler la nature d'une campagne (§ 10.2 : le débloquant reste le détail, jamais l'existence).

5. Deux corrections de rendu, la même cause. Les pastilles — et, pour la toute première, la moitié gauche de son icône — débordaient de leur boîte via -translate-y/x-1/2, et un conteneur overflow-x-auto force overflow-y à se fermer aussi (règle CSS, pas un bug de rendu) : tout ce qui débordait au-dessus ou complètement à gauche était rogné, irrécupérable au défilement. Corrigé par une marge dédiée (pt-4, px-3) plutôt qu'en tolérant le rognage.

Ce qui est sacrifié, en connaissance de cause

  • L'hypothèse pessimiste du Crit'Air peut se tromper dans le sens défavorable : un véhicule réellement récent, saisi à la main sans date et non identifié par champ K, hérite de l'année la plus ancienne du modèle — donc, potentiellement, d'une classe Crit'Air plus sévère que la réalité. Assumé : suggérer de vérifier une vignette dont on n'a pas besoin coûte moins qu'affirmer à tort qu'on n'en a pas besoin.
  • Les campagnes sans date de publication connue restent hors frise — les positionner sans date fausserait l'échelle. Elles sont comptées à part, en texte, jamais plaquées arbitrairement sur l'axe.
  • L'échelle (56 px/an, plancher 20 px, taille d'icône) est un réglage visuel choisi par lecture du code, jamais mesuré sur un rendu réel : pas d'accès navigateur en session pour juger l'équilibre final, seulement les captures d'écran transmises au fil de l'échange.
  • enrichirZfeFoyer (D-011) garde son propre repli Crit'Air, plus ancien et non aligné : il recalcule encore classerCritair sans l'hypothèse pessimiste si l'attribut manque à son passage — résiduel improbable puisque le nouveau rattrapage tourne avant lui dans le même flux de création, mais pas formellement unifié.
  • Aucun test automatisé nouveau pour enrichissement-critair.ts ni pour le calcul de position de la frise (fusionnerFrise, l'échelle temporelle) : la suite existante (273 tests) passe inchangée parce qu'aucune de ces deux zones n'était couverte avant, et ne l'est toujours pas après.

Vérification

npm run typecheck et npm test (273 tests, inchangés) passent après chaque changement. Conteneur fokan-app reconstruit et relancé à chaque étape, démarrage propre vérifié en logs (✓ Ready, heartbeat pg-boss). Rendu final jugé sur les captures d'écran du foyer de test (Citroën C5 Aircross, 28 campagnes) transmises en cours de session — pas d'accès navigateur direct pour une vérification visuelle de première main.

D-015 — 26 juillet 2026 · Le premier membre d'un foyer se crée automatiquement au paiement

Précise le § 10.1 et le § 6.3 du cadrage.

Contexte : en vérifiant pourquoi aucun foyer n'était à la fois abonné et rattaché à un compte, j'ai trouvé la cause structurelle. memberships n'était alimenté que par le flux d'invitation, qui suppose qu'un membre existe déjà pour en inviter un autre — aucun chemin ne créait le premier membre d'un foyer. Le paiement Stripe activait bien l'abonnement, mais sans créer ni compte ni membership : le parcours a délibérément aucun champ e-mail avant le paiement (§ 10.1, « un champ de moins entre l'intention et la contrepartie »), donc personne n'a de session au retour sur success_url, sauf s'il était déjà connecté avant de payer. Le moteur de rappels de D-013 n'avait donc structurellement personne à qui écrire pour le parcours normal.

Ce qui existait déjà et qu'il ne manquait qu'à déclencher : src/lib/membership.ts (resolveMembership) sait déjà faire du premier visiteur authentifié d'un foyer sans membre son premier membre, et sait consommer un token d'invitation — logique déjà en place, jamais atteinte faute de session au bon moment.

Le fix, à la ligne près : dans le webhook checkout.session.completed, si payerUserId est vide (le payeur n'était pas connecté — le cas courant), on envoie un lien magique à l'adresse que Stripe vient de vérifier par le paiement (session.customer_details.email), avec callbackURL: /foyer/{id}. Le clic crée le compte (better-auth) et pose la session ; le rendu de /foyer/{id} qui suit fait le reste via resolveMembership, sans code nouveau à cet endroit. Si le payeur était déjà connecté, rien n'est envoyé : il se réclame tout seul au retour sur success_url, qui porte déjà sa session — un second mail aurait été superflu.

Nouveau contexte de mail (activation, troisième aux côtés de connexion/invitation) : « Ton foyer est activé sur fokan » — ton majordome, jamais transactionnel-froid, cohérent avec les deux autres.

Ce qui est sacrifié, en connaissance de cause :

  • Aucune garde contre un double envoi en cas de retry du webhook. Stripe peut livrer checkout.session.completed plus d'une fois ; un retry renverrait un second mail d'activation. Coût jugé négligeable (un mail redondant, pas une donnée corrompue) au regard de la complexité d'une déduplication dédiée — à revoir si les retries s'avèrent fréquents en usage réel.
  • Rien ne relance l'envoi si le lien expire sans avoir été cliqué (15 minutes, § 25). Un payeur qui laisse traîner le mail devra repasser par une connexion classique depuis la page du foyer — pas pire que le point de départ, mais pas couvert non plus.

Vérification : chaîne complète rejouée sur un foyer de test — signInMagicLink envoie le bon mail (« Ton foyer est activé — accède à ton calendrier »), magicLinkVerify crée le compte et pose la session, resolveMembership fait de ce compte le premier membre du foyer. Foyer et compte de test supprimés. npm run typecheck et npm test (273 tests, inchangés — aucune logique pure nouvelle, seulement un branchement) passent.

D-014 — 26 juillet 2026 · Le setup mail Kankwa est vérifié : relais Infomaniak, pas de VPS frontal (résout le point ouvert du § 28)

Révise le § 26.1/26.2 du cadrage et clôt la ligne « Vérification du setup mail Kankwa » du § 28.

Contexte : le § 26 spéculait un montage où « le VPS frontal du § 24.2 fait aussi relais SMTP sortant », avec la note « vérifier le routage réel du setup Kankwa » — jamais faite jusqu'ici. Le domaine fokan n'existe pas encore ; en attendant sa création, il fallait préparer l'adapter et les variables d'environnement pour que brancher un vrai SMTP plus tard soit un simple remplissage de .env, sur le modèle exact de Kankwa.

Ce qui a été vérifié (inspection du conteneur kankwa-api réellement en production) : Kankwa n'envoie pas depuis un relais auto-hébergé mais via Infomaniak (mail.infomaniak.com:587, authentifié), avec SMTP_USER=contact@kankwa.fr et FROM_EMAIL=no-reply@kankwa.fr — deux adresses différentes sur le même domaine, l'une qui s'authentifie, l'autre qui apparaît en expéditeur (Infomaniak l'autorise tant que l'alias existe côté boîte mail).

Conséquence pour le § 26.2 point 1 (« jamais d'envoi depuis une IP résidentielle ») : le risque visé — IP en liste PBL Spamhaus, port 25 sortant bloqué par les FAI — concerne l'envoi direct vers le serveur du destinataire. Une soumission SMTP authentifiée en 587/465 vers un relais externe ne l'encourt pas : c'est l'IP du relais qui compte pour la délivrabilité, pas celle du serveur qui s'y connecte. Le VPS frontal dédié au relais sortant, envisagé au § 24.2, n'est donc pas une condition pour démarrer — Infomaniak (ou un tiers équivalent) suffit dès le premier jour, exactement comme pour Kankwa.

Ce qui a été préparé côté fokan, en attendant le domaine :

  • src/adapters/mail.ts (déjà écrit en Phase 0) exploite déjà exactement les cinq variables du montage Kankwa (SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, FROM_EMAIL/FROM_NAME) — rien à modifier dans le code, seul .env recevra les vraies valeurs.
  • .env.example documente désormais explicitement la distinction boîte authentifiée / alias expéditeur, pour ne pas la redécouvrir plus tard.

Ce qui reste à faire une fois le domaine créé (hors périmètre de ce travail, à faire ensemble) : boîte no-reply@<domaine> chez le fournisseur choisi, enregistrements SPF/DKIM/DMARC (le DKIM est fourni par le fournisseur à la création de la boîte, pas choisi à l'avance), puis remplissage de .env sur le serveur et redémarrage du conteneur.

D-013 — 26 juillet 2026 · Le scheduler de notifications entre : le produit se met enfin à parler

Précise le § 20.5 du cadrage et complète le § 5.e de docs/pilier-logement.md.

Contexte : constat fait en faisant l'état des lieux après la Phase 2. Le produit fabriquait 465 échéances actives sur 26 foyers, dont 326 datées dans le futur — et aucun mail n'était jamais sorti de l'application. sendMail n'était appelé nulle part ailleurs que pour les liens magiques de connexion. Chaque phase depuis la Phase 0 avait ajouté de l'inventaire, aucune n'avait ajouté de la livraison. Le garde-fou notifications de D-012 n'avait pas pu être implémenté pour cette raison exacte, et l'abonnement — dont les rappels sont le produit (§ 10.2) — n'avait aucune contrepartie technique.

Ce qui change

1. La politique du silence est un module pur. src/engine/notifications.ts décide QUAND, À QUI et COMBIEN ; il ne décide jamais QUOI — le contenu vient des fiches (invariant n° 6). Aucune horloge, aucune requête : aujourdhui et le compte d'envois du mois sont passés en argument. Le silence devient testable, au même niveau d'exigence que la réconciliation.

2. L'avance de rappel dépend de la confiance, pas de la fiche. estimee → le jour où la fenêtre s'ouvre (pour une fiche saisonnière, le mois est la fenêtre : prévenir en août pour septembre, c'est trop tôt pour agir et trop tard pour oublier — le cadrage situe d'ailleurs ce mail en septembre). connue → trois semaines d'avance, parce qu'il y a un rendez-vous à prendre derrière une date opposable. librejamais : sans date il n'y a pas de bon moment, et en fabriquer un ferait une to-do list déguisée. Sur les 465 échéances, 138 sont libres et ne déclencheront donc rien.

3. Le budget est une politique du moteur, pas de chaque template (§ 20.5, repris à la lettre) : deux envois par mois et par foyer, trois échéances par envoi, priorité legal+assurance > legal > securite > argent > administratif > confort.

4. Une seule exception au budget, écrite en compréhension. Un envoi immédiat hors budget est réservé au couple (enjeu assurantiel, date certaine) — une obligation dont le manquement ne coûte pas un désagrément mais une garantie. Aujourd'hui seule la reconnaissance CatNat y entre, mais la règle ne nomme aucune fiche : c'est le couple qui décide. Une échéance assurantielle à date seulement estimée n'y entre pas — on ne réveille personne pour une date qu'on a soi-même approchée.

5. Au plus un mail par foyer et par jour. Quand une urgence tombe le même jour qu'un envoi groupé, l'urgence part seule : la noyer parmi deux échéances saisonnières lui ferait perdre exactement ce qui la rend urgente. Le groupé repart le lendemain, ce qui ne coûte rien à une fenêtre qui se joue en semaines.

6. Outbox notifications_log, clé (occurrence, canal, fenêtre notifiée). La fenêtre entre dans la clé et ce n'est pas un détail : une échéance annuelle garde la même occurrence tant que personne n'a répondu « fait », mais sa fenêtre glisse d'une année sur l'autre. Ne dédupliquer que sur l'occurrence aurait rendu muet, à vie, tout rappel déjà envoyé une fois. Aucune adresse mail n'y est stockée : les destinataires se déduisent des membres au moment de l'envoi (§ 8).

7. Deux portes fermées avant tout envoi : l'abonnement actif (les rappels au bon moment sont le produit payant) et l'existence d'un destinataire. La seconde n'est pas théorique — voir plus bas.

Deux défauts trouvés en vérifiant, pas en relisant

  • L'outbox se serait figée en échec. La première version faisait un onConflictDoNothing : une tentative ratée laissait une ligne echec sur la clé, que rien ne pouvait plus mettre à jour. Le rappel serait alors reparti chaque jour, indéfiniment, y compris après réparation de l'envoi, puisque seul le statut envoye fait taire l'outbox. Corrigé en onConflictDoUpdate.
  • L'objet du mail annonçait un mois faux. Simulé sur les données réelles d'un foyer, il a produit « 3 choses à prévoir d'ici septembre » un 1er octobre : les items étant triés par priorité et non par date, le dernier du lot n'est pas le plus lointain. Le mois a été retiré de l'objet — chaque échéance porte déjà le sien dans le corps.

Ce qui est sacrifié, en connaissance de cause

  • Aucun mail ne peut partir aujourd'hui, et c'est structurel : le SMTP n'est pas configuré (§ 26 — domaine d'envoi dédié, SPF/DKIM/DMARC, jamais depuis une IP résidentielle : un chantier d'infrastructure à part entière). Le seul foyer abonné n'a par ailleurs aucun membre rattaché, et les deux foyers qui ont des membres ne sont pas abonnés. L'engrenage est complet et vérifié de bout en bout, il n'a simplement encore rien à entraîner.
  • Une occurrence par échéance, pas par cycle. Le modèle du cadrage prévoit une occurrence par cycle, créée quand l'utilisateur répond « fait » — geste qui n'existe pas encore dans le hub. La clé d'unicité inclut la fenêtre précisément pour que les rappels annuels continuent de fonctionner d'ici là.
  • Préférences de notification par personne non implémentées (prévues sur memberships au § 19.2) : tous les membres d'un foyer reçoivent le même mail.

Vérification

Simulation sur les données réelles d'un foyer (20 échéances) aux dates du 1er août, 1er septembre et 1er octobre 2026 : envoi unique en août (l'objet propre de la fiche antiparasitaires), regroupement de trois en septembre avec les deux fiches legal+assurance en tête. Chemin d'envoi complet éprouvé sur un foyer de test abonné avec compte et membership, contre un serveur SMTP jetable : mail reçu, objet « 3 choses ce mois-ci », corps rendu depuis les fiches, trois lignes d'outbox sous un seul envoi_id, rejeu le jour même et les deux jours suivants → silence. Sans SMTP, les mêmes passages journalisent echec et réessaient le lendemain sans dupliquer de ligne. Le mail a été lu en clair : une répétition (« si c'est déjà fait, dis-le-moi et je me tais » trois fois d'affilée, les fiches le disant déjà) a été corrigée dans le pied de page. Foyer, compte et outbox de test supprimés.

npm run typecheck, npm run knowledge:check (64 fiches) et npm test (273 tests, dont 18 sur la politique du silence) passent. Conteneur reconstruit, file rappels-quotidiens planifiée à 8 h.

D-012 — 26 juillet 2026 · Le moteur apprend la notion de sujet, et la veille événementielle entre (Phase 2 du pilier logement)

Révise le § 5 de docs/pilier-logement.md et docs/phase-0/quiz.md (écran de fabrication).

Contexte : jusqu'ici le cron recalculait. Il rejouait des règles connues sur des données stables — un entretien de chaudière par logement, un contrôle technique par véhicule — et l'identité d'une échéance tenait dans le couple template × asset. La Phase 2 lui demande autre chose : observer le monde extérieur, c'est-à-dire des événements datés qui apparaissent sans prévenir et repartent seuls.

1. L'unique évolution du moteur de réconciliation depuis son écriture

La clé devient template × asset × sujet (src/engine/reconcile.ts). Sans ça, deux arrêtés de catastrophe naturelle publiés à trois semaines d'intervalle sur la même commune se seraient écrasés l'un l'autre : le second n'aurait pas créé d'échéance, il aurait remplacé la date limite du premier, sans erreur, sans trace, et avec un délai d'assurance à la clé.

Le sujet est vide pour toutes les fiches sauf deux, et l'extension est rétrocompatible par construction : sujet absent et sujet vide désignent la même échéance. Vérifié en base avant déploiement — les 423 échéances existantes portent toutes un sujet vide et ne bougent pas d'un octet à la réconciliation suivante.

Le DSL d'applicabilité, lui, n'a pas changé d'un caractère. Il répond toujours « cette fiche concerne-t-elle cet asset » ; c'est un module pur séparé (src/engine/sujets.ts) qui répond « combien de fois ». Séparation délibérée : mettre le comptage dans le DSL aurait fait entrer une notion de cardinalité dans le langage des fiches, pour deux fiches sur soixante-quatre.

2. Deux fiches, deux événements que personne ne surveille

  • catnat-declaration-sinistre — un sinistre n'est indemnisable qu'après un arrêté interministériel, et l'assuré a 30 jours à compter de la publication au JO pour déclarer (délai porté de 10 à 30 jours par la loi n° 2021-1837). Le compte à rebours ne démarre ni au sinistre ni à sa découverte : une sécheresse de 2025 peut être reconnue en 2027.
  • restriction-eau-usages — arroser ou remplir sa piscine hors des créneaux d'un arrêté préfectoral est une contravention de 5e classe, 1 500 € (art. R216-9 du Code de l'environnement).

Les deux s'éteignent seules : délai épuisé ou arrêté levé, l'attribut disparaît du logement et la réconciliation archive en silence. Aucune logique d'expiration n'est écrite nulle part — c'est le glissement silencieux qui s'en charge, comme pour les ZFE (D-011).

3. Le garde-fou du § 5.e est structurel, pas cosmétique

C'est la phase qui pouvait rendre le pilier bavard. Trois filtres, chacun né d'un relevé réel :

  • la vigilance ne déclenche rien. Elle n'interdit rien, elle appelle à la sobriété. Et le filtre est écrit dans la fiche (in: [alerte, alerte_renforcee, crise]), pas enfoui dans l'adapter : un pilier est de la configuration (invariant n° 7), et un garde-fou qu'on ne peut pas lire dans la base de connaissance n'est pas relisible ;
  • une zone dont aucun usage ne concerne un particulier est ignorée. Un arrêté peut être en alerte pour les seuls prélèvements agricoles ; l'habitant n'a alors rien à faire ;
  • c'est le niveau le plus strict qui s'impose quand plusieurs zones se superposent — relevé à Nantes : alerte renforcée sur l'eau potable, alerte sur la Loire.

En revanche, le budget d'envoi n'est pas implémenté, et ce n'est pas un oubli : il n'existe encore aucun moteur de notification à contraindre (l'adapter mail ne sert que les liens magiques). La règle est écrite pour le jour où il existera — une reconnaissance CatNat justifie un envoi immédiat hors budget, une restriction d'eau non — mais coder un budget pour un émetteur inexistant aurait été du décor.

4. Cadence quotidienne, à 3 h

Nouvelle file veille-arretes-quotidienne, planifiée une heure avant la réconciliation de 4 h pour qu'elle travaille sur des attributs du jour. Quotidienne et non hebdomadaire : sur un délai de 30 jours, un passage hebdomadaire en consommerait jusqu'à un quart avant de prévenir qui que ce soit.

Deux régimes, et c'est ce qui rend le passage bon marché : CatNat s'interroge par commune (nouvelle table arretes_commune, un appel sert tous les foyers d'une commune, zéro donnée personnelle, rétention 12 mois) tandis que les restrictions d'eau s'interrogent par point — le zonage ne suit pas les limites communales, donc un appel par foyer, et rien à mutualiser.

Ce qui est sacrifié, en connaissance de cause

  • La fraîcheur de GASPAR est le facteur limitant, et il est mesuré. La source n'est pas le Journal officiel mais le jeu GASPAR, qui le recopie par lots. Relevé le 26/07/2026 : l'arrêté publié au JO le 12/06/2026 est présent, celui du 17/07/2026 ne l'est pas ; data.gouv.fr date la dernière mise à jour du fichier au 08/07/2026 et signale lui-même que « la fréquence de mise à jour n'est pas respectée ». Un arrêté peut donc apparaître alors que ses 30 jours sont épuisés — auquel cas rien n'est annoncé, plutôt qu'un rappel arrivé trop tard. Le jour où cette latence devient rédhibitoire, c'est la source qu'il faut changer (JORF en open data chez DILA, avec un appariement nom de commune → code INSEE à écrire) : le reste de la chaîne travaille déjà sur la seule date de publication au JO et ignore d'où elle vient.
  • Une seconde de plafond en plus sur l'écran de fabrication (8 → 10 s) : la veille enchaîne deux appels après le relevé de risques, séquentiellement, parce que les deux enrichissements écrivent sur la même ligne assets et que les paralléliser ferait perdre les attributs du plus lent.
  • sujet_label est une dénormalisation d'affichage. Elle duplique une information dérivable, et c'est délibéré : une échéance mise en sourdine doit continuer à dire de quoi elle parlait, même quand l'arrêté a quitté la table de référence.

Vérification

Passage quotidien réel : 3 communes, 24 foyers, 0 échec, 14,3 s — dont un foyer en restriction d'eau alerte (Toulouse, zone Garonne aval, arrêté valable jusqu'au 31/10/2026), échéance créée en confiance connue avec la bonne fenêtre. Aucune des trois communes réelles n'a d'arrêté CatNat ouvert : la chaîne CatNat a donc été éprouvée sur un foyer de test à Montpellier avec trois arrêtés injectés (deux ouverts, un expiré) — deux échéances distinctes créées avec leurs sujets et leurs dates limites propres, l'expiré ignoré, rejeu idempotent, retrait d'un arrêté suivi de l'archivage de la seule échéance concernée, hub rendant la désignation du sujet. Foyer de test et arrêtés injectés supprimés (24 foyers réels restants).

npm run typecheck, npm run knowledge:check (64 fiches) et npm test (255 tests, 43 de plus qu'en Phase 1, dont 6 sur le moteur — inviolabilité de l'état utilisateur avec sujets, rétrocompatibilité de la clé, idempotence du rejeu) passent.

D-011 — 26 juillet 2026 · Crit'Air et ZFE entrent, sans qu'aucune règle ZFE ne soit gravée (lève le refus du § 7 du plan pilier logement)

Révise le § 2.6 et le § 7 de docs/pilier-logement.md (« Base nationale consolidée des ZFE — à ne pas graver, statut politique instable » ; « ZFE — statut politique instable, et de toute façon pilier véhicules ») ainsi que docs/phase-0/quiz.md.

Contexte : le sujet avait été écarté deux fois, pour une raison qui reste entièrement valable — et qui s'est même vérifiée depuis. La suppression des ZFE a été votée par les deux chambres du Parlement en avril 2026 dans la loi de simplification de la vie économique, avant d'être censurée par le Conseil constitutionnel le 21 mai 2026 pour cavalier législatif : un motif de procédure, pas un examen du fond. Les zones sont donc pleinement en vigueur, mais rien n'empêche le législateur de recommencer dans un véhicule approprié.

Ce qui a changé, ce n'est pas la stabilité du sujet : c'est qu'on sait maintenant le traiter sans en dépendre.

Ce qui change

1. Le classement Crit'Air se déduit, il ne se demande pas. src/knowledge/critair.ts (module pur, 14 tests) applique le tableau officiel à partir de la motorisation et de l'année de première immatriculation — les deux entrées que le quiz possède déjà — en préférant la norme Euro du référentiel RDW quand le véhicule a été identifié par son champ K. Un point mérite d'être noté : le classement d'un véhicule ne change jamais, il est fixé à sa première immatriculation. C'est ce qui le sépare radicalement des ZFE et justifie deux traitements distincts.

2. Aucune règle ZFE n'est écrite dans le knowledge. Nouvelle table de référence zfe_zones, alimentée par la Base Nationale des ZFE (35 périmètres, transport.data.gouv.fr) au même passage cron hebdomadaire que le référentiel véhicules. Les seuils (V3, NC…), les dates et les URL d'arrêté viennent tous de la source.

C'est la condition qui rend le sujet tenable : si les ZFE étaient abrogées, la table se viderait au passage suivant et les trois fiches s'archiveraient d'elles-mêmes (glissement silencieux, § 20.4), sans une ligne de code à changer. Le refus du § 7 portait sur le fait de graver des règles instables — pas sur le sujet lui-même.

3. Le croisement se fait à la frontière, pas dans le DSL. La question mêle deux assets : où l'on habite et ce qu'on conduit. Le DSL n'évalue qu'un asset à la fois, et il n'était pas question de l'étendre pour ça. src/jobs/enrichissement-zfe.ts calcule le croisement dans le même passage que le relevé Géorisques — il a déjà les coordonnées — et n'écrit sur le véhicule que des scalaires tranchés (critair, zfe_proche, zfe_restreint, zfe_restriction_a_venir). Le moteur reste déterministe et ignore tout de la géométrie.

4. Rayon d'alerte de 20 km. Une ZFE ne concerne pas que ses résidents mais ceux qui y entrent, et on n'entre pas dans une agglomération depuis 200 km. Vérifié : Melun, à 45 km de la zone parisienne, ne remonte rien.

5. Trois fiches (critair-vignette, zfe-restriction-actuelle, zfe-durcissement) et une stratégie de fenêtre zfe_echeance. Le durcissement est l'échéance dormante par excellence : la date figure dans un arrêté déjà pris, souvent des années à l'avance, et personne ne la surveille. C'est la seule des trois qui porte une date, et sa confiance est « connue ».

6. Une seule question ajoutée au quiz : « la vignette Crit'Air, déjà commandée ? ». Tout le reste se déduit. Sans réponse, la fiche s'affiche quand même — commander une vignette qu'on possède déjà coûte moins cher que de rater une amende.

7. Le DSL accepte les booléens dans in / not_in. Nécessaire pour exprimer « pas déclaré comme déjà fait » : exists: false ne convenait pas, il aurait aussi exclu la réponse « non ».

Ce qui est sacrifié, en connaissance de cause

  • Le classement déduit peut se tromper aux bords d'année. Un véhicule immatriculé en janvier sur une homologation de l'année précédente relève de la norme antérieure ; seule l'identification par le champ K le rattrape. Les fiches disent « d'après le classement déduit de sa motorisation et de son année » et renvoient au simulateur officiel — jamais une affirmation sèche, même réserve que pour le PPRT et les rappels constructeur.
  • Le rayon de 20 km est un choix, pas une mesure. Il n'existe aucune donnée sur les trajets réels d'un foyer, et il n'en existera pas — fokan ne piste personne. Vingt kilomètres correspond au bassin de déplacement quotidien ; c'est assumé comme une approximation.
  • La base publie nom à null pour 34 périmètres sur 35. Un premier jet de l'écran de fabrication s'appuyait dessus et annonçait « aucune zone à portée » à Strasbourg, qui en a une. Le résumé est désormais bâti sur les booléens du croisement.
  • Le sujet peut disparaître. C'est prévu par construction (point 2), mais ça reste du travail qui peut devenir sans objet.

Vérification

Sync réelle : 35 zones ingérées depuis la BNZFE, 23 périodes actives, 9 durcissements à venir, 3 périodes déjà closes. Croisement vérifié sur six villes : Paris et Lyon dedans, Lyon plus strict que Paris (Crit'Air 3 contre 4), Strasbourg avec un durcissement au 1er janvier 2027, Toulouse, Bormes-les-Mimosas et Melun sans rien. Parcours complets en production : Paris + diesel 2004 → critair-vignette et zfe-restriction-actuelle ; Strasbourg + diesel 2008 → critair-vignette et zfe-durcissement daté « avant janvier 2027 », confiance connue ; Bormes + diesel 2004 → aucune des trois. Pages publiques en 200. npm run typecheck, npm run knowledge:check (62 fiches) et npm test (212 tests) passent. Foyers de test supprimés.

D-010 — 26 juillet 2026 · La datation du logement est branchée ; plomb et amiante ouvrent (précise D-008)

Révise docs/phase-0/quiz.md (écran 0 et écran 1) et corrige la fiche ramonage.

Contexte : un audit croisant les 21 clés when de toutes les fiches avec les attributs réellement produits a montré que annee_construction et departement étaient stockés et lus par personne, alors que quiz.md annonce des inférences départementales. Trois promesses, trois natures de problème.

Ce qui change

1. annee_construction n'était pas sous-exploitée, elle était morte. Le quiz ne la demandait nulle part et l'adapter la récupérait sans que le client la reporte — le champ était dans le schéma depuis le début, alimenté par rien. Mesure sur 500 DPE réels : annee_construction est renseignée à 57 %, periode_construction à 100 %. Les deux sont désormais reportées dans les réponses, et résolues en un attribut unique epoque_construction (src/knowledge/construction.ts, module pur, 16 tests).

  • Trois valeurs, pas dix : avant_1949, 1949_1997, apres_1997. Ce sont les deux seuls pivots qui commandent une obligation, et aucune fiche n'a ainsi à connaître le découpage en tranches de l'ADEME.
  • Lecture directe, sans confirmation : c'est la seule caractéristique du DPE qu'aucun chantier ne peut démentir. Un logement de 1974 restera de 1974 — le critère de D-008 (« on confirme ce qui se transforme ») la place du côté des faits, pas des états.
  • Question de repli au quiz : quand aucun diagnostic ne date le logement (pas d'adresse, pas de DPE, ou tranche à cheval sur un pivot), l'écran 1 pose la question en trois chips + « je ne sais pas ». Elle disparaît dès que la source a répondu — le quiz ne redemande jamais ce qu'il sait.
  • Deux ambiguïtés traitées explicitement : la tranche « 1948-1974 » englobe une année antérieure au pivot plomb (classée 1949_1997 — manquer une obligation plutôt qu'en affirmer une fausse, même arbitrage que pour le débroussaillement) ; la tranche « 1989-2000 » chevauche le 1er juillet 1997 et ne produit rien, ce qui est précisément le cas où l'année exacte, quand elle existe, fait la différence.

2. Deux fiches nouvelles, ciblées propriétaires — c'est au vendeur ou au bailleur que le document est réclamé : plomb-crep (epoque_construction: avant_1949) et amiante-etat (avant_1949 ou 1949_1997).

3. ramonage passe en v3, sur une erreur factuelle qui était publiée. La fiche et sa page affirmaient que le décret n° 2023-641 avait « unifié la règle au niveau national ». Il pose un plancher de 12 mois : l'art. R1331-71 du Code de la santé publique laisse les arrêtés préfectoraux imposer davantage, dont un passage en période de chauffe, ce que fait une majorité de départements. Aucun jeu national ne recense ces arrêtés (§ 2.7 du plan pilier logement) : la fiche énonce le plancher et renvoie à la préfecture, même réserve que pour le PPRT.

4. declaration-revenus passe en v3 avec une stratégie de fenêtre. L'inférence départementale promise est réelle, mais elle ne change pas si la fiche s'applique — elle change quand. Nouvelle stratégie declaration_revenus (src/engine/windows.ts) : zones 01-19, 20-54, 55 et au-delà. La fiche annonçait « avril » à tout le monde, c'est-à-dire le mois de l'ouverture du service et non celui de l'échéance ; les zones 1 et 2 sont désormais calées sur mai, la zone 3 sur juin. La Corse est traitée à part, Number("2A") valant NaN — sans quoi elle aurait perdu toute inférence départementale en silence.

5. ZFE/Crit'Air est retiré des promesses de quiz.md : déjà écarté au § 7 du plan pilier logement (statut politique instable), et de toute façon pilier véhicules.

Ce qui est sacrifié, en connaissance de cause

  • Le quiz gagne une question conditionnelle. Elle ne s'affiche que si la source n'a rien dit, elle tient en un tap, et « je ne sais pas » y est de plein droit — mais c'est une question de plus, à surveiller si le taux de complétion de l'écran 1 bouge.
  • Le mois seul ne distingue pas les zones 1 et 2 (21 et 28 mai). La granularité de nos fenêtres est le mois, et graver le jour exact — refixé chaque année par la DGFiP — vieillirait au premier changement. D'où la confiance « estimée ».
  • Plomb et amiante ne se déclenchent que sur les propriétaires. Un locataire d'un logement d'avant 1949 est le premier concerné par le saturnisme, mais l'obligation pèse sur le bailleur, et fokan ne fait pas de santé (§ constitution). La page publique porte l'information, la fiche ne la pousse pas.

Vérification

Quatre profils testés en production : avant 1949 + propriétaire → plomb et amiante ; 1985 + propriétaire → amiante seul ; avant 1949 + locataire → aucune des deux ; tranche ambiguë « 1989-2000 » → aucune des deux. Déclaration de revenus vérifiée sur deux foyers réels : Paris (75, zone 3) → « juin 2027 », Toulouse (31, zone 2) → « mai 2027 ». Adapter adresse vérifié en direct : l'API renvoie bien periodeConstruction: "avant 1948" pour l'adresse d'essai. Pages publiques en 200. npm run typecheck, npm run knowledge:check (59 fiches) et npm test (175 tests) passent. Foyers de test supprimés.

D-009 — 26 juillet 2026 · L'écran de fabrication devient le moment où le travail se fait (complète D-008)

Révise docs/phase-0/quiz.md (écran final) et la mise en œuvre de la Phase 1 du pilier logement.

Contexte : D-008 avait placé le relevé Géorisques en tâche de fond, pour ne pas suspendre l'onboarding à une API d'État. C'était le bon réflexe et le mauvais résultat. Mesures : POST /api/quiz répond en 60 ms, le hub se rend en 43 ms, et le relevé prend 1 à 6 s — donc l'aperçu s'affichait toujours avant que les risques n'existent, sur une page que personne ne recharge. Un foyer de Bormes-les-Mimosas voyait 26 échéances au lieu de 28 et ne saurait jamais que le débroussaillement et le radon en faisaient partie. Pendant ce temps, l'écran « On monte ta veille… » faisait défiler quatre étapes sur un setInterval de 420 ms derrière un plancher artificiel de 1 700 ms — soit 96 % d'un écran qui ne recouvrait aucun travail.

Ce qui change

  • POST /api/quiz répond en flux (NDJSON, src/app/api/quiz/route.ts). Première ligne : le plan des étapes, que le serveur connaît d'avance puisqu'il a les réponses. Puis une ligne par étape réellement franchie, avec ce qu'elle a trouvé. Dernière ligne : l'identifiant du foyer.
  • Le plan est propre à chaque foyer (src/engine/fabrication.ts, fonction pure testée) : pas d'adresse, pas d'étape « risques » et pas d'attente ; pas de véhicule, pas d'étape « véhicules ». Un foyer minimal traverse l'écran en 20 ms de travail réel.
  • Le relevé Géorisques est attendu, sous plafond de 8 s. Au-delà, la file pg-boss reprend la main exactement comme avant et le parcours continue. Le cas normal (mesuré entre 0,86 et 1,18 s de bout en bout, tout compris) affiche donc les risques dès le premier rendu du hub.
  • Les étapes nomment un constat, jamais un geste : « zone de débroussaillement obligatoire », « PEUGEOT 308 — 20 campagnes de rappel recensées ». Le mode d'emploi reste derrière l'abonnement (D-005) — c'est testé (resumerRisques ne doit contenir ni mois, ni action, ni prix).
  • Le plancher artificiel disparaît. Reste une cadence d'affichage minimale de 420 ms par étape franchie, qui espace la lecture de ce qui est déjà acquis sans simuler aucun traitement — la distinction est explicite dans le code.

Ce qui est sacrifié, en connaissance de cause

  • Le premier foyer après un déploiement subit le démarrage à froid : mesuré à 8,2 s, donc le plafond se déclenche et son hub s'affiche sans les risques (le job de fond les ajoute ensuite). Un foyer par déploiement, contre la certitude de ne jamais bloquer : l'échange est bon.
  • Le parcours dépend maintenant d'une réponse en flux. Un intermédiaire qui tamponnerait la réponse ferait tout arriver d'un bloc à la fin — l'écran resterait juste, mais perdrait sa progression. X-Accel-Buffering: no et no-transform sont posés ; vérifié en direct, les lignes arrivent bien au fil de l'eau.

Vérification

Flux horodaté en production sur trois parcours : Bormes + véhicule à chaud (plan à 0 s, trois étapes en 30 ms, risques à 0,86 s), le même à froid (plafond à 8,21 s, bascule en file), et un foyer sans adresse ni véhicule (plan réduit à deux étapes, fin à 20 ms). Hub vérifié au premier rendu : 28 échéances dont debroussaillement-old et radon-depistage, là où le fond de tâche seul en donnait 26. Réponses invalides : toujours un 400 JSON, jamais un flux. npm run typecheck et npm test (155 tests) passent. Foyers de test supprimés.

D-008 — 25 juillet 2026 · Un aléa géographique dérivé alimente le moteur sans confirmation (précise le § 1 du plan pilier logement)

Précise l'invariant du § 1 de docs/pilier-logement.md (« une valeur dérivée ne produit jamais une échéance à elle seule »), à l'occasion de la livraison de la Phase 1 du pilier logement.

Contexte : cet invariant a été écrit face au DPE, et il reste entièrement justifié pour lui — un diagnostic de 2019 décrit un logement qui a pu être isolé depuis, sans que la source le sache. D'où le mécanisme des trois niveaux de vérité, contexte jamais lu par applicabilite.when, et l'écran de confirmation dans le quiz. La Phase 1 (Géorisques) apporte une donnée dérivée d'une autre nature : elle ne décrit pas le logement mais le lieu — zone d'obligation de débroussaillement, potentiel radon de la commune, exposition du sol aux argiles, périmètre d'une centrale, aléa d'inondation à l'adresse.

Ce qui change

  • Les attributs risque_* posés sur l'asset logement par src/jobs/enrichissement-georisques.ts sont lus directement par le DSL d'applicabilité, sans étape de confirmation. Ce sont des attributs plats et scalaires, pas du contexte.
  • Le critère qui sépare les deux régimes est explicite : une valeur dérivée qui décrit le logement se confirme ; une valeur dérivée qui décrit le lieu ne se confirme pas. Un propriétaire peut changer sa chaudière sans prévenir l'ADEME ; il ne peut pas déplacer sa parcelle hors de la zone argileuse, ni sortir sa commune du périmètre d'un PPI.
  • Corollaire produit, et c'est la vraie raison : demander « es-tu en zone de débroussaillement ? » ou « ta commune est-elle classée radon 3 ? » serait une question mémoire déguisée (interdite § 4.2), posée à quelqu'un qui a strictement moins d'information que la source. On ne demande pas à l'utilisateur de valider ce qu'il ignore.

Ce qui ne change pas

  • contexte / contexte_confirme restent réservés à ce pour quoi ils ont été créés : le DPE et tout ce qui décrira le logement. src/knowledge/dpe-confirmation.ts et son écran de quiz sont intacts.
  • Le moteur reste 100 % déterministe (invariant n° 5) : l'adapter est une frontière, il écrit des scalaires, le DSL ne fait que les comparer.
  • Le test du geste (§ 1 du plan pilier logement) reste le filtre d'entrée : le séisme, les mouvements de terrain et tout score global ne sont pas collectés du tout, faute d'action à proposer.

Ce qui est sacrifié, en connaissance de cause

  • Un aléa mal rattaché produit une fiche que personne n'a demandée. Quatre garde-fous, tous vérifiés en production sur des adresses réelles : seul libelleStatutAdresse est lu, jamais le statut de la commune (à Toulouse, l'inondation est « Existant » sur la commune mais « non Connu » à l'adresse) ; le débroussaillement n'est retenu que si le code commune de la zone est celui de l'adresse (l'endpoint renvoie volontiers la zone du voisin — relevé à Fréjus, qui remonte Mandelieu-la-Napoule) ; le volet technologique se limite aux installations classées, à l'exclusion de la pollution des sols et des canalisations, « Concerne » à l'adresse dans la moitié des villes sans qu'aucun travail n'en découle ; et une source qui ne répond pas ne fait écrire aucun attribut, jamais un false de complaisance.
  • Aucun rafraîchissement périodique n'est mis en place : le relevé se fait une fois, à la création du foyer. La carte des argiles a pourtant été refondue par arrêté le 9 janvier 2026. Le job étant idempotent, le rejouer suffira — c'est un cron à ajouter, pas une reprise de conception, et ça relève de la Phase 2 (veille événementielle). Comblé le 26/07/2026 : rafraichirRisquesTousFoyers rejoue le relevé pour tous les foyers dans le passage hebdomadaire, en quatrième et dernier segment (src/instrumentation.ts). Comme prévu, il n'y a rien eu à concevoir — le job était déjà idempotent. Mesuré en production : 23 foyers, 7 enrichis (les seuls à avoir donné une adresse), 0 échec, 40 s, soit ~1,75 s par foyer et une dizaine de minutes à l'échelle de sortie du cadrage. Une seconde d'écart entre deux foyers, pour ne pas marteler une API d'État sans quota documenté.
  • Pas de compte Cerbère (décision ouverte n° 4 du plan pilier logement) : l'API v1 reste libre et suffit. Le jour où elle ferme, seul src/adapters/georisques.ts est concerné — l'en-tête d'authentification s'ajoute dans une fonction, appel().

Vérification

Adapter appelé réellement sur cinq adresses couvrant les six fiches (Toulouse, Bormes-les-Mimosas, Quimper, Feyzin, Golfech), puis parcours complet POST /api/quiz → job pg-boss → réconciliation → hub sur trois d'entre elles : debroussaillement-old + radon-depistage à Bormes, iode-pastilles + inondation-gestes + argile-fissures à Golfech, pprt-travaux-prescrits + argile-fissures à Feyzin. Idempotence contrôlée en rejouant le job deux fois sur un foyer déjà enrichi : 15 échéances avant, 15 après, aucune archivée. npm run typecheck, npm run knowledge:check (57 fiches) et npm test (145 tests) passent. Foyers de test supprimés.

D-007 — 25 juillet 2026 · L'opt-in post-quiz des contrats est retiré (révise D-006)

Révise la section « Ce qui ne change pas » de D-006 ainsi que le § 5.6 du cadrage et docs/phase-0/quiz.md.

Contexte : D-006 avait délibérément gardé le flux opt-in post-quiz (src/app/foyer/[id]/contrats.tsx, POST /api/contrat) pour deux raisons : rattraper un contrat oublié/skippé au quiz, et couvrir le cas d'un véhicule ou animal acquis après coup (non pertinent au moment du quiz, donc jamais proposé). En pratique, ce bloc reposait la question dans le hub juste après que le quiz venait de la poser — vécu comme une redite plutôt qu'un rattrapage, à l'encontre de la règle d'or (§ 4.1 : chaque interaction supprime plus de travail qu'elle n'en coûte, jamais l'inverse).

Ce qui change

  • Le composant Contrats du hub est supprimé (src/app/foyer/[id]/contrats.tsx), ainsi que son usage dans src/app/foyer/[id]/page.tsx et la route POST /api/contrat qui ne servait plus qu'à lui.
  • relevantContractTypes et declaredContractTypes (src/engine/contrats.ts) sont retirées : elles n'avaient plus qu'un seul et unique appelant (le hub), devenu inexistant. relevantContractTypesForProfil reste seule, utilisée uniquement côté quiz.
  • Le quiz (écran 8) est désormais le seul moment où un foyer déclare un contrat.

Ce qui est sacrifié, en connaissance de cause

  • Un foyer qui ignore un type de contrat au quiz, ou qui acquiert un véhicule/animal après coup, n'a plus aucun moyen de déclarer le contrat correspondant — ni dans l'immédiat, ni plus tard. C'est un vrai recul de couverture, accepté sciemment : mieux vaut une déclaration incomplète mais silencieuse qu'un rappel perçu comme redondant. Si le besoin de rattrapage revient (retours utilisateurs, ou nouveau produit type « mes contrats » consulté à la demande plutôt qu'imposé dans le hub), à retraiter en connaissance de cause plutôt qu'à réintroduire ce bloc tel quel.

Vérification

npm run typecheck et npm test (130 tests) passent après suppression. Vérifié qu'aucune autre référence à contrats.tsx, /api/contrat, relevantContractTypes ou declaredContractTypes ne subsiste dans le code.

D-006 — 25 juillet 2026 · Les contrats rejoignent le quiz (annule partiellement D-005/§ 5.6)

Révise le § 5.6 du cadrage (« Les contrats — intégrés aux piliers, en opt-in ») ainsi que docs/phase-0/quiz.md.

Contexte : le § 5.6 réservait volontairement les contrats à un moment post-quiz, « contre une promesse monétaire explicite » — l'idée étant d'échanger la question contre un service déjà rendu, jamais de l'ajouter au coût d'entrée du quiz. En pratique, ça reportait la capture d'une information que l'utilisateur connaît déjà au moment du quiz (« oui j'ai une assurance habitation, chez telle mutuelle ») à un second passage, avec le risque réel qu'il ne revienne jamais le faire.

Ce qui change

Un écran de quiz (l'écran 8, dans le prolongement direct de l'écran papiers/passeport — pas un volet à part) propose les types de contrats pertinents pour ce foyer — filtrés par ce qu'on sait déjà à ce stade du quiz (véhicule déclaré → assurance auto ; animal déclaré → assurance animale ; propriétaire → assurance emprunteur ; les autres types — habitation, mutuelle, box, mobile, salle de sport — toujours proposés). Un seul champ optionnel par contrat sélectionné : « échéance approximative ? », « je ne sais pas » accepté. Le nom de l'assureur n'est volontairement pas demandé ici — il ne sert aucune inférence (seule l'échéance en déclenche une), donc pas de saisie qui ne sert pas un rappel (cohérent avec le reste du quiz). Rien n'est obligatoire : ignorer l'écran entier reste un parcours valide.

Ce qui ne change pas

  • Le flux opt-in post-quiz est conservé, pas remplacé : src/app/foyer/[id]/page.tsx continue de proposer, dans le hub, les types de contrats pertinents non encore déclarés (declaredContractTypes exclut automatiquement ceux du quiz). Un foyer qui prend un animal ou une voiture après coup peut toujours déclarer l'assurance correspondante plus tard. Ce flux-là garde son champ « chez qui ? » (src/app/foyer/[id]/contrats.tsx) : ajouté après coup, sans le contexte du quiz, l'assureur reste la seule façon de distinguer un contrat oublié d'un autre. Retiré par D-007 : le quiz est désormais le seul moment de déclaration, ce flux n'existe plus.
  • La règle de pertinence est une source unique (relevantContractTypesForProfil dans src/engine/contrats.ts), appelée à partir du profil brut du quiz — reste vraie, simplement il n'y a plus qu'un seul appelant depuis D-007.
  • La provenance de l'asset reste "quiz" pour un contrat déclaré à cet écran (déjà le comportement par défaut de POST /api/quiz).

Ce qui est sacrifié, en connaissance de cause

  • Le budget « 8 écrans, ≤ 2 minutes » de docs/phase-0/quiz.md passe à 9 écrans. Le nouvel écran est entièrement skippable et ne demande rien de plus qu'un tap par contrat déjà connu — le coût marginal réel dépend du nombre de contrats que le foyer choisit de déclarer, pas d'un minimum imposé.
  • « Contre une promesse monétaire explicite » ne s'applique plus à la déclaration faite pendant le quiz (gratuit) — cette justification originale du § 5.6 concernait spécifiquement le moment post-quiz, qui continue d'exister pour les contrats non déclarés ici.

Vérification

Testé par simulation (5 profils de quiz représentatifs + les 8 types de contrat, cf. session du 25/07/2026) : les 8 fiches *-echeance se déclenchent identiquement, que le contrat soit déclaré pendant le quiz ou via l'opt-in post-quiz — le moteur de réconciliation ne fait aucune différence entre les deux provenances, comme attendu (§ 20.1 : Connaissance et Instance ignorent la provenance, seule assets.provenance la trace pour l'audit).

D-005 — 25 juillet 2026 · Le mur e-mail post-quiz est retiré ; refonte du front autour de la révélation graduée

Révise le § 10.2 du cadrage (« La preview post-quiz, email requis avant affichage »).

Contexte : le parcours accumulait trois portes avant la moindre contrepartie — 8 questions, puis un mur e-mail, puis le paywall. Un visiteur donnait deux minutes et se voyait redemander quelque chose avant d'avoir rien vu. Le quiz devenait rétrospectivement un péage, exactement le contraire de la règle d'or (§ 4.1 : chaque interaction supprime plus de travail qu'elle n'en coûte).

1. Rien n'est demandé avant d'avoir donné

L'aperçu du foyer s'affiche immédiatement après le quiz, sans adresse mail. EmailGate et hasUnlockedPreview() sont supprimés ; /api/checkout n'exige plus d'adresse (Stripe Checkout la collecte lui-même — un champ de moins entre l'intention et le paiement).

Sur la friction anti-scraping, qui était l'autre justification du mur : elle était faible. Chaque aperçu suppose un quiz complet et un UUID non devinable ; le coût d'un scraping reste dominé par le quiz, pas par le mur. Un rate-limit sur /api/quiz couvre le besoin réel — à mettre en place, non fait à ce stade.

2. La contrepartie remplace la barrière pour la capture du lead

L'adresse se demande après le service rendu, formulée comme un service supplémentaire (« je te renvoie ce lien par mail ») et non comme une condition. Elle n'apparaît qu'une fois le calendrier parcouru (sentinelle d'intersection), est refusable, et le refus est mémorisé. Complément indispensable : le foyer visité est mémorisé en localStorage, et la nav propose « Reprendre mon foyer » — sans quoi une URL en UUID rendrait le résultat irretrouvable.

Arbitrage assumé : moins de leads en volume, de bien meilleure qualité, et surtout plus aucune perception de piège. Le CTA de sortie du parcours devient le paiement, pas la collecte d'adresse.

3. La frontière gratuit/payant est reformulée : révéler l'inventaire, jamais le mode d'emploi

Le § 10.2 restait juste sur le fond mais était appliqué de façon binaire (3 échéances en clair, le reste en titres grisés) : trop de substance sur les trois, aucune sensation de volume sur le reste. Nouvelle ligne, appliquée partout :

Gratuit — la sensation Payant — le service
Le volume (« 34 échéances ») La date et la fenêtre
La répartition par pilier Le pourquoi, l'enjeu
Le rythme de l'année (densité mois par mois) La règle et sa source
Le compte par pilier (jamais le titre) Le titre, l'action, le premier pas
La mise à jour continue

Exception volontaire et bornée : 2-3 échéances réelles du foyer restent entièrement révélées (titre, date, enjeu) même sans abonnement — seule brèche assumée dans la ligne ci-dessus. Sans elle, tout ce qu'un visiteur sans abonnement voit reste abstrait (compteur, timeline, agrégats) ; rien ne prouve concrètement qu'on a trouvé quelque chose pour son foyer. Un échantillon borné à 2-3 items est de nature différente d'exposer l'inventaire entier ou les catégories de rappel à tout le monde — c'est le patron freemium standard, et le § 10.2 le prévoyait dès l'origine. (Une itération intermédiaire de cette révision l'avait supprimé par excès de zèle en généralisant le raisonnement du mur d'échéances ; remis en place le jour même après revue.)

Pièce centrale : une timeline 12 mois où chaque échéance est une pastille anonyme positionnée sur son mois, couleur = pilier. Elle fait ressentir la charge de l'année sans jamais associer un titre à une date — la corrélation reste impossible, et c'est ce qui la rend publiable. C'est aussi l'objet partageable attendu au § 11.3.

Conséquence sur la landing : les trois cartes de démonstration mail sont retirées. Elles donnaient gratuitement le savoir vendu (délais de rendez-vous en mairie, règle des 6 mois de validité, tension saisonnière sur le contrôle technique). Le mur d'échéances qui les a remplacées est passé par deux versions : une première listait les vrais intitulés des 45 fiches en défilement (encore trop — cela publiait le catalogue complet, réservé aux abonnés) ; la version retenue est un champ de pastilles colorées par pilier, sans aucun mot — le volume et la répartition se ressentent, aucun sujet n'est nommé ni cherchable.

Rappels constructeur (rappels.tsx) suivent la même ligne : le teaser sans abonnement révèle l'existence et le compte des campagnes, jamais leurs catégories (« freinage », « direction »…) — connaître la catégorie concernée suffisait à motiver un passage au garage sans jamais payer.

4. Refonte du front

Tailwind v4 (thème en CSS via @theme inline, aucun tailwind.config.js), socle de tokens en trois couches (palette → sémantique → exposition Tailwind) avec mode sombre traité uniquement dans globals.css : aucun composant ne connaît son existence. Typographie Fraunces (display) + Inter (texte), auto-hébergées par next/font/google — aucun appel navigateur vers Google (§ 16). Primitives partagées dans src/components/ui/ en remplacement des 50 style={{}} inline.

Le paywall devient une section pleine confrontant le prix à ce qu'il achète, placée après les listes verrouillées. Le quiz rend de la valeur pendant la saisie (piliers qui s'allument, décompte factuel d'éléments — jamais un nombre d'échéances promis), offre « Passer » sur tous les écrans, conserve un brouillon en sessionStorage et remplace l'alert() de fin de parcours par un vrai état d'erreur.

Piège de bundling rencontré, à ne pas réintroduire : un module JSX sans directive importé par un composant client rendu dans le layout racine casse le manifeste RSC au prerender (Next 15.5). D'où src/components/ui/button-styles.ts, module purement TS, seul consommé par les composants client.

5. Reste à faire

Rate-limit sur /api/quiz ; vignettes OG par guide (seule la vignette racine existe) ; validation juridique de la case de renonciation (§ 10.7, inchangé).


D-004 — 25 juillet 2026 · Véhicules : le champ K remplace la plaque (annule D-002) ; l'entretien constructeur passe en base

Contexte : le référentiel RDW Open Data (domaine public, ~2,9 M configurations) publie les réceptions CE de toutes les autorités européennes, y compris françaises, indexées exactement comme les champs K et D.2 de la carte grise. Un foyer qui recopie son champ K identifie son véhicule aussi précisément qu'une API plaque — gratuitement, sans licence SIV, sans coût variable. C'est ce que D-002 jugeait impossible en juillet.

Un audit global de la couche connaissance a par ailleurs montré que les deux mécaniques cohabitaient mal, et que la plus faible avait la priorité.

1. La mécanique plaque est retirée du produit

Fait constaté en production : PLAQUE_API_URL n'ayant jamais été renseignée, l'adapter retombait sur son mock déterministe — en prod. Quatre véhicules fictifs avaient été écrits en base, dont un ayant écrasé une identification exacte, et le drapeau plaque_verifiee masquait ensuite le widget à jamais. La fonctionnalité payante détruisait la donnée juste, sans erreur ni trace.

Décision : suppression complète de l'adapter, des deux routes API, du composant UI et des variables d'environnement. Les 4 véhicules fictifs et leurs 17 échéances ont été purgés. Le champ K devient l'unique voie d'identification exacte, disponible gratuitement pour tous — ce qui annule aussi le raisonnement économique du § 10.6 (D-003, point 3) : il n'y a plus d'appel API à rationner.

Conséquence sur le payant : le débloquant du pilier Véhicules n'est plus « ta vraie plaque » mais les rappels constructeur (déjà livrés, teaser gratuit + détail abonné). Copy commerciale mise à jour en conséquence.

Ce qu'on perd : la date de 1re immatriculation, que la plaque fournissait et que le champ K ne donne pas (une réception date l'homologation du type, pas l'exemplaire). La fenêtre de contrôle technique repart donc de l'année déclarée — estimée au lieu de connue. date_premiere_immat reste dans le schéma pour une date apprise ultérieurement.

2. L'entretien constructeur quitte le YAML pour la base

knowledge/vehicules/entretien-constructeur.yaml était à cheval sur les deux couches et échouait aux critères de la Connaissance : aucun éditorial, aucun ID stable à vie, produit par un pipeline et non à la main, ~12 900 triplets (marque, modèle, énergie) cibles — une relecture de diff git y aurait été une fiction. Il ne pouvait pas joindre le catalogue, inventait donc ses propres clés (groupe_id, famille_moteur) héritées d'ADEME et sans producteur depuis la bascule vers RDW, et imposait une lecture disque au cœur du moteur de fenêtres.

Décision : table vehicules_entretien, clé identique à celle des rappels. La relecture humaine devient des colonnes (confiance, revu_par, revu_le) donc requêtable, au lieu d'un historique git que personne n'aurait lu.

Règle de tri retenue, applicable aux piliers suivants : si je supprime la table et rejoue le pipeline, est-ce que je récupère exactement la même chose ? Oui → référentiel en base. Non, il y a du jugement humain irremplaçable dedans → Connaissance en git. Les 45 fiches d'échéance restent en YAML sans discussion : éditorial, revue par diff, atomicité avec le code (l'enum strategy, le DSL d'applicabilité), bicéphalie avec les pages SEO, et « une fiche invalide casse le build, jamais la prod » — un principe qui s'inverse mécaniquement en base.

3. Une seule résolution véhicule

Trois politiques de rapprochement (marque, modèle) coexistaient : ILIKE pour l'autocomplétion, égalité stricte pour les rappels, includes() bidirectionnel pour l'entretien. La troisième faisait matcher « C5 AIRCROSS » sur une entrée « C5 » — une préconisation attribuée au mauvais véhicule, en silence. Unifiées dans src/knowledge/vehicules-resolution.ts, sur clés normalisées stockées. Aucune correspondance approchée : sur des campagnes de sécurité, un faux positif coûte plus cher qu'un silence.

4. Le moteur de fenêtres redevient pur

computeWindow lisait un YAML sur disque, ce qui violait l'invariant n° 5 et forçait les tests à mocker un module. L'entretien lui est désormais passé en argument, chargé par la couche I/O (reconcile-db.ts).

5. Ingestion : les garanties d'upsert deviennent communes

src/db/upsert.ts — taille de lot et clause SET dérivées du schéma, appliquées aux cinq tables synchronisées. rappels écrivait encore sa clause à la main, donc exposée au défaut silencieux qu'on avait déjà corrigé ailleurs : une colonne ajoutée n'aurait jamais été rafraîchie aux passages suivants. 18 tests couvrent désormais chaque table.

6. carburantenergie

Un concept, un seul nom, de bout en bout : quiz, asset, référentiel, contrat d'API. Les attributs des véhicules existants ont été migrés en base ; energieDe() lit encore l'ancien nom en repli. La fiche courroie-distribution conditionnait sur carburant — sans mise à jour simultanée du YAML, elle aurait cessé de s'appliquer en silence. C'est l'illustration exacte de l'argument d'atomicité qui justifie de garder les fiches en git.

7. Qualité des libellés du référentiel — mesurée, et corrigée à la lecture

Codes internes en guise de nom commercial (« CITROEN 7066 ») : mesuré après coup, négligeable. Sur les 15 marques grand public (88 % du référentiel), les modèles à ≤ 5 configurations pèsent 0,072 % du volume, et la quasi-totalité sont des noms de variantes parfaitement légitimes (AUDI Q4 SB E-TRON PERFORMANCE, BMW 225XE ACTIVE TOURER). Les vrais codes internes identifiés se comptent sur les doigts (CITROEN 7066, CITROEN S1HDZE, AUDI 4L, 8G7, S8Q, RENAULT 57), une trentaine de configurations. Aucune table de correspondance ne se justifie.

Piège évité : le critère « modèle à configuration unique » (2 348 modèles, 1,2 % des réceptions) aurait été trompeur — il est dominé par des camping-cars de très petite série (Pilote, Carthago, Adria, Knaus), dont les noms sont réels.

Accents perdus à la source : le vrai défaut, celui-là corrigé. RDW publie certains libellés avec le caractère accentué remplacé par « ? » ou U+FFFD — vérifié directement sur l'API, ce n'est pas notre ingestion. 26 modèles, 718 configurations.

Le coût n'était pas cosmétique mais structurel : le catalogue éclatait un même modèle en plusieurs entrées (A5 COUPE 5 340 configurations, A5 COUP<55> 463, A5 COUP? 1 — MINI Cooper Coupé en comptait cinq orthographes). Trois entrées identiques à l'écran, et 463 configurations décrochées des rappels attachés à la quatrième.

Corrigé à la lecture, pas à l'ingestion : reparerLibelle() dans vehicules-cle.ts, appliqué avant le calcul de la clé (les orthographes convergent) et à l'affichage (l'autocomplétion fusionne les doublons). Rien à réimporter, effet immédiat sur les 2,9 M de lignes déjà en base. 701 des 718 configurations rejoignent leur modèle canonique.

Table de réparation volontairement fermée — COUPÉ, CITROËN, PÖSSL, — parce que chacune est vérifiable : la source publie les deux orthographes pour les mêmes voitures, ou ce sont des noms propres attestés. Les 6 corruptions qu'on ne sait pas reconstituer (?MISSIS, 600 DB <20>CHARMING<4E>) sont laissées telles quelles plutôt qu'inventées ; le caractère de remplacement résiduel est simplement retiré de l'affichage.

Ce qui NE change pas : les 3 couches, les 7 invariants, le format des fiches, le stack. La Connaissance reste git-only, sans back-office.

D-003 — 23 juillet 2026 · Mise à jour du cadrage : modèle économique réécrit (§ 10)

Contexte : nouvelle version du document de cadrage reçue, avec le § 10 (Modèle économique) entièrement réécrit suite à un audit de scénarios. Remplace le modèle mensuel 4-5 €/mois construit en Phase 0/MVP jusqu'ici.

Ce qui change, par rapport à ce qui est déployé aujourd'hui sur fokan.g0tch.myds.me :

  1. Prix et cycle : mensuel → annuel. 19,99 €/an par foyer (palier unique), au lieu de 4,90 €/mois. Arbitrage documenté § 10.3 : le mensuel est jugé incompatible avec un produit « conçu pour être oublié » (churn silencieux), l'annuel préserve l'effet de stock avec un seul moment de friction par an.

  2. Le gratuit se resserre fortement (révélation graduée, § 10.2) :

    • Plus de hub figé en accès libre. Après le quiz, une preview email-gated affiche le total + répartition par pilier + 2-3 échéances urgentes en détail, le reste verrouillé (titre/pilier visibles, date/enjeu masqués).
    • Plus d'export .ics gratuit (retiré explicitement — « supprimait le risque qu'un fichier recopiable suffise à lui seul »).
    • Le catalogue de guides /guide n'est plus navigable gratuitement. Chaque guide reste trouvable individuellement (recherche, lien direct, maillage interne, sitemap.xml pour le crawl), mais la page d'index groupée est réservée aux abonnés. Conflit direct avec le chantier UI/UX de la session précédente, qui vient de construire et publier cette page d'index comme une des pièces maîtresses de la refonte — voir note ci-dessous.
  3. L'API plaque sort entièrement du gratuit (annule l'esprit de D-002). D-002 prévoyait 1 plaque offerte à tous au quiz. Le § 10.6 (nouveau, chiffré) tranche differently : à l'échelle visée (~90 000 quiz cumulés), offrir l'appel API à 96-98 % de visiteurs qui ne convertiront jamais coûterait 10-25 k€ sans retour — un CAC caché. Nouvelle règle : zéro appel plaque en gratuit. Le quiz gratuit devient déclaratif pur pour les véhicules (type + année, fenêtre générique comme les autres piliers). L'ajout de la vraie plaque devient le débloquant explicite du pilier Véhicules en payant (1 appel par véhicule, mis en cache, jamais rejoué ; 2 véhicules inclus).

  4. Nouveau blocage légal explicite (§ 10.7, § 28) : avant tout encaissement réel, une case de renonciation au droit de rétractation de 14 jours (art. L221-28 Code conso) est obligatoire au paiement pour fermer la faille quiz → paiement → full reveal → rétractation. Marqué « à faire valider par un professionnel du droit avant le lancement » — je ne suis pas qualifié pour rédiger la formulation finale, seulement pour l'implémenter une fois validée.

  5. Comptes/abonnement : households doit porter un statut d'abonnement (§ 21 : « abonnement (statut, période) ») — absent du schéma actuel, qui n'a que les tables de compte (user/session/account/verification, memberships, invitations) sans aucune notion de paiement.

Incohérence relevée dans le document source lui-même : le § 10 porte la mention « Section réécrite in extenso — version finale », mais §§ 9.1, 9.4, 12 (roadmap Phase 0/MVP) et le point 9 du § 18 n'ont pas été mis à jour en cohérence — ils mentionnent encore « export ics gratuit », « abonnement mensuel », « prix mensuel affiché », « récap mensuel ». J'ai annoté ces sections dans le cadrage stocké (notes en tête de §§ 9, 12, 18) plutôt que de trancher unilatéralement. Je traite le § 10 comme seule autorité sur le modèle économique (il est explicitement daté et marqué comme version finale), les mentions mensuelles ailleurs comme des artefacts non nettoyés — à confirmer avec l'utilisateur.

Ce qui NE change PAS : toute l'architecture technique (§§ 19-27 identiques) — le moteur de réconciliation, le format des fiches YAML, le stack (Next/Drizzle/pg-boss/Zod/React Email), l'hébergement, better-auth, la stratégie mail. Le modèle de contrats (§ 5.6) est inchangé. Rien du travail déjà livré sur ces fronts n'est invalidé.

Décision d'implémentation : reportée. Vu l'ampleur (retrait d'une page qui vient d'être construite et mise en avant, changement de prix affiché partout, refonte du quiz véhicule, ajout d'un mur de paiement Stripe + case légale, ajout du statut d'abonnement en base), aucun code n'est modifié dans l'immédiat — cadrage mis à jour et écarts documentés d'abord, arbitrages à valider avec l'utilisateur avant de toucher au produit.

D-002 — 22 juillet 2026 · Véhicules : API plaque payante self-service (annulée le 25/07/2026 par D-004)

Contexte : l'intention initiale (D-001 puis « API gratuite ») s'est heurtée à un fait juridique vérifié : la réutilisation des données SIV exige une licence du ministère de l'Intérieur avec redevance — « gratuit + zéro saisie » est impossible en France (la seule voie gratuite, HistoVec, exige de recopier nom + numéro de formule de la carte grise : plus de saisie, pas moins).

Décision : API plaque payante self-service (~5 c/requête, fournisseur à confirmer par essai comparatif), avec le réglage du § 7.3 du cadrage : 1 plaque offerte par quiz, cache des résultats par plaque, limite anti-abus par IP. L'utilisateur ne saisit que sa plaque — zéro saisie de documents, priorité confirmée.

Date CT : non fournie par les API self-service → fenêtre calculée depuis la date de 1re immatriculation retournée par l'API (exacte pour les < 4 ans, estimée ensuite), affinable en un tap plus tard (ex. lecture optionnelle de la vignette pare-brise). Les contrats B2B « date CT réelle » (AAA Data/Autorigin) restent suspendus, réactivables si le besoin se confirme.

Conséquences : coût variable borné (~quelques euros en Phase 0, ~50-100 €/1 000 quiz) assumé comme coût d'acquisition ; l'effet waouh de la détection est conservé pour le test de désirabilité ; repli déclaratif (marque/modèle/année) pour qui refuse de donner sa plaque.

D-001 — 22 juillet 2026 · Véhicules : full gratuit, pas d'API plaque (annulée le jour même par D-002)

Décision initiale : volet véhicules 100 % déclaratif pour un coût nul. Annulée après vérification : l'utilisateur ne doit pas saisir manuellement, et aucune API gratuite n'existe légalement.