The send put on hold
Tuesday May 19, ten twenty in the morning. I'm about to launch the second SMS wave of the re-enrollment campaign. Fifty-three former contacts from a ceramics workshop, a short link /r/<short_code> that sends each one to their personal funnel. Standard pre-flight: I grep the corresponding tokens to verify they're active. Fifty-two return what I expect. The fifty-third carries a non-null used_at, dated the same morning, because the test funnel I had gone through two hours earlier had consumed the token. If I launch the send, this contact receives a link that will answer 410 Gone. Catherine, testing the mobile version from the branch, writes me at the same moment to say she'd like me to check "the fifty-three anyway, because you know how it is". She's right. I don't send.
The rule that had served me for two months
Since late April, I've lived under a simple rule, stated in four lines in the project doctrine and that I hold as the most effective net I've laid down so far. Any stored value that resembles a duplication must be classified Live, Snapshot or Cache before being created, and each category imposes a different implementation. Live means don't store — read on the fly via a view. Snapshot means store and never recalculate — history is untouchable. Cache means store but declare the refresh mechanism in the same commit — named trigger, GENERATED ALWAYS AS, or materialised view. No cache survives without its explicit refresher; if you can't guarantee it, you fall back to Live.
This rule came out of an audit incident where I had discovered that a montant_total column carried four-digit discrepancies on several hundred rows, for weeks, because nobody had asked the question. I won't re-tell that day; what interests me is what the rule prevented afterwards.
Three times the rule held
First save, late April. A pricing overhaul lands on the table, and the first intuition of a bulk script is to retroactively recalculate the inscriptions.tarif_applique column to align all rows on the new grid. The reflex is coherent — we like coherence. It's fatal. tarif_applique is a Snapshot: the price at the day of enrollment, that must stay frozen when the course is reassessed afterwards. Retroactive recalculation steals history, breaks accounting reconciliation, and prevents defending a billing against a parent who'd contest it. The rule says: retroactive recalculation is a functional fault; evolution applies through a new event, credit note plus new invoice. The script is rewritten in two hours, history is preserved, the new grid applies to enrollments after the cutover. Without the Snapshot vocabulary, I would have accepted the bulk thinking I was doing rigour a favour.
Second save, mid May. A migration review arrives with a column added to cours to save a join in a daily export. The commit contains neither GENERATED ALWAYS AS, nor trigger, nor scheduled REFRESH. The rule rejects it automatically — not by me, by the discipline I impose on myself in review: no cache survives without its refresher declared in the same commit. The column would have worked the first day. It would have drifted silently the first time an enrollment came through a path that didn't invoke the ad hoc update. That's exactly the pattern that drove me into the wall on montant_total; the rule, this time, closes the door before the incident, not after.
Third save, early May. Niran arrives with an aggregate counter he wants to store for his financial analyses. Dark hoodie, his soda can on the corner of the laptop, he asks the question in one phrase: "column on cours, or view?". I answer in one phrase too: "how many rows per read, and how many reads per minute?". He answers "a few hundred, maybe two per minute". View. The commit that follows is a v_cours_recettes_mensuelles view, not a column. The on-the-fly compute is acceptable performance-wise, so Live, so v_* view, not column.
Three saves, three distinct mechanics: a Snapshot we were about to profane, a Cache we were about to ship without a refresher, a Live we were about to store out of laziness. The rule worked precisely because it forces the question before the code.
The betrayal
It's precisely the rigour of this rule that made its failure instructive. Back to Tuesday May 19. The helper generateTokenForContact looks for an existing token for the contact, returns it if it exists, creates one otherwise. Idempotent by construction. Each of its columns has been classified properly.
// lib/reinscription.ts — version before 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 // ← returns as-is, used_at included
}
The id is a Snapshot — identity frozen at creation. The short_code is a Snapshot — eight base32 characters generated once, never regenerated under penalty of breaking already-sent SMS. The contact_id is a Snapshot. Each column, taken in isolation, is conformant to its category. The rule is respected column by column.
But used_at is a Live marker. A transient usage state that must reflect the funnel's current reality. And the helper, returning the entire object without touching used_at, delivered a mixed object whose coherence contract had never been stated. The rule classified columns. It said nothing about what happens to the object when we resurrect it. The fix, once the betrayal understood, fits in a few lines: if we return an existing token, we reset used_at to NULL in the same transaction as the 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)
}
The grain of the rule, the grain of the error
It took me a few hours to understand what this incident said, because the temptation is to patch the spot and move on. The lesson is not that the rule is wrong; it's right. The lesson is that its classification grain — the column — did not exhaust the error grain — the resurrected business object. The same object in the database regularly carries multiple columns of different categories — Snapshot for identity, Live for transient usage states, sometimes Cache for derived aggregates. Any helper of the getOrCreate* or getOrResurrect* type that touches such an object must, in the same transaction, preserve Snapshots and reset Lives. Without this discipline, column-by-column coherence composes into object incoherence.
The v0.7 toolkit ratified this by laying a project rule right next to R6, rather than by rewriting R6. Hybrid Snapshot/Live reset — four paragraphs, a table of applicable cases, an associated contract test that requires a negative case. Not a refactor. An extension that recognises R6 describes the static state of a column and that helpers resurrecting an object introduce a transitional dynamic R6 didn't cover.
// minimal hybrid-snapshot-live-reset pattern
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)
}
That's what changed in two months. Not the rule itself, which keeps holding three times for one failure. A project clause that closes the incident class it couldn't cover. The toolkit obeys its own R6 on this point: doctrine is Live, not duplicated — the mother rule stays generic, the extension lives in the project where it applies, and migrates upward only when three independent projects validate it.
Coda
I didn't launch the morning send. I reset the token, reran the grep, saw fifty-three clean lines, then sent. Catherine answered "hum it bugs, but it's quickly fixed" in a neutral voice. It wasn't a bug, it was a rule that hadn't taken the object's grain. Three saves, one betrayal: a rule isn't evaluated by its success rate, it's evaluated by the quality of the extensions it accepts when it gets surprised. If mine gets surprised one time in three and the extension fits in four paragraphs, I keep going.
Article #61 of the series My ERP with Claude Code. Counterpart Toolkit v0.7, rule R6 Live/Snapshot/Cache mandatory, project extension hybrid-snapshot-live-reset published May 19, 2026. The doctrine repo remains CC-BY-4.0.
Top comments (0)