2026-08-16 · 4 MIN

My hand correction to a generated file lasted exactly one run: building a claim register a gate can enforce

— WRITING

+

2026-08-16 · 4 MIN

On 2026-08-13 I checked four psychological claims on a publication against their primary sources and corrected them by hand in src/content/phenomena.json. Two were names with nothing behind them: PubMed title/abstract hits for the exact phrase "anticipatory rehearsal" come back at zero, and "threat simulation" is Revonsuo's theory about the function of dreaming, borrowed here to describe rehearsing an argument in the shower. Two were sentences that misstated a finding. The Zeigarnik memory claim ("It keeps every unfinished task running as an open loop") is the half that a 2025 meta-analysis in Humanities and Social Sciences Communications reports does not hold, and "Step back through the door and the file often reopens" is an actionable instruction resting on a replication that failed in BMC Psychology in 2021.

I counted the occurrences in the audit commit: 0, 0, 0, 0.

I counted them again in the next content commit: 2 "threat simulation", 3 "anticipatory rehearsal", 4 "open loop", 1 "Step back through the door". Those are the same four numbers the file carried before the audit.

The file I corrected was a copy

src/content/phenomena.json is generated. scripts/ingest.mjs rebuilds it from a content queue that lives outside the repo, and the queue still held the original wording. The correction landed on a rendering of the content while the generator kept the original and re-emitted it.

The first fix was to move the check to the write. A deploy gate discovers the problem after the generator has already run, with no queue id in hand. ingest.mjs now refuses before writeFileSync and prints the queue's own id, and it exits rather than dropping the offending entry, because dropping it would shrink the archive and leave the queue wrong for the next run.

Article preview with liquid-pouring photograph and headline about unexpected kindness and cruelty — Article exists with publication metadata, confirmed not yet live, cross-platform distribution planned.

Article preview with liquid-pouring photograph and headline about unexpected kindness and cruelty — Article exists with publication metadata, confirmed not yet live, cross-platform distribution planned.

A term list cannot see a paragraph

The shared list caught the two names. Nothing caught the two sentences. They sat in the tree for two days while both checks printed a clean pass over all 64 entries, because a substring list knows about words and nobody had written down that those sentences had ever been corrected. A check that covers half a defect reads exactly like a check that covers all of it.

a retired terma corrected passage
example"anticipatory rehearsal""Step back through the door and the file often reopens"
recorded inRETIRED in scripts/retired-terms.mjs, with the reason it wentretiredProse in FACTS.json, beside the DOI, the verbatim quote, and the replacement wording
watched before 2026-08-15yesno, nothing in the repo knew the sentence had changed
present after the regeneration3 occurrences1 doorway line, 4 of "open loop"
fixable in phenomena.jsonno, the file is generatedno, the file is generated
entries carrying a sources array0 of 640 of 64

The register is the gate input, not documentation

The corrected passages live in FACTS.json next to the paper that corrected them. Both readers load them from there, so there is no second copy of the list to drift off the register a human maintains:

export const REGRESSED = (() => {
  let register;
  try {
    register = JSON.parse(readFileSync(FACTS, "utf8"));
  } catch (err) {
    /* Fail closed and loudly. A missing or unparseable register is not "no claims to check",
     * it is the check having quietly stopped running, which is how the last two got through. */
    throw new Error(`retired-terms: cannot read FACTS.json (${err.message}). The claim register is a gate input, not documentation.`);
  }
  return (register.claims ?? []).flatMap((c) =>
    (c.retiredProse ?? []).map((p) => [p.text, p.replacedWith, c.id]),
  );
})();

The throw is the part I would keep if I kept nothing else. An empty array on a missing register reports zero findings, which is indistinguishable from a pass.

The first cut of the deploy gate also failed any entry using "there's a name for" or "it's called" with no sources array. That flags 22 of the 64 entries, and the ways through were 22 hurried lookups or an allowlist, which is a supported way to ship past a failure. The rule narrowed instead: what stays blocked is supplying a name and asserting it is established vocabulary, matched on psychologists|researchers|studies|the literature|the term for, because that second half is checkable.

Both readers also take their field set from one proseOf() function. The gate used to read row.narration ?? row, which checks narration alone on every entry that has one, and all 64 do. beats carries the clip's on-screen text, ships in the entry payload, and standfirst() in src/lib/publication.ts falls back to beats[0] whenever narration[1] is missing.

How we built this: Why Your Brain Does That.


One shipped product, taken apart, once a month. What it does, what it cost to build, what the pipeline behind it looks like, and what the numbers did — read off the repository and the live site, not written from memory. Join the list.

← All writing