Measured while implementing #11626 (PR #11849), and deliberately left out of that PR's surface — its declared surface was scripts/check-engine-double-contract.mjs plus its ledger, and this needs edits in five test files across four packages.
The measurement
check:engine-double-contract admits a double either by ≥2 engine siblings or, after #11626, by a declared IDataEngine. Probing the tree with the sibling threshold dropped to zero (a patched copy of the gate, so the criterion is the gate's own code) surfaced 7 constructs that already call assertEngineUpdateDispatch and were invisible to the gate. #11626 admitted the 2 that carry an IDataEngine annotation. Five remain invisible, all on the update slice, all genuinely pinned to the producer's predicate:
| file:line | engine siblings | members |
|---|
packages/metadata-protocol/src/protocol.bulk-record-not-found.test.ts:114 | 1 (findOne) | {del, engine, findOne, rows, update} |
packages/metadata-protocol/src/protocol.many-data-atomic.test.ts:93 | 0 | {commits, del, engine, handle, rollbacks, rows, update} |
packages/metadata-protocol/src/protocol.record-not-found.test.ts:53 | 1 (findOne) | {del, findOne, p, store, update} |
packages/metadata-protocol/src/protocol.update-path-id-wins.test.ts:128 | 1 (findOne) | {del, findOne, p, store, update} |
packages/plugins/plugin-auth/src/member-role-canonical.test.ts:393 | 1 (find) | {calls, find, rows, update} |
Why it is worth closing
These are the good case, not the bad one: five authors wrote a minimal double and bound it to the producer's decision by hand, which is exactly what the gate asks for. They get no ratchet for it. If any of them drops its assertEngineUpdateDispatch call tomorrow, or drops the update member entirely, nothing reddens and no ledger row names the loss — the #9680 shape, on doubles that are currently correct.
The remedy, and why it is cheap
One type annotation per double, e.g. const engine: IDataEngine = { … } as unknown as IDataEngine or a factory return type, plus the import type { IDataEngine } from '@objectstack/spec/contracts' where it is missing. Each then enters discovery through #11626's declaration route and lands in the pinned ledger via --write. No behavioural change, no padding — the annotation states what the object already is.
⚠️ Worth confirming per file rather than applying blind: packages/metadata-protocol cannot import from @objectstack/objectql (turbo rejects the resulting cycle — see the modules note in scripts/check-engine-double-contract.mjs), but the four doubles above already import the predicate successfully, and IDataEngine is a type-only import from @objectstack/spec, which every one of these packages already depends on. Check that the annotation type-checks against each double's actual member set — a double narrower than IDataEngine needs as unknown as IDataEngine rather than a bare annotation, which is the spelling both #11626 doubles already use.
Not measured
Whether the delete slice has an equivalent population. The probe above ran both slices and found the pinned-but-invisible set to be update-only on this tree, but that is a fact about today's corpus, not a property.
Re-derive: patch a copy of scripts/check-engine-double-contract.mjs to make the two siblings.length < 2 thresholds configurable, run it over testFiles() for both slices, and select rows with pinned === true && siblings.length < 2 && !declared.
Generated by Claude Code
Measured while implementing #11626 (PR #11849), and deliberately left out of that PR's surface — its declared surface was
scripts/check-engine-double-contract.mjsplus its ledger, and this needs edits in five test files across four packages.The measurement
check:engine-double-contractadmits a double either by ≥2 engine siblings or, after #11626, by a declaredIDataEngine. Probing the tree with the sibling threshold dropped to zero (a patched copy of the gate, so the criterion is the gate's own code) surfaced 7 constructs that already callassertEngineUpdateDispatchand were invisible to the gate. #11626 admitted the 2 that carry anIDataEngineannotation. Five remain invisible, all on theupdateslice, all genuinely pinned to the producer's predicate:packages/metadata-protocol/src/protocol.bulk-record-not-found.test.ts:114findOne){del, engine, findOne, rows, update}packages/metadata-protocol/src/protocol.many-data-atomic.test.ts:93{commits, del, engine, handle, rollbacks, rows, update}packages/metadata-protocol/src/protocol.record-not-found.test.ts:53findOne){del, findOne, p, store, update}packages/metadata-protocol/src/protocol.update-path-id-wins.test.ts:128findOne){del, findOne, p, store, update}packages/plugins/plugin-auth/src/member-role-canonical.test.ts:393find){calls, find, rows, update}Why it is worth closing
These are the good case, not the bad one: five authors wrote a minimal double and bound it to the producer's decision by hand, which is exactly what the gate asks for. They get no ratchet for it. If any of them drops its
assertEngineUpdateDispatchcall tomorrow, or drops theupdatemember entirely, nothing reddens and no ledger row names the loss — the #9680 shape, on doubles that are currently correct.The remedy, and why it is cheap
One type annotation per double, e.g.
const engine: IDataEngine = { … } as unknown as IDataEngineor a factory return type, plus theimport type { IDataEngine } from '@objectstack/spec/contracts'where it is missing. Each then enters discovery through #11626's declaration route and lands in the pinned ledger via--write. No behavioural change, no padding — the annotation states what the object already is.packages/metadata-protocolcannot import from@objectstack/objectql(turbo rejects the resulting cycle — see themodulesnote inscripts/check-engine-double-contract.mjs), but the four doubles above already import the predicate successfully, andIDataEngineis a type-only import from@objectstack/spec, which every one of these packages already depends on. Check that the annotation type-checks against each double's actual member set — a double narrower thanIDataEngineneedsas unknown as IDataEnginerather than a bare annotation, which is the spelling both #11626 doubles already use.Not measured
Whether the
deleteslice has an equivalent population. The probe above ran both slices and found the pinned-but-invisible set to be update-only on this tree, but that is a fact about today's corpus, not a property.Re-derive: patch a copy of
scripts/check-engine-double-contract.mjsto make the twosiblings.length < 2thresholds configurable, run it overtestFiles()for both slices, and select rows withpinned === true && siblings.length < 2 && !declared.Generated by Claude Code