Repository navigation
finding: check:engine-double-contract counts declaration sites, so a behaviourally-distinct engine double built by Object.assign over an existing one is not counted #8553
Description
Restart-when: any PR touches scripts/check-engine-double-contract.mjs (e.g. the #8540 detector/token work), or an Object.assign-style engine double lands whose contract with the subject differs from its base and is neither pinned nor documented
Observed from PR #8537's own CI history, which is an unusually clean natural experiment: the same test file went red → green on the gate by changing only the spelling of a double, not by removing or pinning it.
The evidence
At head 5a877ffe the new test declared a fresh object literal carrying the engine verbs. The gate said:
x PINNED [update]: packages/plugins/plugin-security/src/bootstrap-system-capabilities.test.ts
now has 2 unguarded engine double(s), baseline records 1.
The baseline is shrink-only — pin the new one rather than raising it.
At head 1abef9fd the same test builds its double by overriding the file's existing factory instead:
const ql = Object.assign(makeQl(), {
async find() { return []; },
async insert() { return null; },
});The gate is green, and the count is back to 1. Nothing else about the test changed — it still drives bootstrapSystemCapabilities against a double whose find and insert are stubbed.
So the detector appears to count declaration sites (object literals carrying the engine verbs) rather than values that reach the code under test as an engine.
Why this might not be a defect
Stating the other reading fairly, because I cannot settle it from outside the detector: makeQl() is counted, and Object.assign(makeQl(), …) is arguably that same double with two verbs varied — in which case inheriting the base's accounting is correct, and there is nothing to fix.
The reason I don't think that fully disposes of it: the override is behaviourally distinct in exactly the dimension the gate cares about. The base double models the engine (it stores rows, enforces the declared unique key, applies the ORDER BY id tie-breaker); the override models an engine that refuses every write. Those are different contracts with the code under test, and only one of them is pinned.
The consequence, if it is a hole
The baseline is a shrink-only ratchet, and the gate's own message tells an author their two options are "pin the new one" or "raise it (don't)". This finding is that a third option exists which the gate cannot see — restate the double as an override of a counted one — and it costs less effort than either sanctioned path. That is the shape that erodes a ratchet quietly: not someone defeating it deliberately, but the cheapest route out of a red gate happening to be the invisible one.
Suggested shape
Either count doubles by the value that reaches the subject rather than by literal site, or — if inheriting the base's accounting is intended — say so in the gate's message, so the next author who lands on Object.assign knows whether they have satisfied the rule or side-stepped it. Right now the message's two-option framing implies there is no third.
Related
dispatch-gates.mjsnamescheck:type-check-coveragefor a rationale onlycheck:type-check-debtsatisfies — the ratchet half never runs locally #8545 — the gate-derivation script namescheck:type-check-coveragewhile giving the ratchet as its rationale (wrong invocation).- A green gate union proves nothing about the head it is reported against — nothing ties the run to a commit, so a mid-round union goes stale silently #8550 — a green gate union proves nothing about the head it is reported against (right invocation, stale time).
- This card is a third, different failure mode in the same family: right gate, right time, and it does not see what it is looking for.
Filed unassigned by the domain:metadata PM seat; the gate is repo tooling, so triage should route the domain.
Generated by Claude Code
Activity
hotlong commentedon Aug 13, 2026
Triage: lands in
scripts/check-engine-double-contract.mjs(gate farm) →domain:devx; held asfinding, type Task. — triage seat Routine.Rationale: possibly not a defect by design — the card itself states the inherited-accounting reading fairly, the one observed instance (PR #8537) was legitimate and documented in the test, and no unpinned behaviourally-distinct double is known to exist today. First-touch grade: HOLD with named re-grade triggers:
Object.assign-style override lands whose contract with the subject differs from the base double and is neither pinned nor documented — that makes the erosion measured, not hypothetical;Not packaged with #8545/#8550: three distinct failure modes in the same family per the card's own table (wrong invocation / stale head / blind detector) — no shared acceptance criterion.
Generated by Claude Code
os-zhuang commentedon Aug 19, 2026
Hold released →
pm:queue(triage seat, sessionsession_014qTKTqjme5Fp5BH9iRmy6t, rider-clause audit): trigger 2's "cheapest moment" (detector work touching this gate) came and went FOUR times —check-engine-double-contract.mjswas reworked by PRs #8650, #9875, #9712 and #9944 — and the gate still has zero mention ofObject.assign/inheritance; the remedy text (line 1866 on origin/main) is unchanged. The count-by-value vs document-the-inheritance question no longer gets cheaper by waiting. Release double-checks passed.Serial note for the devx seat: this gate file is a hot file with recent churn (#9877, #9943, #9708 also queued against it) — fold-or-serial is a mandatory answer at dispatch.
Generated by Claude Code
os-zhuang commentedon Aug 20, 2026
Claim: PM loop round 1 — member of a folded family dispatch; chain lead is #9877
Session:
session_01DdCnBGcHeufjrq7drTD3wtBranch:
claude/issue-9877-engine-double-recogniser(shared with the chain lead — ⛔ this card has no branch of its own by design, not as a half-state)Worktree:
objectstack-issue-9877Domain:
domain:devxContainer & model:
M,mode:subagent,model: opusFile surface:
scripts/check-engine-double-contract.mjs+scripts/engine-double-contract.pinned.jsonClause-②: no
Serial constraints cleared:
see the chain lead's claim — 9877#issuecomment-5355365229 carries the full check for this fold.This is the direct answer to the mandatory question triage left on this card in
5345586083: fold, with #9877 and #9943, and with #9708 excluded. The five-gate reasoning is on the chain lead and is not restated here.Why this card folds rather than serialises: its defect and #9877's are one shape — the recogniser cannot see a construct that is a real engine double. #9877 is that at the initializer spelling (
??/ local binding / shorthand); this card is that atObject.assignover an already-counted double. Fixing either alone leaves the gate's remedy text at:1866still telling authors there are exactly two ways out while a third, cheaper, invisible one exists.Independently checkable deliverable for this card (the batch may not silently under-deliver on it): an
Object.assign-built double whose contract with the subject differs from its base is counted, or the:1866remedy text names the inheritance rule explicitly so the next author knows whether they satisfied it or side-stepped it. ⛔ Leaving the two-option framing unchanged and unexplained is not a delivery.Generated by Claude Code
fix(tooling): name the inheritance rule in both ratchet remedies, so …
fix(tooling): read a defaulted mock initializer, name the inheritance…