0134 — Bind a guard to the resource it protects, not to the caller that happens to reach it

Context

Two unrelated bugs filed on the night of 2026-09-12 turned out to be the same bug. A third lane was asked to write down the shape, because a generalisation living as a sentence inside two unrelated tickets is a generalisation nobody will find.

The shape:

A guard's coverage is defined by who calls it. The thing it protects is a resource. Any write that reaches the resource by another route passes unchecked — and the guard's own documentation reads exactly like coverage.

Instance 1 — the vendor write invariants (a table row). src/lib/domain/vendors/vendor-write-invariants.ts said, verbatim, "Enforced at the single write chokepoint (saveVendor)." It had exactly one non-test call site. Six routes reached PK='VENDOR#<id>' / SK='META':

route verb guarded before #8097
src/lib/data/dynamo/property.ts saveVendor putItem yes
agents/clara/lib/data/dynamo/property.ts saveVendor putItem no
src/lib/data/dynamo/vendor-identity.ts engageVendor transact Put no
scripts/backfill-vendors.ts batchPut no
scripts/strip-vendor-legacy-fields.ts updateItemFields no
scripts/vendors/classify-trades.ts updateItem no

One of six. The chokepoint was a function; the vendor card is a table row; and put is only one of the verbs that reaches a row. The update verb was the one no put-shaped search could ever have found — and UpdateItem is an upsert, so updateItemFields(vendorPK(typo), 'META', …) did not merely edit an unguarded row, it minted one.

Instance 2 — the row fence on a session's right to write (agent-smith, out of this repo). The fence is wired thoroughly into pr_drive_cli.py, which routes every external-effect verb through row_fence.gate(...) and fails closed. The defect is that lanes do not go through that CLI at all — a lane shelling out to gh pr create from a Claude session is unfenced. There are two routes to the effect and the guarded one is the route nobody takes. That instance is another lane's to fix; it is recorded here because the class is the thing this ADR is about, and one instance is a coincidence.

THREE MORE INSTANCES, 2026-09-15 — and they are NOT all "who calls it". Recorded rather than re-decided: the decisions below are unchanged and all three are consistent with them. What they change is the shape of the question a reader should ask, because the caller turned out to be one proxy among several.

THE GENERALISED TEST. A guard is bound to a proxy whenever its coverage is decided by something that merely correlates with the resource:

the proxy the resource how it fails
the CALLER (instances 1, 2) a table row; a session's right to write another route reaches it
the WALK (instances 3, 5) the trees that deploy a file the traversal skips
the SPELLING (instance 4) the routing map the same fact written another way

Name the resource in one sentence, then ask what decides the guard's coverage. If those are two different sentences, the difference is the gap — and decision 4 below says it gets written down even when it cannot be closed.

Why this needs its own record. It is adjacent to, but distinct from, the family of checks that cannot fail. Here the check works perfectly on every call it receives. It simply does not receive the calls that matter — so it passes its own tests, and a reader hunting for checks-that-cannot-fail will not find it. The failure is silent and looks exactly like coverage.

Decision

1. Prefer an enforcement point the resource cannot be reached without. Bind to the thing, not to a caller:

A guard bound to a verb is checked even by code that does not know the guard exists. That is the property being bought.

2. Enumerate the verbs, and keep the enumeration mechanical. A guard wired into "every write verb" is true on the day it is written and says nothing about the seventh verb. Adding a write primitive is a normal, reviewable, innocent act; noticing that it opened a new route to a guarded resource is not. So the enumeration must be derived from the code and asserted, not maintained by memory.

3. Every route is GUARDED or EXEMPT-with-a-written-reason. There is no third state, and in particular no silent one. Not everything should be guarded — deleting a vendor card creates neither a duplicate nor a disconnected vendor, so guarding deletes would be ceremony. The point is not that every route is guarded; it is that every route has been looked at, and that the reason an unguarded one is unguarded is written next to it.

4. The prose must state what the guard does NOT cover. This is the cheap half, and it is worth doing even where the structural half is impossible or partial — because the prose is what makes the gap invisible. A docstring may never claim the resource is protected when what is protected is a function. If a coverage claim is enforced nowhere, it is a comment that will be wrong within a quarter; prefer a claim some test can read.

Consequences

Committed to:

Harder: adding a write primitive to helpers.ts now costs one decision — guard it, or write down why not. That cost is the feature.

Easier: a reviewer can answer "is this resource actually protected?" by reading one ledger instead of grepping for call sites, which is the method that produced the wrong answer twice in the instances above.

Known limit, stated because it is the same class one level up. The ledger discovers write verbs by matching new PutCommand( / UpdateCommand / DeleteCommand / BatchWriteCommand / TransactWriteCommand. That is itself a binding to a calling convention. A verb that reached DynamoDB some other way would be invisible to it. It is not hidden: the test asserts the discovered population is non-empty and contains every verb known today, so a scanner going blind reddens instead of passing. The honest statement is that this moves the boundary out by one layer; it does not abolish it.

Alternatives considered

Leave the guard at saveVendor and fix the callers. Rejected: it is the status quo that produced the bug. It requires every future author to know the chokepoint exists, which is exactly the knowledge the six unguarded routes demonstrate people do not have. It also cannot be enforced — "did you remember to call it" has no mechanical form.

Enforce the full invariant at the resource layer. Rejected on evidence, not taste. Rule (1) (af.vendorId required) needs to know whether the row already exists, and the data layer cannot read. It is also genuinely wrong at that layer: af is a per-org field under the 2026-09-11 two-entity ruling, so the platform vendor card is af-less on purpose and engageVendor writes one. A resource-level "af required" would refuse a correct write. Coverage is therefore split deliberately across two layers, and vendor-write-invariants.ts states which rule each layer can and cannot see — which is decision 4 applied to this ADR's own subject.

A generic "resource guard" framework. Rejected as premature. Two instances justify a written class and one concrete ledger; they do not justify an abstraction over guards, which would add a layer whose own coverage would then need a ledger.

An ESLint rule instead of a drift test. Rejected: the property is whole-module reachability (does this verb reach the fence, possibly through a module-local helper), which is awkward in a per-file lint pass and trivial in a test that reads both modules. The repo already uses *.drift.test.ts for exactly this shape — see raw-store-importers.drift.test.ts and no-forged-contexts.drift.test.ts.