Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/classified-lookup-keeps-its-scope.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@objectstack/runtime": patch
---

A scoped service that has no instance for your environment no longer answers as if you had forgotten to name one.

`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.

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.

- **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.
- **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.
- **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.

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