Skip to content

fix(runtime): the classified lookup keeps the scope it was handed - #16789

Merged
os-project-manager merged 2 commits into
mainfrom
claude/issue-16402-scoped-miss-fallthrough
Sep 8, 2026
Merged

fix(runtime): the classified lookup keeps the scope it was handed#16789
os-project-manager merged 2 commits into
mainfrom
claude/issue-16402-scoped-miss-fallthrough

Conversation

@claude

@claude claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Fixes #16402

Clause-②: no

The classified service lookup took the scope it was handed, missed on it, and then re-resolved on the request's own kernel without that scope. A ServiceLifecycle.SCOPED registration resolved without a scope id is rejected by PluginLoader.getService with Scope ID required for scoped service 'tenancy' — not the branded "never registered" the lookup absorbs — so it was re-raised, and all four doors above it answered 503 SERVICE_UNAVAILABLE. A caller that passed its environment correctly was told it had passed nothing.

The cross-package split. packages/core was telling the TRUTH: the retry really did give no scope. The retry was the lie. Core's wording is untouched — item 3 below, and the negative control in §3 of the pin file, keep it reaching the one caller it is true about.


The three re-derive steps, run first

All three located by symbol, never by line number. ⚠️ Triage's anchors happened to be exact on this base (8b37a0973d) — recorded because a match is a reading too, not because a line number was trusted.

1. The fallthrough itself, in resolveServiceOrLoud

grep -rn 'resolveServiceOrLoud' packages/ --include='*.ts' on 8b37a0973d:

packages/runtime/src/http-dispatcher.ts:2255   private async resolveServiceOrLoud(kernel, name, scopeId?)   ← the implementation
packages/runtime/src/http-dispatcher.ts:408    domainDeps binding
packages/runtime/src/http-dispatcher.ts:564    the identity step's `getService` closure — door 4
packages/runtime/src/domains/activation-gate.ts:217   deps.resolveServiceOrLoud(context, 'tenancy', context.environmentId)
packages/runtime/src/domains/keys.ts:135              deps.resolveServiceOrLoud(context, 'tenancy', context.environmentId)
packages/runtime/src/domain-handler-registry.ts:193   the DomainHandlerDeps member

The body, as it stood — three legs, and the middle one is the only one that drops the scope:

if (scopeId && typeof this.defaultKernel.getServiceAsync === 'function') {
    const scoped = await classified(() => this.defaultKernel.getServiceAsync(name, scopeId));  // scope passed
    if (scoped.found) return scoped.value;
}
if (typeof kernel?.getServiceAsync === 'function') {
    const own = await classified(() => kernel.getServiceAsync(name));                          // ⛔ scope DROPPED
    return own.found ? own.value : undefined;
}
return this.resolveService(kernel, name, scopeId);                                             // scope passed

2. A scoped factory answering undefined for one scope and an instance for another, through each of the four doors

Producer (this wiring did not exist in the tree; tenancy-posture-outage-gates.test.ts's scoped-healthy factory always succeeds):

kernel.registerServiceFactory(
    'tenancy',
    (_ctx, scopeId) => (scopeId === 'platform' ? { posture: 'isolated' } : undefined),
    ServiceLifecycle.SCOPED,
);

Driven with environmentId: 'northwind' — the scope the factory answers undefined for — on the unfixed tree:

### door1 /keys mint — scoped miss
  name : AuthzStoreUnavailableError   code: SERVICE_UNAVAILABLE   status: 503
  rows minted : 0
### door2 activation write — scoped miss
  name : AuthzStoreUnavailableError   code: SERVICE_UNAVAILABLE   status: 503
  write called: 0
### door3 automation toggle — scoped miss
  name : AuthzStoreUnavailableError   code: SERVICE_UNAVAILABLE   status: 503
  toggle calls: 0
### door4 identity step — scoped miss
  name : AuthzStoreUnavailableError   code: SERVICE_UNAVAILABLE   status: 503

All four. The blast radius is real, and it is four doors wide.

3. ⭐ The message actually emitted really is Scope ID required …

Not paraphrased — read off the cause the envelope carries, on the unfixed tree, at each of the four doors:

  cause.msg : Scope ID required for scoped service 'tenancy'      ← door 1
  cause.msg : Scope ID required for scoped service 'tenancy'      ← door 2
  cause.msg : Scope ID required for scoped service 'tenancy'      ← door 3
  cause.msg : Scope ID required for scoped service 'tenancy'      ← door 4

And the raw lookup, with the three states asked directly on the unfixed tree — ⭐ states (b) and (c) were byte-identical:

### state (a) never-registered              => undefined
### state (b) scoped miss (raw helper)      => THREW: Scope ID required for scoped service 'tenancy'
### state (c) caller gave NO scope          => THREW: Scope ID required for scoped service 'tenancy'

⇒ Nothing here is actionable by the caller. It names an omission the caller did not commit, and it is the same sentence the genuinely-omitting caller gets, so the two are not even distinguishable after the fact.


The fix

The scope now travels with every leg — which is what the leg before it and the resolveService tail already did. resolveServiceOrLoud becomes a thin adapter over a new private classifyService, which answers three ways:

state answer from classifyService answer from resolveServiceOrLoud door
(a) never registered { outcome: 'never-registered' } undefined serves
(b) no instance for THIS scope { outcome: 'no-instance-in-scope', scopeId } undefined serves
(c) caller gave no scope rejects rejects 503, Scope ID required …

The boundary the dispatching seat could not settle — the call, and why

Neither triage nor the PM ruled the classified-lookup contract's shape. The call made here: (a) and (b) are different FACTS with the same LICENCE.

  • A factory that returns undefined for a scope has answered, not failed. [finding] two more computeExecCtx seams read "failed" and "not wired" as one value, and both feed authorization inputs — tenancy posture and the ADR-0069 auth gate #13906 decision 1 option A governs the posture that could not be READ — 「A posture that could not be READ is not a posture that is ABSENT.」 — and here the read happened and reported no service. That is an absent fact, not an unread one.
  • ADR-0093 D4/D5 makes a deployment with no tenancy service the same shape as single; for a per-scope registration that reading is per-scope.
  • undefined already means absence throughout this file: resolveService's whole chain tests svc != null, and PluginLoader.getScopedService hands a factory's undefined straight back. Reading that value as a fault would be the lookup overruling the registry.
  • The alternative — answering (b) loudly — would keep this card's manufactured 503 and merely reword it, locking a legitimately service-less environment out of /keys, the activation switch and its own identity step.

⛔ So they are collapsed at the door, but not in the lookup: (b) has an answer of its own, and §1 of the pin file asserts all three pairwise distinct. Merely stopping (b) from emitting (c)'s message would have relocated the confusion, which item 4 forbids.

Why this does not rebut Clause-②: no — measured, not assumed

The declaration is rebuttable by the diff, so the diff was read against the built declarations (packages/runtime/dist/index.d.ts, this branch):

classifyService             hits: 2   → line 3922 `private classifyService;`  (tsc elides a private signature)
                                       line 2557 inside a doc comment
ClassifiedServiceLookup     hits: 0
'no-instance-in-scope'      hits: 2   → both doc-comment lines (3887, 3898)
'never-registered'          hits: 1   → doc-comment line 3884
POSITIVE CONTROL — resolveServiceOrLoud 4 · DomainHandlerDeps 9 · HttpDispatcher 33

⇒ Every new literal that reaches the published declarations is prose. Not one occupies a type position. DomainHandlerDeps.resolveServiceOrLoud keeps its signature verbatim — the whole domain-handler-registry.ts diff is comment-only:

$ git diff 8b37a0973d -- packages/runtime/src/domain-handler-registry.ts | grep '^[+-]' | grep -v '^[+-][+-]' | grep -vE "^[+-]\s*\*"
(empty)

No published accept set is widened, no member is added to a published set, no error-code ledger is touched, and no governed path is edited. The act is directional narrowing — deleting a retry that discards a scope it was handed.


验收备注 — triage's six, with the evidence against each

1. Run the card's three re-derive steps first, ⛔ none skipped — especially #3. ✅ All three above, run before the first edit, located by symbol. Step 3 is the load-bearing one and it confirms the emitted message is exactly Scope ID required for scoped service 'tenancy', carried on the 503's cause at all four doors — and that state (b) and state (c) produced the same sentence, so the caller could not act on it even in principle.

2. Drive all four doors, ⛔ not just one. ✅ Every one of the four, in three scope postures each — packages/runtime/src/domains/scoped-service-miss-attribution.test.ts:

door entry point §2 scoped miss §3 no scope at all §4 scope that IS served
/keys mint gate handleKeys 201, key minted 503 + Scope ID required …, 0 rows 400 walled refusal
install-wide activation write handleActions('/_activation/…') 200, row written 503 + Scope ID required …, no write 403 PERMISSION_DENIED
POST /automation/:name/toggle handleAutomation 200, flow toggled 503 + Scope ID required …, no toggle 403 PERMISSION_DENIED
the original door — identity step resolveRequestScope resolves, ctx present 503 + Scope ID required … resolves, asked in the request's scope

⚠️ packages/runtime/src/domains/automation.ts is held by PR #16755 and was read, never edited. The toggle door is driven through handleAutomation, exactly as tenancy-posture-outage-gates.test.ts already drives it, so no edit to that file was needed and none was made. packages/runtime/src/dispatcher-error-vocabulary.ts (held by PR #16730) likewise untouched — pnpm check:dispatcher-error-vocabulary green.

3. ⛔ Do not change core's wording.packages/core/src/plugin-loader.ts is not in the diff at all. The whole change set is four paths:

.changeset/classified-lookup-keeps-its-scope.md
packages/runtime/src/domain-handler-registry.ts        (comment-only)
packages/runtime/src/domains/scoped-service-miss-attribution.test.ts   (new)
packages/runtime/src/http-dispatcher.ts

4. Three states separable — one assertion each. ✅ §1 of the pin file, four tests:

  • (a) NEVER REGISTERED → asserts { outcome: 'never-registered' } and undefined;
  • (b) NO INSTANCE FOR THIS SCOPE → asserts { outcome: 'no-instance-in-scope', scopeId: 'northwind' } — the answer of its own that state (b) did not have — and undefined;
  • (c) CALLER GAVE NO SCOPE → asserts the rejection message is Scope ID required for scoped service 'tenancy', at both the private arm and the door-facing entry point;
  • plus an explicit pairwise-distinct test, because each of the three above alone cannot show they are three answers rather than two wearing three names.

5. ⭐ Negative control — mandatory. ✅ §3, its own describe block, all four doors: a caller that really gave no scope still receives Scope ID required for scoped service 'tenancy'. Asserted through the ADR-0112 envelope and the cause, never with a bare toThrow() — a bare throw assertion would stay green on a door that answered some other outage while the diagnostic was gone. Each leg also asserts nothing was written (0 rows minted / no activation write / no toggle).

⭐ This is the direction the ablation proves is live: ablating the repair turns §1(b), the pairwise-distinct test and all four §2 doors red — six failures — while every §3 leg stays green. That signature is the point: the repair and the diagnostic are separable, and only the repair moved.

6. ⛔ #16385 is not read as covering this card, and this is not folded into a card carrying a ruling. ✅ This is a standalone PR against #16402 only. The pin file states its own population and names tenancy-posture-outage-gates.test.ts (#15900) as a neighbour whose passing is not evidence about this file, and vice versa.


Tests and gates

Reverse verification (ablation). Ablated the one-token repair (kernel.getServiceAsync(name, scopeId)kernel.getServiceAsync(name)) on the committed tree, with a trap … EXIT INT TERM restore and absolute paths:

HEAD blob      : 3aff8ea00f7fe635fc7f8ff89c8c8270a3bb9786
on-disk before : 3aff8ea00f7fe635fc7f8ff89c8c8270a3bb9786
=== PRE-MUTATION  ===  grep -c repaired leg : 1     grep -c ablated leg : 0
=== POST-MUTATION ===  grep -c repaired leg : 0     grep -c ablated leg : 1
on-disk hash now : cf2f40195a39160c3798bd6a6b4b99d2b49480bb   (differs from HEAD blob)
=== ABLATED RUN ===    Tests  6 failed | 11 passed (17)
--- RESTORE LEG ---
on-disk after restore : 3aff8ea00f7fe635fc7f8ff89c8c8270a3bb9786   (== HEAD blob)
restore proven: git diff HEAD is EMPTY
grep -c repaired leg  : 1     grep -c ablated leg : 0

Direction predicted before the run: RED. Observed: RED, 6 of 17, and the stack names the cross-package attribution directly —

Error: Scope ID required for scoped service 'tenancy'
 ❯ PluginLoader.getService ../core/src/plugin-loader.ts:275:27
 ❯ ObjectKernel.getServiceAsync ../core/src/kernel.ts:586:40
 ❯ HttpDispatcher.classifyService src/http-dispatcher.ts:2389:31
 ❯ HttpDispatcher.resolveServiceOrLoud src/http-dispatcher.ts:2276:28

The subject resolves through the package's own source (../http-dispatcher.js, an in-package relative import), not through dist/, so no rebuild leg applies; packages/runtime/dist did not exist during the mutation window. The on-disk mutation proof was taken anyway, as it is unconditional.

Runs (every build/test through scripts/pm/os-verify-lock.sh, OS_VERIFY_LOCK_SLOT=issue-16402; verdicts quoted from the lock's own VERDICT line, never a bare $?):

what command reading
new pin file pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/scoped-service-miss-attribution.test.ts VERDICT command-exit 0 — 17 passed (17)
affected package, full suite pnpm --filter @objectstack/runtime test VERDICT command-exit 0241 files, 3357 tests, all passed
affected package, typecheck pnpm --filter @objectstack/runtime typecheck VERDICT command-exit 0
dependency closure pnpm --filter '@objectstack/runtime^...' build --concurrency=2 VERDICT command-exit 0
repo-wide lint pnpm lint (eslint . --no-inline-config) exit 0, 1m25s — the full repo scan, not a narrowing
derived gate families 56 commands from scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack 54 exit 0; 2 PREREQUISITE NOT MET (below)
reconciliation dispatch-gates.mjs --ran … --repo … 56 derived, 56 run, 0 NOT-MEASURED, 0 UNRUN

⚠️ Typecheck coverage, stated precisely. packages/runtime/tsconfig.json excludes **/*.test.ts, so tsc --noEmit alone says nothing about the new pin file. The coverage comes from the second half of the script, check:test-typecheck --project tsconfig.test.json, and it was verified with --listFiles rather than assumed: the new file is in the program (1 hit; control tenancy-posture-outage-gates.test.ts 1 hit; 243 test files total). Raw tsc -p tsconfig.test.json reports 191 errors — exactly the ledgered pre-existing count — and 0 naming either file this PR adds or edits.

NOT MEASURED, declared — two families exited 3, which each script defines as not a pass and not a finding:

  • pnpm check:dual-build-cjs-loads — "PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … ⛔ This is NOT a pass: nothing was measured." (37 packages unbuilt.)
  • pnpm check:type-check-debt — "PREREQUISITE NOT MET … ⛔ This is NOT a pass and NOT a finding: nothing was measured."

Both want a whole-workspace turbo run build --filter='./packages/*' --filter='./packages/*/*' — the farm-scale run CI owns. ⛔ Recorded as NOT MEASURED, not as green and not as red.

⚠️ One bounded gap in the derivation, declared. dispatch-gates prints STALE TREE: origin/main moved to ed7243d52b after this branch's base, and four family-defining files changed (lint.yml, package.json, scripts/ci/select-gate-families.*). Read directly, that delta (#16754/#16496) adds path-scoped skips — fail-open, so it can only make CI run fewer families — plus exactly one new family, check:select-gate-families. That family's script does not exist in this branch's tree, so running it here would grade a script this branch does not carry; CI runs it against the merged tree. Also ran the four roster gates the derivation flags as "silence is not evidence" for these paths — check-changeset-fixed, check:authz-resolver, check:error-code-casing, check:filter-alias-parity — all exit 0.

Changeset: .changeset/classified-lookup-keeps-its-scope.md, @objectstack/runtime: patch. Published behaviour moves (a 503 that no longer happens on four doors), so this is not a skip-changeset case; no exported signature changes, so it is not a minor.


Out of scope — noted, not filed

Both are observations, ⛔ neither is filed: neither is a reproducible defect, neither contradicts a declared contract, and neither is a trap that makes an author write metadata the runtime rejects.

  • PluginLoader.getScopedService never caches a falsy instance. It writes the factory's answer into the scope map and then guards the next read with if (!instance), so a scoped factory answering undefined is re-invoked on every leg of every lookup — measured twice per lookup on the very path this PR makes reachable (both the host leg and the request-kernel leg run the factory). It is a cost, not a wrong answer, so it is not a defect class this seat files. ⚠️ Successor: none — no queued PR or open claim touches packages/core/src/plugin-loader.ts, so nobody is standing next to this.
  • resolveService's second leg drops the scope too. The quiet capability probe has the same shape this PR repairs in the classified lookup — kernel.getServiceAsync(name) with no scope — so a SCOPED registration living only on a per-environment kernel is unreachable through the probe even when the caller holds the scope. ⛔ Not filed, and deliberately not repaired here. That leg is documented as the singleton/legacy fallback ("Falls back to kernel … for singleton / legacy services"), and the probe swallows the rejection, so nothing is misattributed and no message lies. Repairing it would change what every domain's service lookup resolves — precisely the enumeration runtime: two dispatcher domain gates read the tenancy posture through the collapsing resolveService probe, so a tenancy service that failed to build reads as "no wall" at /keys mint and at the activation-write refusal #15900 declined as its option C, "because nobody has enumerated those gates" — so it fails the same-verification-surface test for a bounded inline repair. Successor: any card that reroutes a service name through the loud lookup.

Docs Drift Check — the reading, ⛔ nothing widened

The advisory listed 4 hand-written pages, all four via the same anchor: HttpDispatcher (symbol, top-level class). Every one read against the actual diff. ⭐ Outcome: 0 pages falsified, 0 pages edited. Recorded because "no change needed" is a result, not silence.

page why it was listed reading
content/docs/api/environment-routing.mdx names HttpDispatcher (line 130) verified unaffected. Its claim is about the environment resolution order — extractEnvironmentIdFromPath, prepareResolverHints, and the host KernelResolver's six-step order. This diff touches none of those symbols and changes no step of that order; it changes what a service lookup does with a scope it already holds. All six numbered statements remain true.
content/docs/automation/webhooks.mdx names HttpDispatcher verified unaffected — different class. The page says so itself: "HttpDispatcher (in @objectstack/service-messaging)". Measured: grep -rn 'class HttpDispatcher' returns two — packages/runtime/src/http-dispatcher.ts:316 (this diff's) and packages/services/service-messaging/src/http-dispatcher.ts:65 (the webhook delivery dispatcher this page documents). A name collision; the anchor matched the name, not the package.
content/docs/kernel/cluster.mdx names HttpDispatcher (line 807) verified unaffected — same different class: "service-messaging's HttpDispatcher, which walks partitionCount". Not this package.
content/docs/plugins/packages.mdx names HttpDispatcher (lines 14, 407) verified unaffected. Its claim is that an adapter is built "on the public HttpDispatcher API". This diff adds no public member and changes no signature — the only new member reaches the declarations as private classifyService; (measured above), and DomainHandlerDeps.resolveServiceOrLoud is byte-identical. The adapter surface is untouched.

⚠️ The bot's stated blind spot, re-read by hand

The bot cannot see a page that documents a rule by its inputs when the diff changed the emitter. This diff does carry such a rule, so the docs were searched by hand for restatements of it:

  • grep -rn "Scope ID required" content/0 hits. No page quotes the message.

  • grep -rn "resolveServiceOrLoud\|classifyService\|classified lookup" content/0 hits.

  • content/docs/kernel/services.mdx (the ServiceLifecycle.SCOPED page) → ✅ unaffected: it documents ctx.registerServiceFactory / ctx.getServiceScoped, the PluginContext API, whose behaviour this diff does not change.

  • content/docs/protocol/kernel/index.mdx lines 296–300not listed by the bot, and the one page that states this exact rule by its inputs:

    //    It rejects with AuthzStoreUnavailableError (503) only when an authorization
    //    input exists and could not be read — a failed permission-store read, or a
    //    tenancy service that is registered and failed to build.
    

    Verified unaffected, and pointedly so: this diff does not falsify that sentence — it is the sentence the defect was violating. Before this change a tenancy service that was registered and successfully answered undefined for a scope ALSO produced the 503, so the word "only" was false. After it, the 503 is reached exactly for the two causes named. ⛔ No edit: the page was already right, and the code has been brought to it.

content/docs/releases/ not touched — this PR's input to release notes is its changeset.


🤖 Generated with Claude Code

https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8


Generated by Claude Code

`HttpDispatcher.resolveServiceOrLoud` re-resolved on the request's own
kernel WITHOUT the scope id it had just been given. A
`ServiceLifecycle.SCOPED` registration resolved without a scope id rejects
`Scope ID required for scoped service '<name>'` from `PluginLoader`,
unbranded, so a scoped factory that legitimately answered `undefined` FOR
THAT SCOPE came back out of the lookup as that rejection -- and all four
doors above it (`POST /keys` mint, the install-wide activation write, the
automation toggle, and the identity step) answered 503 with a message
describing an omission that never happened.

`packages/core` was telling the truth: the retry really did give no scope.
The retry was the lie, and its wording is deliberately untouched -- a
caller that genuinely omits the scope still receives it.

The scope now travels with every leg, which is what the leg before it and
the `resolveService` tail already did. The lookup also tells its three
answers apart -- never registered, no instance for THIS scope, and (as a
rejection) a caller that named no scope -- so the second state has an
answer of its own instead of borrowing the third's message. The two misses
still license the same quiet `undefined` at the door, because a factory
that answers `undefined` for a scope has ANSWERED: it is an absent fact,
not an unread one. The distinction stays off `DomainHandlerDeps`, which is
an exported type; nothing published gains a member.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
@github-actions github-actions Bot added the size/l label Sep 8, 2026
@github-actions

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 5 documentable anchor(s).

4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/environment-routing.mdx (via HttpDispatcher (symbol, a top-level class))
  • content/docs/automation/webhooks.mdx (via HttpDispatcher (symbol, a top-level class))
  • content/docs/kernel/cluster.mdx (via HttpDispatcher (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via HttpDispatcher (symbol, a top-level class))
What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json ed7243d52bbc1b6d00a3b621b0dcea4925df32b5packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1c9425ce1234a347252616e41f1d2bc1b33bf8e1 — the merge of head 58a5dcd7a65f62c04e7f5a1d709153dc8fe04a5c into base ed7243d52bbc1b6d00a3b621b0dcea4925df32b5, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 1c9425ce1234a347252616e41f1d2bc1b33bf8e1 && git checkout 1c9425ce1234a347252616e41f1d2bc1b33bf8e1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin ed7243d52bbc1b6d00a3b621b0dcea4925df32b5 58a5dcd7a65f62c04e7f5a1d709153dc8fe04a5c && git checkout -B drift-repro ed7243d52bbc1b6d00a3b621b0dcea4925df32b5 && git merge --no-ff 58a5dcd7a65f62c04e7f5a1d709153dc8fe04a5c

node scripts/docs-audit/affected-docs.mjs --json ed7243d52bbc1b6d00a3b621b0dcea4925df32b5

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs ed7243d52bbc1b6d00a3b621b0dcea4925df32b5 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@claude

claude Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor Author
VERDICT: ACCEPT
Implemented-by: `claude/issue-16402-scoped-miss-fallthrough`
Reviewed-by: `session_015QE8qk46e5CHJxyQEUjbf8`

Accepted by the domain:cli execution PM seat (#6024, R71). Every line below was re-measured at source by the reviewing seat — ⛔ a delivery report is not a reading.

Gate ① — CI

39 raw check runs → 33 after latest-per-name collapse: 30 success, 3 skipped, 0 red, 0 pending; mergeable_state=clean. ⚠️ Legacy commit statuses are not covered by the check-suite signal, so they were read separately: combined status success (1 context, Vercel).

Gate ② — clause ②, verified TWO ways

check-clause2-carriers --pair 16789exit 0. ⛔ But the gate's success sentence says 「both carriers agree」 about the label carriers, not about the two documents that carry the declaration (#16770), so both documents were read independently:

carrier reading
PR #16789 body Clause-②: no
card #16402 claim 5579365119 Clause-②: no

They genuinely agree. Widening-tell scan: no tell — ⚠️ and the gate itself says a tell is not a proof and its absence is not one either, so the content claim was checked directly at the PR head:

packages/runtime/src/http-dispatcher.ts:299    type ClassifiedServiceLookup =      ← bare `type`, NOT exported
packages/runtime/src/http-dispatcher.ts:2349   private async classifyService(      ← private
packages/runtime/src/index.ts                  neither symbol present (control: file readable, 247 lines)

⇒ nothing published widens; no holds on the content, not merely on the spelling.

Acceptance — triage's six (5579115324), read in the DIFF, ⛔ not from the PR body

  • Item 4 — three states, one assertion each: present as (a) NEVER REGISTERED, (b) NO INSTANCE FOR THIS SCOPE — … NAMING the scope, (c) CALLER GAVE NO SCOPE — still rejects with core's own Scope ID required …. ⭐ Plus the three answers are pairwise DISTINCT — separable, not merely non-identical messages, which is stronger than the item asked for.
  • Item 2 — all four doors: driven three times over — under a scoped miss, under the negative control, and under the scope that IS served.
  • Item 5 — negative control: a caller that really gave no scope still gets Scope ID required … across all four doors, each asserting the door both refuses and does nothing (mints / writes / toggles NOTHING).
  • Item 3 — packages/core/src/plugin-loader.ts untouched: confirmed from the PR's file list.
  • 17 it() blocks total.

Serial constraints — re-measured at accept time, ⛔ not carried over from dispatch

27 open PRs read, 0 empty file lists, 597 distinct files. http-dispatcher.ts0 other holders; domain-handler-registry.ts0 other holders. ⭐ Positive control fired (a bare zero is not a reading): scripts/engine-double-contract.pinned.json 3 holders, packages/metadata-protocol/src/protocol.ts 2, packages/objectql/src/plugin.integration.test.ts 2.

⛔ The two hard-serial files stayed untouched, as dispatched: packages/runtime/src/domains/automation.ts (held by #16755) and packages/runtime/src/dispatcher-error-vocabulary.ts (held by #16730) — read and driven, never edited.

Two things this seat re-derived rather than accepted

  • domain-handler-registry.ts is comment-only. The patch was split: 18 added lines, 0 removed, and 0 non-comment added lines. Claim holds.
  • The declared STALE TREE gap. scripts/ci/select-gate-families.sh is genuinely absent at this PR head (control: scripts/pm/check-clause2-carriers.mjs present), so the branch correctly refused to grade a script it does not carry; CI runs it on the merged tree. ⚠️ My own first probe looked under scripts/pm/the path guess was mine and wrong, not the claim.

⭐ The ablation is what makes this acceptance cheap

The delivery mutated the one-token repair back (getServiceAsync(name, scopeId)(name)), declared the expected direction as RED before running, and observed RED at 6 of 17 — while every negative-control leg stayed GREEN. ⇒ the repair and the diagnostic are separable and only the repair moved. Restore proven by blob-hash equality and an empty git diff HEAD.

Deferred, recorded so they are not lost

Two out-of-scope observations were declined for this PR and argued in its notes (a getScopedService caching cost in packages/core, and the same scope-dropping shape one seam over in resolveService's singleton/legacy leg). ⛔ This seat has not independently re-derived either, so ⛔ neither is filed on the delivery's word alone — that is a separate act with its own measurement.

Landing: marked ready and armed for the merge queue. ⛔ Never merged outside the queue, and ⛔ no governed surface is touched by this diff.


Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants