fokan/CLAUDE.md
Gautier Stefanini 8776f8ce0b Vérifier la carte grise par ses octets, pas son Content-Type déclaré, et fermer docling au réseau
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>
2026-08-09 08:20:17 +00:00

32 KiB
Raw Permalink Blame History

fokan — règles de travail

Projet « Veille du foyer » (marque sœur de Kankwa, éditée par MIAW). La source de vérité complète est docs/cadrage-projet-fokan.md — en cas de doute sur une décision produit ou technique, la réponse y est probablement déjà, validée. Les divergences postérieures sont tranchées et datées dans docs/decisions.md, qui prime sur le cadrage.

Documents de référence par pilier : logement · véhicules · papiers · enfance & scolarité · animaux · contrats (transverse, pas un pilier). État connu des défauts et chantiers restants — les cinq audits se complètent : docs/audit-2026-08-09.md (le plus récent — sécurité API/back/front, fonctionnel, et revérification de l'étude de marché gratuit/payant du cadrage ; § 0 documente les deux correctifs de sécurité appliqués le jour même), docs/audit-2026-07-30.md (connaissance, piliers, documents, ajouts classés par valeur, question du 6e pilier), docs/audit-2026-07-28.md (sécurité et exploitation à l'origine du produit) et docs/audit-mail-2026-07-30.md (service mail de bout en bout : les cinq messages qui peuvent partir, leurs déclencheurs, leurs conditions et leur contenu — seule référence sur ce volet ; son § 6 porte les quatre décisions produit encore ouvertes, avec options chiffrées et recommandation, son § 7 les deux chantiers qui débordent du canal) et docs/audit-ressources-2026-08-05.md (conditions de ressources : les cinq dénominateurs des barèmes officiels, ce que coûte la conversion depuis le net mensuel, et l'audit aide par aide de ce qui mérite une fiche — seule référence sur ce volet).

Constitution produit (arbitre de toute feature)

  • Règle d'or : chaque interaction supprime plus de travail qu'elle n'en coûte. Onboarding ≤ 2 min, aucune interaction récurrente > 10 s, le mode « je ne fais rien » est parfaitement servi.
  • Inférence > saisie : tout ce qui peut être déduit n'est jamais demandé. Aucune saisie obligatoire (exception unique : les contrats, opt-in). Jamais de question mémoire.
  • « Je ne sais pas » est une réponse de première classe : le moteur manipule des fenêtres avec niveau de confiance, pas des dates exigées.
  • Le silence est une feature : budget notifications strict, regroupement, priorisation légal > confort.
  • Aucune comptabilité morale : glissement silencieux, pas de rouge, pas de retard, pas de complétion, pas de gamification. Test universel : « ça marche pour quelqu'un qui a oublié que le service existe ? »
  • Refus définitifs : pas de to-do list/organiseur du quotidien, pas de coffre-fort documentaire (on indexe, on n'archive pas — le fichier brut est toujours jeté), pas de dashboard à tenir, pas de santé, pas de social/cadeaux, pas de conseil financier.

Invariants d'architecture (gravés — § 19.3 du cadrage)

  1. IDs de templates stables à vie.
  2. Réconciliation idempotente, rejouable sans effet de bord.
  3. État utilisateur inviolable (« fait », mute, responsables, dates apprises survivent à toute évolution de la base).
  4. Séparation stricte des 3 couches : Connaissance (templates YAML/MDX) / Instance (foyer, assets, deadlines) / Occurrences (timeline append-only).
  5. LLM aux frontières uniquement — le moteur d'échéances est 100 % déterministe et testable.
  6. Contenu multi-canal (mail, hub, SEO) dans le template — bicéphalie structurelle.
  7. Un pilier = de la configuration, jamais du code (un pilier est un asset_type, pas une table). Les piliers sont cinq depuis D-019 (le cadrage en gravait quatre) : logement, véhicules, papiers, enfance & scolarité, animaux. Le premier s'appelait « maison » jusqu'à D-040 — et logement désigne désormais trois choses qui ne se croisent jamais : 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.
  8. Une couche de carte aussi (D-022) : un fichier dans src/carte/couches/, une ligne dans le registre, et aucun composant de rendu touché. Une couche se justifie quand la frontière EST la règle — quand « suis-je dedans ? » change ce qu'il faut faire. Sur la carte, la couleur dit la couche, l'épaisseur dit le rapport au foyer (D-025).

Ce que le hub montre, et ce qu'il fait payer

  • Filtrer n'autorise pas à compter moralement (D-041). Le calendrier du foyer se filtre (domaine, enjeu, échéance, récurrence) et se trie, mais son défaut est neutre : aucun filtre actif, tri par domaine, c'est-à-dire le rendu d'avant. Les chiffres des puces sont des volumes, jamais un « à traiter » ; l'état vide dit que la combinaison ne ramène rien, jamais « bravo, tout est à jour ». Un horizon n'avale jamais une échéance non datée — « je ne sais pas » est une réponse de première classe, et la ranger sous « dans les 2 mois » lui ferait répondre une question qu'elle ne tranche pas. Les comptes des puces sont des facettes : chaque puce compte ce que donnerait son activation, les autres axes restant tels quels, faute de quoi elle annoncerait un total que le lecteur ne peut pas vérifier à l'écran.
  • Deux guides entiers par visiteur, puis l'aperçu (D-042). Le catalogue était réservé aux abonnés, mais le rail « à lire aussi » de chaque fiche EST la table des matières qu'il prétendait protéger. Trois choses à savoir avant d'y toucher : aucune IP n'est stockée (HMAC salé, purge à 90 jours), le crawler reçoit le texte entier avec le balisage isAccessibleForFree:false + hasPart qui le déclare — sans quoi le paywall détruirait le SEO qu'il monétise —, et 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. 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, c'est assumé.

Ce que le mode d'accès change, et ce qu'il ne touche jamais

Le produit a deux modes, et une seule porte (D-065). FOKAN_MODE vaut gratuit ou payant, défaut payant — une variable absente ou mal orthographiée doit laisser le produit dans l'état qu'on lui connaît, jamais ouvrir l'accès à tout le monde sur une faute de frappe. Cinq choses à savoir avant d'y toucher :

  • getSubscription().isActive ne connaît pas le modèle économique. C'est la porte unique de tout le produit (hub, .ics, gestes, rappels, catalogue, rejeu) et elle n'a pas bougé : 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. acces_mode dit comment elle a été franchie — sans cette colonne, le plafond de places gratuites compterait les foyers payants et fermerait les inscriptions au profit de gens qui ont payé.
  • Le mode se lit côté serveur, et descend en props. process.env.FOKAN_MODE n'existe pas dans le bundle navigateur : un import de lib/mode-serveur.ts depuis un composant "use client" répondrait « payant » quoi qu'il arrive, et la page se contredirait à l'hydratation, en silence. Le vocabulaire partagé est dans lib/mode.ts (pur, isomorphe) ; les phrases entières dont la structure change restent dans leur composant — on mutualise le vocabulaire, jamais le discours. Quand une phrase peut être dite vraie dans les deux modes, on la neutralise plutôt que de la dupliquer (c'est ce qui a été fait pour le quota de rejeux : « par an », pas « par année d'abonnement »).
  • La liste d'attente n'existe pas. Pas de table, pas de file, pas de rang stocké : un foyer sans place porte subscription_status = "attente", et le passage de 4 h admet les plus anciens (attente_depuis) quand il reste de la place. Relever FOKAN_PLACES_GRATUITES suffit — la variable est relue à chaque exécution du cron. La décision se prend à l'écriture et non à l'affichage, sans quoi la place irait à qui recharge sa page le plus souvent. 0 est un plafond légitime, pas un synonyme de « vide ».
  • Le full reveal est le même, la contrepartie change. POST /api/activer est le pendant de /api/checkout : même écran, même position, une adresse au lieu d'une carte. Aucune session n'est ouverte sur une adresse simplement tapée — ce serait donner le foyer à qui saisirait l'adresse d'un autre ; le lien magique est ce qui prouve la boîte. Le mode gratuit ferme /api/checkout au serveur (une clé Stripe encore présente la ferait aboutir), mais jamais /api/stripe/portal : un abonnement souscrit en ligne doit se résilier en ligne (art. L215-1-1), et fermer la sortie enfermerait ceux qui ont payé.
  • En gratuit, les pages légales n'affichent AUCUNE identité (D-066, qui révise D-065) : le service est édité sans structure, à titre non professionnel. Plus de marque « MIAW », plus de siège, de SIREN, de responsable de publication ni d'hébergeur nommé — retirés, pas mis en « à compléter », donc plus d'encart rouge. Une seule mention reste réclamée : 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. Assumé et écrit dans mentionsManquantes : l'anonymat du III-2 LCEN suppose un hébergeur tiers détenant l'identité, ce qu'un service auto-hébergé n'a pas. En payant, les neuf mentions et l'encart reviennent d'eux-mêmes — même code, autre branche. /cgv garde son URL et change de titre. La confidentialité perd Stripe quatre fois : déclarer un sous-traitant qui ne traite rien n'est pas prudent, c'est inexact.
  • La responsabilité se définit, elle ne s'exclut jamais (D-067). Une clause qui supprime ou réduit 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, donc réputée non écrite, et elle emporte la crédibilité de l'article en tombant. L'article « Responsabilité » des CGU/CGV définit donc le périmètre : service d'information et non de conseil, obligation de moyens, utilisateur seul décideur, et une échéance absente qui ne décharge de rien — c'est le silence, pas l'erreur, qui est le cas le plus probable. Il ne reste aucune clause de limitation dans les trois pages légales, et c'est ce qui a permis de supprimer le paragraphe de soupape qui rappelait ce qu'elles ne limitaient pas. Les deux qui subsistaient ont été retournées en non-promesses : « le service ne garantit pas X » et non « l'éditeur ne répond pas de X » ; « aucune somme n'ayant été versée, aucun remboursement » et non « aucune indemnité ». Un test interdit les formes de rédaction, pas les mots (legal.test.tsx) — c'est par une phrase ajoutée de bonne foi qu'elles reviennent. L'exposition ré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, et ce qui protège est la chaîne de causalité que les quatre paragraphes de périmètre attaquent. Et les CGU ne couvrent pas les guides — publics, indexés, deux lisibles sans compte : qui arrive de Google n'a rien accepté, d'où une phrase au pied de chaque fiche, à côté de la source, et nulle part ailleurs.
  • Ce qu'on ne promet pas (D-067) : qu'un accès gratuit devienne payant sans accord. L'art. L121-12 du Code de la consommation l'interdit, et une clause nulle ne protège rien tout en signalant qu'on a essayé. La liberté visée est entière sans elle — rendre payant pour l'avenir, y compris pour les accès ouverts, et fermer celui qui ne souscrit pas. Modifier les conditions sans préavis est en revanche possible, à une condition qui va à l'inverse de l'intuition : la version applicable est celle publiée, et la poursuite de l'usage vaut acceptation — sans quoi un texte changé en silence ne serait opposable à personne (art. 1119 du Code civil). Les articles se numérotent par compteur, jamais à la main.
  • Aucun compteur d'accès n'est affiché, et un seul mail existe (D-066). Ni « il reste 12 places » (une urgence là où le § 4.5 interdit tout compte à rebours), ni « 100 accès ouverts » (la taille du service publiée à qui passe) : le seul chiffre montré est le rang du visiteur, une fois inscrit. Le refus se dit « on est victimes de notre succès », avec sa raison. Et le lien magique ne porte plus que deux contextesconnexion et invitation : les quatre messages d'accès (paiement, 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 ; « ta place s'est libérée » suppose qu'il se souvienne d'avoir patienté. Conséquence de sécurité : invitation est le seul contexte qui affirme quelque chose, mais urlFoyer reste la cible à protéger — 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.

Ce qu'une tranche de revenus autorise, et ce qu'elle n'autorise pas

On ne simule pas des droits, on filtre des échéances (D-051). Le quiz demande 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 à 4 pastilles selon la famille. Quatre points à connaître avant d'y toucher :

  • Il n'existe aucun verdict « éligible » dans le moteur, seulement estExclu (src/knowledge/ressources.ts). La tranche est un attribut d'applicabilité comme chauffage: gaz : elle décide si une fiche s'applique, jamais ce que le foyer touchera. 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. mesdroitssociaux.gouv.fr simule 58 prestations et le fait mieux ; ce que personne ne fait, c'est dire que c'est maintenant.
  • Cinq aides, cinq dénominateurs : RFR ou revenu net catégoriel, année N1 ou N2, divisé par les unités de consommation (chèque énergie), par les parts fiscales (leasing social), ou par rien (bourses, ARS). Une seule question suffit parce que les diviseurs se déduisent de la composition et que les deux notions de revenu coïncident pour un foyer salarié — mais c'est ce qui rend les bornes nécessairement dynamiques : 30 000 € sont sous le plafond pour une famille de cinq et au-dessus pour une personne seule, sur la même aide.
  • Tout doute affiche. Barème périmé, composition hors table, foyer qui n'a pas répondu, conversion approximative : estExclu répond faux. La conversion net → annuel (~11,3) est le maillon faible, et la parade est le sens de l'arrondi — la borne affichée se place au-dessus du plafond réel, pour qu'un foyer près du seuil tombe dans la tranche du dessous et voie la fiche. Sur-inclure coûte une ligne ; exclure à tort coûte l'aide. Même arbitrage sur les parts fiscales, où l'on retient délibérément l'hypothèse la plus généreuse faute de savoir si deux adultes déclarent ensemble.
  • Un barème périmé cesse de filtrer, il ne filtre jamais faux — clause d'effacement automatique de D-023. Chaque barème porte valideJusquAu ; passée la date, la fiche se réaffiche à tout le monde et ne propose plus de coupure au quiz. La CI échoue dessus, et le loader casse le build sur un identifiant de barème inconnu.

Le verdict n'est jamais stocké : ce qu'on garde est la tranche déclarée, et l'exclusion se recalcule à chaque réconciliation — un enfant ajouté relève le plafond sans rejeu du quiz. Ce qu'on stocke sont les bornes réellement affichées, jamais un identifiant : un barème révisé les déplacerait, et un identifiant relu sur les nouvelles ferait dire au foyer ce qu'il n'a jamais dit.

Ce qu'une source officielle ne dispense pas de faire

Recopier un jeu de données d'État n'est pas la fin du travail — six défauts réels l'ont montré les 29 et 30/07/2026, et la parade est la même à chaque fois : la géométrie peut venir d'ailleurs, la règle jamais.

  • Une source consolidée a des trous : la Base Nationale des ZFE publiait 19 agglomérations sur 25, et trois des 19 portaient une règle périmée (D-023). Deux fichiers en git comblent, chacun avec sa date de lecture et sa clause d'effacement automatique.
  • Une source publie plus que ce qu'on en lit — la faute la plus coûteuse, et la seule qu'on ait commise deux fois. Géorisques publiait ppi, risque_iode et rayon_ppi par installation, qu'on ignorait : d'où un cercle de 20 km et une promesse de comprimés gratuits autour d'un irradiateur industriel (D-024). RappelConso publiait informations_complementaires, qu'on ne demandait même pas dans la requête : elle porte le numéro de réception CE sur 94,6 % des fiches automobiles, c'est-à-dire le champ K de la carte grise, la seule donnée qui désigne une version plutôt qu'un modèle (D-034). Lire une fiche entière avant de choisir ses colonnes, et relire le jour où l'on se dit qu'une limite est structurelle : elle n'est souvent que celle du jeu qu'on a consulté.
  • Un parseur s'écrit d'après un relevé des notations réelles, jamais d'après le format théorique : sur 1 626 valeurs, la réception CE s'écrit avec des plages (*05-*15), des to, des à, des and qui énumèrent au lieu d'encadrer, des bases implicites (, *34), des *00-*99 qui veulent dire « toutes », et des noms de modèles glissés dans le même champ (D-034 § 3). Recenser les formes, écrire un test par forme, et ne réparer aucune coquille qu'on ne saurait deviner juste.
  • Qualifier plutôt que filtrer quand on sait mesurer la précision mais pas l'exhaustivité : le champ K identifie la version visée par un rappel, ce qui ferait tomber 33 campagnes à une seule. On l'affiche en second niveau et on ne retire rien — une fiche qui omettrait une réception produirait un silence sur un rappel de sécurité, ce qui coûte bien plus cher qu'une sur-inclusion annoncée comme telle (D-034 § 2).
  • Un périmètre réglementaire est publié en morceaux, et la clé est le morceau (D-037). Un PPRT est une procédure (id_gaspar) mais une géométrie par fragment (id_sup) : dédupliquer sur la procédure jetait 69,7 % de la géométrie sur 67 procédures relevées, jusqu'à 78 morceaux sur 78 à Carling. Le PPRT de Lacq Mont a deux morceaux à 3,1 km l'un de l'autre — garder le premier venu, c'est répondre « tu n'es concerné par rien » à qui habite dans le second. Le pire est que la parade était déjà écrite dans le commentaire de la table voisine, qui nommait la divergence sans que personne ne la corrige : un défaut nommé dans le code n'est pas un défaut corrigé.
  • Un MultiPolygon n'a pas d'anneau « principal » (D-038). Quatre fonctions réduisaient une géométrie à son anneau le plus étendu en se rassurant d'un commentaire — « les autres sont des enclaves ou des secteurs annexes ». Mesuré : 41 polygones jetés sur la Base Nationale des ZFE (Toulouse en publie 8, Montpellier a un secteur à 13 km du principal), 6 zones d'alerte sur 14 échantillonnées au SANDRE. Et ce n'est pas qu'une affaire de dessin : enveloppe calculée sur le seul anneau principal rend un rayon qui écarte du test de proximité un logement situé dans un secteur annexe. Les trous, eux, restent volontairement ignorés — les omettre sur-inclut, et c'est le sens d'erreur qu'on assume.
  • Une distance à un sommet n'est pas une distance à un polygone (D-037) : un périmètre assez vaste pour contenir toute l'emprise a tous ses sommets au-delà — 98 m pour le plus proche d'un point pourtant intérieur, sur un contour de 61 247 sommets. Filtrer des candidats là-dessus écarte précisément celui qui contient le logement. On trie avec, on ne filtre jamais avec.
  • Une indisponibilité n'est pas une abrogation : une purge « ce qui n'a pas été revu » est juste pour un jeu recopié en entier, fausse pour une source qui répond 504 une fois sur trois. On purge sur la déclaration (D-023 § 4).
  • Une source qui ne sert pas la donnée peut dire où la trouver : VigiEau ne publie aucune géométrie, mais déclare l'idSandre de chaque zone — et le WFS du SANDRE en rend une pour 334 ko, là où le jeu national en pèse 399 Mo (D-027). Avant de se résigner à un gros fichier, chercher la clé de jointure qu'on tient déjà.
  • Deux serveurs de la même famille ne se comportent pas pareil : Géorisques et le SANDRE sont tous deux des MapServer et refusent tous deux application/json qu'ils annoncent (c'est geojson) — mais le premier ignore les filtres d'attribut en silence, le second les honore. On essaie, on ne suppose pas.

Ce travail se montre dans le produit, plus sur la page d'accueil (D-064, qui renverse le § 5 de D-033). La landing nommait les douze jeux de données appelés, hôte et lien compris : le plan de montage offert à qui voudrait recopier le concept. Elle n'en garde que la méthode. Les sources restent nommées là où elles engagent — la référence de chaque guide (template.source), la source de chaque couche de carte — et pas dans un inventaire public.

Ce que le quiz doit rendre

Le révélé du quiz est complet à sa date (D-027). Les passages quotidiens veillent sur ce qui évolue ensuite ; ils ne terminent jamais le travail du jour même. Tout ce qu'un écran montre après le quiz se relève dans le passage d'enrichissement lui-même.

Plus d'exception depuis D-035 : l'écran de fabrication reste actif tant que le passage dure et trace chaque étape au fil de l'eau. Il s'arrêtait à 10 s, et rendait la main non pas à un écran d'attente mais à un aperçu qui se présentait comme fini — les deux moissonnages spatiaux (périmètres PPRT et inondation, les seuls sans identifiant à demander, interrogés par emprise autour du point) atterrissaient après, donc deux couches de carte apparaissaient à la visite suivante. Mesuré à Marseille le 30/07/2026 : risques à 8 s, périmètres à 16 s, fin à 16,5 s, et le hub porte désormais ses 9 périmètres au premier rendu.

Ce qui reste borné est un garde-fou de 90 s contre la panne, pas un budget d'attente : chaque appel sortant a son propre timeout, c'est leur somme qu'il faut tenir. Au-delà, la file pg-boss reprend et les étapes non franchies le disent à l'écran.

Les trois relevés d'adresse partent ensemble (D-036) — rapport Géorisques, moissonnage PPRT, moissonnage inondation : 16,5 s → 7,8 s sur la même adresse. Ce n'est pas du multithread, Node est mono-thread et rien ici n'occupe le processeur ; c'est de la concurrence sur du réseau. Ce qui décide de ce qui peut partir de front, c'est l'écriture, pas la lecture : ces trois-là écrivent dans des tables différentes, tandis que la veille et la distribution font un lire-modifier-écrire sur la même ligne assets et restent en série (audit F5). On les rejoint dans l'ordre du plan de fabrication, pas dans celui où elles finissent.

Une emprise géographique se dit en kilomètres, jamais en degrés (D-036) : un degré de latitude vaut 111 km partout, un degré de longitude rétrécit vers le nord. Une constante unique sur les deux axes couvrait 8,9 km en latitude pour 6,5 km en longitude à Marseille, et 5,6 km à Dunkerque — sous une marge d'affichage de 5 km. Toute emprise se déduit désormais de la règle d'affichage de sa couche, axe par axe, plus une marge : un périmètre manqué au moissonnage ne se voit pas, il n'apparaît simplement jamais.

Stack (décisions validées)

TypeScript de bout en bout · Next.js (App Router) unique pour SEO/hub PWA/API · PostgreSQL + Drizzle · pg-boss pour jobs et cron (pas de Redis) · Zod partout · React Email + adapter d'envoi · base de connaissance en YAML + MDX dans le repo, validée en CI (Git-only, pas de back-office) · better-auth auto-hébergé (mot de passe + liens magiques) · monorepo, CI GitHub Actions, déploiement Docker sur le serveur local (prod locale assumée, clause de sortie à ~200-300 foyers payants).

Priorité absolue des tests : le moteur de réconciliation (transitions du § 20.4, idempotence, inviolabilité de l'état utilisateur) — src/engine/reconcile.ts (pur, sans DB) + src/engine/reconcile.test.ts. npm test lance vitest.

Le cron réel tourne : src/instrumentation.ts démarre pg-boss dans le même processus que le serveur Next.js au boot (zéro conteneur worker séparé) et journalise un heartbeat events.name = "cron_*" à chaque exécution. Quatre passages planifiés, heure de Paris : veille des arrêtés à 3 h (CatNat, restrictions d'eau), réconciliation à 4 h (le passage quotidien de sécurité du § 20.4), rappels à 8 h (la seule file qui parle à l'extérieur), référentiel véhicules le dimanche à 0 h 30 (RDW, rappels, ZFE, nucléaire, calendrier scolaire, risques, sécheresse, scolarité).

Ce que le foyer répond, et ce que le moteur en fait

La boucle d'état existe depuis D-055 — elle était le plus vieux manque du produit (audit du 30/07 § 1). Quatre gestes : « c'est fait » / « c'était déjà fait », sourdine, reprise, responsable. Cinq choses à savoir avant d'y toucher :

  • La fiche décide, pas l'écran. comportement_fait était déclaré sur les 78 fiches depuis l'origine et lu par personne. C'est lui qui dit si « c'est fait » recale un cycle, clôt une ligne, ou n'a aucun sens — voir la répartition ci-dessous.
  • Une date apprise recale un cycle ; elle ne déplace jamais une date qu'un tiers décide. Allowlist STRATEGIES_ANCRABLES, défaut = refus. 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 portent leur propre arithmétique — la fiche CT voiture déclare biennial et 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 que le foyer a fourni (« dès que possible — la dernière fois, c'était en mars 2021 »), jamais un décompte.
  • Le geste n'écrit pas la prochaine date, il écrit l'ancre et rend la main à runReconciliation — sans quoi l'écran et le passage de 4 h finiraient par diverger (invariant n° 2). Et il porte sur le groupe de grouperEcheances, pas sur la ligne : trois enfants, trois échéances, un seul tap.
  • « Fait » et « muet » ne font PAS la même chose (D-056, qui révise D-055). clos_le sort l'échéance du moteur ; mute_le ne coupe que le mail et la laisse au calendrier, donc continue de la faire recalculer — une ligne qu'on affiche et qu'on gèle vieillit à l'écran. C'est la règle de D-045 (« ton calendrier reste complet, on cesse simplement de t'écrire ») appliquée à l'échéance. Un seul endroit filtre sur la sourdine : notifiable(). Et « muet » n'écrit aucune date, sinon on inscrirait dans l'histoire du foyer un entretien qui n'a peut-être jamais eu lieu.
  • « C'est fait » vaut pour UN rendez-vous (D-058). clos_le est accompagné de clos_fenetre, la signature de la fenêtre visée : tant que le moteur veut poser la même, la ligne reste rangée ; dès qu'il en calcule une autre, elle revient d'elle-même. Une seule règle, et les deux comportements en découlent sans qu'aucune fiche les déclare — une vérification de papiers revient chaque printemps parce que sa fenêtre bouge, un retrait de comprimés d'iode reste clos parce que la sienne ne bouge jamais. La clôture ne se lève qu'à un endroit : la branche toUpdateWindow.
  • Trois comportements, pas deux (D-059). comportement_fait est obligatoire et vaut reprogrammer_cycle (46), clore (26) ou aucun (6) — dont 43 réellement ancrables. aucun = un état du monde, qu'on ne « fait » pas : l'écran n'affiche alors aucun geste plutôt qu'un bouton qui mentirait. iode-pastilles y est rangée délibérément — retirer les comprimés est bien un acte, mais les boîtes périment et aucune source publique ne donne le calendrier des campagnes ASNR (le flux d'avis d'incidents existe, il ne porte ni localisation ni niveau, et n'est pas un signal de danger public). Même règle pour la sourdine : on ne propose pas de couper des mails sur une fenêtre libre, qui n'en envoie jamais.
  • Un déclencheur, ce n'est pas un état (D-059) : declaration-fonciere-90j se posait sur « avoir une piscine » au lieu de « venir d'en construire une », et annonçait donc « 90 jours pour déclarer » à vie, sans date. C'est la déclaration de travaux du quiz qui ouvre le délai, avec le mois pour sujet — sans quoi la clôture d'un chantier vaudrait pour le suivant. Même faute, même parade pour carte-grise-changement-adresse, qui attendait un demenagement_recent que personne n'écrivait : on pose le mois du déménagement, jamais un booléen qui resterait vrai à vie (D-063).

Ce qu'un rejeu de quiz apporte, et ce qu'il n'a pas le droit d'emporter

Un rejeu apporte des nouvelles, il ne remplace pas le foyer (D-063). Quatre régimes, et le choix entre eux tient à une seule question — cette chose a-t-elle une identité qu'on puisse reconnaître sans deviner ?

  • Foyer et logement sont mis à jour en place, même id : leurs échéances gardent tout.
  • Contrats appariés sur leur type, qui vient d'une énumération fermée — deux chaînes égales ou non, aucune heuristique. Les remplacer coûtait l'état utilisateur de la seule saisie opt-in du produit.
  • Véhicules, personnes, animaux remplacés : là, rien ne relie « l'ancienne deuxième voiture » à « la nouvelle deuxième voiture ». Leurs échéances repartent de zéro, et l'écran le dit avant validation.
  • Travaux et déménagement s'accumulent sur le logement, parce que le formulaire, lui, repart à vide. La question porte sur ce qui vient d'arriver, pas sur l'état du monde : la ramener cochée ferait redéclarer une piscine de mars huit mois plus tard. Mais remettre à zéro l'asset effacerait un délai encore ouvert — d'où la mémoire d'un côté, le message de l'autre. Les deux s'oublient d'eux-mêmes (13 mois pour les travaux, 4 pour le déménagement : environ quatre fois le délai qu'ils ouvrent), sans quoi une ligne close depuis un an traînerait pour toujours.

Un mois par chantier, donc une échéance par mois : deux constructions finies à trois mois d'écart ont deux dates limites, et deux du même mois n'en font qu'une — c'est une visite au service des impôts, pas deux. Seuls les quatre types qui créent de la surface ouvrent le délai ; la liste vit dans src/engine/travaux.ts, la fiche garde la sienne en YAML faute d'un DSL qui sache fouiller des objets, et un test tient l'accord des deux.

  • Tout geste se défait. annuler_fait sur la carte pour les fiches cycliques, reprendre depuis l'étagère (« ce que tu as marqué comme fait », qui ne contient que des fiches clore) — sans quoi « inviolable » voudrait dire « irréversible ». Une date apprise se compare en jours et dans le fuseau du foyer, jamais en instants : la comparer à Date.now() après normalisation à midi refusait tout geste posé avant 14 h de Paris, et aucun test ne pouvait le voir.

Contraintes transverses

  • Hébergement France, RGPD by design, minimisation (aucun blob stocké, jamais).
  • Jamais d'envoi mail depuis une IP résidentielle ; SPF/DKIM/DMARC stricts ; plan B routeur SaaS à un fichier de config.
  • Charge serveur minimale : données textuelles structurées, batch quotidien, pas de temps réel.
  • Ton produit : majordome complice et déculpabilisant, jamais productiviste ni moralisateur. Chaque rappel donne le pourquoi en une ligne.