The decision record for the class of bug where one rule has to hold everywhere and quietly doesn't — written from four mechanisms already in our codebase.
2026-09-02 · origin CAM-2592 / PR #6792 · this page is the record · seven audits, findings verified at source
Revised 2026-09-03 — three of the eight moved; the ladder itself is unchanged.
A guard that lists what's dangerous makes every new lane dangerous. The maintenance summarizer scrubbed tenant text for two named origins. Everything else fell through to the raw words — and AppFolio sync, never on that list, is 1,504 of the last 1,650 work orders.
That polarity is the whole lesson, and it generalizes. When a guard enumerates what is forbidden, anything new is unlisted — and unlisted means allowed. Allowlists keyed on where a thing came from are the bug factory.
The same shape turned up in seven more places. The fair-housing rules exist in three separate wordings that already disagree — the copy that grades Clara knows source of income is protected in Colorado; neither copy that actually talks to a prospect mentions it. The consent loader returns no opt-out for any channel that isn't text or voice, so the day we add one it contacts people who asked us not to.
The instinct — raise an alert when something writes without the stamp — is already built: seven detectors, a nightly reconciler across seven invariants, an alarm ledger, Slack and email routing. Here is how it is going.
Both alarms anyone bothered to annotate were false positives. Every detector-side commit since August has been noise-tuning — excluding bench data, archived rows, count races.
The one real bug in that area was found by a person; the detector was written afterward. And the orphan scan was half of all production database reads before someone moved it off a five-minute tick.
The structural reason is already stated in ADR-0119, and the whole decision turns on this sentence:
An omitted write has no signature. The absent second call is invisible to every instrument we own.
So detection is the backstop, never the primary — and only for half of these invariants. Which half is not a matter of taste: a detector works when the invariant is a property of stored data, or a difference against an outside ledger (Twilio, for uncaptured texts). Otherwise the write must be prevented, not observed. And if you can force the write to leave a mark no other path can forge, you have already built the real guard, and the detector is just a net underneath it.
Four mechanisms, ordered by how hard each is to skip by accident. The bars are the content, not decoration. Start at the top and take the first row that fits the situation.
Reach for it when legitimate exceptions exist, or the caller has to actively decide something.
A required field on the options type whose value can be minted only two ways: the real thing, or a deliberate escape that must state its reason. A new caller cannot compile without choosing one. The exception list maintains itself, and the justification sits at the call site instead of in a document nobody re-reads.
ours: ADR-0089 · ADR-0119 · roughly 190 call sites
Reach for it when writer and wrapper sit in the same package and there are zero legitimate exceptions.
One public writer; one private raw writer nothing else can name; a pre-pass with no null branch; and a test that scans the source for the private name appearing anywhere else. Worth knowing what is not doing the work — the clever type that omits the field is ergonomic sugar, and the drift test is the sustainer rather than the enforcer.
ours: ADR-0032 · holds on 7 of 8 entities
Reach for it when the primitive must stay exported across a package boundary.
Keeping a function private is impossible when the wrapper lives in one tree and the primitive in another — which is exactly why the messaging gate can be bypassed at all. An import ban carrying a justified, dated allowlist converts a convention into a registry without moving any code.
ours: the raw database-helper ban, eslint.config.mjs:250
Reach for it when you cannot prevent the write, but the violation leaves a trace in the source.
Walk the tree rather than listing files; ship a dated allowlist of what already exists; ratchet in both directions, so a new violation fails even inside an allowlisted file and a stale row fails too — meaning a fix must delete its own row. The best one we have also teaches at the point of failure, telling you the answer is not another allowlist row.
ours: the customer-identifier fence
Last resorts. A detector only when the violation is visible at rest; a lens when nothing else fits, accepting that it is probabilistic and skips drafts.
Worth saying plainly: the review bot is not useless here. It caught all three customer-identifier violations on 19 August that CI missed — which is what earned the fence one row above. Lenses are good at finding a class of problem. They are bad at holding it.
ours: 10 lenses · 7 detectors · 0 verified detector catches
It is the one everybody believes in, and it is built by convention rather than construction. It is also not as leaky as it first looks: there are two gates at different heights, and the lower one holds for everyone.
The fix is therefore not to force everyone through the upper gate. It is row three of the ladder: ban the lower primitive by import, with a dated allowlist naming those ten and why. That turns a convention into a registry without moving any code across the package boundary that made the private-writer trick impossible here.
The counterpart is worth naming, because it is the one place the top tier was reached on purpose. The channel-adapter architecture makes a message envelope the required field — every inbound lane has to arrive in the same shape, which is why texts and email genuinely converge today rather than by anyone remembering to make them. Inbound and outbound both sit at the top of the ladder.
Its single gap is the same mistake in a different costume: adapter registration is a map where a total record belonged, so adding a carrier compiles with no adapter behind it and fails at runtime when somebody actually messages us there. Two carriers are already in that state. One type change closes it — the mechanism is the one the sensor registry already uses, where the compiler enforces totality.
| Invariant | Mechanism | State | The gap |
|---|---|---|---|
| Channels reach one inbound seam | Envelope, required | holds | The best example we have. Only adapter registration is a Map where a total record belongs, so a new carrier compiles with nothing behind it. |
| Person link on every entity | Private writer | 7 of 8 | User rows are born unstamped on every signup, then patched a few lines later. |
| Outbound record and language | Required field | holds | ADR-0119 is accepted but design-only. The generalization was never built. |
| Customer identifiers | Derived fence | holds | Runs after merge only, so it detects rather than prevents. |
| Texts pass the policy gate | Convention | two altitudes | Consent holds at the lower layer. The human-takeover gate and the kill switch do not. |
| Emails pass the same gate | Envelope seam | holds | Fixed 2026-09-03 (PR #6870). The takeover hold moved INTO the dispatch envelope rather than being wired per channel, so every channel inherits it. Construction, not detection — the reason this one is closed rather than watched. |
| Fair-housing rules | One constant + derived fence | holds | Fixed 2026-09-03 (PRs #6810/#6817/#6819). One exported list, ten consumers, and a sweep that fails when an eleventh hand-types its own. Source of income now reaches the prompts, not just the grader. |
| Work-order closure | Derived fence | in review | PR #6906. Four implementations, not three — and the fourth was the worse one: a TENANT standing down a visit closed the work order locally, left it open in AppFolio forever, and recorded it as "completed" when nobody did the work. A tier-2 private-writer upgrade is still available and was not taken; see the caution below. |
| Dates that decide, not display | — | leaks | The lint rule covers rendering. Nothing covers a date that drives a decision. |
It is 1,736 lines. It peaked at 2,236 and was cut back, so the bloat is already being fought. It is wrong in at least eight verified places — including a claim that a guard lane gates every pull request, which stopped being true two weeks ago and let two real defects through on this very piece of work.
The deeper reason is not accuracy, it is delivery. CLAUDE.md teaches at the start of a session, when nobody needs it yet. A fence teaches at the moment you do the wrong thing, which is why the best one we have carries this line in its header, where it gets read exactly when it is useful:
If you trip this fence, the fix is almost never to add an allowlist row — it is to move the value onto the property record and read it at runtime.
CLAUDE.md's job is the why you go and read after a guard fires. It cannot be the guard.
Accepted, design-only, never implemented — and it is the generalization of the whole answer. Highest leverage item here.
Shipped as one constant with ten consumers (not three) and an exhaustiveness sweep, so completeness is enforced rather than claimed.
Mirror the database-helper ban with a dated allowlist of the ten justified callers. One lint block, no code moves.
Every signup is born unstamped today, and only a runtime watch would notice if the repair throws.
Not all 362 — the block that got cut was 351 seconds. A single manifest walker is about one.
Three of the eight moved on 2026-09-03. Two patterns are worth adding to the record, because neither was visible when the ladder was written.
We reach for tier 4 by default, and the ladder says not to. Both fixes shipped that day used the derived fence — walk the tree, dated exceptions, two-way ratchet. It is a good mechanism and using the same one twice is what makes it scale. But the rule is start at the top and take the first row that fits, and for work-order closure a higher rung was available and not taken: closeWorkOrder stays exported, so a bypass still compiles and the fence catches it afterwards. Tier 2 fits exactly, and the machinery is already in that file — saveWorkOrder is a public wrapper over a private _unsafe_saveWorkOrder for the spine stamp. Routing terminal-status writes through the same shape would make the bypass fail to compile. The fence is holding the line today; the upgrade is real and unclaimed.
The guard you write is an instrument, and instruments lie the same way the code does. The closure work produced three defects in four review rounds, all one shape — a write that a later read-modify-write silently discards. The guard written to catch that shape then shipped with two defects of its own: it asked "is there any mutation after?" when the lines below any closure branch are full of them, and it measured its window in raw lines, so a twenty-line comment pushed the real defect outside it. It passed its own synthetic positive control both times. It only failed when the control was run against the real file. A hand-written control is the easiest shape to accidentally make passable — which is the same lesson as the banner below, one level up.