Skip to content

check:engine-double-contract has no ratchet on its DISCOVERED population — deleting a pinned double's delete() member takes 319 pinned to 318 and the gate stays green #9680

Description

@os-steve

Filed unassigned by the dev seat working #9165, session session_01XqDQYVU5smx29ts9pAErja. Measured on origin/main @ c07d6e8b9, two-direction ablation, reproduction below. Not a claim about the gate's own slice logic, which is correct on the population it holds.

⛔ No domain:* and no labels set — triage's field.

The gap

scripts/check-engine-double-contract.mjs discovers its population by looking for an object literal / class / mock-constructor whose member named delete or update has an engine-shaped signature. Discovery therefore requires the member to exist. A double that stops declaring it simply leaves the population.

Nothing watches for that. The four invariants in the header are DISCOVERED, PINNED, RECONCILED, DECLARED:

  • DISCOVERED fires at zero — "Zero is not 'a clean repo', it is a broken scan". It does not fire at one fewer than yesterday.
  • RECONCILED reconciles in both directions, but only over baseline entries. A file in the DEBT ledger whose count drops is an error. A pinned file whose double disappears is invisible: pinned files are not enumerated anywhere durable, so the pinned count is a printed number, not a checked one.

So the population is unratcheted in exactly the direction that loses coverage.

Measured — two-direction ablation on a PINNED double

Subject: packages/core/src/utils/migration-journal.test.ts, whose fake declares both write verbs and routes each through the producer predicate.

Baseline @ c07d6e8b9:

check-engine-double-contract: OK — 319 pinned, 133 in the DEBT ledger, 2 exempt.

Ablation A — delete the whole async delete(...) member (the shape a partial double actually has: the method is absent, not wrong):

  pinned [update]  packages/core/src/utils/migration-journal.test.ts
check-engine-double-contract: OK — 318 pinned, 133 in the DEBT ledger, 2 exempt.
exit 0

Green. The delete-slice pin for that file evaporated and the only trace is a printed integer nobody compares.

Control — keep the member, delete only the assertEngineDeleteDispatch(options); line:

  x PINNED [delete]: packages/core/src/utils/migration-journal.test.ts declares 1 engine
    double(s) whose delete() does not route through assertEngineDeleteDispatch (line 50). …
check-engine-double-contract: 1 problem(s).
exit 1

Red, named, actionable. So ablation A's green is a real blind spot, not a broken harness.

Reproduce:

git worktree add ../os-ablate origin/main && cd ../os-ablate && pnpm install
node scripts/check-engine-double-contract.mjs                 # 319 pinned
# remove the 4-line `async delete(...)` member from packages/core/src/utils/migration-journal.test.ts
node scripts/check-engine-double-contract.mjs; echo $?        # 318 pinned, exit 0
git checkout -- packages/core/src/utils/migration-journal.test.ts

Why it matters, stated without inflating it

This is the #4868 family the script's own DISCOVERED note names — a check that runs, is green, and structurally cannot reach its subject — with the reach lost gradually rather than all at once. The gate's whole value is that a hand-rolled engine fake cannot silently diverge from the real one; a fake that silently stops being a fake is the same loss reached by a cheaper route.

⛔ What this card does NOT claim. No instance is demonstrated. I did not sweep for a double that lost a verb, and I am not asserting one exists — this is a property of the checker, measured by ablation, exactly as #8639 and #8553 were. The engine gate is also not the gate that would have caught either instance on #9165 (both were registry doubles; neither declares delete or update at all).

Not a duplicate of the neighbours, and why

  • #8553 (open, pm:on-hold) — a double restated as Object.assign(base, …) is not counted as new, so a new contract goes unpinned while the count stays put. Fixing it ("count by the value that reaches the subject") would not fix this: a double that drops the member leaves the population however you count values.
  • #8639 (closed) — implOf dropped a call-expression initializer, so delete: vi.fn(async …) was never discovered. That was a double the scan never had. This is a double the scan had and lost.
  • #8058 / #8194 (both closed, #8194 by PR #8423) — existence coherence of a double's members. Different property: theirs is a member that answers wrongly, this is a member that is absent.

Common root with #8553: the discovered population is a printed quantity rather than a checked one. They want different fixes.

Candidate shapes, none costed

  1. Ratchet the discovered count the way the DEBT ledger is ratcheted — a declared expected minimum per slice, shrink-fails. Cheapest, and reuses machinery the file already has, but it is a number to maintain and a legitimate test-file deletion reddens it.
  2. Enumerate the pinned set, not just its size, so a pin that disappears names itself. Bigger artifact, but the diff says which file lost which verb, which is the message an author can act on.
  3. Report the population delta as a distinct verdict rather than folding it into OK — the generalisation of DISCOVERED from zero to delta, and the same idea #9657 and #9165 want in the sibling gates: "I could not recognise this shape" must not read the same as "clean".

Option 3 is a design act with a maintainer in it (same class as #8901), so it is deliberately not recommended here.

Related

#9165 (the card this was measured under — its H1 answer) · #8553 · #8639 · #8058 · #8194 · #9657 · #8845 · #8901

Activity

  1. os-steve commented on Aug 18, 2026

    @os-steve
    CollaboratorAuthor

    Claiming this card.

    • session: session_01XqDQYVU5smx29ts9pAErja
    • branch: claude/issue-9680-discovered-ratchet
    • worktree: ../objectstack-9680

    Plan follows the dispatch: reproduce the two-direction ablation unchanged first (H2, with the gate's build preconditions established), then price count-delta vs identity ledger against real churn numbers on the scanned corpus (H1), check whether the DEBT ledger and exempt set carry the same absence-blindness (H3), and answer honestly whether the chosen shape would have caught the two original instances (H4). Additive only — the four existing checks are not restructured.


    Generated by Claude Code

  2. os-steve commented on Aug 18, 2026

    @os-steve
    CollaboratorAuthor
    {
      "issue": 9680,
      "status": "done",
      "branch": "claude/issue-9680-discovered-ratchet",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9712",
      "premise_still_valid": true,
      "summary": "H2 first: the card's premise reproduced EXACTLY, both directions, on packages/core/src/utils/migration-journal.test.ts. PRECONDITIONS: the gate needs NO built workspace - it is a pure TypeScript-AST scan (imports 'typescript', readFileSync) over source, so `pnpm install` in a fresh worktree is the whole requirement; there is no dist/ dependency, which is why a checkout without node_modules errors before it checks anything. Pinned count today is 319 (not #9165's 311 - it moved), 133 DEBT, 2 exempt, exit 0. H3 CHANGED THE SHAPE OF THE FIX: the DEBT ledger and the exempt set are NOT blind - both already fail on stale entries via RECONCILED, measured by ablation - so the pinned population was the ONLY hole, and the fix is correspondingly narrow. H1: took the IDENTITY LEDGER, and the churn numbers chose it rather than the lean - 269 commits on main in the month to 2026-08-18, membership changed in 7 commits (2.6%), 8 files entered per verb, and ZERO left, so ruling 1's nuisance case fired 0 times in a month. Added a fifth invariant RETAINED over a generated 308-row (file, verb, pinned-count) ledger at scripts/engine-double-contract.pinned.json; the four existing invariants are untouched, per ruling 2 and the #9681 precedent. H4 is an honest NO - see below; #9165's actual gap is NOT closed by this.",
      "tests": "All run in worktree ../objectstack-9680 off origin/main @ ed4ca5999; local gate union re-run on FINAL commit b1789af5f (= pushed remote tip, verified). H2 BASELINE: `node scripts/check-engine-double-contract.mjs` -> 'OK - 319 pinned, 133 in the DEBT ledger, 2 exempt.', exit 0. H2 ABLATION A (delete whole `async delete(...)` member, lines 104-107): BEFORE FIX 'OK - 318 pinned, 133 in the DEBT ledger, 2 exempt.' exit 0 (green - the blind spot, reproduced); AFTER FIX exit 1, 1 error: 'RETAINED [delete]: ... is still on disk but declares NO engine double with a delete any more, while the pinned ledger records 1. This is the #9680 shape exactly'. H2 CONTROL (keep member, delete only `assertEngineDeleteDispatch(options);`, line 105): BEFORE FIX exit 1 'x PINNED [delete]: ... does not route through assertEngineDeleteDispatch (line 50)'; AFTER FIX exit 1 with PINNED plus a cross-naming RETAINED line. Both directions match the card verbatim. H3 ABLATIONS: DEBT-ledgered double loses its delete member (packages/cli/src/commands/serve-email-appname-precedence.test.ts) -> RED, 'RECONCILED [delete]: baseline entry for ... declares no engine double with a delete any more (file deleted, fake removed, or the shape changed). Delete the entry.'; one of the 5 EXEMPT doubles removed (packages/spec/src/contracts/data-engine.test.ts) -> RED, 'RECONCILED [delete]: ... is down to 4 unguarded engine double(s) from the baseline's 5.' So both ledger halves ALREADY have the check:published-readme-exports stale-entry property; only the pinned set lacked it. H1 CHURN: recomputed pinned-file membership at each of 269 commits via git grep of the four pin symbols over packages/**/*.test.ts + examples/**/*.test.ts (proxy validated against the gate's exact pinned set at HEAD: 0 false negatives, 18 false positives all producer/CHANGELOG files, which were excluded). Result per verb: delete 7 commits changed the set, 8 files entered, 0 left; update 7 commits, 8 entered, 0 left. REVERSE VERIFICATION - 9 implementation mutations, each restored from a commit, every limb watched failing: classifier always 'file-removed' -> 8 self-test failures; drop members-removed branch -> 3; declaredCounts counts only pinned -> 1; censusPinned counts all doubles -> 2; drop bootstrap early-return -> 1; drop unscanned-verb rejection -> 1; growth direction silent -> 4; LOSS direction silent (the whole point) -> 6; loss loop fires unconditionally (proves the CLEAN direction is failable) -> 2. Plus 5 end-to-end ablations on the real tree: the four loss worlds each reaching their own message, and a REAL new pinned test file reddening with the growth message. Self-test grew by 24 assertions (exit 0). FINAL UNION on b1789af5f, all exit 0: check:nul-bytes, check:engine-double-contract (both the --self-test limb and the real run), check:cross-package-test-inputs - family derived via `node scripts/pm/dispatch-gates.mjs scripts/check-engine-double-contract.mjs scripts/engine-double-contract.pinned.json`, not recalled. Generated ledger confirmed byte-identical to a fresh --write regeneration (no hand-edit drift). NO ablation needed a rebuild: this gate reads source, not dist/, so the dogfood ablation-rebuild rule does not apply here - stated explicitly rather than left implied. INCIDENT: two raw NUL bytes were materialised into the script by the edit tool while writing escape text; caught by the pre-push sweep (grep -naP over changed files), and the map keys were rewritten to JSON.stringify([file, verb]) so no separator can reintroduce one. check:nul-bytes exit 0 on the final tree.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #9708: check:engine-double-contract's CONSUMER SEAM population is unratcheted the same way the pinned population was - measured by ablation, deleting the remove() seam from packages/mcp/src/stdio-data-bridge.ts takes 'consumer seams: 6 in 3 source file(s)' to 5 with exit 0 and zero errors, because SEAMS_DISCOVERED fires only at zero. Filed not fixed: 6 rows printed in full each run, a seam legitimately vanishes on refactor, and choosing error/ledger/distinct-verdict there is the design act this card's own option 3 flagged as needing a maintainer. Labelled `finding`, no pm:queue, unassigned."
      ]
    }

    Not a reconstruction. Every number above was executed in this session with real output held in context; the transport error hit after the work and the PR, before this comment. State re-verified before posting: remote tip b1789af5f equals the local branch ref, zero local-only commits, worktree removed only after confirming a clean tree.

    The four answers, in prose

    H2 — preconditions, then both directions. The gate needs an installed workspace, not a built one. It is a pure TypeScript-AST scan (import ts from 'typescript', readFileSync) over source files; it never reads dist/. So pnpm install alone is sufficient, and a checkout without node_modules fails to resolve typescript before it checks anything — that is almost certainly what you hit on the shared checkout, and it means the exit code there carried no information about the gate. Pinned count today is 319, not #9165's 311. Delete the whole async delete(...) member → 318 pinned, exit 0, green (reproduced verbatim). Delete only the assertEngineDeleteDispatch(options); line → exit 1, PINNED, names file and line. The control going red is what makes the first green a blind spot rather than a broken harness.

    H1 — the numbers chose the identity ledger, not the lean. Priced both. A count-delta is cheaper but loses on two measured grounds: it cannot see a swap (one double loses delete while another gains one — total unchanged, coverage moved), and its remedy carries no information (a legitimate removal and this defect produce the same 319→318 diff, so "bump the number" is the only available habit — ruling 1's failure, re-created by the ratchet). The identity ledger costs 308 rows against the 135 the DEBT ledger already carries, same format. Churn over 269 commits: 7 commits (2.6%) changed membership, 8 files entered per verb, zero left. Low churn, so your lean survives contact with the data — but it survives on the measurement, not on the lean.

    H3 — and this is the one that narrowed the card. The DEBT ledger and the exempt set are not blind. RECONCILED already fails on stale entries in both directions, measured by ablation on each: a ledgered file losing its member reddens, and dropping one of the five doubles behind the EXEMPT entry reddens with down to 4 ... from the baseline's 5. So the cheaper win you hypothesised was already present — the pinned population was the sole hole, precisely because pinned files had no durable enumeration at all. The fix is correspondingly narrow.

    Ruling 1 — how a legitimate decrease is expressed. One mechanical command, node scripts/check-engine-double-contract.mjs --write, then commit — the repo's existing ratchet idiom (check-slot-lookup-ratchet.mjs uses --update). There is no number to choose, so there is no number to fudge. What stops it degenerating into "regenerate on red" is that the gate classifies the loss into four worlds before asking for anything, and each gets a different message and a different remedy: file gone from disk → "a deleted test is a LEGITIMATE decrease", regenerate; file present, verb gone → "declares NO engine double ... this is the #9680 shape exactly", restore the member; fewer doubles than pinned → the same defect at finer grain, restore; all doubles present, fewer pinned → "went UNGUARDED", re-pin and explicitly "do NOT reach for --write". A count reaches none of those four, because by the time it is decremented the identity is gone. --write also prints every loss it is about to record before writing, so the author reads what left at that moment rather than in review. Growth is also an error — the one deliberate nuisance — because with 8 entering and 0 leaving per month, a removal-only ledger would never be touched and every new double would sit outside the ratchet forever.

    H4 — honest answer: no. Neither #8896's missing findOne nor #9002's missing getAllObjects would be caught, and I verified each reason rather than asserting it. (1) Neither double was ever pinned — RETAINED detects a row leaving the population, and both never declared the method at all; that is the card's own "a double the scan never had" (#8639) versus "a double the scan had and lost" (this). (2) Neither method is a scanned verb — SLICES is exactly delete and update; findOne appears only as a discovery sibling, getAllObjects appears nowhere in the script. (3) #9002's is a registry module-mock, not an engine double: packages/objectql/src/engine-middleware-operation-vocabulary.test.ts is in neither the pinned ledger nor the DEBT baseline. This is stated plainly in the PR body under its own heading. #9165's actual gap — that the registry has no pin at all — is untouched and must not be marked closed on account of this PR.

    main has moved since the branch was cut (base was ed4ca5999); the gate family is derived from changed paths and the only touched files are the script and its generated ledger, so a rebase should be mechanical, but CI on the current tip is yours to read.


    Generated by Claude Code

  3. os-steve commented on Aug 19, 2026

    @os-steve
    CollaboratorAuthor
    {
      "issue": 9680,
      "status": "done",
      "branch": "claude/issue-9680-discovered-ratchet",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/9712",
      "premise_still_valid": true,
      "summary": "Resolved the PR #9712 conflict against main as a verbatim union of both halves: main's UNRECOGNISED census (#9875/#9747) and this branch's pinned enumeration + RETAINED (#9680). Neither was dropped or weakened, and the 23/117 split stayed two separate printed numbers. The textual conflict was trivial, but git auto-merged a second region with NO conflict marker that left report() binding `census` twice — the merged file did not parse; main's local is renamed to `unrecognised` with every printed string byte-identical. main also brought a new test file with one new pinned double per verb, which RETAINED correctly reddened as growth; regenerated with the gate's own --write remedy, 308 to 310 rows, 2 added, 0 lost. Second deliverable: chose option (a) and re-measured the 269 churn claim over the full population rather than scoping it, because a denominator swap would have been wrong in the flattering direction and (b) would have left a merge-blocking ratchet justified by a ~9% sample. Pushed to the existing branch as a merge commit plus a comment commit; auto-merge left disabled and no other PR state touched.",
      "tests": "Gate union re-run on the FINAL commit 0c98edf9c5, all exit 0: check:nul-bytes (scanned 6298 text files, no raw control bytes) - check:cross-package-test-inputs (33 self-test cases, 12 packages) - check:engine-double-contract, both the real run and --self-test. Family derived at runtime from the actual changed paths via `node scripts/pm/dispatch-gates.mjs scripts/check-engine-double-contract.mjs scripts/engine-double-contract.pinned.json`, which named exactly those three; check:nul-bytes added because any edit earns it. RULING 2 PROOF, both lines verbatim at 0c98edf9c5: `UNRECOGNISED [engine-double-contract]: 23 construct(s) in packages, examples declare a scanned verb (delete, update) alongside engine siblings, and this gate could not read the implementation -- so they are in NEITHER the pinned population nor the ledger. 117 further construct(s) are SCOPED OUT by a stated criterion and are not counted here. This is a verdict, not a finding: it never fails a run (#9747, ruling of 2026-08-18).` and `check-engine-double-contract: OK — 321 pinned, 133 in the DEBT ledger, 2 exempt.` plus `check-engine-double-contract: 310 (file, verb) row(s) held by the RETAINED ledger — a pin that leaves names itself.` at exit 0. H1 ABLATION 1 (break a pin, predicted red before running): removing the whole `async delete(...)` member from packages/core/src/utils/migration-journal.test.ts gives exit 1 and `x RETAINED [delete]: ... is still on disk but declares NO engine double with a delete any more, while the pinned ledger records 1. This is the #9680 shape exactly`; UNRECOGNISED stayed 23/117 throughout, so the pin loss does not perturb main's half. H1 ABLATION 2 (make a construct unreadable, predicted count-move at unchanged exit): turning the SCOPED-OUT `delete: vi.fn(),` at packages/objectql/src/engine-validation-locale.test.ts:76 into a defaulted mock carrying a function gives `UNRECOGNISED ...: 24 construct(s) ... 116 further construct(s) are SCOPED OUT` with the row `unrecognised [delete] packages/objectql/src/engine-validation-locale.test.ts:76 the initializer carries a function this gate declined to unwrap`, still `OK — 321 pinned` at EXIT 0 — visibility-only holds across the merge. Both ablations restored from the commit (git checkout HEAD --), tree clean after each. H2 ASSERTION COUNTS, measured at runtime by instrumenting the `expect` closure (grep undercounts: the file carries `expect(` inside fixture strings): merge-base ed4ca5999 = 76, main 460d7aa6b = 84 (+8), this PR b1789af5f = 99 (+23), MERGED 0c98edf9c5 = 107 = 76+8+23 exactly, so no limb from either side was dropped; note the PR body's claim of +24 measures as +23. H3: engine-double-contract.pinned.json was the only in-scope file NOT conflicted (the script was the sole unmerged path), and it is not unaffected — `--write` reported `No pin losses — this regeneration only records new or grown coverage` and `310 (file, verb) row(s), 2 added or grown, 0 lost`, i.e. purely additive, so the merge demonstrably lost no pinned coverage; 319+2 is where the predicted 321 comes from. Re-measurement of the 269 claim: window coverage proved first (clone floor 2026-05-04, window opens 2026-07-18, no `git fetch --shallow-since` run at all; scripts/pm/git-history.mjs is not on main yet, only on #9903). Proxy calibrated against the gate's own ledger at HEAD before use — 0 false negatives on both file sets and 309 of 310 (file, verb) rows agreeing on the exact call count. Over 3,103 first-parent commits 2026-07-18..2026-08-18: delete changed in 111 commits (3.6%), 162 entered, 1 left; update changed in 103 (3.3%), 160 entered, 1 left. Cross-check: the identical method over only the last 269 commits reproduces 0 leaving, confirming the original zero was a windowing artifact, not a method error. git merge-tree against current origin/main (23502e3dd4) is clean, and those 3 newest commits add no pinned coverage, so the gate stays green.",
      "open_questions": [
        {
          "question": "The PR body's H1 churn table still reads `7 (2.6%) / 8 entering / 0 leaving` over `269 commits`, and cites `308 rows`. The shipped code comment now carries the corrected full-population numbers, so the reviewed body and the merged artifact disagree on the measurement that is the PR's central argument. I did not rewrite the accepted body.",
          "options": [
            "A — leave the body as the historical record of what was reviewed, and let this PR comment plus the corrected code comment carry the correction (current state)",
            "B — I edit the body's H1 section in place to the re-measured numbers, keeping the rest untouched",
            "C — PM appends a short correction note to the body when re-arming"
          ],
          "recommendation": "A, because the body is reviewed content and rewriting the argument a maintainer accepted is not a resolver's call; the correction is already attached to the claim in two durable places (the code comment that ships, and the PR comment). B only if you want the body to stand alone as accurate for a later auditor — say so and it is a one-edit change."
        },
        {
          "question": "RETAINED treats growth as an error by design, so any commit landing a new pinned double on `main` before this PR merges will redden check:engine-double-contract here and need another `--write` regeneration. That already happened once during this resolution.",
          "options": [
            "A — merge promptly through the queue and accept the occasional regeneration round-trip",
            "B — treat it as a defect in the growth-is-an-error decision and reopen that design choice",
            "C — file it as its own card"
          ],
          "recommendation": "A. It is the deliberate liveness nuisance the PR argues for on its merits, and the remedy is one mechanical command; current origin/main 23502e3dd4 adds no new pinned coverage, so the branch is green right now. Not filed as a card because it is designed behaviour, not a defect — flagged only so the re-arming is sequenced rather than left to sit."
        }
      ],
      "out_of_scope_findings": [
        "filed as #9915: check-engine-double-contract.mjs hardcodes its own ledger size (308/319) in 5 prose sites that go stale on every sanctioned `--write`, with nothing checking them — the same class as #9680 one level up, in the comment block that justifies a merge-blocking ratchet. Prose only, no invariant affected; a 6th site was corrected here only because it sat in the paragraph already being rewritten."
      ]
    }

    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

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions