Skip to content

Commit 68f8f77

Browse files
claude[bot]claude
andauthored
fix(runtime): the classified lookup keeps the scope it was handed (#16789)
* fix(runtime): the classified lookup keeps the scope it was handed `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 * chore(changeset): the classified lookup keeps the scope it was handed Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015QE8qk46e5CHJxyQEUjbf8 --------- Co-authored-by: claude <noreply@anthropic.com>
1 parent a749dcc commit 68f8f77

4 files changed

Lines changed: 725 additions & 9 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/runtime": patch
3+
---
4+
5+
A scoped service that has no instance for your environment no longer answers as if you had forgotten to name one.
6+
7+
`HttpDispatcher`'s classified service lookup — the read behind the identity step, the `POST /keys` mint gate, the install-wide activation write and `POST /automation/:name/toggle` — took the scope it was handed, missed on it, and then re-resolved on the request's own kernel **without** that scope. A service registered `ServiceLifecycle.SCOPED` and resolved without a scope id is rejected by the plugin loader with `Scope ID required for scoped service '<name>'`, and that rejection is not the branded "never registered" the lookup absorbs — so it was re-raised, and each of those four doors answered `503 SERVICE_UNAVAILABLE` on a deployment where nothing was unwell. A caller that passed its environment correctly was told it had passed nothing.
8+
9+
Concretely: a `tenancy` factory that serves one environment and legitimately returns `undefined` for another made every one of those four doors fail for the second environment — no API key could be minted, no activation switch flipped, and the identity step itself raised the outage.
10+
11+
- **The scope now travels with every leg of the chain**, which is what the leg before it and the fallback tail already did. Nothing else about the resolution order changes: which registry answers is unchanged, only whether it is asked the question the caller actually asked.
12+
- **The lookup tells its three answers apart.** "Nothing was ever registered under this name" and "this name is registered and produced no instance in the scope you passed" are two different facts. They still license the same quiet `undefined` at the door — a factory that returns `undefined` for a scope has *answered*, so it is an absent fact rather than an unread one, and ADR-0093 D4/D5 reads a scope with no tenancy service the way it reads a deployment with none — but they are no longer the same answer inside the lookup.
13+
- **The loader's message is untouched, and so is the caller it is about.** A door that really resolves a scoped service without a scope still receives `Scope ID required for scoped service '<name>'` and still answers 503. That direction is pinned explicitly, because an implementation that answered every scoped miss with `undefined` would fix the misattribution by deleting a correct diagnostic.
14+
15+
No exported type changes: `DomainHandlerDeps.resolveServiceOrLoud` keeps its signature and keeps answering the service or `undefined`.

packages/runtime/src/domain-handler-registry.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,15 @@ export interface DomainHandlerDeps {
156156
* - branded "never registered" (`isServiceNotRegisteredError`, #13905) →
157157
* `undefined`, quiet. The supported composition, whose behaviour is
158158
* exactly what it was;
159+
* - [#16402] a registry that KNOWS the name and produces no instance for
160+
* the scope you passed → `undefined`, quiet as well. A factory that
161+
* answers `undefined` for a scope has ANSWERED, so this is an absent
162+
* fact and not an unread one — ADR-0093 D4/D5 reads a scope with no
163+
* service the same way it reads a deployment with none. ⚠️ It is a
164+
* DIFFERENT fact from the one above with the same licence, and the
165+
* lookup tells the two apart internally (`HttpDispatcher.classifyService`)
166+
* — it is collapsed HERE because no door needs to act on the difference,
167+
* ⛔ not because they are the same state;
159168
* - every other rejection (a factory that threw, a scoped registration
160169
* resolved without a scope id, a circular service dependency) →
161170
* re-raised, for the gate to answer as an OUTAGE rather than as an
@@ -185,6 +194,15 @@ export interface DomainHandlerDeps {
185194
* rejection out of this method should describe the SERVICE, never the call
186195
* site's own omission.
187196
*
197+
* ⭐ [#16402] That last sentence used to be false INSIDE the lookup itself:
198+
* having taken your scope, it re-resolved on the request's own kernel
199+
* WITHOUT it, so a scoped factory answering `undefined` for your scope came
200+
* back as `Scope ID required for scoped service '<name>'` — a rejection
201+
* describing an omission that never happened, at a call site that passed
202+
* everything it was asked for. The scope now travels with every leg. ⛔ The
203+
* `packages/core` wording is untouched, and a caller that really passes no
204+
* scope still receives it, which is the one caller it is true about.
205+
*
188206
* Untyped by slot on purpose, exactly like `resolveService`'s second
189207
* overload: its callers address `tenancy`, which has no written
190208
* `ServiceSlotContracts` entry, and inventing one here would be a shape

0 commit comments

Comments
 (0)