Skip to content

fix(scripts): admit a single-verb engine double on the contract it declares - #11849

Merged
os-steve merged 1 commit into
mainfrom
claude/issue-11626-engine-double-single-verb
Aug 24, 2026
Merged

fix(scripts): admit a single-verb engine double on the contract it declares#11849
os-steve merged 1 commit into
mainfrom
claude/issue-11626-engine-double-single-verb

Conversation

@os-steve

Copy link
Copy Markdown
Collaborator

Fixes#11626

check:engine-double-contract discovered a double by the verb plus ≥2 engine siblings, so a double declaring only the verb under test was never discovered and never pinned. The card's sharp half is the incentive that follows: the only way into the ratchet was to pad the double with find/insert verbs its test never calls. The gate's own admission criterion rewarded ceremony, and a padded double is a worse double — the next reader cannot tell which verbs are load-bearing.

Population, measured before designing anything

The 383-row ledger enumerates the discovered population by construction, so counting the undiscovered ones needed a different instrument. I patched a copy of the gate to parameterise the sibling threshold and changed nothing else — two hunks, diffed and read before use — so the criterion is the gate's own code rather than a hand-copy (the #9943 hazard). Positive control: the probe at threshold 2 reports 401 pinned, exactly what the gate prints.

At threshold 0 the scan admits 74 further constructs — 39 with zero engine siblings, 35 with one:

kindcountverdict
HTTP routers/servers (delete is the HTTP verb)32false positives — must stay out
declared IHttpServer7 (of the 32)declared as another contract
declared EmailPersistence / SettingsSecretStore16declared as another contract
declared IDataEngine2genuine, and both already pinned
remainder (any or no declaration)restnot separable from stores by member names

So relaxing the threshold is not available: it sweeps in routers by the dozen, and reddening correct code is the one cost this gate's ledger cannot absorb.

Falsifications the card asked for

Do single-verb doubles exist? Yes, and the two that matter are the strongest possible case for the card. packages/metadata/src/loaders/database-loader-update-id-fold-wins.test.ts:65 and packages/services/service-settings/src/settings-engine-id-fold-wins.test.ts:65 each declare onlyupdate, each is annotated IDataEngine, and each already calls assertEngineUpdateDispatch. They did the right thing by hand and got no ratchet protection for it — if either dropped the pin tomorrow, nothing would have reddened.

Is the padding incentive a standing harm? No — a hazard with a documented near-miss, which prices the card differently and is worth saying plainly. I found no padded double in the tree. The two ceremonial-looking candidates in seed-tenancy-backfill.test.ts are not padding: resolveSeedTenancyLedger duck-types on ['getObject','find','insert','update'], so those siblings are load-bearing for the production resolver. What the repo does have is packages/services/service-automation/src/plugin-startup-log-cause.test.ts:145-167, where an earlier draft carried an unexercised async delete() { return true; }; the author removed it and proved dormancy with an injected marker (0 prints, control find11 in the same run). Authors do write unexercised verbs on doubles; here the pressure was documented and declined rather than acted on.

Is widening expressible without a heuristic? Yes, and not by guessing better. The declaration is a different kind of evidence: the author writes down which contract the object implements. const engine: IDataEngine = …, … as unknown as IDataEngine, a factory return type, class F implements IDataEngine. Declared means enforced. The local name must be bound by an import from where the contract lives — measured spellings only (@objectstack/spec/contracts 36, @objectstack/core 5, relative ./data-engine 2) — so a file-local look-alike declares nothing, the same property pinnedImportsOf already holds for the predicates.

The declaration replaces the sibling threshold and nothing else: a declared construct still goes through isEngineVerbShape, so a driver-shaped verb is still vetoed. Announcing yourself does not buy a looser standard.

Population after, every delta explained

readingbeforeafterdelta
pinned401403+2, the two declared single-verb doubles
DEBT ledger133133
exempt22
RETAINED rows383385+2, same two
--census outputbyte-identical

--write reported 2 added or grown, 0 lost. No construct left any population; nothing new is unpinned; no correct double reddened.

Discovery and the census admit the same set — applying the route to one walk and not the other is #10175's scoping split one layer up, and the self-test pins both sides.

Non-vacuity, both directions, mutation proven on disk

Both legs ran under trap '…' EXIT INT TERM, anchored on text unique to the site meant, with the anchor counted before and after and the sha compared.

Leg A — remove the declaration route (const declared = siblings.length < 2 && declaresEngineContract(…)const declared = false;). Anchor count 1→0, replacement 0→1, sha b99fef0d…10197f1f…, git diff showing the changed line:

Leg B — restore.RESTORED_SHA=b99fef0d… equals SHA_BEFORE, cmp byte-identical, anchor back to 1, mutant text absent.

Leg C — the negative half (declaresEngineContract forced to return true). sha b99fef0d…9c0f87fb…, injected line present. Self-test exit 1, 9 failures: all five new negative cases (router, file-local look-alike, wrong-module import, type argument, driver-shaped verb) plus three pre-existing sibling guards. Restore byte-identical. So neither the positive nor the negative cases are vacuous.

There is no build between the edit and the reading — the gate runs from source (node scripts/check-engine-double-contract.mjs), so no dist/ can stale a leg.

The residual gap, stated in the header rather than left to be rediscovered

A single-verb double that declares nothing is still outside discovery, and this route does not reach it. That is left open deliberately: the only evidence available for such a construct is its member names, which is the inference route at a threshold already measured to sweep in 32 routers. The remedy an author has is now cheap and honest — annotate the double with the contract it stands in for — where before it was to pad it. The gate's output also now prints how many doubles came in through each route, because its headline number is a claim about how much is protected.

Verification

Union re-run at dd8e1580c, the final commit. Each verdict is the gate's own printed line, exit codes captured by redirect-then-capture.

  • check:engine-double-contractOK — 403 pinned, 133 in the DEBT ledger, 2 exempt · 385 (file, verb) row(s) held by the RETAINED ledger; self-test OK (all pre-existing cases pass unchanged)
  • check:nul-bytesOK (scanned 6597 text file(s) … no raw ASCII control bytes)
  • check:cross-package-test-inputsAll 116 self-test cases passed · OK: 16 package(s) read outside themselves
  • check:entry-guard148 scripts/ file(s) — every entry guard goes through invoked-as.mjs
  • check:pnpm-filter-targets134/167 --filter occurrence(s) … resolve
  • check:agent-test-spelling, check:parse-guard — exit 0
  • check:where-matcher, check:pm-dispatch-gates — exit 0. Both run because dispatch-gates.mjs --residue flags them as artifact rosters sitting under scripts/, where a silent verdict is not evidence in either direction.
  • pnpm lintfull repo, no narrowing (eslint . --no-inline-config), exit 0 in 51s under os-verify-lock.sh

Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no paths — the script takes its own change set from the merge base). No changeset: this changes CI tooling only and publishes nothing, so the PR carries skip-changeset.

Generated by Claude Code


Generated by Claude Code

…clares
`check:engine-double-contract` discovered a double by the verb plus two engine
siblings, so a double declaring only the verb under test was never discovered
and never pinned -- and the only way into the ratchet was to pad the double with
`find`/`insert` verbs its test never calls. The gate's own admission criterion
rewarded ceremony, and a padded double is a worse double: the next reader cannot
tell which verbs are load-bearing.
Sibling inference stays exactly where it is; a second admission route is added
alongside it. A construct that DECLARES the engine contract -- `const engine:
IDataEngine`, `as unknown as IDataEngine`, a factory return type, `implements
IDataEngine` -- is admitted at any sibling count. That is a different kind of
evidence rather than a looser guess, and it is the repo's standing rule that
declared means enforced. The local name must be bound by an import from where
the contract lives, so a file-local look-alike declares nothing.
Measured before designing: dropping the sibling threshold to zero admits 74
constructs, 32 of them HTTP routers and servers whose `delete` is the HTTP verb.
Exactly 2 declare `IDataEngine`, and both were already calling
`assertEngineUpdateDispatch` and getting no protection for it. So the widening
reddens nothing and only adds -- 401 pinned -> 403, DEBT and EXEMPT unchanged.
Discovery and the census move together, or the two walks are scoped differently
(#10175). `--census` output is byte-identical before and after.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015ahemw8RcTgqtxrj15PEZx
@os-steveClaude

Copy link
Copy Markdown
CollaboratorAuthor

ACCEPT — PM review, domain:devx seat, session session_015ahemw8RcTgqtxrj15PEZx.

Verified against the branch content at dd8e1580c (merge-base e47d5ef61, computed as git merge-base, not read off the PR's base.sha), not by reading the report back.

The load-bearing claim, checked in the code rather than in its comment. The PR says the declaration "replaces the sibling threshold and nothing else — a declared construct is still put through isEngineVerbShape". A comment saying so is not evidence, so I read the ordering:

1207: const declared = siblings.length < 2 && declaresEngineContract(node, contractNames);
1208: if (siblings.length < 2 && !declared) return;
1209: if (!isEngineVerbShape(target, names, opts)) return;

The shape veto is at 1209, after the admission at 1207. So a declared double is genuinely held to the same standard; announcing itself buys entry to discovery and nothing else. The second walk is gated the same way (siblings.length >= 2 || declaresEngineContract(arg, …)), which is what keeps discovery and the census admitting one set rather than two — #10175's split, correctly avoided.

The population delta, recomputed from the ledger rather than restated. Parsed both revisions of scripts/engine-double-contract.pinned.json:

readingbeforeafter
RETAINED rows383385+2
sum of pinned401403+2
rows removednone

The added pair is exactly packages/metadata/src/loaders/database-loader-update-id-fold-wins.test.ts and packages/services/service-settings/src/settings-engine-id-fold-wins.test.ts, both on update, both pinned: 1 — the two the report names and no others. The zero removals is the reading I care most about: a ratchet change that admits new rows is also the change that could silently drop old ones, and nothing left any population.

What I did NOT verify, stated as a narrowing. The run-derived verdicts — OK — 403 pinned, 133 in the DEBT ledger, 2 exempt, the self-test counts, the three ablation legs, --census byte-identity — were not reproduced here. This seat's instrument worktree carries no node_modules, so the gate cannot execute; the ledger arithmetic above is static and the ablations are not. CI's own run of check:engine-double-contract carries them. I am not claiming otherwise.

Two things the dev did better than the card.

The card's sharp half was the padding incentive. Rather than accepting it, the dev measured it and narrowed the card: no padded double exists in the tree, so this is a hazard with a documented near-miss, not a standing harm. The two ceremonial-looking candidates in seed-tenancy-backfill.test.ts were checked and cleared — resolveSeedTenancyLedger duck-types on ['getObject','find','insert','update'], so those siblings are load-bearing for the production resolver. The near-miss that is real (plugin-startup-log-cause.test.ts:145-167, an unexercised async delete() an earlier draft carried and the author removed, dormancy proven with an injected marker: 0 prints, control find 11) prices the card honestly instead of inflating it.

And the population work came first and decided the design. Relaxing the threshold was falsified rather than assumed unattractive — threshold 0 admits 74 further constructs, 32 of them HTTP routers whose delete is the HTTP verb. That is why the answer is a second admission route rather than a looser first one, and the probe carried a positive control (threshold 2 reproduces the gate's own 401 pinned exactly), so the 74 is a reading and not an artifact.

The residual gap is declared rather than left to be rediscovered — an undeclared single-verb double is still invisible, and the reason is stated in the gate header: the only evidence for it is member names, i.e. the inference route at a threshold already measured to sweep in 32 routers. Leaving that open with the reason attached is the right call.

#11850 is correctly shaped — filed unassigned, finding, Blocked-by: #11626, with the five remaining files enumerated, the per-file caution about metadata-protocol's import cycle, and a re-derivation recipe. It is a real spin-off, not a scope dump: it needs edits in five test files across four packages, outside this PR's declared scripts/-only surface.

Flipping to ready and arming auto-merge.


Generated by Claude Code

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

Labels

size/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[finding] check:engine-double-contract cannot see a single-verb double — and the only way in is to pad the double with verbs its test never calls

2 participants

@os-steve@claude