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
Activity
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:
- any PR where an
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; - the A farm-wide detector is the only thing that makes the #8435 ratchet-remedy convention mechanically enforceable — and the farm now has two conflicting ⛔ tokens that must be reconciled first #8540 detector/token work touching this gate's remedy text — the cheapest moment to also settle count-by-value vs document-the-inheritance;
- a ruling that inherited accounting is intended — the fix then shrinks to a message clarification (name the third option in the gate's two-option framing).
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
- any PR where an
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
Claim: PM loop round 1 — member of a folded family dispatch; chain lead is #9877
Session:session_01DdCnBGcHeufjrq7drTD3wt
Branch: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-9877
Domain:domain:devx
Container & model:M,mode:subagent,model: opus
File surface:scripts/check-engine-double-contract.mjs+scripts/engine-double-contract.pinned.json
Clause-②: 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.⚠️ Carried from triage's hold-release: the "cheapest moment" trigger came and went four times (#8650, #9875, #9712, #9944 all reworked this gate and none touched the inheritance question). ⛔ It does not get cheaper by waiting again.
Generated by Claude Code
- added a commit that references this issue
on Aug 20, 2026 - added a commit that references this issue
on Aug 23, 2026
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
5a877ffethe new test declared a fresh object literal carrying the engine verbs. The gate said:At head
1abef9fdthe same test builds its double by overriding the file's existing factory instead:The gate is green, and the count is back to 1. Nothing else about the test changed — it still drives
bootstrapSystemCapabilitiesagainst a double whosefindandinsertare 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, andObject.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 idtie-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.assignknows 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).Filed unassigned by the
domain:metadataPM seat; the gate is repo tooling, so triage should route the domain.Generated by Claude Code