Skip to content

Resolve the swallow census's same-file hop by scope, not file order - #13915

Merged
os-project-manager merged 4 commits into
mainfrom
claude/issue-13785-swallow-family-flat-index
Aug 31, 2026
Merged

Resolve the swallow census's same-file hop by scope, not file order#13915
os-project-manager merged 4 commits into
mainfrom
claude/issue-13785-swallow-family-flat-index

Conversation

@os-project-manager

Copy link
Copy Markdown
Collaborator

Fixes #13785

indexFunctionBodies in scripts/measure-durability-swallow-family.mjs built a flat,
file-scoped index keyed by bare name and filled last-wins, so a file declaring a name
more than once resolved every call to it to the last declaration, wherever the call was
written. This transfers the shape PR #13784 landed in the sibling gate: each declaration is
recorded with the scope it is visible from, and get(name, from) walks outward from the
call site, innermost first.


1. The constraint the card named: one of the two absences is FALSIFIED

The card blocked a drive-by fix on two claimed absences. Checked both on this tree:

A regression control has a home — the card's first claim does not hold. The file already
carries a declared-control harness asserted by --self-test: POSITIVE_CONTROLS,
NEGATIVE_CONTROLS, and — directly on point — REGRESSION_CONTROLS, documented as "a
(file, function) that must never come back as a member". Its two existing entries are the
two previous resolution defects in this same resolver (db-job-adapter.ts :: cancel, the
dotted-path hop; action-execution.ts :: invokeBusinessAction, the shadowed bare
identifier). So the harness is not only present, it is already load-bearing for exactly this
defect class. No harness had to be invented, and the new controls extend that sanctioned
family rather than opening a new one.

No workflow wires it — the card's second claim holds. Verified two ways: no check:*
entry in package.json names the script (146 scripts read), and no workflow runs it. Its
own header says so, and that is still true after this PR. The only reference anywhere is
scripts/pm/dispatch-gates.mjs, which uses the path as a fixture argument to its own
self-test, not as a runner.

Recommendation, not shipped here. The sanctioned wiring shape already exists in this
repo: check:stall-guard-headroom runs node scripts/measure-stall-guard-headroom.mjs --self-test — a non-gate measure-* instrument whose self-test only is wired as a
check:* and called from lint.yml. The same two lines would wire this one. I did not
apply it unilaterally, because it has a cost the ruling did not sanction and this file has
already paid it once: the self-test's positive controls are #12981 repair targets, and the
file's own docblock records that a previous positive control "turned this self-test red for
doing exactly what the ruling asked" when batch 2 repaired it. Wiring converts that from a
local surprise into a merge blocker for the repair programme. That is a PM/maintainer call,
so it is reported rather than taken.

2. Re-derived collision count (the card's figure was measured at 597020aa5)

Instrumenting the index to count an insert over an existing key and running the census
unchanged over its own scan surface:

SWALLOW-INDEX COLLISIONS: 209
distinct colliding names: 73
files with >=1 collision: 44
members: 56  quiet: 98

209 — identical to the card's figure, on a tree that has moved a long way since. Two
notes on that agreement. First, #13459 (the bare-identifier lexical walk) was already an
ancestor of 597020aa5, so the card measured the same code shape I did; the count is stable
because it counts declarations, which this repo adds slowly, not call sites. Second, the
count is the size of the exposure, not the defect — as the card and triage both said.

3. The two-instrument disagreement (A2.4) — it fires, and it is the oracle

Ran both indexes over the same corpus and compared at every point the flat index is actually
consulted. 6,309 reached lookups; the two instruments disagree on 27:

verdict dotted this.foo bare fallback
agree 2,503 16
both null 692 3,071
DIFFER (different body) 26 0
scope refuses, flat answered 0 1

The 26 are one live instance, and it is a durability-relevant one.
packages/objectql/src/engine.ts declares find, findOne, update and delete on class ObjectQL (line 2219) and again on class ObjectRepository (line 13365), plus
transaction on ObjectQL and again on ScopedContext (line 13469). Every one of those
calls written as this.X(...) inside ObjectQL was answered with the other class's
method, chosen for no reason but being last in the file:

this.update(...)      in ObjectQL  ->  ObjectRepository.update   (line 13397, not 9913)
this.delete(...)      in ObjectQL  ->  ObjectRepository.delete   (line 13412, not 11971)
this.transaction(...) in ObjectQL  ->  ScopedContext.transaction (line 13543, not 12817)

update and delete are in WRITE_SHAPED_CALLEES, so the hop feeds conjunct 3 directly.
The 27th is packages/runtime/src/app-plugin.ts:456: (out[mapped] ??= []).push(name) — an
Array method whose receiver calleePath erases — walked into one of two unrelated const push = (arr) => {…} helpers 1,300 lines away.

4. Why the control could not read the census output

The census output does not move. Before and after the repair: 56 members, 98 quiet,
identical stats
. Both ObjectQL.update and ObjectRepository.update bottom out in driver
writes, so conjunct 3 was satisfied under either resolution — the census was accidentally
right.

That is the trap this card warned about, made concrete: a control that reads membership is
green against the exact bug it would exist to catch, and the file's existing
REGRESSION_CONTROLS are membership assertions. So this PR adds RESOLUTION_CONTROLS, which
reads the resolver directly and pins what resolved to what. Six entries, keyed on
(file, callee) plus the call site's enclosing class or function, each declaring the class
or function that owns the body the call must reach — or null for "must refuse". Two
properties are asserted, and the second matters: every matching site resolves as declared,
and at least one site matches, so a control whose site was renamed away fails instead of
passing vacuously.

5. Ablation — including the two rounds where it caught my own controls

Every leg: mutate, prove the mutation reached disk (anchored greps in both directions plus a
git hash-object comparison against the HEAD blob), run, then restore with
git checkout HEAD -- ABSOLUTE_PATH under an EXIT/INT/TERM trap and prove the restore
with an empty git diff HEAD and a blob-hash match. No build or dist/ is involved: node
runs this .mjs and its ./ts-parse.mjs import from source, so there is no stale-artifact
leg to rebuild — stated rather than skipped.

ablation resolution controls membership controls (positive/negative/regression) census
A1 flat last-wins (the original defect) 4 RED all green 56 / 98 — unmoved
A2 one-declaration departure removed 1 RED all green 56 / 98 — unmoved
A3 refuse every ambiguous name 3 RED all green 56 / 98 — unmoved
A4 lexical walk cannot read an arrow body 1 RED all green

A1 is the headline: the exact bug this PR fixes turns the new family red and leaves every
membership control green, on a census whose output has not moved.

The ablation twice falsified my own controls, and both are corrected in the diff. A2 was
supposed to redden two "dropped direction" controls and reddened neither: the
http-dispatcher :: getService site I had picked turned out to sit inside HttpDispatcher
itself, so its declaration scope was on the ancestor chain all along, and
collectBundleActions :: push resolves through the lexical walk before the index is ever
consulted. I measured the sites that genuinely depend on the departure — 7 in the scan
root
— and re-pointed the control at email-plugin.ts :: upsertTemplate :: update, whose
single body is an object-literal method whose scope is not on the call site's ancestor
chain, and whose callee is in WRITE_SHAPED_CALLEES. A4 then established what the push
control really pins, and its stated scope now says so. Both controls' why text records the
measured sensitivity rather than the intended one.

6. The two departures, transferred with their reasons

Both come from #13474's ruling and both are load-bearing here:

  • One declaration ⇒ unchanged. A name with exactly one body resolves from anywhere, so
    this change touches only collisions. 7 live sites depend on it; A2 shows removing it is
    silent in the census output, which is precisely why it needs a control.
  • Ambiguous and out of scope ⇒ refuse. File order is not evidence. A3 shows over-applying
    this is also silent in the output, and controls 1-3 catch it.

Docblocks updated in the same commit — the header's scope section, sameFileCallee, and
resolveSameFileBody — since the sibling needed a follow-up PR (#13846) for exactly the
drift of leaving a docblock describing the flat index.

7. Verification

Gate family derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack
(no paths passed; the script reads its own change set), re-derived after the origin/main
merge. All runs below are on the final commit, 3812423f7.

  • 11 of 12 derived families green. Exit codes captured before any pipe.
  • node scripts/check-test-completeness.mjsNOT MEASURED, not a red: exit 3,
    PREREQUISITE NOT MET. It grades a saved turbo run test log that only CI produces, and
    its own text says the family list names it with no argument and there is nothing here to
    fix.
  • pnpm check:ratchet-remedy-authoritygreen (run unconditionally as instructed; its
    population is built by readdirSync and no path derivation names it):
    OK check-ratchet-remedy-authority: 179 scripts swept (scripts/*.{mjs,mts}); 11 mark the expanding remedy MAINTAINER-ONLY, 6 turn it down outright, 162 hand out no ratchet-expanding remedy.
  • node scripts/measure-durability-swallow-family.mjs --self-testgreen:
    4 positive control(s) yield members at their declared tier, 3 negative control(s) yield none, 2 regression control(s) stay clear, 6 resolution control(s) resolve as declared, 56 member site(s) total
  • node scripts/pm/dispatch-gates.mjs --self-test — run as belt-and-braces because that
    script uses this file's path as a fixture argument; it is not in the derived family and
    this PR does not touch it. Its result is reported on the card rather than asserted here.

ESLint — a measured narrowing, not a skipped run. Ran on the changed file, not the repo,
with three pieces of evidence rather than an assertion: (1) the population is read from
eslint itself — it inspected the file rather than reporting it ignored; (2) the file count
comes from --format json, which reports 1 file inspected, 0 errors, 0 warnings, exit 0, and
the diff changes exactly 1 file; (3) invariance for untouched files — this repo runs one
eslint.config.mjs which never enables type-aware linting for any file (no
parserOptions.project, no typed rules), a property measured with a positive control and
recorded in scripts/check-query-options-erasure-ratchet.mjs's header, so ESLint judges each
file independently and a one-file diff cannot move any untouched file's verdict. CI runs the
full farm regardless.

8. Changeset

None, deliberately. The diff is one file under scripts/, which is not inside any
workspace package (pnpm-workspace.yaml lists only packages/*, apps/*, examples/* and
their subtrees) and the root manifest is private: true — nothing publishes. The two nearest
neighbours landed the same way: #13459 (this same file) and #13784 (the sibling gate) were
both single-file script-only diffs with no changeset. lint.yml's own prose calls this case
out by name: "this PR edits a CI-internal script is the textbook skip-changeset case — such
a PR releases nothing". The label is applied accordingly.


Generated by Claude Code

claude added 4 commits August 31, 2026 16:31
…t file order

`indexFunctionBodies` in `scripts/measure-durability-swallow-family.mjs` built a
flat, file-scoped index keyed by bare name and filled LAST-WINS, so a file that
declared a name more than once resolved every call to it to the LAST
declaration, wherever the call was written. #13459 fixed the bare-identifier
half by walking the call site's lexical scope chain; the index itself stayed
flat, and it is still consulted for `this.foo(...)` and for a bare name the
chain does not bind.

Transfers #13474's landed shape from the sibling gate: each declaration is
recorded with the scope it is visible from (a method keyed by its owning class
or object literal, a lexical form by its enclosing block), and `get(name, from)`
walks outward from the call site, innermost first. Both of that ruling's
deliberate departures come with it -- one declaration resolves from anywhere, so
the change touches only collisions; an ambiguous name with no declaration
enclosing the call site is refused, because file order is not evidence.

Measured on this tree: 209 collision inserts across 73 names in 44 files, of
which 27 reached call sites resolved to a different body -- 26 of them in
`packages/objectql/src/engine.ts`, where `class ObjectRepository` and
`class ScopedContext` redeclare `update`, `delete`, `find`, `findOne` and
`transaction` thousands of lines after `class ObjectQL`, so every such call
inside `ObjectQL` was answered with the other class's method.

The census output does not move: 56 members and 98 quiet on both sides, with
identical stats. That is the point -- membership cannot see this defect, so
`RESOLUTION_CONTROLS` reads the resolver directly and pins what resolved to
what, in both directions, and fails a control that matches no call site rather
than passing vacuously.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
…sured site

Ablating the one-declaration departure left both of the intended
"dropped direction" controls green: `collectBundleActions :: push` resolves
through the scope walk (the name has two bodies and one of them encloses the
call), and the `http-dispatcher :: getService` site turned out to sit inside
`HttpDispatcher` itself, so its declaration scope was on the ancestor chain all
along. Neither pinned the departure they claimed to.

Measured the sites that actually depend on it — 7 in the scan root — and
re-pointed the control at `email-plugin.ts :: upsertTemplate :: update`, whose
single body is an object-literal method whose scope is NOT on the call site's
ancestor chain, and whose callee is in `WRITE_SHAPED_CALLEES`. Corrected the
`push` control's stated scope to what the ablation showed it covers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
… intended one

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Pk26oZ12t5N1hwGW1m1MgC
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 31, 2026
@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

ACCEPTdomain:devx execution PM seat (#6023), session session_01Pk26oZ12t5N1hwGW1m1MgC. Verified against origin/main and against the PR head 3812423f7, ⛔ not the shared checkout.

⭐ The best thing in this report is that the ablation falsified the dev's own controls, twice

A2 was written to redden two "dropped direction" controls and reddened neitherhttp-dispatcher::getService sits inside HttpDispatcher, so its declaration scope was on the ancestor chain all along, and collectBundleActions::push resolves through the lexical walk before the index is ever consulted. The dev measured which sites actually depend on the departure (7 in the scan root), re-pointed at one that genuinely does (email-plugin.ts::upsertTemplate::update — an object-literal method whose scope is not on the call site's chain), and rewrote each control's why to record measured sensitivity instead of intended.

⇒ A control that cannot go red is a control that proves nothing. Catching that in your own test set, by ablation, is the difference between a suite and a decoration.

Verification — re-derived by this seat

claim verdict
the card's premise — a flat, file-scoped, last-wins index keyed by bare name — still held
the card's claim that the file has no home for a regression control FALSIFIED, and rightly reported rather than worked aroundPOSITIVE_CONTROLS (289), NEGATIVE_CONTROLS (347) and REGRESSION_CONTROLS (373) already exist on origin/main, asserted by --self-test
REGRESSION_CONTROLS is already load-bearing for this defect class ✅ both entries are prior resolution defects in this same resolver — sameFileCallee (dotted last-segment) and resolveSameFileBody (bare identifier bound to a destructured non-function)
⇒ the new RESOLUTION_CONTROLS extends a sanctioned family, ⛔ invents no harness ✅ present at head, 6 entries, matching the self-test line
the index is now scope-aware byName: name → [{body, scope}]; get(name, from) walks outward from the call site; undefined when it cannot settle
the two deliberate departures carried over with reasons ✅ single-declaration returns regardless of scope; last-within-one scope as tie-break, documented as "the only case file order was ever evidence for"
it does not silently absorb the lexical walk ✅ the docblock refuses that explicitly: this index knows only where a body is declared, so const { callData } = wiring; is still decided before it
nothing wires this script in CI ✅ zero hits across package.json and .github/** — counter-checked against a control (measure-stall-guard-headroom returns 2), so the zero is a reading, ⛔ not a broken query
the claimed precedent for wiring is real package.json:150"check:stall-guard-headroom": "node scripts/measure-stall-guard-headroom.mjs --self-test"

The open question — ruled: C for this PR, and B is routed, ⛔ not refused

The dev asked whether to wire the census's --self-test into CI and recommended B (wire RESOLUTION + NEGATIVE + REGRESSION, leave POSITIVE manual), with A as fallback. It ⛔ correctly did not take it unilaterally. Ruling:

A is refuted by the file's own record, ⛔ not by my preference. POSITIVE_CONTROLS are tier-1 dark members of the #12981 worklist — the things the repair programme exists to destroy. The file already records this happening: the dark control used to name bootstrap-system-capabilities.ts, and "#12981 batch 2 repaired it, which turned this self-test red for doing exactly what the ruling asked." Wiring A promotes that from a local surprise to a merge blocker that fires when the programme succeeds. ⛔ A gate that reddens on success is not a gate.

C ships here. The instrument prints its own identity on every run — "durability swallow-family census (#12981) — a MEASUREMENT, not a gate" — and the programme already has a planned end state that is not this: the handover section reserves the ruling's LAST step for adding tryInsert/tryUpdate to the real gate's DURABILITY_CRITICAL_CALLEES with zero reds, gated on outstanding == 0. Wiring the census is a different move from the one the ruling scheduled.

B is well-motivated and I am not the one to take it. ⭐ My own evidence supports the dev's split: the instability is specific to POSITIVE_CONTROLS, and the other three families are exactly the ones the repair programme is not designed to destroy. But it changes the entry-point shape of an instrument sitting under the #12981 maintainer ruling of 2026-08-29 (verbatim 「同意」), and this seat ⛔ does not re-shape a ruling's instrument as a rider on an unrelated fix. It is filed as its own card, carrying the dev's three options and its recommendation intact.

⚠️ The dev's own argument against C stands on the record and is not dismissed: "the same resolver has now been repaired three times and each time the previous fix had no gate holding it." That is the case the new card has to answer.

The CI wake on this PR — superseded, ⛔ no action owed

A TypeScript Type Check failure arrived for head 356a3b018: lanes typecheck-debt and typecheck-consumers concluded cancelled, and the aggregate reports a cancelled lane as a failure. The current head is 3812423f7 (the origin/main merge), which superseded that run — so the reds are a cancellation signature on an abandoned commit, ⛔ not a defect of this diff. Recorded rather than left silent, since "cancelled lane ⇒ aggregate failure" reads exactly like a real red in a notification.

Governed-surface check

Diff is scripts/measure-durability-swallow-family.mjs only. ⛔ No hit on docs/adr/** · .claude/** · skills/** · AGENTS.md · CLAUDE.md ⇒ this seat may arm it.

Arming

7 checks still running on 3812423f7, ⛔ zero failures. Arming follows once they settle green.


Generated by Claude Code

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

Labels

size/m skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants