Found while implementing #13476. Recorded for triage; no severity asserted.
The shape
PluginLoader.getService (reached through kernel.getServiceAsync) answers two different facts with the same bare Error:
const registration = this.serviceFactories.get(name);
if (!registration) {
const instance = this.serviceInstances.get(name);
if (!instance) {
throw new Error(`Service '${name}' not found`); // NOT REGISTERED
}
return instance as T;
}
// ... a factory that throws surfaces as ITS OWN error // FAILED TO CONSTRUCT
"Nothing ever registered this service" and "the service is registered and could not be built" are different facts, and a consumer receives them as the same kind of rejection with no discriminator beyond message text.
Why this is load-bearing, and where it was measured
#13476 repaired RestServer.computeExecCtx's data-engine seam for the PROVIDER branch, where the wiring fact is separately available (the provider is either wired or not). The KERNEL branch of the same seam could not be repaired:
const ql = kernel
? await seamOrUndefined(() => kernel.getServiceAsync('objectql')) // still absorbs
: await wiredEngineOrLoud(Boolean(this.objectQLProvider), ...); // repaired
Making that branch loud would refuse service to every embedder running a kernel with no data plane — a SUPPORTED configuration (rest-api-plugin.ts declares optionalDependencies: ['com.objectstack.engine.objectql']). So the branch keeps absorbing, and a multi-tenant host whose engine fails to construct still reaches the resolver as "no engine is wired".
The synchronous context accessor already draws exactly this distinction, deliberately and with the reasoning written down (kernel.ts, the #4085 comment): it asks pluginLoader.hasService(name) and raises two different messages, because reading "not found" off the async path "reported every missing service as is async - use await — the wrong fix, pointing at the wrong layer". The async path did not get the same treatment.
Notes for whoever takes it
pluginLoader.hasService(name) is the authoritative synchronous registry probe and already exists; Kernel.hasAnyService wraps it but is PRIVATE, and PluginContext.getServices() returns only this.services (instances), not factory registrations — so no public surface currently answers "is this service registered?".- Message matching is not a fix: the repo already documents callers matching on
is async, and adding a second text-matching classifier on a resolution path is the failure mode this filing is about.
Related, and distinct
Generated by Claude Code
Found while implementing #13476. Recorded for triage; no severity asserted.
The shape
PluginLoader.getService(reached throughkernel.getServiceAsync) answers two different facts with the same bareError:"Nothing ever registered this service" and "the service is registered and could not be built" are different facts, and a consumer receives them as the same kind of rejection with no discriminator beyond message text.
Why this is load-bearing, and where it was measured
#13476 repaired
RestServer.computeExecCtx's data-engine seam for the PROVIDER branch, where the wiring fact is separately available (the provider is either wired or not). The KERNEL branch of the same seam could not be repaired:Making that branch loud would refuse service to every embedder running a kernel with no data plane — a SUPPORTED configuration (
rest-api-plugin.tsdeclaresoptionalDependencies: ['com.objectstack.engine.objectql']). So the branch keeps absorbing, and a multi-tenant host whose engine fails to construct still reaches the resolver as "no engine is wired".The synchronous context accessor already draws exactly this distinction, deliberately and with the reasoning written down (
kernel.ts, the#4085comment): it askspluginLoader.hasService(name)and raises two different messages, because reading "not found" off the async path "reported every missing service asis async - use await— the wrong fix, pointing at the wrong layer". The async path did not get the same treatment.Notes for whoever takes it
pluginLoader.hasService(name)is the authoritative synchronous registry probe and already exists;Kernel.hasAnyServicewraps it but is PRIVATE, andPluginContext.getServices()returns onlythis.services(instances), not factory registrations — so no public surface currently answers "is this service registered?".is async, and adding a second text-matching classifier on a resolution path is the failure mode this filing is about.Related, and distinct
objectQLProviderinrest-api-plugin.tsabsorbs before the transport sees it — the #13476 repair does not reach the single-kernel wiring #13904 — the shipped plugin provider absorbing one layer out.Generated by Claude Code