Le domaine réel est fokan.fr depuis un moment (.env, legal.ts, contact@fokan.fr,
BIMI), mais sitemap.ts, robots.ts, layout.tsx et checkout/route.ts gardaient
encore l'ancien fokan.g0tch.myds.me comme valeur par défaut si la variable
d'env est absente.
Deux failles relevées par l'audit du 09/08 : docling écoutait sur toutes les
interfaces sans authentification alors que fokan-app le joint déjà par ai-net ;
la route carte-grise ne vérifiait le type de fichier que sur l'en-tête déclaré
par l'appelant, jamais sur le contenu réel.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
L'article Responsabilité se terminait par « ces stipulations ne limitent en
rien… en cas de dommage corporel, de faute lourde ou dolosive ». Ce paragraphe
n'existait que pour sauver deux clauses de limitation restées dans le texte.
Elles sont retournées en non-promesses :
« l'éditeur ne répond pas de X » -> « le service ne garantit pas X »
« n'ouvre droit à aucune indemnité » -> « aucune somme n'ayant été versée,
aucun remboursement »
La première forme de chaque paire réduit le droit à réparation (art. R212-1,
6° du Code de la consommation, liste noire — réputée non écrite) ; la seconde
définit l'obligation, ou constate qu'il n'y a rien à rendre. Portée utile
identique, risque incomparable.
Plus aucune clause de limitation dans les trois pages, donc plus rien à sauver,
donc plus de soupape à écrire — et l'atteinte à la personne n'est plus nommée.
Le test porte sur les formes de rédaction et non sur un mot, sur les trois
pages et dans les deux modes : c'est par une phrase ajoutée de bonne foi que
ces clauses reviennent. Il a d'ailleurs attrapé deux occurrences que la
réécriture manuelle avait laissées (Fermeture, Disponibilité).
L'exposition réelle est inchangée et 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. 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 avant tout acte, silence
qui ne décharge de rien.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Trois retraits demandés, dont un qui contredisait le contrat.
« Ton accès est gratuit et sans durée — aucune carte bancaire ne t'a été
demandée, et il n'y en aura jamais » : l'article 4 des CGU dit précisément
le contraire, et le dit exprès (la gratuité n'est acquise pour aucune durée,
le service peut devenir payant, y compris pour les accès déjà ouverts).
Un écran qui promet l'inverse du contrat n'est pas un détail de ton : c'est
lui qui serait opposé le jour venu. Même raison pour « et sans durée » sur
le mur.
« C'est un service qu'on ouvre, pas une audience qu'on constitue » quitte la
réassurance : ce qui la rend vérifiable est ce qui reste (aucune publicité,
aucune revente, aucun traceur), la chute était le commentaire de l'auteur.
L'article Responsabilité ne nomme plus l'atteinte à la personne. L'effet est
intact : une clause qui limiterait sa réparation est nulle d'ordre public,
donc déjà emportée par « dans les cas où la loi l'interdit » et « aucun droit
d'ordre public ». Le mot n'ajoutait aucun droit et faisait entrer, dans les
conditions d'un service de dates d'échéances, un dommage qu'il n'a pas à
mettre en scène — un lecteur ne retient pas la nuance entre « ceci n'est pas
limité » et « ceci peut arriver ». Un test le tient absent pour qu'il ne
revienne pas par une réécriture.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Se protéger d'un conseil mal suivi passait par une clause de non-responsabilité.
Écrite ainsi, elle saute : supprimer ou réduire le droit à réparation d'un
consommateur est sur la liste noire de l'art. R212-1, 6° du Code de la
consommation — irréfragablement abusive, réputée non écrite, et elle emporte la
crédibilité de l'article en tombant.
L'article « Responsabilité » définit donc le périmètre au lieu d'exclure la
réparation, ce qui donne la même protection par le seul chemin qui résiste :
un service d'information et non de conseil, une obligation de moyens jamais de
résultat, l'utilisateur seul décideur tenu de vérifier avant d'agir, et une
échéance ABSENTE qui ne décharge de rien. Ce dernier point manquait le plus :
le risque n'est pas d'abord l'erreur mais le silence — une fiche qui ne s'est
pas appliquée, une source indisponible, un barème périmé qui cesse de filtrer.
Le paragraphe qui écarte toute limitation pour dommage corporel et faute lourde
ou dolosive n'affaiblit pas l'article, c'est lui qui le fait tenir : revendiquer
ces exclusions-là ferait tomber l'ensemble.
Il devient un article autonome placé après l'objet. Il vivait en deuxième
paragraphe d'un article intitulé « Objet du service », où ni un lecteur ni un
juge ne le cherchent.
Et il ne suffisait pas. Des CGU n'engagent que ceux qui les ont acceptées, or
les 78 guides sont publics, indexés, et deux se lisent en entier sans compte :
qui arrive de Google, lit, agit et provoque un dégât n'a jamais rien accepté.
C'est la surface la plus exposée du produit et la seule que les conditions
laissaient entièrement découverte. Une phrase au pied de chaque fiche, à côté de
la référence — là où le lecteur cherche déjà d'où vient l'information — et nulle
part ailleurs : un avertissement répété partout devient du décor.
Sur l'évolution du service, la liberté est écrite en entier : modification à
tout moment, interruption sans préavis, passage au payant y compris pour les
accès ouverts. Deux choses n'y sont pas.
Un prélèvement sans accord, d'abord : l'art. L121-12 l'interdit, une clause
contraire serait nulle, et une clause nulle ne protège rien tout en signalant
qu'on a essayé. Le résultat commercial visé est atteint sans elle — fermer
l'accès de qui ne souscrit pas reste possible, et débiter quelqu'un qui n'a rien
accepté est de toute façon impossible faute de moyen de paiement conservé.
Un préavis chiffré, ensuite, mais pour la raison inverse de l'intuition : des
conditions changées en silence ne sont opposables à personne (art. 1119). La
version applicable est celle publiée, sa date est affichée, la poursuite de
l'usage vaut acceptation. On modifie quand on veut, et le texte tient.
Les articles se numérotent enfin par compteur : quinze littéraux écrits à la
main de part et d'autre du branchement ne survivaient pas à deux insertions au
milieu, et deux articles portant le même chiffre sont une référence croisée qui
ne désigne plus rien.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Trois mentions survivaient en gratuit, formulées au passé : « les foyers qui ont
payé avant le passage en gratuit », la facturation conservée chez Stripe, et le
prestataire qui « ne conserve que » l'ancien. Elles étaient justes tant qu'il
restait des abonnés d'avant — la base a été remise à zéro au lancement, il n'y
en a aucun.
Déclarer un sous-traitant qui ne traite rien est exactement le défaut qu'on
venait de corriger, dans l'autre sens. La ligne de facturation disparaît aussi :
annoncer une durée de conservation comptable qui ne porte sur rien n'informe
personne.
Le test exige désormais zéro occurrence, pas moins d'occurrences.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Trois retours à l'usage, une heure après la bascule.
Les pages légales réclamaient cinq mentions en gratuit — identité, siège,
responsable de publication, hébergeur, contact — et affichaient « MIAW » comme
éditeur. Or il n'y a pas d'entreprise : chacun de ces champs restait en « à
compléter » avec un encart rouge en tête des trois pages, indéfiniment. En
gratuit, il n'en reste qu'une : l'adresse de contact, qui n'est pas une
identité d'entreprise mais une boîte, et que l'art. 13 du RGPD impose pour
exercer ses droits. Le reste est retiré, pas mis à blanc.
Ce qui est assumé plutôt que maquillé : l'anonymat de l'art. 6 III-2 LCEN
suppose qu'un hébergeur TIERS détienne l'identité, ce qu'un service
auto-hébergé n'a pas. Le produit cesse de réclamer ces champs à un écran — c'est
la décision de l'éditeur — sans prétendre que leur absence est sans
conséquence. En payant, les neuf mentions et l'encart reviennent d'eux-mêmes.
Le mur annonçait « il reste 100 accès » puis « 100 accès ouverts à ce jour ».
Le premier fabrique une urgence là où le produit s'interdit tout compte à
rebours ; le second publie la taille du service à qui passe. Les deux
disparaissent. Le seul chiffre qu'un visiteur voit est son propre rang, une
fois inscrit. Et le refus explique au lieu de constater : « on est victimes de
notre succès », avec sa raison et le geste qui suit.
Le lien magique portait six contextes ; il en porte deux. Les quatre messages
d'accès — paiement passé, inscription, entrée en file, admission — sont
retombés sur un texte générique, seule façon d'être vrai dans les quatre : « ton
calendrier est déverrouillé » ment à qui attend une place, et « ta place s'est
libérée » suppose qu'il se souvienne d'avoir patienté. Le lien mène de toute
façon au même endroit, et la page dit l'état mieux qu'un objet de message. Il
ne parle ni de prix, ni de gratuité : la bascule de mode ne le touche pas.
La cible d'attaque du sceau interne se déplace sans disparaître. `invitation`
reste le seul contexte qui affirme quelque chose, mais `urlFoyer` est rendu en
lien cliquable : un metadata non scellé qui le poserait ferait partir le lien
d'un attaquant depuis un domaine aligné SPF/DKIM/DMARC. lireMetadata le jetait
déjà ; un test le grave, à la place de celui qui gardait « activation ».
Ce qu'on perd et qui est assumé : le ton d'accueil du premier accès.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le libellé était juste et le mode bien posé : `/`, `/connexion`, `/mes-foyers`
et la 404 étaient simplement préphotographiées au build, où l'env_file du
conteneur n'existe pas encore — docker compose ne le monte qu'à l'exécution.
Leur HTML restait donc figé sur le défaut « payant », et la landing continuait
d'annoncer « sans payer ».
C'est le pendant, au build, du piège que lib/mode.ts décrit côté client : une
variable d'exécution ne peut pas atteindre du HTML figé avant elle. Corriger
page par page aurait laissé le trou ouvert pour la prochaine page statique
qu'on ajoute ; c'est fermé une fois dans le layout racine, qui est aussi le
fichier où vit le pied de page.
Le coût est borné et mesuré plutôt que supposé : les quatre pages ne lisent ni
base ni réseau, loadTemplates() garde son cache en module, et les 76 guides
étaient déjà dynamiques depuis D-042. Les fichiers spéciaux (sitemap, robots,
opengraph-image, icône) ne passent pas par ce layout et restent générés une
fois.
Deux tests tiennent l'autre moitié du contrat — que le libellé suive bien le
mode, et que l'URL du document ne bouge jamais.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Passer fokan en gratuit demandait de toucher le prix, le mur, les CGV, la
politique de confidentialité, les messages d'erreur d'API et jusqu'au texte
affiché dans l'agenda du foyer. `FOKAN_MODE` remplace tout ça par un mot, avec
« payant » pour défaut — une variable oubliée ne doit jamais ouvrir l'accès.
Ce qui rend la bascule bon marché était déjà là : `getSubscription().isActive`
est la porte unique du produit, et un accès gratuit est un statut « active »
sans date de fin, que `!periodEnd` rendait déjà permanent. Elle n'a pas changé.
Le mode décide de ce qu'on raconte à qui n'est pas entré, jamais de ce qu'on
lui ouvre.
La liste d'attente n'existe pas : pas de table, pas de file, pas de rang
stocké. Un foyer sans place porte le statut « attente », et le passage de 4 h
admet les plus anciens sur `attente_depuis`. Relever FOKAN_PLACES_GRATUITES
suffit — la variable est relue à chaque exécution du cron.
Trois pièges fermés au passage :
- le mode se lit côté serveur et descend en props. `process.env` n'existe pas
dans le bundle navigateur : un import depuis un composant client aurait rendu
« payant » côté client et « gratuit » côté serveur, en silence, à
l'hydratation ;
- /api/checkout est fermé au serveur, pas seulement à l'écran. Une clé Stripe
encore présente — le cas normal d'une bascule qu'on veut pouvoir défaire —
l'aurait fait aboutir, et on aurait encaissé pour un accès offert ;
- /api/stripe/portal reste ouvert. Un abonnement souscrit en ligne doit se
résilier en ligne (art. L215-1-1) : fermer la sortie parce qu'on ferme
l'entrée enfermerait ceux qui ont payé.
Cinq mentions légales tombent avec la vente — SIREN, téléphone, médiateur,
régime de TVA, garantie de conformité — parce qu'un article décrivant une
obligation qui n'existe pas est pire qu'un article absent. Deux apparaissent :
la gratuité (un service gratuit qui tait son financement se fait lire « c'est
toi le produit ») et le plafond d'accès, qui est une limitation subie. La
politique de confidentialité perd Stripe quatre fois : déclarer un
sous-traitant qui ne traite rien n'est pas prudent, c'est inexact.
Un test rend réellement les trois pages dans les deux modes plutôt que de
relire des ternaires — c'est la seule façon de vérifier qu'aucune n'affirme
une chose fausse, et il tient aussi la numérotation des articles.
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.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le bloc « d'où vient ce qu'on sait » nommait douze jeux de données, avec
leur hôte et leur lien. D-033 l'avait voulu ainsi pour rendre contrôlable
la promesse du bandeau ; c'est aussi la carte du concept, offerte à qui
voudrait le recopier. La section garde la méthode et perd l'inventaire.
Les sources restent nommées là où elles engagent : la référence de chaque
guide, la source de chaque couche de carte.
Part avec elle la phrase sur le service gratuit qui se paie en publicité.
Elle portait la dernière posture économique de la page depuis que le prix
l'a quittée (D-028) — mais l'information, elle, reste dite deux fois au
moment du geste : « sans compte, sans carte bancaire » sous le bouton, et
« sans payer » dans le CTA final. La colonne libérée dit la clause
d'effacement en langage de visiteur : dans le doute, l'échéance s'affiche.
Deux sections énuméraient par ailleurs les mêmes échéances à quelques
centimètres d'écart — contrôle technique, rappels, courroie, Crit'Air,
bourses, Parcoursup, arrêt de l'ADSL, tout cela deux fois. Le bloc créé
pour dire le mécanisme avait glissé vers l'inventaire du bloc suivant. Il
ne répond plus qu'à « comment le savez-vous ? », et son titre le dit.
Enfin le vocabulaire : « obligation » devient « échéance » (le compteur
disait un volume, pas un reproche), « abonnement » devient « inscription »,
et « rien à tenir à jour » gagne un « si tu ne le souhaites pas » — la
phrase était devenue fausse le 6 août, quand D-055 a donné au foyer de
quoi cocher.
D-064. 905 tests, 78 fiches, build passé.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Trois régressions silencieuses au rejeu, une par question posée.
Les contrats étaient remplacés comme les véhicules, au motif qu'une liste
rejouée n'a pas d'identité stable. C'est vrai d'une voiture, faux d'un
contrat : son type vient d'une énumération fermée. On appariait donc rien
alors qu'il n'y avait rien à deviner, et on payait l'état utilisateur de la
seule saisie opt-in du produit.
La déclaration de travaux ne gardait qu'un mois pour tout le logement. Une
piscine finie en juin et une véranda finie en août partageaient une date
limite — celle de la dernière déclarée, l'autre disparaissant avec son sujet.
Un mois par chantier, un sujet par mois, et deux chantiers du même mois
restent une visite unique au service des impôts. Le formulaire repart à vide
à chaque rejeu, parce que la question porte sur ce qui vient d'arriver ; le
logement, lui, accumule et oublie à treize mois.
Enfin, carte-grise-changement-adresse attendait depuis sa publication un
demenagement_recent que personne n'écrivait. On attendait un écran de
correction d'adresse pour le poser ; le rejeu du quiz le fait depuis le
28/07, il tient les deux adresses dans la même fonction. On pose le mois,
jamais un booléen — sans quoi la fiche annoncerait un mois pour la carte
grise à vie, ce que D-059 venait de corriger sur la piscine.
Vérifié sur une copie de la production : deux déclarations foncières
vivantes après rejeu, cinq contrats gardant leur identifiant, un second
rejeu qui ne bouge plus rien. 905 tests.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`<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, où
« mars 2026 » s'écrit aussi bien — et le Zod de POST /api/quiz valide le
même motif que le moteur, donc il refuse tout le passage, pas seulement
ce champ facultatif.
Une liste des 13 derniers mois ferme la question : plus de valeur à
valider, seulement des valeurs à choisir, dans la forme exacte où
l'échéance la lira. Aucun mois à venir. Le mois déjà déclaré reste dans
la liste même trop ancien pour elle — sans quoi un rejeu effacerait une
réponse que personne n'a retirée (D-062).
L'aide sous la question des travaux est retirée, à la demande du porteur
de projet.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Les réponses reviennent à l'écran par deux chemins — le brouillon local
d'un quiz interrompu et le rejeu d'un foyer (D-018) — et les deux
fusionnaient par étalement, ce qui remplace `logement` en entier. Un
enregistrement d'avant D-059 n'a pas de `travaux_recents` ; l'écran des
équipements appelle `.includes` dessus ; React démonte tout l'arbre.
Les quatre foyers de production sont dans ce cas : les quatre auraient eu
l'écran blanc en rejouant leur quiz.
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, donc un champ ajouté demain ne compile pas tant qu'il n'a pas
son repli. Combler `travaux_recents` seul aurait repoussé le même écran
blanc au prochain champ (D-061).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Un rejeu par mois était une carence de 30 jours : elle refusait le second
passage à qui déménage la veille d'acheter une voiture, et ne bornait
jamais le total, qui restait de douze par an. Cinq rejeux par période
d'abonnement (D-060) permettent les deux corrections le même jour et
font attendre le sixième.
`quiz_rejoue_le` devient `quiz_rejeux` : on garde la liste des dates,
jamais le verdict. Le quota se recompte à chaque lecture contre la
période payée en cours, donc la reconduction annuelle le relève sans
qu'aucun code ne tourne à cette date. Repli sur une année glissante pour
les foyers sans période connue, et aucune date de reprise annoncée à un
abonnement résilié en fin de période.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Rattrapage documentaire des D-055 à D-059, qui n'avaient été consignés que dans
le journal des décisions.
`pilier-logement` gagne deux sections : le tri des quinze fiches sans date en
trois familles — par ce qui les fait revenir, pas par leur nature — et la
recherche sur l'iode. Le flux d'avis d'incidents de l'ASNR y est consigné avec
son URL, ses chiffres et les trois raisons de l'écarter, pour que personne ne
refasse la recherche : aucune clé de rattachement (1 item sur 50 porte un n° INB,
0 un code INSEE), aucun niveau INES sur 49 sur 50, et un contenu fait de
non-conformités d'exploitation plutôt que de danger public. Plus la déclaration
de travaux, et la raison pour laquelle le carnet d'information n'a pas bougé.
`pilier-vehicules` note ses trois fiches passées en `aucun` — un état du monde ne
se fait pas — et la distinction avec `zfe-durcissement`, qui reste un vrai
rendez-vous daté.
`pilier-papiers` complète sa réserve du point 9 : les quatre titres passent en
`yearly` et cessent d'être effaçables d'un geste, mais restent en confiance
`estimee`. Ce qu'il leur faudrait est la date d'expiration du titre, une donnée
d'asset et non une ancre de cycle — chantier distinct, toujours ouvert.
L'audit mail voit son défaut 4.3 passer d'ouvert à instruit. CLAUDE.md et le
README reprennent les compteurs justes : 46 `reprogrammer_cycle`, 26 `clore`,
6 `aucun`, dont 43 réellement ancrables ; 843 tests.
Les chiffres du journal des décisions ne sont pas retouchés — ils décrivent
l'état à leur date, et c'est la convention du document.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Les 15 fiches durablement sans date se coupent en trois, sur ce qui les fait
revenir plutôt que sur leur nature — l'identité d'une échéance étant
fiche × asset × sujet, les trois familles tombent sur ces trois pièces.
① Fait une fois, fait à vie (5 fiches) : rien à changer, `clore` + fenêtre fixe
donne déjà le bon comportement, et un objet neuf produit une ligne neuve par
l'assetId.
③ Un savoir qui ne se fait pas (5 fiches) : on ne « fait » pas un état du monde.
Plutôt que de renommer le bouton, on le retire — `comportement_fait` gagne la
valeur `aucun`. `iode-pastilles` y bascule délibérément contre sa nature : le
retrait est un acte, mais les boîtes périment et aucune source publique ne donne
le calendrier des campagnes. Le flux ASNR d'avis d'incidents existe et a été
appelé : 1 item sur 50 porte un n° INB, 0 un code INSEE, 49 sur 50 n'annoncent
aucun niveau INES, et le contenu est fait de non-conformités d'exploitation, pas
de danger public. Laisser clore ferait croire l'affaire réglée jusqu'à une
péremption que rien ne peut rappeler.
② Le défaut de fond : `declaration-fonciere-90j` se déclenchait sur
`equipements contains piscine` — posséder une piscine, pas venir d'en construire
une. Le déclencheur n'était pas approximatif, il était faux. Une déclaration de
travaux entre au quiz, sur l'écran des équipements et sans écran de plus : six
puces, et un mois qui n'apparaît qu'une fois quelque chose de coché. Fenêtre =
1er du mois + 90 jours, avance de 90 jours pour que le rappel parte à l'ouverture
du délai et non à sa fin. Le 1er du mois plutôt que le jour réel avance la limite
d'au plus trente jours : on prévient trop tôt, jamais trop tard.
Le sujet est le mois, sans quoi la clôture d'un chantier vaudrait pour le suivant.
`comportement_fait` devient obligatoire : les 78 fiches le déclarent, une omission
faisait disparaître un geste en silence, elle échoue maintenant en CI. Et « pas de
rappel par mail » cesse de s'afficher sur les fenêtres libres, qui n'en envoient
jamais.
843 tests. Chaîne quiz → assets → état désiré vérifiée sur cinq cas, écran vérifié
au navigateur.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
« C'est fait » écrivait `clos_le`, et `clos_le` sortait l'échéance du moteur
définitivement. Un foyer qui vérifie ses titres d'identité en mars 2027 ne les
revoyait ni en 2028 ni en 2035 — alors que la fiche demande précisément de
recommencer chaque printemps, et que le geste veut dire « j'ai vérifié », pas
« j'ai renouvelé ».
Neuf fiches touchées, pas seulement les papiers : les quatre titres, la garde de
vacances, le vaccin antirabique, la déclaration de revenus, les deux bourses. 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.
La clôture enregistre désormais LA FENÊTRE qu'elle vise, et le moteur compare.
Tant qu'il veut poser la même, la ligne reste rangée ; dès qu'il en calcule une
autre, elle revient. Une seule règle, et les deux comportements en découlent sans
qu'aucune fiche ait à les déclarer : une vérification de papiers revient parce
que sa fenêtre bouge chaque printemps, un retrait de comprimés d'iode reste clos
parce que la sienne ne bouge jamais.
Les quatre titres passent en `yearly` au passage. Trois se déclaraient `once`
alors que leur stratégie recalculait déjà une fenêtre par an — même défaut de
bicéphalie qu'`argile-fissures`, avec une conséquence de plus : le filtre « ce
qui revient » les rangeait du mauvais côté.
Vérifié en base sur copie de production : passeport-enfant clos sur mars 2027
revient quand la fenêtre de l'année suivante est calculée, amiante-etat reste
clos après cinq réconciliations. 838 tests, dont un qui joue dix ans de passages.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`argile-fissures` était en `freq: once` + `comportement_fait: clore`. Sa page
publique se termine par : « 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 ».
Ce n'est 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 quittait le
calendrier après le geste qui n'avait produit que la moitié de sa valeur.
Défaut de bicéphalie d'un genre nouveau : les deux têtes existent, ne se
contredisent sur aucun fait, mais la partie moteur ne tire pas la conséquence de
ce que la partie éditoriale affirme. `knowledge:check` ne peut rien y voir — il
vérifie que les contenus sont présents, pas qu'ils disent la même chose que la
récurrence.
Les 30 autres fiches `once` ont été balayées : dix ressortent sur une recherche de
mentions de répétition, toutes des faux positifs. Elles relèvent soit du
renouvellement d'un objet (CNI, passeport, permis, DPE, amiante — chaque titre se
refait une fois), soit de la périodicité de l'administration et non du foyer
(« le ministère publie chaque année une note de service » décrit Affelnet, pas un
geste à recommencer).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
L'accusé d'un geste remplaçait les boutons « jusqu'au prochain rendu du serveur ».
Aucun rendu ne le retirait : `router.refresh()` renvoie bien de nouvelles props,
mais l'état local d'un composant client survit au rafraîchissement. Un foyer qui
coupait les rappels d'une échéance ne pouvait plus les rallumer sans recharger la
page — et pareil pour annuler un « c'est fait ».
Les boutons restent donc toujours affichés, le message vit à côté d'eux, et il
s'efface au changement d'état lu sur le serveur plutôt qu'au bout d'un délai :
c'est le 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 serveur aurait tardé.
Vérifié en navigateur : couper puis rallumer les mails, marquer fait puis annuler,
sans un seul rechargement.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Quatre remarques sur les cartes d'échéance. Deux de mise en page, deux qui
touchent au dessin — dont une qui révise D-055 jusqu'à l'invariant n° 3.
« Ne pas me le rappeler » retirait du calendrier en plus de couper le mail.
L'argument qui le condamne était déjà écrit, deux jours plus tôt, pour l'opt-out
global : « ton calendrier reste complet, on cesse simplement de t'écrire ». Une
obligation légale dont on ne veut pas les mails ne doit pas disparaître de
l'écran — ce n'est pas la taire, c'est la faire oublier.
Conséquence qui ne se devine pas : `muted` sort de `horsMoteur`. Une échéance
qu'on continue d'afficher doit continuer d'être recalculée, sinon sa date se fige
et vieillit à l'écran. L'invariant tient — le choix du foyer survit à toute
évolution de la base — il a cessé de vouloir dire « gèle ». Trois prédicats au
lieu de deux, et `mute_le` ne filtre plus que dans `notifiable()`.
Le basculement des cartes cycliques vers l'étagère est refusé : un ramonage fait
en septembre est dû en septembre suivant, la carte change de date au lieu de
disparaître, et la faire basculer ferait rétrécir le calendrier à mesure qu'on
s'en sert. Mais la demande visait un vrai trou : un « c'est fait » posé par
erreur poussait l'échéance d'un cycle sans retour possible, l'étagère ne recevant
que les fiches closes. D'où `annuler_fait`, sur la carte.
Un défaut que seule la vérification à l'écran pouvait montrer : la route
normalisait la date à midi puis comparait ce midi à `Date.now()`. Tout « c'est
fait » posé avant 12 h UTC — 14 h à Paris — était rejeté « date invalide ». Le
geste le plus courant du produit, cassé la moitié de chaque journée, avec 823
tests verts et un build propre. La règle vit désormais dans le module pur,
compare des jours et non des instants, et lit le jour dans le fuseau du foyer.
Le bouton Guide occupait une troisième colonne du flex : il réservait sa largeur
sur toute la hauteur et rognait des lignes situées bien en dessous de lui. Il
flotte — 654 px de texte sous le bouton pour une carte de 655.
L'enjeu quitte le bas de carte pour la pastille, en `title` + `sr-only`. Pas le
survol seul : c'est le défaut corrigé l'avant-veille sur l'axe de la frise. Au
doigt il reste la forme de l'icône, distincte par enjeu, jamais la couleur seule.
829 tests. Vérifié en navigateur, desktop et mobile, sur copie de la production.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
L'encart demandé « à droite de Mon foyer » avait atterri en bas de page, sous
l'abonnement. Deux conséquences, et la seconde est la vraie : il se lisait comme
une dépendance de la facturation, et il était hors du premier écran — donc
invisible pour qui ne fait pas défiler.
Le foyer et le compte deviennent deux colonnes de la même rangée : ce qu'on
surveille d'un côté, qui on est de l'autre, sans hiérarchie entre les deux.
La liste des foyers perd son `lg:grid-cols-2` au passage. Elle n'occupe plus
qu'une demi-page, où deux cartes de foyer tiendraient à l'étroit ; elle empile,
ce qui reste juste à un foyer comme à trois.
Mesuré au rendu : 1440 px, les deux sections démarrent à la même ordonnée (238)
pour 536 px chacune ; 390 px, elles s'empilent (233 puis 544) sans débordement
horizontal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`comportement_fait` était déclaré sur les 78 fiches depuis l'origine — 45
`reprogrammer_cycle`, 33 `clore` — et lu par aucune ligne de code. La question
« que veut dire *c'est fait* sur cette fiche » avait donc déjà sa réponse, écrite
par qui les a rédigées ; il manquait quelqu'un pour la lui poser.
Mesuré avant : sur 69 échéances en production, aucune en confiance `connue`. Le
chemin des 21 jours d'avance du planificateur attendait une date que rien au monde
ne pouvait lui donner.
Trois règles portent le reste :
- une date apprise recale un CYCLE, jamais une date qu'un tiers décide. La
campagne de bourses ouvre le 1er septembre quoi qu'ait fait le foyer ; lui
appliquer l'ancre ferait dire une date fausse à partir d'une information vraie.
Le CT et la courroie l'acceptent mais gardent leur arithmétique — la fiche CT
voiture déclare `biennial`, celle des deux-roues `custom/3`, donc un cycle
déduit de `recurrence` marcherait sur l'une par coïncidence ;
- on ne repousse pas une fenêtre dépassée : un entretien annuel déclaré fait en
2021 est dû aujourd'hui, pas en 2027. Le libellé dit le fait fourni par le
foyer, jamais un décompte ;
- « fait » et « muet » sont deux colonnes pour un seul effet moteur. Le moteur
peut les confondre, l'écran non — et « muet » n'écrit aucune date, sinon on
inscrirait dans l'histoire du foyer un entretien qui n'a peut-être pas eu lieu.
Le geste n'écrit pas la prochaine date : il pose l'ancre et rend la main à
`runReconciliation`, sans quoi l'écran et le passage de 4 h finiraient par
diverger. Il porte sur le groupe de `grouperEcheances`, pas sur la ligne.
`muted` est remplacé par `mute_le` et non doublé : deux colonnes pour un fait
divergent le jour où un correctif n'en met qu'une à jour. 0 ligne concernée, 0019
transfère quand même.
« Mon compte » crée le mot de passe depuis une session prouvée, via
`auth.api.setPassword` — `serverOnly` chez better-auth, donc joignable de nulle
part ailleurs. L'audit mail § 6.4 recommandait de désactiver `emailAndPassword` ;
ce qui manquait n'était pas une raison de retirer la branche, c'était l'écran qui
la rend atteignable. Le lien magique reste le chemin de secours, ce qui dispense
du `sendResetPassword` et du sixième gabarit de mail.
Vérifié sur une copie de la base de production, dans un navigateur : deux
réconciliations d'affilée ne touchent aucun état utilisateur, la seconde ne crée
rien, et « reprendre » rend son occurrence ouverte à une échéance close.
819 tests (+36), tsc, eslint, knowledge:check et build verts.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docling n'a pas de DOCLING_SERVE_OCR_ENGINE : contrairement aux autres
réglages touchés dans cette même décision, le moteur OCR par défaut est câblé
en dur dans le modèle Pydantic de la requête, pas lu depuis l'environnement.
Le forcer au niveau du service exigerait de patcher l'image (tirée en
:latest, hors dépôt), ce qui casserait silencieusement à la prochaine mise à
jour — sur un service partagé avec d'autres projets de la machine.
ocr_engine=rapidocr reste donc porté par chaque appel de
src/app/api/vehicule/carte-grise/route.ts, et le compose de docling
(/home/miaw/docling, non versionné) porte désormais un commentaire pour tout
futur appelant qui omettrait ce paramètre et hériterait d'easyocr.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
easyocr, le défaut de docling, occupait 7 295 Mio de VRAM et continuait de
croître au fil des conversions (4 053 après cinq, 7 295 après dix), sur une
carte de 12 Go partagée avec localai. rapidocr tient 881 Mio, stables sur
douze conversions et 1 017 sous trois requêtes simultanées.
Sept fois moins, et sans rien céder : sur les deux documents où les 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 pour une lecture moins bonne.
La réparation l→1 de carte-grise-ocr.ts reste en place bien que le moteur qui
la motivait ne soit plus celui qu'on appelle : elle ne coûte rien, et le
prochain moteur en refera la démonstration.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Revue de mise en ligne des trois pages (/mentions-legales, /cgv,
/confidentialite). Elles existaient et étaient bien construites — un module
unique, 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.
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,
et 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_*.
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.
Trois manquements de fond :
- Résiliation en trois clics (art. L215-1-1). Les CGV annonçaient une
résiliation « en écrivant à » l'éditeur alors que le portail Stripe est
branché dans l'espace abonné depuis toujours : le texte décrivait moins que
ce que le produit fait, et moins que ce que la loi impose.
- La plateforme européenne RLL n'existe plus. Le règlement (UE) 2024/3228 a
abrogé le 524/2013 ; 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.
- Garantie légale de conformité des services numériques, 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.
La politique de confidentialité ne déclarait pas le compteur de lectures de
D-042 — l'omission la plus coûteuse, et du même biais que celles de la
connaissance. La page affirmait « aucune adresse IP conservée en base », vrai
au pied de la lettre et trompeur sur le fond : lectures_guides stocke un HMAC
salé et tronqué de l'IP, donnée personnelle pseudonymisée et non anonyme, donc
à déclarer.
Réserve consignée et écartée par le porteur de projet : encaisser sans
immatriculation rend les CGV inopposables. Le code ne bloque pas la décision,
il la rend visible.
Reste ouvert, et ce n'est pas rédactionnel : les durées de conservation
annoncées ne sont tenues que pour une seule donnée. Rien ne purge les foyers
dormants, les leads et le journal d'envois. Aucune rédaction ne rattrape ça —
le job est à écrire avant l'ouverture du paiement réel.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le champ K est la clé la plus précieuse du pilier véhicules et la plus pénible
à fournir. La photo — ou le PDF du certificat provisoire — la remplace, via le
docling déjà installé sur la machine.
Ouvrir une caméra dans un quiz qui n'a « aucune saisie libre » aurait dû être
une contradiction. Ça n'en est pas une : K et P.2 sont les deux seuls champs de
la carte grise qui soient auto-validants. K est cherché à l'identique parmi les
31 546 réceptions du référentiel, P.2 est confronté aux puissances homologuées
pour CETTE réception. L'OCR propose, le référentiel dispose — et ce n'est pas
théorique : sur une photo dégradée, un 898 s'est lu 828.
Le relevé des 31 546 réceptions a démenti deux fois l'intuition. Trois
directives commencent par des lettres (KS07/46, KS18/858, NKS — 1 247
réceptions) qu'un motif e\d+\*\d aurait ratées en silence. Et le champ K se
reconnaît à sa FORME, jamais à son étiquette : ce sont les étiquettes qui se
dégradent en premier (P.1 lu Pl, S.1 lu 5.1, X.1 lu X.l) pendant que les
valeurs passent.
Deux défauts trouvés par les tests, pas par la relecture :
- borner la capture du nombre plutôt que la valeur tronquait 12000 en 1200,
qui repassait sous le plafond de 3 000 kW ;
- le test de bout en bout contre une réception RÉELLE a échoué là où l'exemple
inventé passait : e11 se lit el1. Réparer l→1 n'est pas une entorse à « ne
réparer aucune coquille qu'on ne saurait deviner juste » — la bonne question
n'est pas « quelle lecture est la plus probable ? » mais « la position
autorise-t-elle autre chose ? ». Sur les segments purement numériques, non.
Le segment de la directive, qui porte de vraies lettres, n'est pas touché.
« Rien n'est conservé » est une propriété du code : aucune écriture disque,
aucune journalisation du texte OCR même en cas d'erreur, et seuls les deux
champs remontent au client. Côté service, l'endpoint synchrone gardait son
résultat — nom, adresse, VIN, plaque — 300 s en mémoire après la réponse ;
c'est fermé. La page de confidentialité est amendée en conséquence : « aucun
document » devenait faux, elle dit « aucun document conservé » et décrit
l'exception transitoire.
Sans capture sur l'entrée fichier : l'attribut force l'appareil photo sur
Android et retire l'accès à la galerie, alors que sans lui les deux systèmes
offrent photo ET photothèque. Un seul chemin de code pour l'ordinateur et le
téléphone.
Aucune migration : reception_numero, puissance_kw et source: "carte_grise"
existaient depuis D-017.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Deux textes de l'écran 7 du quiz réécrits, à la demande du porteur de projet.
L'aide disait « tous adultes confondus » et « ne pas te parler d'aides dont les plafonds
t'excluent » : la formulation décrivait le filtre plutôt que ce qu'il apporte, et posait
le mot « aides » avant que le foyer ait répondu.
Le paragraphe sous les pastilles portait en plus la doctrine de D-051 en toutes lettres
(« on ne te dira jamais ce à quoi tu as droit : ça, c'est le travail des simulateurs
officiels »). Elle reste vraie et reste appliquée — il n'existe toujours aucun verdict
« éligible » dans le moteur, et la politique de confidentialité l'explique — mais elle
n'a plus à s'énoncer au milieu d'un quiz de deux minutes.
Ce qui ne change pas : les bornes restent calculées sur la composition réellement
déclarée, et le paragraphe continue de la nommer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
L'écran de revenus a porté le quiz à dix écrans sans que la copie suive. « 9 questions »
restait écrit à sept endroits, dont trois que les moteurs de recherche ont déjà indexés :
la description du site, celle de la landing, son partage social — plus le lede du hero,
l'étape 1 de « Comment ça marche », la description de /quiz, et l'encart de conversion
répété au bas des 78 guides.
Deux résumés de domaine étaient en retard d'un cran sur les fiches livrées : la
déclaration de scolarité des 16-18 ans côté scolarité, le leasing social côté véhicules.
« D'où vient ce qu'on sait » gagne une douzième entrée, les plafonds de ressources. Elle
détonne volontairement dans une liste de jeux de données appelés : les barèmes sont
recopiés à la main depuis les arrêtés, et c'est ce qui mérite d'être dit. La ligne
précise aussi ce qu'ils font — écarter ce qui ne concerne pas le foyer, jamais annoncer
un droit.
Vérifié au passage, et sain : les compteurs de guides, de fiches, de piliers, de couches,
le quota de lecture libre et la barre de progression du quiz sont tous dérivés de leur
source. Ils s'étaient mis à jour seuls. Le « 2 minutes » est conservé — l'écran ajouté
est un tap sur quatre pastilles.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`bourse-college`, `bourse-lycee`, `dse-bourse-superieur` et `cheque-energie-guichet`
citaient leur plafond dans leur copie et s'affichaient à tout le monde. 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 majorité des ménages, partait à tous.
Le quiz demande désormais une tranche de revenus en net mensuel, facultative, dont les
bornes sont calculées sur les plafonds réels de la composition du foyer — 3 pastilles
pour une personne seule, 4 pour un couple avec enfants.
Ce que ça autorise est étroit, et c'est la décision (D-051) : on filtre des échéances,
on ne simule pas des droits. Il n'existe aucun verdict « éligible » dans le moteur,
seulement `estExclu`. Aucun montant, aucun total, aucun « tu as droit à ».
Trois points qui ne se devinent pas :
- Cinq aides, cinq dénominateurs — RFR ou revenu net catégoriel, N−1 ou N−2, divisé par
les unités de consommation, par les parts fiscales, ou par rien. Une seule question
suffit parce que les diviseurs se déduisent de la composition, et c'est aussi ce qui
rend les bornes nécessairement dynamiques.
- Le doute affiche toujours. Barème périmé, composition hors table, foyer muet,
conversion approximative : `estExclu` répond faux. La conversion net → annuel est le
maillon faible, et la parade est le sens de l'arrondi.
- Le verdict n'est jamais stocké, seulement la tranche déclarée — un enfant ajouté
relève le plafond sans rejeu du quiz.
Deux fiches nouvelles : la déclaration de scolarité des 16-18 ans, sans laquelle la Caf
ne verse pas l'ARS (466,02 €), et le leasing social. Le Pass'Sport n'est pas écrit : la
saison 2026-2027 n'est pas annoncée, et affirmer une fenêtre non publiée serait la faute
qu'on se reproche ailleurs.
747 tests, 78 fiches.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
L'année glissante posait deux questions auxquelles l'axe ne répondait que dans un
`title` : de quelle année parle cette colonne, et quel mois porte cette initiale.
« J » ne distingue ni janvier de juin ni juin de juillet. Un tooltip n'existe pas
au doigt, donc la réponse manquait entièrement sur téléphone — là où se lit la
révélation de fin de quiz.
Ce qui coince est mesuré au navigateur, pas supposé. En Inter 600, 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. Douze étiquettes ne rentrent donc pas
sur un téléphone, et une année ne rentre dans aucune colonne : resserrer les
gouttières gagne 2 px, et réduire le corps répond à un problème de lisibilité en
rapetissant le texte.
L'année devient un bandeau. Une année glissante ne traverse qu'une frontière : au
plus deux tranches, donc au plus deux étiquettes, qui tiennent à toute largeur. Un
filet vertical marque la bascule dans la zone des pastilles, là où l'œil est, et
désigne la colonne de janvier sans qu'elle ait à se nommer. Chaque année est posée
sur la colonne où elle commence — alignée à droite elle 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, où il se lisait comme l'étiquette du
dernier mois.
Les mois passent en densité variable : 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. Elle n'affiche rien du tout, pas même un point de repère :
la première version en posait un, et une étiquette de 29 px débordant d'une
colonne de 17 px venait toucher celui de sa voisine, à 21 px de là.
Les paliers sont des `@container` et non des `sm:`. 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 — et un palier accroché à la
taille de l'écran ferait déborder la landing pour servir le foyer.
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.
D-050.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le crème vivait sur `<body>`, que Gmail supprime en réécrivant le message : la
carte blanche se retrouvait collée au fond du client, blanc sur blanc chez les
uns, bloc éblouissant sur fond noir chez les autres. Elle n'a plus de fond du tout
— autour d'elle passe celui du lecteur, et c'est la bordure qui délimite, ce qui
reste juste sur n'importe quel fond, y compris ceux qu'on ne connaît pas. Le mode
sombre est déclaré (`color-scheme`) plutôt que subi : sans cette déclaration, Gmail
et Apple inversent au jugé et le vert de marque en ressort délavé.
Un piège qui ne se voyait 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 ignore la marge d'un
élément `table`. Tout le message se collait aux bords. Elle est passée sur les
`<td>`. Un châssis unique remplace les trois mises en page séparées.
Le logo entre dans les mails, en image et non en texte, sur un bandeau qui reste
sombre dans les deux modes : il n'a ainsi qu'une seule version à exister, juste
partout. `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.
Les fiches promettaient 24 fois un geste qui n'existe pas — « si c'est déjà fait,
dis-le-moi et je me tais », « dis-moi sa date d'expiration et je gère le tempo ».
Le pied de page avait déjà été purgé de la même promesse ; le corps la répétait.
Retirées sans remplacement : elles reviendront le jour où l'état utilisateur
s'écrira. Le bloc d'accompagnement du rappel groupé disparaît aussi — « pour que
tu n'aies pas à y penser toi-même » se démentait dans le seul message qui demande
justement d'y penser. Et chaque échéance dont la fiche a une page publique porte
désormais un lien vers elle : la bicéphalie ne servait que le SEO, alors que c'est
au destinataire d'un rappel que l'explication entière manque le plus.
Enfin, le lien d'activation. Il n'était pas un raccourci : sa vérification était
la seule chose au monde capable de créer le compte de l'acheteur, better-auth ne
le créant qu'à ce moment, `resolveMembership` n'inscrivant le premier membre que
s'il y a une session, et `destinatairesFoyer` ne connaissant que les
appartenances. Un mail lu le soir, un lien ouvert deux fois, un antispam qui le
déclenche à vide, et un foyer payé 19,99 € restait sans compte, sans appartenance,
donc sans destinataire de rappel, définitivement. Ce n'est pas un cas de bord : ce
message n'est envoyé que quand personne n'était connecté avant de payer, donc
l'unique mail d'activation qui existe était précisément celui dont l'expiration
était fatale. Le webhook crée le compte et l'appartenance lui-même, depuis
l'adresse que Stripe vient de collecter. Contrepartie assumée : Stripe ne prouve
pas la possession de la boîte, et qui paierait pour l'adresse d'un tiers lui
rattacherait un foyer — un ajout indésirable, jamais une prise de contrôle, et
facturé à celui qui le tente.
`npm run mails:echantillon` rend un exemplaire de chaque message depuis les vraies
fiches, et, si une adresse est donnée, le rappel réel de ses foyers.
D-048, D-049.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le ver « ChainDrop » publié sur npm le 4 août au matin partait d'un hook
`preinstall` sur `keyv`, `cacheable`, `flat-cache` et `file-entry-cache`, volait
les jetons npm et GitHub, les clés SSH et les chaînes de connexion, puis se
republiait avec ce qu'il venait de prendre — plus de 1 300 versions en une
matinée.
fokan n'a pas été touché, et il faut dire pourquoi : `node_modules` et le lock
dataient du 29/07, six jours avant la fenêtre, et `npm ci` résout depuis le lock.
Les trois paquets sont pourtant bien là, en transitif sous ESLint, chacun tiré
par un `^`. Un seul `npm install` ce jour-là aurait suffi. `ignore-scripts=true`
rend désormais un paquet compromis inerte tant que rien ne l'importe ; les trois
qui ont réellement besoin de leur binaire sont reliés nommément par `npm rebuild`
dans la CI et dans les deux étages du Dockerfile.
Séparément, le `COPY . .` du builder emportait tout le répertoire de travail dans
une couche de l'image : le `.env` de développement — gitignoré, donc bien présent
sur le disque — devenait lisible par n'importe quel script de build, et le
`node_modules` de l'hôte écrasait celui que `npm ci` venait d'installer à la ligne
précédente. Un `.dockerignore` ferme les deux.
Au passage, les actions GitHub sont épinglées au commit et non au tag — un tag est
mutable, c'est le `^` de npm sous un autre nom — et le jeton du runner passe en
lecture seule.
D-047.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le docstring disait « les douze mois à venir », le code rangeait par index de mois
calendaire. Un foyer qui ouvrait sa page en août voyait donc sept colonnes
derrière lui, où il ne se passera plus rien, et perdait de vue le début de l'année
suivante — 7 de ses 12 échéances datées, sur le foyer témoin.
Le second défaut ne se voyait pas : septembre 2026 et septembre 2027 tombaient
dans la même colonne. Comme l'horizon est naturellement borné par un cycle de
récurrence, la collision était structurelle. Le rangement se fait désormais sur
une différence de dates réelle, la colonne 0 étant le mois en cours.
Deux effets de bord traités au passage. « Sans date fixée » se calculait par
soustraction et aurait compté comme non datée une échéance simplement située
au-delà de douze mois. Et le repère du mois courant, désormais toujours en
première colonne et souvent vide, se réduisait à un trait gris qui se lisait
comme un artefact.
D-046.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Un élément `absolute` sans `left` prend pour origine sa position statique, celle
qu'il aurait eue dans le flux — et la feuille de style native du navigateur pose
`text-align: center` sur tout `<button>`. L'origine du curseur tombait donc au
milieu du rail, et le déplacement s'y ajoutait au lieu de partir du bord.
Mesuré avant correction : curseur à 24 px éteint et 44 px allumé sur un rail de
44, soit la moitié blanche visible hors du vert. Après : 2 px de marge de chaque
côté, symétrique, et le déplacement vaut exactement la course utile.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
L'encart de « Mon espace » affichait « Rappels par mail activés » à la position
du bouton : l'œil y cherchait une action et trouvait un état, lequel n'était même
pas un état mais une constante — aucun réglage n'existait derrière, et rien nulle
part ne permettait de cesser de recevoir. Il devient deux lignes, un prix et un
interrupteur, plus le portail de facturation Stripe. Le geste marche sans session
(URL-capacité HMAC, endpoint one-click, en-têtes List-Unsubscribe), et le jeton ne
sait que couper : rallumer exige une session.
Séparément, 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 — 50
échéances, 50 occurrences, chacune figée à la première valeur calculée. La
« timeline append-only » de l'invariant n° 4 n'avait jamais rien appendu, et
l'historique vendu au § 10.2 n'existait pas. Une occurrence est désormais close
quand sa fenêtre est passée et qu'une nouvelle prend sa place, et la page foyer
porte un horizon « Déjà passé » borné à la période d'abonnement en cours.
Au passage : cancel_at_period_end est lu dans le webhook, l'écran annonçait un
renouvellement à qui venait de résilier.
D-045.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le trait du logo devient l'initiale de « okan » plutôt que de répéter
« fokan » à côté d'une icône : le pied du F touche la ligne de base du
texte, resserré jusqu'à quelques pixels du jambage du F (calé par rendu
canvas + lecture de pixels, pas à l'œil). Le point du logo disparaît — il
limitait le resserrement contre le jambage du k, et le trait seul se
suffit comme signature. Le composant Logo devient un lockup unique en em,
avec son propre aria-label puisque rien dans le DOM n'épelle plus le nom
sans lui.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Logo + CTA quiz tenaient la largeur visée pour un visiteur anonyme (§ 4.1),
mais « Me connecter » et le CTA ensemble ne rentraient plus sous 640px — le
texte du bouton retombait sur deux lignes et débordait de la barre fixe.
En dessous de sm, la barre ne montre plus que le logo et un bouton menu ;
les liens et CTA (connexion, quiz, Guides/Mon espace, reprendre son foyer)
passent dans un panneau déroulant, refermé automatiquement à la navigation
et à Échap. Le bouton menu lui-même ne s'affiche que s'il a quelque chose à
montrer, pour rester cohérent avec la nav desktop sur /quiz.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Le footer reprend le vrai logo (D-043 en avait corrigé la nav, pas lui).
Newsreader remplace Fraunces sur --font-display : même axe optique, même
mécanique de rendu, mais un f sans le crochet haut qui posait problème à la
taille du logo — choisi après comparatif visuel des cinq candidats.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Le trait du logo (encre) et son point (sauge) remplacent vert/sable dans tout
le socle de design — palette, favicon, vignette de partage et les trois
emails, qui ne peuvent pas lire les variables CSS. Le bouton utilise un
palier de sauge légèrement plus sombre que la teinte nommée : en texte blanc,
la teinte pure tombe sous le seuil d'accessibilité AA.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Trouvé en production, moins d'une minute après le déploiement du
garde-fou, sur un visiteur qui n'était pas nous : deux guides de
scolarité enregistrés au compteur à cinq secondes d'intervalle, sans
que personne ne les ait ouverts.
Un prefetch de next/link exécute le rendu serveur COMPLET de la page,
donc le compteur. Le rail en propose trois par fiche : le quota partait
au survol, et le mur tombait sur le premier guide réellement lu. 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.
prefetch={false} sur les quatre liens vers /guide/<slug> (index, rail,
ligne du hub, popup de carte), plus un filet côté serveur sur l'en-tête
Next-Router-Prefetch, pour qu'un lien ajouté demain ne rouvre pas le
trou en silence. Une navigation cliente 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 y servir un mur le montrerait à qui avait encore des
lectures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
/guide réservait son catalogue aux abonnés, mais chaque fiche prise
isolément se lisait en entier, indéfiniment. Or le rail 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.
Deux lectures entières par visiteur, puis un aperçu d'environ 30 %,
un dégradé et le mur (D-042).
Deux et non zéro : le canal n° 1 est le SEO longue traîne, et qui
arrive de Google sur « le ramonage est-il obligatoire » doit obtenir sa
réponse. Deux et non dix : au-delà, on n'a plus vendu un abonnement, on
a donné la base de connaissance.
Aucune IP n'est stockée — lectures_guides.visiteur est un HMAC salé
tronqué, purgé à 90 jours par le passage de 4 h. Le compteur est une
friction commerciale, pas une frontière de sécurité : il est partagé
derrière un CGNAT et remis à zéro par un changement d'IP, et c'est
assumé.
Le crawler reçoit le texte entier, avec isAccessibleForFree:false et
hasPart/cssSelector qui le déclarent — la « flexible sampling » que
Google autorise. Sans ça, le paywall détruirait le canal qu'il monétise.
On coupe le markdown au jeton, jamais le HTML au caractère : une balise
laissée ouverte n'abîme pas l'article, elle avale le reste de la page.
Piège dans le piège, trouvé sur un test qui échouait — marked résout
les liens de référence au LEXER, pas au parser, donc reparser la seule
amorce arrive trop tard et rend « [le décret][d] » en clair. La seule
coupe correcte tranche des jetons déjà tokenisés dans le contexte du
document complet.
Base injoignable → le guide s'ouvre. Perdre quelques lectures gratuites
est réversible ; perdre l'indexation d'une fiche ne l'est pas.
L'index promettait « chaque guide reste trouvable individuellement ».
Ce n'était plus vrai, et une promesse qui survit à sa règle est pire
qu'un silence.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sur un foyer chargé — logement, deux véhicules, trois enfants, deux
animaux — « qu'est-ce qui tombe avant la rentrée ? » se répondait en
parcourant cinq sections à la main. Quatre axes de filtrage (domaine,
enjeu, échéance, récurrence) et trois tris (D-041).
Le défaut reste neutre : aucun filtre actif, tri par domaine, c'est-à-
dire exactement le rendu d'avant. La barre est une sortie, jamais un
péage d'entrée — « je ne fais rien » doit rester parfaitement servi. Et
aucune comptabilité morale n'entre par cette porte : les chiffres des
puces sont des volumes, l'état vide dit que la combinaison ne ramène
rien, jamais « bravo, tout est à jour ».
Deux règles valent leur test :
- Un horizon n'avale JAMAIS une échéance non datée. « Je ne sais pas »
est une réponse de première classe ; ranger une fenêtre inconnue sous
« dans les 2 mois » lui ferait répondre une question qu'elle ne
tranche pas.
- Les comptes des puces sont des facettes : chacune compte ce que
donnerait son activation, les autres axes restant tels quels. Sinon
« Animaux (3) » ramènerait zéro une fois cochée — un compte que le
lecteur ne peut pas vérifier à l'écran.
identiteIcs quitte hub.ts, qui importe Drizzle et node:fs : le
calendrier est un composant client et en a besoin pour ses key React,
l'importer de là aurait tiré la base dans le bundle du navigateur.
Rappels, lui, est un composant serveur asynchrone et traverse la
frontière en children.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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. Le mot s'affichait pourtant à 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. Une maison n'est pas un
appartement ; un logement, si (D-040).
Aucune migration : le pilier n'est jamais persisté, il est lu du
template à chaque construction du hub. Les identifiants de fiches ne
bougent pas — l'invariant n° 1 tient, et aucune URL de guide ne change.
Le renommage crée une homonymie VOULUE, qu'il ne faut pas défaire :
`logement` désigne désormais le pilier (un rangement d'affichage),
l'asset_type (ce sur quoi une fiche s'applique — une fiche du pilier
logement peut porter sur l'asset foyer), et QuizAnswers.logement.type,
qui vaut « maison » ou « appartement » et désigne le bâti. Ce dernier
est le seul « maison » qui reste dans le code, et il est juste.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Audit de bout en bout du service mail (docs/audit-mail-2026-07-30.md) :
quatorze défauts relevés, dix corrigés ici. Trois changent le dessin de
D-013 plutôt que de réparer un écart, d'où D-039.
Le plus coûteux : la boucle d'envoi marquait « envoyé » dès qu'un membre
avait été servi. 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. La clé de l'outbox
porte désormais le destinataire, et c'est le user_id qui est stocké,
jamais l'adresse.
Le plus discret : `metadata` de /sign-in/magic-link est un champ public
du corps de la requête. Sans compte ni session, un appel forgé faisait
partir depuis un domaine aligné SPF/DKIM/DMARC un « ton foyer est activé,
le paiement est passé ». Un sceau dérivé de BETTER_AUTH_SECRET distingue
l'appel interne ; tout écart retombe sur « connexion », le seul contexte
qui n'affirme rien.
Et une avance de rappel de 21 jours sur une fenêtre CatNat de 30 jours
consommait 30 % du délai légal tous les jours sans que rien ne le signale.
Aucun gabarit mail n'avait jamais pu être testé : vitest n'a pas le relais
JSX de Next, tout rendu échouait sur « React is not defined ».
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le panneau de couches est conditionné à `!apercu` depuis toujours : il ne
s'affiche pas sur un foyer bridé, et on ne le voit effectivement pas. Mais
les couches partaient entières au composant client, avec pour seul bridage
un compteur mis à zéro — si bien que « Périmètres nucléaires »,
« Restrictions d'eau » et « Zones à faibles émissions » se lisaient dans la
source de la page.
C'est le défaut que le commentaire en tête de carte-foyer.tsx décrit déjà
pour les contours et les titres : « un bridage d'apparence ». Le même
piège, un cran plus loin.
Même parade que `apercuDeLElement` : une réduction typée. `teinteDeLaCouche`
ne rend qu'un identifiant et une couleur — ce dont les halos ont besoin — et
la légende ne part plus du tout en aperçu. `label`, `resume` et `pilier`
restent au serveur. L'appelant n'a plus rien à masquer, il n'envoie rien.
Vérifié sur un foyer bridé réel : plus aucun libellé de couche dans la
source. Vérifié sur /guide/zfe-vehicule-non-autorise : la carte non bridée
garde sa légende et ses teintes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
La phrase au-dessus de la carte énumérait les familles trouvées
(« … : périmètres nucléaires, restrictions d'eau et zones à faibles
émissions »), au motif que D-026 § 2 range « de quelles familles » dans ce
qui se montre. Arbitré autrement : nommer les familles répond déjà à une
part de la question que l'abonnement est censé ouvrir.
Elle ne reprend pas non plus le décompte — le pied de l'aperçu le porte
déjà, et deux formulations du même chiffre à trois centimètres l'une de
l'autre se lisent comme une maladresse.
Reste une fuite du même ordre, laissée en l'état faute d'arbitrage : la
légende dans la carte nomme ces mêmes familles en clair, compteurs à zéro.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Quatre fonctions 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 — 41 polygones jetés sur la Base
Nationale des ZFE, où Toulouse en publie huit et Montpellier en a un à
13 km du principal ; 6 zones d'alerte sur 14 échantillonnées au SANDRE ;
et la zone « Littoral » en comptait 112 dont on gardait une.
Ce n'est pas qu'une affaire de dessin. `enveloppe` calculée sur le seul
anneau principal rend un rayon trop court, et ce rayon sert au test de
proximité : un logement situé dans un secteur annexe était écarté avant
même que la question de l'affichage se pose. `croiserZfe` répond désormais
« dedans » dès qu'un anneau contient le point.
`contour` devient `contours` sur zfe_zones et zones_secheresse. Deux
migrations plutôt qu'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. Rattrapage fait sans attendre les
crons — d'où scripts/sync-zfe.ts, qui n'existait que pour les compléments.
Les trous restent ignorés, et c'est écrit plutôt que subi : les omettre
sur-inclut, ce qui est le sens d'erreur qu'on assume. PPRT et inondation
gardent un anneau par ligne, leurs couches Géorisques ne publiant que des
Polygon — vérifié sur 2 853 features, zéro MultiPolygon.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Le moissonnage dédupliquait sur `id_gaspar`, l'identifiant de la procédure,
alors que le WFS publie une feature par fragment avec son propre `id_sup`.
Sur trente sites industriels et 67 procédures relevées : 221 fragments
publiés, 67 conservés, 154 jetés — 69,7 % de la géométrie.
Le PPRT de Lacq Mont a deux morceaux dont les centres sont distants de
3,1 km. On gardait celui que le serveur rendait en premier : un foyer situé
dans l'autre s'entendait répondre qu'il n'était concerné par rien, quand
dedans les travaux prescrits sont financés à 90 %.
La parade était déjà écrite — `pprn_inondation` porte la clé de fragment
depuis sa création, et son commentaire nommait la divergence avec cette
table sans que personne ne la corrige.
Aucun filtre de distance à l'ingestion, délibérément : la distance connue
est celle du sommet le plus proche, pas du polygone, et un périmètre assez
vaste pour contenir l'emprise a tous ses sommets au-delà. Filtrer là-dessus
rejetterait celui qui contient le logement. Reste un plafond de 200
fragments après tri, pour le volume seul.
Audit des autres sources dans D-037 § 4 : inondation, nucléaire et rapport
d'adresse conformes ; ZFE et sécheresse écartent les polygones annexes d'un
MultiPolygon, sans conséquence sur aucune règle mais visible sur la carte.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Question posée : les PPRT sont-ils des points ou des contours, et teste-t-on
bien l'intersection dans la marge ? Trois réponses, dont la dernière est un
défaut.
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, et je l'ai 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
un cercle englobant, qui sur-inclut par construction ; c'est le contour réel qui
tranche ensuite entre « concerne » et « 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 savoir si le foyer est dedans, et dedans
un PPRT les travaux prescrits sont financés à 90 %. Sur les 8 PPRT réels de
l'échantillon, 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. Coût du correctif : 180 sommets qui en deviennent 593.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>