Skip to content

Commit add4360

Browse files
claude[bot]claude
andauthored
fix(core): discriminate "service never registered" from "service failed to construct" on the async resolution path (#14005)
* fix(core): discriminate 'service never registered' from 'service failed to construct' on the async path Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L * docs(runtime): classify the new service-resolution code; changeset Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L * fix(runtime,core): keep tracker ids out of the vocabulary why-string Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L * test(core): spell the contract test's imports and mock context so tsc checks them Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d101943 commit add4360

6 files changed

Lines changed: 459 additions & 1 deletion

File tree

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
---
2+
"@objectstack/core": minor
3+
"@objectstack/runtime": patch
4+
---
5+
6+
fix(core): tell "service never registered" apart from "service failed to construct" on the async path (#13905)
7+
8+
`PluginLoader.getService` — reached through `Kernel.getServiceAsync` — answered two
9+
different facts with the same bare `Error`. "Nothing ever registered this service" and
10+
"the service is registered and could not be built" arrived at a caller as one
11+
indistinguishable rejection, separated only by message text.
12+
13+
That was load-bearing one layer out. `RestServer.computeExecCtx`'s kernel branch absorbs a
14+
failed `getServiceAsync('objectql')` and degrades to "no engine is wired", and it must keep
15+
doing so — a kernel with no data plane is a supported configuration, declared by
16+
`rest-api-plugin.ts` as `optionalDependencies: ['com.objectstack.engine.objectql']`. So a
17+
multi-tenant host whose engine *failed to construct* reached the same resolver as "no
18+
engine is wired", degrading silently where it should have refused loudly. The branch could
19+
not be repaired from outside, because the fact it needed had been collapsed before it
20+
arrived.
21+
22+
The asynchronous path now carries the distinction the **synchronous** context accessor in
23+
`kernel.ts` has always drawn from the registry. `@objectstack/core` publishes exactly two
24+
new symbols for it:
25+
26+
- `isServiceNotRegisteredError(err)` — true only when nothing was ever registered under
27+
that name;
28+
- `SERVICE_NOT_REGISTERED_CODE` — the code the rejection carries.
29+
30+
The test is closed and its default is loud: exactly one rejection in `getService` means
31+
"never registered" and only that one is branded, so every other way it can fail — a factory
32+
that threw, a missing scope id, an unset loader context, a circular service dependency —
33+
stays unbranded, and a consumer that absorbs only the branded rejection is loud about
34+
everything else, including rejections added later.
35+
36+
⛔ Not message matching. Adding a second text classifier on a resolution path is the failure
37+
mode this change removes: reading "not found" off the async path once reported every
38+
missing service as `is async - use await` — the wrong fix, pointing at the wrong layer.
39+
40+
Nothing existing moves. The rejection keeps a byte-identical message and `name: 'Error'`;
41+
the only observable change is the two added own-properties.

packages/core/src/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,15 @@ export * from './lite-kernel.js';
1414
export * from './types.js';
1515
export * from './logger.js';
1616
export * from './plugin-loader.js';
17+
18+
// [#13905] The async service-resolution discriminator — the two symbols a
19+
// CONSUMER needs to tell "nothing ever registered this service" from "the
20+
// service IS registered and could not be built", now that
21+
// `Kernel.getServiceAsync` no longer answers both with one bare `Error`.
22+
// Named rather than `export *` on purpose: the construction site is
23+
// `PluginLoader.getService` alone, so the factory stays package-internal and
24+
// the published increment is exactly this predicate and its code.
25+
export { SERVICE_NOT_REGISTERED_CODE, isServiceNotRegisteredError } from './service-not-registered.js';
1726
// `./api-registry.js` + `./api-registry-plugin.js` were RETIRED in #4939
1827
// (ADR-0049 enforce-or-remove). `createApiRegistryPlugin()` registered an
1928
// `api-registry` service that only `packages/core/examples/` ever composed —

packages/core/src/plugin-loader.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import { Plugin, PluginContext } from './types.js';
44
import type { Logger } from '@objectstack/spec/contracts';
55
import { parseSignature } from './security/plugin-artifact-signature.js';
6+
import { serviceNotRegisteredError } from './service-not-registered.js';
67

78
/**
89
* Service Lifecycle Types
@@ -205,7 +206,17 @@ export class PluginLoader {
205206
// Fall back to static service instances
206207
const instance = this.serviceInstances.get(name);
207208
if (!instance) {
208-
throw new Error(`Service '${name}' not found`);
209+
// [#13905] The ONE rejection on this method that means "nothing
210+
// was ever registered under this name". Branded so a caller
211+
// holding only the rejection can tell it from a service that IS
212+
// registered and failed to construct (a factory that threw, a
213+
// missing scope id, an unset context, a circular dependency) —
214+
// which all reject from below, unbranded, and so stay loud.
215+
// The message is unchanged; the discriminator rides beside it.
216+
// ⛔ Not message text: see `service-not-registered.ts` for why
217+
// this repo does not classify a resolution fault by matching on
218+
// it.
219+
throw serviceNotRegisteredError(name);
209220
}
210221
return instance as T;
211222
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#13905] The discriminator that tells **"nothing ever registered this
5+
* service"** apart from **"the service IS registered and could not be built"**
6+
* on the ASYNCHRONOUS resolution path.
7+
*
8+
* ## The fault
9+
*
10+
* `PluginLoader.getService` (reached through `Kernel.getServiceAsync`) answered
11+
* both facts with the same bare `Error`. A caller that holds only the rejection
12+
* therefore could not tell an UNWIRED embedder from a BROKEN one, and the only
13+
* thing separating them was message text.
14+
*
15+
* That mattered one layer out. `RestServer.computeExecCtx`'s kernel branch
16+
* absorbs a failed `getServiceAsync('objectql')` and degrades to "no engine is
17+
* wired". It must keep doing so — a kernel with no data plane is a SUPPORTED
18+
* configuration (`rest-api-plugin.ts` declares
19+
* `optionalDependencies: ['com.objectstack.engine.objectql']`) — but a
20+
* multi-tenant host whose engine FAILED TO CONSTRUCT reached that same resolver
21+
* as "no engine is wired", degrading silently where it should have refused
22+
* loudly. The branch could not be repaired from the outside, because the fact
23+
* it needed had been collapsed before it arrived.
24+
*
25+
* ## Why a brand, and ⛔ not message text
26+
*
27+
* The SYNCHRONOUS accessor in `kernel.ts` already draws exactly this line, and
28+
* the comment there records what happened the last time someone read the fact
29+
* off the wrong surface: reading "not found" off the async path "reported every
30+
* missing service as `is async - use await` — the wrong fix, pointing at the
31+
* wrong layer". A second text classifier on a resolution path is the failure
32+
* mode this module removes, ⛔ not a repair of it.
33+
*
34+
* The sync side decides from the REGISTRY — synchronous and authoritative — and
35+
* raises two different messages. The async side now carries that same
36+
* distinction as a branded, `code`-bearing rejection: one fact, spelled for a
37+
* caller that only ever sees the rejection.
38+
*
39+
* ## The test is CLOSED, and its default is LOUD
40+
*
41+
* Exactly one throw in `PluginLoader.getService` means "never registered", and
42+
* it is the one branded here. Every other way that method can reject — a
43+
* factory that threw, a missing scope id, an unset loader context, a circular
44+
* service dependency — is a service that IS registered and could not be
45+
* produced, and stays unbranded. So `false` is the safe answer: a consumer that
46+
* absorbs only the branded rejection stays loud about everything else,
47+
* including rejections added to that method later.
48+
*
49+
* ## Two deliberate omissions
50+
*
51+
* - **No `status`.** An ADR-0112 envelope pairs `code` with a `status`, but
52+
* the whole point of this discriminator is that the CONSUMER decides what an
53+
* unwired service means — absorb and degrade (the supported no-data-plane
54+
* kernel) or refuse. Carrying an HTTP status here would presuppose that
55+
* decision at the layer that must not make it.
56+
* - **No `name` override.** The rejection stays `name: 'Error'` with a
57+
* byte-identical message, so `String(err)`, logs and existing assertions
58+
* render exactly as before. The only observable change is two added
59+
* own-properties.
60+
*
61+
* Brand shape follows `AuthzStoreUnavailableError` (2026-08-30): a string-keyed
62+
* own property rather than `instanceof`, so the predicate still answers
63+
* correctly when two copies of `@objectstack/core` are installed (a duplicated
64+
* module makes `instanceof` say "no" to an error it built itself).
65+
*
66+
* ⚠️ The brand does NOT survive `structuredClone`, and no claim here depends on
67+
* it doing so — measured on Node 22: cloning an `Error` keeps `name`, `message`,
68+
* `stack` and `cause` and DROPS every other own property, brand and `code`
69+
* alike. This discriminator is for an in-process rejection travelling from
70+
* `PluginLoader.getService` to a seam that catches it, which is the only path
71+
* it is used on.
72+
*/
73+
74+
/**
75+
* The code carried by the "never registered" rejection.
76+
*
77+
* ⚠️ Spelled the ADR-0112 way, but deliberately NOT wire vocabulary: this value
78+
* is read in-process by the seam that catches the rejection and is never
79+
* serialized into an `error.code` envelope. `dispatcher-error-vocabulary.ts`
80+
* classifies it `door: 'none'` / `boot-refusal` for exactly that reason — the
81+
* same class as the migration-journal runner refusals. If a transport ever
82+
* needs to ANSWER with this fact, that is a registration question for #8846's
83+
* ledger, ⛔ not something to start doing at a door.
84+
*/
85+
export const SERVICE_NOT_REGISTERED_CODE = 'SERVICE_NOT_REGISTERED';
86+
87+
/**
88+
* The own-property brand {@link isServiceNotRegisteredError} tests for.
89+
* A plain string key rather than `instanceof` or a `Symbol.for` registry key,
90+
* so a duplicated copy of this module still brands identically. See the module
91+
* doc for what it deliberately does NOT claim.
92+
*/
93+
const SERVICE_NOT_REGISTERED_BRAND = '__objectstackServiceNotRegistered';
94+
95+
/**
96+
* Build the rejection for "no factory and no instance is registered under this
97+
* name". Package-internal on purpose: `PluginLoader.getService` is the single
98+
* construction site, and `@objectstack/core` publishes only the two symbols a
99+
* CONSUMER needs ({@link SERVICE_NOT_REGISTERED_CODE} and
100+
* {@link isServiceNotRegisteredError}) — see `index.ts`.
101+
*
102+
* The message is kept verbatim: callers and tests that render or assert on it
103+
* must not move when the discriminator arrives.
104+
*/
105+
export function serviceNotRegisteredError(name: string): Error {
106+
const err = new Error(`Service '${name}' not found`) as Error & {
107+
[SERVICE_NOT_REGISTERED_BRAND]?: true;
108+
code?: string;
109+
serviceName?: string;
110+
};
111+
err[SERVICE_NOT_REGISTERED_BRAND] = true;
112+
err.code = SERVICE_NOT_REGISTERED_CODE;
113+
err.serviceName = name;
114+
return err;
115+
}
116+
117+
/**
118+
* True when `err` is the rejection meaning **nothing was ever registered under
119+
* that service name** — never when a registered service failed to construct.
120+
*
121+
* The predicate a seam uses to keep absorbing the supported "no data plane"
122+
* composition while staying loud about a service that IS wired and broke.
123+
*/
124+
export function isServiceNotRegisteredError(
125+
err: unknown,
126+
): err is Error & { readonly code: typeof SERVICE_NOT_REGISTERED_CODE; readonly serviceName: string } {
127+
return (
128+
typeof err === 'object'
129+
&& err !== null
130+
&& (err as Record<string, unknown>)[SERVICE_NOT_REGISTERED_BRAND] === true
131+
);
132+
}

0 commit comments

Comments
 (0)