0134 — Bind a guard to the resource it protects, not to the caller that happens to reach it
- Status: Accepted
- Date: 2026-09-12
- Amended: 2026-09-15 — three further instances recorded (#8718, #8725/#8726, #8731) and the class generalised past "who calls it". No decision changed.
- Deciders: Gera (+ the two lanes that hit this independently, and a peer session that noticed they were one shape)
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.
Instance 3 — bound to its own WALK.
src/__tests__/customer-identifier-fence.drift.test.tsclaimed five shared runtime trees and walked 4,008 of the 5,862 files in them: its traversal stepped over__tests__,__mocks__,__fixtures__,fixturesand every*.test.ts. Three PRs in one night put a live customer's identifiers into that blind spot and the fence was 13/13 GREEN through all three (#8718). Those are #8718's own figures; the gap they imply — 1,854 files — is the number itscustomer-identifier-fixture-baseline.tsheader pins, so the claim is checkable in the tree rather than only in a PR body. The resource is the trees; the guard was bound to the traversal it happened to perform. Closed by defining the fixture scope as the SUBTRACTION of two walks, so a file cannot fall out of both.Instance 4 — bound to a SPELLING.
western-slope-front-door, a live company's anchor property, sat insrc/lib/domain/properties/phone-lookup.tsas an actual routing key rather than a label. The fence above could not see it: it matches a list of identifier spellings, and the resource is the routing map. Closed by reading the id fromconfig/phone-registry.json(#8725, #8726) — the identifier left shared source, rather than the fence learning one more spelling.Instance 5 — bound to a COPIED tree list.
src/__tests__/outbound-link-origin-fence.drift.test.tssaid, in its own scope comment, "the same four trees as the customer-identifier fence: what deploys". It walked 3,983 of the 5,873 files under those trees and never namedpackagesat all. The dates are the whole point: the outbound fence was written at 15:55 on 2026-09-03 (857b29282f) with the sibling's four trees, andpackagesjoined the sibling at 23:35 that same day (7701b52dbf). The copy was correct for seven hours. A copy does not follow the thing it was copied from, and nothing in either file could notice. Closed by #8731, whose first draft of the coverage control derived its denominator from that same list, so deleting a tree passed every test in the file: this ADR's defect class reappearing inside the fix for it. The denominator now comes from the sibling fence's source text.
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 condition the storage layer evaluates (a DynamoDB
ConditionExpression) — unbypassable by construction, and it needs no read, which is usually what made people put the check at a route in the first place; - failing that, a hook on the verb (every write primitive in the data layer) rather than on one caller of it.
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:
guardVendorCardPut/guardVendorCardUpdatelive in the data layer, keyed onPK='VENDOR#<id>' AND SK='META', and are called from every write verb in bothhelpers.tscopies (src/, and the Clara mirror that actually ships in the Lambda bundle). Guards early-return on any other key, so non-vendor callers are unaffected.- The duplicate-vendor rule is handed to DynamoDB as a
ConditionExpressionrather than asserted in JS — the data layer cannot read, and this is the shape that removes the need to. src/lib/domain/vendors/__tests__/vendor-card-verb-coverage.drift.test.tsmaintains the ledger: it discovers the write verbs each module exports and fails the build on any that is neither guarded nor exempt-with-a-reason. It reads the modules as text, so it cannot be satisfied by the code changing shape underneath it, and it carries its own populated-set control and mutation control — a ledger that can go vacuously green is the defect it exists to prevent.
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.