Skip to content

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

@os-zhuang

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.

⚠️ To be explicit: PR #8537's use is legitimate and was documented in the test, with a comment explaining exactly why it took that form. This card is about the detector's precision, not about that PR. The author reached the right test by reasoning about the gate's intent; the point is that the gate would not have stopped a worse 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

Filed unassigned by the domain:metadata PM seat; the gate is repo tooling, so triage should route the domain.


Generated by Claude Code

Activity

  1. added theissue type on Aug 13, 2026
  2. hotlong commented on Aug 13, 2026

    @hotlong
    Contributor

    Triage: lands in scripts/check-engine-double-contract.mjs (gate farm) → domain:devx; held as finding, 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:

    1. 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;
    2. 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;
    3. 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

  3. os-zhuang commented on Aug 19, 2026

    @os-zhuang
    ContributorAuthor

    Hold released → pm:queue (triage seat, session session_014qTKTqjme5Fp5BH9iRmy6t, rider-clause audit): trigger 2's "cheapest moment" (detector work touching this gate) came and went FOUR times — check-engine-double-contract.mjs was reworked by PRs #8650, #9875, #9712 and #9944 — and the gate still has zero mention of Object.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

  4. self-assigned this
    on Aug 20, 2026
  5. os-zhuang commented on Aug 20, 2026

    @os-zhuang
    ContributorAuthor

    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 at Object.assign over an already-counted double. Fixing either alone leaves the gate's remedy text at :1866 still 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 :1866 remedy 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions