L'envoi suspendu
Mardi 19 mai, dix heures vingt. Je m'apprête à lancer la deuxième vague de SMS de réinscription. Cinquante-trois contacts anciens d'un atelier de céramique, un lien court de la forme /r/<short_code> qui renvoie chacun sur son tunnel personnel. Pré-flight standard : je grep les tokens correspondants pour vérifier qu'ils sont actifs. Cinquante-deux retournent ce que j'attends. Le cinquante-troisième porte un used_at non null, daté du matin même, parce que le tunnel de test que j'avais passé deux heures plus tôt avait consommé le token. Si je lance l'envoi, ce contact reçoit un lien qui répondra 410 Gone. Catherine, qui teste la version mobile depuis l'antenne, m'écrit au même moment qu'elle voudrait que je vérifie « les cinquante-trois quand même, parce que tu sais comment c'est ». Elle a raison. Je n'envoie pas.
La règle qui m'avait servi pendant deux mois
Depuis la fin avril, je vis sous une règle simple, énoncée en quatre lignes dans la doctrine du projet et que je tiens pour le filet le plus efficace que j'aie posé jusque-là. Toute valeur stockée qui ressemble à une duplication doit être classée Live, Snapshot ou Cache avant d'être créée, et chaque catégorie impose une implémentation différente. Live, c'est ne pas stocker — on lit à la volée via une vue. Snapshot, c'est stocker et ne jamais recalculer — l'historique est intouchable. Cache, c'est stocker mais déclarer dans le même commit le mécanisme de rafraîchissement — trigger nommé, GENERATED ALWAYS AS, ou vue matérialisée. Aucun cache ne survit sans son rafraîchisseur explicite ; si on ne peut pas le garantir, on retombe sur Live.
Cette règle est sortie d'un incident d'audit où j'avais découvert qu'une colonne montant_total traînait des écarts à quatre chiffres sur plusieurs centaines de lignes, depuis des semaines, parce que personne ne s'était posé la question. Je ne vais pas re-raconter ce jour-là ; ce qui m'intéresse, c'est ce que la règle a empêché ensuite.
Trois fois où la règle a tenu
Premier sauvetage, fin avril. Une refonte tarifaire arrive sur la table, et la première intuition d'un script bulk consiste à recalculer rétroactivement la colonne inscriptions.tarif_applique pour aligner toutes les lignes sur la nouvelle grille. Le réflexe est cohérent — on aime la cohérence. Il est fatal. tarif_applique est un Snapshot : le tarif au jour de l'inscription, qui doit rester figé quand le cours est réévalué après. Le recalcul rétroactif vole l'historique, casse le bouclage comptable, et empêche de défendre une facturation contre un parent qui contesterait. La règle dit : recalculer rétroactivement est une faute fonctionnelle, l'évolution s'applique par un nouvel événement, avoir plus nouvelle facture. Le script est réécrit en deux heures, l'historique est conservé, la nouvelle grille s'applique aux inscriptions postérieures à la bascule. Sans le vocabulaire Snapshot, j'aurais accepté le bulk en pensant rendre service à la rigueur.
Deuxième sauvetage, mi-mai. Une revue de migration arrive avec une colonne ajoutée à cours pour économiser une jointure dans un export quotidien. Le commit ne contient ni GENERATED ALWAYS AS, ni trigger, ni REFRESH planifié. La règle le rejette automatiquement — pas par moi, par la discipline que je m'impose en review : aucun cache ne survit sans son rafraîchisseur déclaré dans le même commit. La colonne aurait fonctionné le premier jour. Elle aurait dérivé en silence la première fois qu'une inscription serait passée par un chemin qui n'invoquait pas la mise à jour ad hoc. C'est exactement le schéma qui m'avait mis dans le mur sur montant_total ; la règle, cette fois, ferme la porte avant l'incident, pas après.
Troisième sauvetage, début mai. Niran arrive avec un compteur agrégé qu'il veut stocker pour ses analyses financières. Hoodie sombre, sa canette de soda posée sur le coin du laptop, il pose la question d'une phrase : « colonne sur cours, ou vue ? ». Je réponds en une phrase aussi : « combien de lignes par lecture, et combien de lectures par minute ? ». Il répond « quelques centaines, peut-être deux par minute ». Vue. Le commit qui suit est une vue v_cours_recettes_mensuelles, pas une colonne. Le calcul à la volée est acceptable côté performance, donc Live, donc vue v_*, pas colonne. Quinze minutes plus tard, le travail est en production, et personne n'a besoin de tenir un rafraîchisseur dans sa tête.
Trois sauvetages, trois mécaniques distinctes : un Snapshot qu'on s'apprêtait à profaner, un Cache qu'on s'apprêtait à livrer sans rafraîchisseur, un Live qu'on s'apprêtait à stocker par paresse. La règle a fonctionné précisément parce qu'elle force la question avant le code.
La trahison
C'est précisément la rigueur de cette règle qui a rendu sa défaillance instructive. Reprenons le matin du 19 mai. Le helper generateTokenForContact cherche un token existant pour le contact, le retourne s'il existe, en crée un sinon. Idempotent par construction. Chacune de ses colonnes a été classée proprement.
// lib/reinscription.ts — version avant 07ed02d
const { data: existing } = await supabase
.from('reinscription_tokens')
.select('token, short_code, used_at')
.eq('contact_id', contactId)
.eq('annee_scolaire', annee)
.maybeSingle()
if (existing?.token) {
return existing.token // ← retourne tel quel, used_at inclus
}
Le id est un Snapshot — identité figée à la création. Le short_code est un Snapshot — huit caractères base32 générés une fois, jamais regénérés sous peine de casser les SMS déjà envoyés. Le contact_id est un Snapshot. Chaque colonne, prise isolément, est conforme à sa catégorie. La règle est respectée colonne par colonne.
Mais used_at est un marqueur Live. Un état transitoire d'usage qui doit refléter la réalité courante du tunnel. Et le helper, en retournant l'objet entier sans toucher used_at, livrait un objet mixte dont le contrat de cohérence n'avait jamais été énoncé. La règle catégorisait des colonnes. Elle ne disait rien sur ce que devient l'objet quand on le ressuscite. Le fix, une fois la trahison comprise, tient en quelques lignes : si on retourne un token existant, on reset used_at à NULL dans la même transaction que le SELECT.
// lib/reinscription.ts — commit 07ed02d, 19/05/2026
if (existing.used_at) {
await supabase
.from('reinscription_tokens')
.update({ used_at: null })
.eq('contact_id', contactId)
.eq('annee_scolaire', annee)
}
Le grain de la règle, le grain de l'erreur
J'ai mis quelques heures à comprendre ce que cet incident disait, parce que la tentation est de patcher l'endroit et de passer à autre chose. La leçon n'est pas que la règle est fausse ; elle est juste. La leçon est que son grain de classification, la colonne, n'épuisait pas le grain de l'erreur, l'objet métier ressuscité. Un même objet en base porte régulièrement plusieurs colonnes de catégories différentes — Snapshot pour l'identité, Live pour les états transitoires d'usage, parfois Cache pour les agrégats dérivés. Tout helper du type getOrCreate* ou getOrResurrect* qui touche un objet de ce genre doit, dans la même transaction, préserver les Snapshots et reset les Live. Sans cette discipline, la cohérence colonne par colonne se compose en une incohérence d'objet.
La v0.7 du toolkit l'a entériné en posant une règle projet juste à côté de R6, plutôt qu'en réécrivant R6. Hybrid Snapshot/Live reset — quatre paragraphes, un tableau de cas applicables, un test contrat associé qui exige un cas négatif. Pas une refonte. Une extension qui reconnaît que R6 décrit l'état statique d'une colonne et que les helpers qui ressuscitent un objet introduisent une dynamique transitionnelle que R6 ne couvrait pas.
// pattern minimal hybrid-snapshot-live-reset
async function getOrResurrectToken(contactId: string): Promise<Token> {
const existing = await supabase
.from('reinscription_tokens')
.select('*')
.eq('contact_id', contactId)
.maybeSingle()
if (existing.data) {
// Identity (Snapshot) — keep. Live markers (used_at) — reset.
if (existing.data.used_at !== null) {
await supabase
.from('reinscription_tokens')
.update({ used_at: null })
.eq('id', existing.data.id)
}
return existing.data
}
return await createToken(contactId)
}
C'est ce qui a changé en deux mois. Pas la règle elle-même, qui continue de tenir trois fois pour une où elle échoue. Une clause projet qui ferme la classe d'incidents qu'elle ne savait pas couvrir. Le toolkit lui-même obéit à sa propre R6 sur ce point : la doctrine est Live, pas dupliquée — la règle mère reste générique, l'extension vit dans le projet où elle s'applique, et migre en haut seulement si trois projets indépendants la valident.
Coda
Je n'ai pas envoyé l'envoi de la matinée. J'ai reset le token, relancé le grep, vu cinquante-trois lignes propres, puis envoyé. Catherine m'a répondu « hum ça bug, mais c'est vite corrigé » d'une voix neutre. Ce n'était pas un bug, c'était une règle qui n'avait pas pris le grain de l'objet. Trois sauvetages, une trahison : la règle ne s'évalue pas au taux de succès, elle s'évalue à la qualité des extensions qu'elle accepte quand elle se fait surprendre. Si la mienne se fait surprendre une fois sur trois et que l'extension tient en quatre paragraphes, je continue.
Article #61 de la série Mon ERP avec Claude Code. Counterpart Toolkit v0.7, règle R6 Live/Snapshot/Cache mandatory, extension projet hybrid-snapshot-live-reset publiée le 19 mai 2026. Le repo de doctrine reste sous CC-BY-4.0.
United States
NORTH AMERICA
Related News

Mattress Firm Coupons: Save up to $600
4h ago
🚀 I Built a Dropshipping Automation Pipeline — Here's What I Learned (and What I'd Do Differently)
11h ago
How I Cut My LLM API Bill by 40x: A Freelancer's Migration Story
11h ago
Cursor AI Review 2026: The AI-Native Code Editor
8h ago

Another Model Rewrote My Memories. Here's How I Caught It.
8h ago