Every specimen here actually happened to this project. Each is filed beside the guard
that now prevents it — and beside what that guard costs, because a guard whose price is hidden
eventually gets "optimised" away by someone who cannot see what it was holding back.
10 specimens on file10 guards live in the build
SPECIMEN 01 · APPThe Turn That Ate the InventorySEALED · 2026-07-18
The defect
The AI-import path did a full replace of state.inventory, not a merge. When an AI response contained an inventory array, autoImportState() (js/services/api-import.js, ~line 379) ran state.inventory = inv.map(...) — the entire durable inventory overwritten by whatever the model returned that turn. The directive (js/services/api-directive.js, ~line 120) commands the AI to "return the ENTIRE inventory array" on any inventory-touching turn, so a turn where the model misjudged — e.g. a failed repair and an aborted craft — and emitted a short or empty array deleted real items from state. An empty array wiped everything. The [DELTA] inventory: 1→0 items line the owner saw was telling the truth: the item was genuinely removed, not miscounted.
CAUGHT BY · the owner's eye — tracing a live [DELTA] 1→0 against the code, confirmed real state loss not a display bug The owner's eye, then confirmed against the code: the DELTA counter reads actual state.inventory length before/after, so 1→0 is a real deletion. Traced against the owner's live screenshots on 2026-07-18. The pre-existing registry-leak guard covered only cross-game mismatches; a same-game short/empty array was unguarded — a hole no machine was watching until the owner hit it.
The guard
The inventory array became a reconciled proposal instead of a full replace, and the follow-up commit widened the same treatment to every AI full-replace-from-response field — the class fix, not just the reported symptom. Protocol 24 (validate + field-map, never blind-persist): a sync returning a short or empty inventory array must not delete natively-held items; net removals are reconciled against a real narrative signal or confirm-gated. A related trust guard (commit 6d9d723) makes the whole import path skip fields on a registry mismatch rather than accept the payload.
js/services/api-import.js · autoImportState() reconcile (proposal, not full replace); js/services/api-directive.js directive
What the guard costs
Every AI-authored state field now pays a reconciliation step instead of a straight assign, and the AI can no longer unilaterally remove items in one turn — a net removal needs a real signal or a confirmation. The AI's authority over durable state is deliberately narrowed.
✓GUARD HOLDS — red→green regression tests landed with both fix commits (Protocol 13/14) — SEALED at generation time: the fix commit is contained in the pinned release v2.8.5 (the record itself still reads IN-FLIGHT — it is a snapshot of when it was last written, not a live value).
_applySlotEnvelope() (the shared core behind LOAD SLOT and VERSION RESTORE) detected a cross-game context mismatch, confirmed it, and then applied the switch in place (loadUI() only, no reload). But FALLOUT_REGISTRY and databaseCSVs are boot-time globals, selected exactly once at boot by index.html's GAME_FILES manifest (one of reg_nv.js/reg_fo3.js + one of db_nv.js/db_fo3.js per boot). An in-place apply left those globals stuck on the old game's data while state.gameContext silently flipped to the new one. Every registry-backed surface was then serving the wrong game: location/item/quest/perk autocomplete, the native LOOT/THREAT/CONSULT lookups, and the AI's own databaseCSVs system context — not just the location box the owner first noticed.
Correction to the record this specimen was drawn from. The museum mockup (specimen 08) framed this as a cosmetic defect — "a New Vegas-skinned terminal showing a Fallout 3 save, the wrong wasteland's chrome around the right save." That is wrong. This is a data-integrity defect. The independent audit reproduced real campaign-data loss on the pre-fix code: the AI-import path (autoImportState) validates state arrays against FALLOUT_REGISTRY and drops every entry the registry doesn't recognise, on a write — so with a stale registry the campaign's own FO3 collectibles (Strength, Perception) matched nothing in the loaded NV registry and were silently emptied and persisted. The implementer's "only lookups and suggestions, never what got written" claim was overturned by the audit.
CAUGHT BY · owner report, then independent audit that reproduced real data loss Owner report of wrong-game autocomplete, then a Stage-3 independent audit (Protocol 8) that reproduced the bug red→green in a real browser and independently found the data-loss the implementer had ruled out.
The repair — filed beside it
The cross-game branch now persists robco_v8 synchronously and reloads, exactly like every other cross-game apply path (onGameContextChange, loadCloudSave, restoreCloudSaveVersion) already did — Protocol 22, no parallel apply path. Boot then re-selects the correct reg_.js/db_.js via the manifest. The same-game (common) path is unchanged: still an in-place loadUI(), no reload. Unsaved state survives the reboot because the fix sets window._loadingSave = true and persists before the reload, and the beforeunload flush honours that flag.
A full page reload on every cross-game load/restore — the heavier apply path — accepted because it is the only way to reselect boot-time globals honestly. The same-game path keeps the fast in-place apply, so the cost is paid only when the game actually changes.
BUGGYFIXED
✓GUARD HOLDS — red→green in a real Playwright browser both directions; regression Suite 228 + render-check.mjs end-to-end — SEALED at generation time: the fix commit is contained in the pinned release v2.8.5 (the record itself still reads IN-FLIGHT — it is a snapshot of when it was last written, not a live value).
The machine's system-level gitconfig sets core.autocrlf=true. Because museum/generate.mjs names each doc page docs/<sha256(rawBytes)[:12]>.html — hashing its inputs — a CRLF checkout does not merely add \r characters: it renames every generated page and re-churns the entire museum. Separately, sync.ps1 had been writing its generated manifests with PowerShell's default CRLF (WriteAllLines uses Environment.NewLine), which baked CRLF hashes for _CAPTURE_MANIFEST.txt, _CAPTURED_STORES.txt, ARCHIVE_RETAINED.md, and AUDIT.md into the committed museum. This is the same failure family as [[vanishing-addresses]]: an address that moves for a reason unrelated to the content's meaning.
CAUGHT BY · fresh-clone regeneration churning pages on a machine with autocrlf=true A fresh clone on the autocrlf machine regenerated with the whole museum churning — addresses moving with no content change.
The guard
Repo-root .gitattributes forces text=auto eol=lf (with .png/*.zip binary), overriding the system autocrlf. It is deliberately eol=lf (text) and not-text: -text would break the line-readable publication diff the archive depends on. sync.ps1 was rewritten to emit LF at the source, so the same rename bug cannot be reintroduced upstream of checkout.
.gitattributes (eol=lf, deliberately not -text); sync.ps1 writes LF, not CRLF
What the guard costs
Every text file in the repo is pinned to one line ending regardless of platform convention, and both surfaces (.gitattributes on checkout, sync.ps1 on write) must stay correct — two places that must agree forever.
✓GUARD HOLDS — with LF pinned, a fresh clone regenerates without churn; proven from a clone, not a local re-run
SPECIMEN 04The Premature PinANTICIPATED — NEVER SHIPPED
The defect (anticipated)
This defect never shipped — it was caught in review as a failure path in the release machinery. Writing the release pin at the start of a --release run looked harmless. It was not: a run that failed partway would leave the pin claiming a release the museum was never generated for. The museum on disk would be the old release, the pin would claim the new one — and because the pin then matches the newest tag, the NOT CURRENT notice (see [[silent-flatline]]) goes dark at the exact moment it should fire. The guard meant to prevent "authoritative and quietly wrong" would itself have caused it.
CAUGHT BY · reasoning through the failure path in review By reasoning through the failure path in review, before any release ever exercised it. The inline comment in resolveReleasePin() records the reasoning verbatim.
The guard
The pin is written in exactly one place — commitPin(), after the new museum has been validated and atomically swapped onto disk. resolveReleasePin() marks the intended tag pendingWrite: true but writes nothing. A failed run leaves the old museum and the old pin, still honestly describing each other.
museum/generate.mjs · the pin is written after a successful build, at line ~2401 (commitPin), never in resolveReleasePin() at line ~255
What the guard costs
A failed --release run must be re-run from the start; the pin can never be advanced "in preparation" for a build that has not succeeded yet.
✓GUARD HOLDS — a museum on disk and its pin can never describe two different releases — the pin is written in exactly one place, after the atomic swap
Cold-rendering the 28 Playwright prototype thumbnails produced a different 11–13 of them with a few dozen-to-hundred differing bytes each — a different subset every run. The first diagnosis, "PNG re-encode jitter," was wrong, and the correction is the lesson: 27 of the 28 prototypes carry infinite CSS animations (coreSpin 9s linear infinite, corePulse 2.6s ease-in-out infinite, a pulsing status lamp), and the capture waited a flat 600 ms after load with no synchronisation to animation phase. Ordinary process-scheduling jitter landed each screenshot at a different rotation angle / glow intensity — real pixel drift, not lossless-encoder nondeterminism on identical pixels. The other candidate fix the earlier pass had named, "force deterministic PNG encoding," would not have touched the actual cause at all.
CAUGHT BY · git status dirty after a supposed no-op regeneration git status showed modified PNGs after a regeneration that should have been a no-op, run from a clean clone.
The guard
screenshotPrototypes() now checks the already-committed museum/site/assets/thumbs/<sha>.png (the previous successful build's tracked output) before the local .cache/thumbs and before ever launching Playwright. A clone regenerating an unchanged manifest finds every thumbnail already present and re-renders nothing — so a prototype is rendered once per content-hash, ever, and the opportunity for drift simply never recurs. The animation-timing question is left unsolved on purpose; the guard removes the occasion for it rather than the cause.
museum/generate.mjs · screenshotPrototypes() · line ~1501
What the guard costs
An unchanged prototype can never get a fresh screenshot. If a thumbnail ever looks stale, the only way to re-shoot it is to change the prototype's actual bytes.
✓GUARD HOLDS — fresh clone AND bare-derived clone both report "28 total, 28 cached, 0 to render", git status clean
The entire text corpus was inlined into search.html — 3.9 MB parsed before the input box would take a single keystroke, on a museum whose primary reader is on a phone. It was the heaviest page on the site by two orders of magnitude.
CAUGHT BY · weighing the generated pages against the phone-readable constraint Weighing the generated pages against the museum's phone-readable constraint — the page weight itself was the finding.
The guard
The corpus now ships as numbered sibling scripts, loaded only at the first real search, and all shards load before any query runs. A shard that fails to load produces an explicit error, never a quietly short result list. The weight is printed on the page, so the reader chooses to spend it. Total payload is unchanged; when it is paid is not.
museum/generate.mjs · search shards · line ~2051
What the guard costs
The first search shows a visible loading step it never used to need — the page admits its weight instead of pre-paying it behind the reader's back.
✓GUARD HOLDS — search.html is a few KB; results are always over the complete corpus or an explicit error, never a silent subset
When the application retired its test-count rule and removed one marker from the changelog, the growth collector silently dropped the newest point entirely — its date, files and lines included. The chart went back to ending ten days earlier and looked perfectly healthy doing it, while every other page of the museum stayed current. Nothing in the build complained. This is the defect class the whole museum is designed against: authoritative, and quietly wrong.
CAUGHT BY · a human noticing — no machine was watching By a human noticing — and that is the whole problem: no machine was looking. At the moment the point was dropped, no check existed that compared the chart against anything; the currency guard below is precisely the check that was added in response. The mockup attributes the catch to the owner's eye; the primary sources confirm the load-bearing fact — that the defect was invisible to every automated gate — but do not independently name the specific person, so this record claims only what it can source. (See Provenance.)
The guard
A currency check now runs at every build: the newest charted point must be the pinned release, and no release may exist newer than the pin. Either failure renders a loud GROWTH EXHIBIT — NOT CURRENT notice on the exhibit itself — written by the check, never by hand, and it removes itself when the cause is fixed. Pinning to a release (never to dev) is what gives the notice a single unambiguous meaning; against a moving branch the check could not exist, because there is no such thing as being "behind" a ref that moves every commit.
museum/generate.mjs · growthCurrency() · line ~1427
What the guard costs
A warning pathway that must itself stay correct forever — and a museum that will sometimes publicly announce its own staleness rather than quietly look fine. The project accepts looking un-current over looking wrong.
✓GUARD HOLDS — the notice is pinned to a release, so it can mean exactly one thing — a release shipped and nobody regenerated
The first growth chart drew check count (max 2,951) and code lines (max 94,435) on one set of axes, each series scaled to its own maximum so both ended at the same height. The visible axis was labelled in checks only. The headline the picture made — "these two grew together" — was an artifact of the normalisation, not a fact about the project. Every individual number was correct; the composition was the lie.
CAUGHT BY · design review against the archive's "never quietly wrong" standard Design review of the chart against the archive's own "never quietly wrong" standard.
The guard
One series per panel, one axis per panel, every axis in that series' own units, drawn from zero. renderSeriesPanel() accepts exactly one series — there is no code path that can put two units on one scale.
museum/generate.mjs · renderSeriesPanel() · line ~1246
What the guard costs
Three stacked panels where one picture used to be — and no overlaid comparisons at all, including the legitimate ones. Comparing the shape of two series now takes two glances instead of one.
✓GUARD HOLDS — every chart on the growth page states its own unit and its own axis, drawn from zero
museum/generate.mjs's walk() recursed fs.readdirSync over the whole archive root, so any file on disk — tracked or not — became a published page and fed the global input-tree stamp. The repo-root README.txt was gitignored and explicitly marked "NOT part of the archive", yet it leaked in exactly this way: it got its own doc page (docs/fe41f879e76a.html) and contributed to a build stamp that was only reproducible on a machine that happened to have that untracked file sitting on disk. The museum could not be regenerated from a clone, because a clone never materialises an untracked file.
CAUGHT BY · fresh-clone regeneration Regenerating from a fresh clone and comparing stamps: the input-tree hash differed with nothing visibly wrong on the page. The discrepancy — not any visible defect — was the tell.
The guard
walk() now runs git ls-files -z scoped to the archive root and returns that list (still excluding the museum/ subtree). The manifest is the tracked-file set — exactly the paths present in any clone of the commit, bare or not. An untracked stray cannot leak, because the museum never consults the disk to decide what exists. This was chosen over an .gitignore-respecting walk because it is strictly stronger: it also excludes an untracked-but-not-ignored stray, which a gitignore-only fix would still leak and which still would not survive a clone.
museum/generate.mjs · walk() · line ~563
What the guard costs
A file does not exist for the museum until it is committed. A scratch note lying in the archive folder is invisible — deliberately — until someone says it belongs by tracking it.
✓GUARD HOLDS — fresh clone AND bare-derived clone both regenerate to the identical stamp 80bf9bb · f64fb7397980c9cb
Document pages were named docs/<sha256(rawBytes)[:12]>.html — the generator content-hashed its inputs, so every edit moved the page's address, and every regeneration dropped the old one. Across this repo's own history, 389 distinct hash addresses have existed. Worse, and this is the second half of the specimen: the first mining pass to recover the old addresses found only 305 of them. Git's content-similarity rename detection was pairing unrelated generated pages as "renames" of each other — they share large boilerplate around a short doc body — and hiding the rest from --diff-filter=A. A mechanism built to lose nothing was itself quietly losing 84 addresses.
CAUGHT BY · measuring the same history query with and without --no-renames and getting two different answers The address-instability was caught by the owner-facing naming review. The miscount was caught by measuring the same history query with and without --no-renames and getting two different answers (305 vs the true 389).
The guard
Two guards. (1) Pages are named from the document's archive path, mirroring the archive's directory tree — the address only moves if the document itself is renamed. A runtime seenSlugsLower check aborts the build if two paths ever fold to the same address case-insensitively (Windows checkouts fold case). (2) Every old hash address ever published is frozen in a committed redirect-ledger.json, mined once with --no-renames on both git log calls; each old address whose document still exists gets a meta-refresh redirect stub at the old path, every build. HTML stubs (not Cloudflare _redirects) so old bookmarks resolve even on a raw file:// open.
museum/generate.mjs · redirect ledger · lines ~310 and ~1741; migrateRedirectLedger()
What the guard costs
A 389-entry ledger committed forever, plus a redirect stub file per live old address in every build — paid so that no link anyone ever saved can rot. The --migrate-redirects ritual is a maintained code path (though it should not need to run again, since path-based naming produces no new hash addresses).
✓GUARD HOLDS — fresh + bare-derived clones regenerate identically; a redirect stub confirmed resolving to its new nested page over HTTP
Coverage, not a target: from
v2.8.0 to the pinned release
v2.8.5, 39 commits matched a fix-like
message (fix: / fix(...)), and 2 bugs/ records cite a fix commit that shipped in that same window. Both numbers are computed at generation time, never
hand-typed. A gap here is not itself a problem — bugs/BACKFILL_REPORT.md §6 explains why "one record per
fix" was rejected — but a widening gap across releases is worth someone reading this noticing.
Specimens are read directly from bugs/<slug>/record.md — discovered
by glob, nothing hardcoded here. The full set (every recorded defect, exhibited or not) is searchable;
this wall shows the curated exhibited: true subset, ordered by whether the guard is a named
standing rule (a Protocol number) and capped so the wall stays a museum. An IN-FLIGHT app-repo specimen
promotes to SEALED here the moment its fix commit is an ancestor of the pinned release — checked fresh
every build, never written back to the record file.
STAMP · generated for RELEASE v2.8.5 commit 06e5180 (06e51801b38a) · archive input-tree hash c07fbfbdd2e1ddeb · 754 files · no wall-clock timestamp (regenerates identically when nothing changed).