diff --git a/.changeset/service-resolution-discriminator.md b/.changeset/service-resolution-discriminator.md new file mode 100644 index 0000000000..c672b112d3 --- /dev/null +++ b/.changeset/service-resolution-discriminator.md @@ -0,0 +1,41 @@ +--- +"@objectstack/core": minor +"@objectstack/runtime": patch +--- + +fix(core): tell "service never registered" apart from "service failed to construct" on the async path (#13905) + +`PluginLoader.getService` — reached through `Kernel.getServiceAsync` — answered two +different facts with the same bare `Error`. "Nothing ever registered this service" and +"the service is registered and could not be built" arrived at a caller as one +indistinguishable rejection, separated only by message text. + +That was load-bearing one layer out. `RestServer.computeExecCtx`'s kernel branch absorbs a +failed `getServiceAsync('objectql')` and degrades to "no engine is wired", and it must keep +doing so — a kernel with no data plane is a supported configuration, declared by +`rest-api-plugin.ts` as `optionalDependencies: ['com.objectstack.engine.objectql']`. So a +multi-tenant host whose engine *failed to construct* reached the same resolver as "no +engine is wired", degrading silently where it should have refused loudly. The branch could +not be repaired from outside, because the fact it needed had been collapsed before it +arrived. + +The asynchronous path now carries the distinction the **synchronous** context accessor in +`kernel.ts` has always drawn from the registry. `@objectstack/core` publishes exactly two +new symbols for it: + +- `isServiceNotRegisteredError(err)` — true only when nothing was ever registered under + that name; +- `SERVICE_NOT_REGISTERED_CODE` — the code the rejection carries. + +The test is closed and its default is loud: exactly one rejection in `getService` means +"never registered" and only that one is branded, so every other way it can fail — a factory +that threw, a missing scope id, an unset loader context, a circular service dependency — +stays unbranded, and a consumer that absorbs only the branded rejection is loud about +everything else, including rejections added later. + +⛔ Not message matching. Adding a second text classifier on a resolution path is the failure +mode this change removes: reading "not found" off the async path once reported every +missing service as `is async - use await` — the wrong fix, pointing at the wrong layer. + +Nothing existing moves. The rejection keeps a byte-identical message and `name: 'Error'`; +the only observable change is the two added own-properties. diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 44a2db434d..e824f0db75 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,6 +14,15 @@ export * from './lite-kernel.js'; export * from './types.js'; export * from './logger.js'; export * from './plugin-loader.js'; + +// [#13905] The async service-resolution discriminator — the two symbols a +// CONSUMER needs to tell "nothing ever registered this service" from "the +// service IS registered and could not be built", now that +// `Kernel.getServiceAsync` no longer answers both with one bare `Error`. +// Named rather than `export *` on purpose: the construction site is +// `PluginLoader.getService` alone, so the factory stays package-internal and +// the published increment is exactly this predicate and its code. +export { SERVICE_NOT_REGISTERED_CODE, isServiceNotRegisteredError } from './service-not-registered.js'; // `./api-registry.js` + `./api-registry-plugin.js` were RETIRED in #4939 // (ADR-0049 enforce-or-remove). `createApiRegistryPlugin()` registered an // `api-registry` service that only `packages/core/examples/` ever composed — diff --git a/packages/core/src/plugin-loader.ts b/packages/core/src/plugin-loader.ts index 5bacc62812..24c3c31c51 100644 --- a/packages/core/src/plugin-loader.ts +++ b/packages/core/src/plugin-loader.ts @@ -3,6 +3,7 @@ import { Plugin, PluginContext } from './types.js'; import type { Logger } from '@objectstack/spec/contracts'; import { parseSignature } from './security/plugin-artifact-signature.js'; +import { serviceNotRegisteredError } from './service-not-registered.js'; /** * Service Lifecycle Types @@ -205,7 +206,17 @@ export class PluginLoader { // Fall back to static service instances const instance = this.serviceInstances.get(name); if (!instance) { - throw new Error(`Service '${name}' not found`); + // [#13905] The ONE rejection on this method that means "nothing + // was ever registered under this name". Branded so a caller + // holding only the rejection can tell it from a service that IS + // registered and failed to construct (a factory that threw, a + // missing scope id, an unset context, a circular dependency) — + // which all reject from below, unbranded, and so stay loud. + // The message is unchanged; the discriminator rides beside it. + // ⛔ Not message text: see `service-not-registered.ts` for why + // this repo does not classify a resolution fault by matching on + // it. + throw serviceNotRegisteredError(name); } return instance as T; } diff --git a/packages/core/src/service-not-registered.ts b/packages/core/src/service-not-registered.ts new file mode 100644 index 0000000000..9f80f1334f --- /dev/null +++ b/packages/core/src/service-not-registered.ts @@ -0,0 +1,132 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#13905] The discriminator that tells **"nothing ever registered this + * service"** apart from **"the service IS registered and could not be built"** + * on the ASYNCHRONOUS resolution path. + * + * ## The fault + * + * `PluginLoader.getService` (reached through `Kernel.getServiceAsync`) answered + * both facts with the same bare `Error`. A caller that holds only the rejection + * therefore could not tell an UNWIRED embedder from a BROKEN one, and the only + * thing separating them was message text. + * + * That mattered one layer out. `RestServer.computeExecCtx`'s kernel branch + * absorbs a failed `getServiceAsync('objectql')` and degrades to "no engine is + * wired". It must keep doing so — a kernel with no data plane is a SUPPORTED + * configuration (`rest-api-plugin.ts` declares + * `optionalDependencies: ['com.objectstack.engine.objectql']`) — but a + * multi-tenant host whose engine FAILED TO CONSTRUCT reached that same resolver + * as "no engine is wired", degrading silently where it should have refused + * loudly. The branch could not be repaired from the outside, because the fact + * it needed had been collapsed before it arrived. + * + * ## Why a brand, and ⛔ not message text + * + * The SYNCHRONOUS accessor in `kernel.ts` already draws exactly this line, and + * the comment there records what happened the last time someone read the fact + * off the wrong surface: reading "not found" off the async path "reported every + * missing service as `is async - use await` — the wrong fix, pointing at the + * wrong layer". A second text classifier on a resolution path is the failure + * mode this module removes, ⛔ not a repair of it. + * + * The sync side decides from the REGISTRY — synchronous and authoritative — and + * raises two different messages. The async side now carries that same + * distinction as a branded, `code`-bearing rejection: one fact, spelled for a + * caller that only ever sees the rejection. + * + * ## The test is CLOSED, and its default is LOUD + * + * Exactly one throw in `PluginLoader.getService` means "never registered", and + * it is the one branded here. Every other way that method can reject — a + * factory that threw, a missing scope id, an unset loader context, a circular + * service dependency — is a service that IS registered and could not be + * produced, and stays unbranded. So `false` is the safe answer: a consumer that + * absorbs only the branded rejection stays loud about everything else, + * including rejections added to that method later. + * + * ## Two deliberate omissions + * + * - **No `status`.** An ADR-0112 envelope pairs `code` with a `status`, but + * the whole point of this discriminator is that the CONSUMER decides what an + * unwired service means — absorb and degrade (the supported no-data-plane + * kernel) or refuse. Carrying an HTTP status here would presuppose that + * decision at the layer that must not make it. + * - **No `name` override.** The rejection stays `name: 'Error'` with a + * byte-identical message, so `String(err)`, logs and existing assertions + * render exactly as before. The only observable change is two added + * own-properties. + * + * Brand shape follows `AuthzStoreUnavailableError` (2026-08-30): a string-keyed + * own property rather than `instanceof`, so the predicate still answers + * correctly when two copies of `@objectstack/core` are installed (a duplicated + * module makes `instanceof` say "no" to an error it built itself). + * + * ⚠️ The brand does NOT survive `structuredClone`, and no claim here depends on + * it doing so — measured on Node 22: cloning an `Error` keeps `name`, `message`, + * `stack` and `cause` and DROPS every other own property, brand and `code` + * alike. This discriminator is for an in-process rejection travelling from + * `PluginLoader.getService` to a seam that catches it, which is the only path + * it is used on. + */ + +/** + * The code carried by the "never registered" rejection. + * + * ⚠️ Spelled the ADR-0112 way, but deliberately NOT wire vocabulary: this value + * is read in-process by the seam that catches the rejection and is never + * serialized into an `error.code` envelope. `dispatcher-error-vocabulary.ts` + * classifies it `door: 'none'` / `boot-refusal` for exactly that reason — the + * same class as the migration-journal runner refusals. If a transport ever + * needs to ANSWER with this fact, that is a registration question for #8846's + * ledger, ⛔ not something to start doing at a door. + */ +export const SERVICE_NOT_REGISTERED_CODE = 'SERVICE_NOT_REGISTERED'; + +/** + * The own-property brand {@link isServiceNotRegisteredError} tests for. + * A plain string key rather than `instanceof` or a `Symbol.for` registry key, + * so a duplicated copy of this module still brands identically. See the module + * doc for what it deliberately does NOT claim. + */ +const SERVICE_NOT_REGISTERED_BRAND = '__objectstackServiceNotRegistered'; + +/** + * Build the rejection for "no factory and no instance is registered under this + * name". Package-internal on purpose: `PluginLoader.getService` is the single + * construction site, and `@objectstack/core` publishes only the two symbols a + * CONSUMER needs ({@link SERVICE_NOT_REGISTERED_CODE} and + * {@link isServiceNotRegisteredError}) — see `index.ts`. + * + * The message is kept verbatim: callers and tests that render or assert on it + * must not move when the discriminator arrives. + */ +export function serviceNotRegisteredError(name: string): Error { + const err = new Error(`Service '${name}' not found`) as Error & { + [SERVICE_NOT_REGISTERED_BRAND]?: true; + code?: string; + serviceName?: string; + }; + err[SERVICE_NOT_REGISTERED_BRAND] = true; + err.code = SERVICE_NOT_REGISTERED_CODE; + err.serviceName = name; + return err; +} + +/** + * True when `err` is the rejection meaning **nothing was ever registered under + * that service name** — never when a registered service failed to construct. + * + * The predicate a seam uses to keep absorbing the supported "no data plane" + * composition while staying loud about a service that IS wired and broke. + */ +export function isServiceNotRegisteredError( + err: unknown, +): err is Error & { readonly code: typeof SERVICE_NOT_REGISTERED_CODE; readonly serviceName: string } { + return ( + typeof err === 'object' + && err !== null + && (err as Record)[SERVICE_NOT_REGISTERED_BRAND] === true + ); +} diff --git a/packages/core/src/service-resolution-discriminator.contract.test.ts b/packages/core/src/service-resolution-discriminator.contract.test.ts new file mode 100644 index 0000000000..67c9f0d814 --- /dev/null +++ b/packages/core/src/service-resolution-discriminator.contract.test.ts @@ -0,0 +1,239 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#13905] The async service-resolution discriminator. + * + * `Kernel.getServiceAsync` used to answer "nothing ever registered this + * service" and "the service IS registered and could not be built" with the same + * bare `Error`, so a caller holding only the rejection could not tell an + * UNWIRED embedder from a BROKEN one. These pins hold the two facts apart, and + * hold the SUPPORTED no-data-plane composition on the quiet side of the line. + */ + +import { describe, it, expect, beforeEach } from 'vitest'; +import { ObjectKernel } from './kernel.js'; +import { PluginLoader, ServiceLifecycle } from './plugin-loader.js'; +import { createLogger } from './logger.js'; +import type { PluginContext } from './types.js'; +import { + isServiceNotRegisteredError, + SERVICE_NOT_REGISTERED_CODE, +} from './service-not-registered.js'; + +function makeLoader(withContext = true): PluginLoader { + const logger = createLogger({ level: 'error' }); + const loader = new PluginLoader(logger); + if (withContext) { + // A real `PluginContext`, not a cast: the loader only ever hands this to + // a factory, and every member is spelled so the compiler checks the + // shape instead of a `as unknown as` silencing it. + const ctx: PluginContext = { + registerService: () => {}, + registerServiceFactory: () => {}, + getService: () => { throw new Error('Mock service not found'); }, + replaceService: () => {}, + getServiceScoped: async () => { throw new Error('not used'); }, + getServices: () => new Map(), + hook: () => {}, + trigger: async () => {}, + logger, + getKernel: () => ({}) as any, + }; + loader.setContext(ctx); + } + return loader; +} + +/** The rejection, or a loud failure if the call unexpectedly resolved. */ +async function rejectionOf(call: () => Promise): Promise { + try { + await call(); + } catch (err) { + return err; + } + throw new Error('expected the call to reject, but it resolved'); +} + +describe('[#13905] the two service-resolution facts are distinguishable', () => { + let loader: PluginLoader; + + beforeEach(() => { + loader = makeLoader(); + }); + + it('brands "never registered" with a code and the service name', async () => { + const err = await rejectionOf(() => loader.getService('ghost')); + + expect(isServiceNotRegisteredError(err)).toBe(true); + expect(err.code).toBe(SERVICE_NOT_REGISTERED_CODE); + expect(err.code).toBe('SERVICE_NOT_REGISTERED'); + expect(err.serviceName).toBe('ghost'); + }); + + it('leaves a factory that THREW unbranded — the other fact', async () => { + loader.registerServiceFactory({ + name: 'exploding', + factory: () => { throw new Error('driver connect failed'); }, + lifecycle: ServiceLifecycle.SINGLETON, + }); + + const err = await rejectionOf(() => loader.getService('exploding')); + + // The factory's own diagnostic survives — it is not replaced by ours… + expect(err.message).toBe('driver connect failed'); + // …and it is NOT the "never registered" fact. + expect(isServiceNotRegisteredError(err)).toBe(false); + expect(err.code).toBeUndefined(); + }); + + it('the two rejections for the same name are distinguishable from each other', async () => { + const absent = await rejectionOf(() => makeLoader().getService('objectql')); + + const broken = makeLoader(); + broken.registerServiceFactory({ + name: 'objectql', + factory: () => { throw new Error('connection refused'); }, + lifecycle: ServiceLifecycle.SINGLETON, + }); + const failed = await rejectionOf(() => broken.getService('objectql')); + + // Both are rejections about the SAME service name — the discriminator + // is the only thing that tells them apart, and it does. + expect(isServiceNotRegisteredError(absent)).toBe(true); + expect(isServiceNotRegisteredError(failed)).toBe(false); + }); +}); + +describe('[#13905] the discriminator is a CLOSED test that defaults loud', () => { + it('does not brand a registered service that could not be produced', async () => { + // Every one of these is "registered, but you cannot have it" — the + // opposite fact from "nothing registered this name". If a later + // rejection is added to `getService` it lands here too, unbranded, + // which is the safe side. + const scoped = makeLoader(); + scoped.registerServiceFactory({ + name: 'needs-scope', + factory: () => ({}), + lifecycle: ServiceLifecycle.SCOPED, + }); + const noScopeId = await rejectionOf(() => scoped.getService('needs-scope')); + expect(noScopeId.message).toContain('Scope ID required'); + expect(isServiceNotRegisteredError(noScopeId)).toBe(false); + + const contextless = makeLoader(false); + contextless.registerServiceFactory({ + name: 'no-context', + factory: () => ({}), + lifecycle: ServiceLifecycle.SINGLETON, + }); + const unset = await rejectionOf(() => contextless.getService('no-context')); + expect(unset.message).toContain('Context not set'); + expect(isServiceNotRegisteredError(unset)).toBe(false); + + const circular = makeLoader(); + circular.registerServiceFactory({ + name: 'circ', + factory: async () => await circular.getService('circ'), + lifecycle: ServiceLifecycle.TRANSIENT, + }); + const cycle = await rejectionOf(() => circular.getService('circ')); + expect(cycle.message).toContain('Circular dependency detected'); + expect(isServiceNotRegisteredError(cycle)).toBe(false); + }); + + it('answers false for non-errors and for a bare Error', () => { + expect(isServiceNotRegisteredError(undefined)).toBe(false); + expect(isServiceNotRegisteredError(null)).toBe(false); + expect(isServiceNotRegisteredError('Service \'x\' not found')).toBe(false); + expect(isServiceNotRegisteredError(new Error("Service 'x' not found"))).toBe(false); + }); +}); + +describe('[#13905] the rejection renders exactly as it did before', () => { + it('keeps the message and the error name byte-identical', async () => { + const err = await rejectionOf(() => makeLoader().getService('ghost')); + + // A2.3: nothing may move for a caller that renders or asserts on this + // text. The discriminator rides BESIDE the message, never replaces it. + expect(err.message).toBe("Service 'ghost' not found"); + expect(err.name).toBe('Error'); + expect(String(err)).toBe("Error: Service 'ghost' not found"); + expect(err instanceof Error).toBe(true); + }); +}); + +describe('[#13905] the SUPPORTED no-data-plane kernel stays quiet, the broken one goes loud', () => { + /** + * The shape a transport seam can now write — absorb ONLY "not wired", + * re-raise everything else. + * + * ⚠️ Test-local on purpose. This card ships the discriminator; rebinding + * `RestServer.computeExecCtx`'s kernel branch to it is a separate card + * (#13476's remainder). This helper exists to prove the discriminator can + * carry that repair, not to perform it. + */ + async function engineOrUndefined(kernel: ObjectKernel): Promise { + try { + return await kernel.getServiceAsync('objectql'); + } catch (err) { + if (isServiceNotRegisteredError(err)) return undefined; + throw err; + } + } + + function makeKernel(): ObjectKernel { + return new ObjectKernel({ + logger: { level: 'error' }, + gracefulShutdown: false, + skipSystemValidation: true, + }); + } + + it('a kernel with NO data plane resolves as "not wired", not as an outage', async () => { + // The composition `rest-api-plugin.ts` declares as supported: + // `optionalDependencies: ['com.objectstack.engine.objectql']` — nothing + // registers `objectql`. Making this loud is the breakage #13476 + // refused to ship, so it must stay quiet. + const kernel = makeKernel(); + await kernel.bootstrap(); + + await expect(engineOrUndefined(kernel)).resolves.toBeUndefined(); + + const err = await rejectionOf(() => kernel.getServiceAsync('objectql')); + expect(isServiceNotRegisteredError(err)).toBe(true); + + await kernel.shutdown(); + }); + + it('a kernel whose engine FAILED TO CONSTRUCT is loud instead of degrading', async () => { + // The multi-tenant host from the filing: the engine IS wired and broke. + // Before the discriminator this reached the resolver as "no engine is + // wired" — indistinguishable from the supported case above. + const kernel = makeKernel(); + kernel.registerServiceFactory( + 'objectql', + () => { throw new Error('driver handshake failed'); }, + ServiceLifecycle.SINGLETON, + ); + await kernel.bootstrap(); + + await expect(engineOrUndefined(kernel)).rejects.toThrow('driver handshake failed'); + + await kernel.shutdown(); + }); +}); + +describe('[#13905] the published increment', () => { + it('reaches consumers through the package entry point, and is exactly two symbols', async () => { + const core: Record = await import('./index.js'); + + expect(typeof core.isServiceNotRegisteredError).toBe('function'); + expect(core.SERVICE_NOT_REGISTERED_CODE).toBe('SERVICE_NOT_REGISTERED'); + expect(core.isServiceNotRegisteredError).toBe(isServiceNotRegisteredError); + + // The construction site is `PluginLoader.getService` alone, so the + // factory stays package-internal. Publishing it would invite a second + // producer of a fact that must have one. + expect(core.serviceNotRegisteredError).toBeUndefined(); + }); +}); diff --git a/packages/runtime/src/dispatcher-error-vocabulary.ts b/packages/runtime/src/dispatcher-error-vocabulary.ts index 7a6e70fefa..1ed9c69ae0 100644 --- a/packages/runtime/src/dispatcher-error-vocabulary.ts +++ b/packages/runtime/src/dispatcher-error-vocabulary.ts @@ -623,6 +623,32 @@ export const UNREGISTERED_CODE_SITES: readonly UnregisteredCodeSite[] = [ 'and grep finds no other consumer in `packages/`. Same class as the two rows above and ruled ' + 'by the same #8035 reasoning: a runner refusal the CLI rethrows is not wire vocabulary.', }, + // [#13905] The async service-resolution discriminator; the pre-HTTP reasoning + // is #8035's, the same one the migration-journal rows above cite. If a + // transport ever answers with this fact it becomes pending-registration and + // joins #8846's ledger batch. Tracker ids live in this comment, never in the + // `why` string below — that string is runtime prose and reaches readers who + // cannot resolve them. + { + code: 'SERVICE_NOT_REGISTERED', + file: 'packages/core/src/service-not-registered.ts', + shape: 'assignconst', + door: 'none', + verdict: 'boot-refusal', + why: + 'The discriminator that tells "nothing ever registered this service" from "the service IS ' + + 'registered and could not be built", stamped on the ONE rejection `PluginLoader.getService` ' + + 'raises for the first fact. Read in-process by the seam that catches the rejection and never ' + + 'serialized: measured on this tree, the only references to the code are its own module and the ' + + '`@objectstack/core` re-export — no door reads it, and both seams that catch `getServiceAsync` ' + + 'today (`seamOrUndefined` in packages/rest, `resolveService` in packages/runtime) use a bare ' + + '`catch` that inspects nothing. It carries no `status` on purpose: the whole point is that the ' + + 'CONSUMER decides whether an unwired service degrades or refuses, so binding an HTTP status here ' + + 'would presuppose that decision at the layer that must not make it. Same class as the ' + + 'migration-journal runner refusals above — a composition fact caught in-process is not wire ' + + 'vocabulary. If a transport ever ANSWERS with this fact, the verdict becomes ' + + 'pending-registration and the code belongs in the ledger batch.', + }, // ── [#13233] field-level catalogs, reached by the OBJECT-LITERAL helper ── // // The 29 rows below are the whole verdict cost of widening `codehelper` to