fix(runtime): the classified lookup keeps the scope it was handed - #16789
Conversation
`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
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8
📓 Docs Drift CheckThis PR changes 1 package(s): 4 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
Accepted by the Gate ① — CI39 raw check runs → 33 after latest-per-name collapse: 30 success, 3 skipped, 0 red, 0 pending; Gate ② — clause ②, verified TWO ways
They genuinely agree. Widening-tell scan: no tell — ⇒ nothing published widens; Acceptance — triage's six (
|
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.SCOPEDregistration resolved without a scope id is rejected byPluginLoader.getServicewithScope 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 answered503 SERVICE_UNAVAILABLE. A caller that passed its environment correctly was told it had passed nothing.⭐ The cross-package split.
packages/corewas 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-derivesteps, run firstAll 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
resolveServiceOrLoudgrep -rn 'resolveServiceOrLoud' packages/ --include='*.ts'on8b37a0973d:The body, as it stood — three legs, and the middle one is the only one that drops the scope:
2. A scoped factory answering
undefinedfor one scope and an instance for another, through each of the four doorsProducer (this wiring did not exist in the tree;
tenancy-posture-outage-gates.test.ts'sscoped-healthyfactory always succeeds):Driven with
environmentId: 'northwind'— the scope the factory answersundefinedfor — on the unfixed tree: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
causethe envelope carries, on the unfixed tree, at each of the four doors:And the raw lookup, with the three states asked directly on the unfixed tree — ⭐ states (b) and (c) were byte-identical:
⇒ 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
resolveServicetail already did.resolveServiceOrLoudbecomes a thin adapter over a new privateclassifyService, which answers three ways:classifyServiceresolveServiceOrLoud{ outcome: 'never-registered' }undefined{ outcome: 'no-instance-in-scope', scopeId }undefinedScope 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.
undefinedfor a scope has answered, not failed. [finding] two morecomputeExecCtxseams 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.single; for a per-scope registration that reading is per-scope.undefinedalready means absence throughout this file:resolveService's whole chain testssvc != null, andPluginLoader.getScopedServicehands a factory'sundefinedstraight back. Reading that value as a fault would be the lookup overruling the registry./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 assumedThe declaration is rebuttable by the diff, so the diff was read against the built declarations (
packages/runtime/dist/index.d.ts, this branch):⇒ Every new literal that reaches the published declarations is prose. Not one occupies a type position.
DomainHandlerDeps.resolveServiceOrLoudkeeps its signature verbatim — the wholedomain-handler-registry.tsdiff is comment-only: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-derivesteps 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 exactlyScope ID required for scoped service 'tenancy', carried on the 503'scauseat 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:/keysmint gatehandleKeysScope ID required …, 0 rowshandleActions('/_activation/…')Scope ID required …, no writePERMISSION_DENIEDPOST /automation/:name/togglehandleAutomationScope ID required …, no togglePERMISSION_DENIEDresolveRequestScopeScope ID required …packages/runtime/src/domains/automation.tsis held by PR #16755 and was read, never edited. The toggle door is driven throughhandleAutomation, exactly astenancy-posture-outage-gates.test.tsalready 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-vocabularygreen.3. ⛔ Do not change core's wording. ✅
packages/core/src/plugin-loader.tsis not in the diff at all. The whole change set is four paths:4. Three states separable — one assertion each. ✅ §1 of the pin file, four tests:
(a) NEVER REGISTERED→ asserts{ outcome: 'never-registered' }andundefined;(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 — andundefined;(c) CALLER GAVE NO SCOPE→ asserts the rejection message isScope ID required for scoped service 'tenancy', at both the private arm and the door-facing entry point;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 thecause, never with a baretoThrow()— 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 atrap … EXIT INT TERMrestore and absolute paths:Direction predicted before the run: RED. Observed: RED, 6 of 17, and the stack names the cross-package attribution directly —
The subject resolves through the package's own source (
../http-dispatcher.js, an in-package relative import), not throughdist/, so no rebuild leg applies;packages/runtime/distdid 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 ownVERDICTline, never a bare$?):pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2 src/domains/scoped-service-miss-attribution.test.tsVERDICT command-exit 0— 17 passed (17)pnpm --filter @objectstack/runtime testVERDICT command-exit 0— 241 files, 3357 tests, all passedpnpm --filter @objectstack/runtime typecheckVERDICT command-exit 0pnpm --filter '@objectstack/runtime^...' build --concurrency=2VERDICT command-exit 0pnpm lint(eslint . --no-inline-config)scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackPREREQUISITE NOT MET(below)dispatch-gates.mjs --ran … --repo …56 derived, 56 run, 0 NOT-MEASURED, 0 UNRUNpackages/runtime/tsconfig.jsonexcludes**/*.test.ts, sotsc --noEmitalone 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--listFilesrather than assumed: the new file is in the program (1 hit; controltenancy-posture-outage-gates.test.ts1 hit; 243 test files total). Rawtsc -p tsconfig.test.jsonreports 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.dispatch-gatesprintsSTALE TREE:origin/mainmoved toed7243d52bafter 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 askip-changesetcase; 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.getScopedServicenever caches a falsy instance. It writes the factory's answer into the scope map and then guards the next read withif (!instance), so a scoped factory answeringundefinedis 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.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 aSCOPEDregistration 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 tokernel… 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 collapsingresolveServiceprobe, so a tenancy service that failed to build reads as "no wall" at/keysmint 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.content/docs/api/environment-routing.mdxHttpDispatcher(line 130)extractEnvironmentIdFromPath,prepareResolverHints, and the hostKernelResolver'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.mdxHttpDispatcherHttpDispatcher(in@objectstack/service-messaging)". Measured:grep -rn 'class HttpDispatcher'returns two —packages/runtime/src/http-dispatcher.ts:316(this diff's) andpackages/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.mdxHttpDispatcher(line 807)service-messaging'sHttpDispatcher, which walkspartitionCount". Not this package.content/docs/plugins/packages.mdxHttpDispatcher(lines 14, 407)HttpDispatcherAPI". This diff adds no public member and changes no signature — the only new member reaches the declarations asprivate classifyService;(measured above), andDomainHandlerDeps.resolveServiceOrLoudis byte-identical. The adapter surface is untouched.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(theServiceLifecycle.SCOPEDpage) → ✅ unaffected: it documentsctx.registerServiceFactory/ctx.getServiceScoped, the PluginContext API, whose behaviour this diff does not change.⭐
content/docs/protocol/kernel/index.mdxlines 296–300 — not listed by the bot, and the one page that states this exact rule by its inputs:✅ 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
undefinedfor 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