fix(core): discriminate "service never registered" from "service failed to construct" on the async resolution path - #14005
Conversation
…ed to construct' on the async path Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
… checks them Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F3jdziLbAPGeceVNmSox5L
📓 Docs Drift CheckThis PR changes 2 package(s): 9 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 37 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 56f5d2bed4476482e68a4a2f53c94f8c91197de8 && git checkout 56f5d2bed4476482e68a4a2f53c94f8c91197de8
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin a6a2af50a745fca3ce878421faa26667e67373d2 6c523862e02b677ee4a3258ad9cf9508481bfe00 && git checkout -B drift-repro a6a2af50a745fca3ce878421faa26667e67373d2 && git merge --no-ff 6c523862e02b677ee4a3258ad9cf9508481bfe00
node scripts/docs-audit/affected-docs.mjs --json a6a2af50a745fca3ce878421faa26667e67373d2
|
PM review — ACCEPT on substance. ⛔ HELD on
|
Contract review (Clause ②) — PASSReviewed at head Carrier action: Generated by Claude Code |
Fixes #13905
PluginLoader.getService— reached throughKernel.getServiceAsync— answered twodifferent 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.
The asynchronous path now carries the distinction the synchronous context accessor
in
kernel.tshas always drawn from the registry, per the 2026-08-07 meta-criterion: oneoperation, two implementations, the governed side wins and the other rebinds.
What landed
One throw is branded.
packages/core/src/service-not-registered.tsbuilds the rejectionfor "no factory and no instance is registered under this name", and
PluginLoader.getServiceraises it in place of the bareError. Nothing else moves.⛔ Not message matching. Adding a second text classifier on a resolution path is the
failure mode this card exists to remove. The message is byte-identical and
namestaysError; the only observable change is two added own-properties.Clause-②: yesExact surface added — two symbols, both from
@objectstack/core:isServiceNotRegisteredError(err)ErrorpluscodeandserviceNameSERVICE_NOT_REGISTERED_CODESERVICE_NOT_REGISTEREDneeds:contract-reviewis attached, because the diff carries the increment.Measured on the built
.d.tsrather than asserted —packages/core/dist/index.d.tsafterpnpm --filter @objectstack/core buildexports exactly those two and not the factory:The construction site is
PluginLoader.getServicealone, so the factory is exported fromits module (for the producer) but deliberately not re-exported from
index.ts—@objectstack/corepublishes only.and./logger, so it stays package-internal.Path limb does NOT fire. Nothing under
packages/spec/src/**; no ledger or schemaentry. The
packages/runtimefile below is a classification table, not a schema.The minimal shape, and the measurement that chose it
The card offered two candidates. Shape 1 (a discriminated rejection) is smaller and
shape 2 does not satisfy the ruled direction:
PluginLoader.hasServiceis already public, butKernel.pluginLoaderisprivateandKernel.hasAnyServiceisprivate, so this meansa new query verb on the published
Kernelclass. More surface, and it leaves theasync path exactly as it was: a caller holding only the rejection still cannot tell the
two facts apart. It adds a probe beside the ungoverned path rather than rebinding it.
question at the point of failure, which is where the consumer actually is.
The test is closed, and its default is loud
Exactly one rejection in
getServicemeans "never registered" and only that one isbranded. Every other way it 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 a consumer that absorbs only the branded
rejection is loud about everything else, including rejections added later. Pinned.
replaceServicekeeps its bareService NAME not foundthrow on purpose: it alreadydecides from
hasService, and it never constructs anything, so it has only one fact toreport. No collapse there.
Two deliberate omissions, both reviewable
status. The whole point is that the consumer decides whether an unwired servicedegrades or refuses; binding an HTTP status here presupposes that decision at the layer
that must not make it.
instanceof— so it still answerscorrectly across a duplicated copy of
@objectstack/core.structuredClone, and nothing here depends on it doing so. Measured on Node 22: cloningan
Errorkeepsname/message/stack/causeand drops every other own property.Why
packages/runtimeis in the diffcheck:dispatcher-error-vocabularyis content-triggered and fires on any file carrying anADR-0112-shaped code. It required a classified row, which is added as
door: 'none'/verdict: 'boot-refusal'— the same class as the migration-journal runnerrefusals, and the honest one: measured on this tree, the only references to the code are
its own module and the
@objectstack/corere-export. Both seams that catchgetServiceAsynctoday use a barecatchthat inspects nothing, so it reaches no wire.Gate:
OK — 52 unregistered code-stamping site(s), all classified.Verification
Ablation on the committed implementation, restored under
trap … EXIT INT TERMwithabsolute paths:
1 -> 0, bare throw1 -> 2, blob24c3c31c… -> f545e555…the supported-configuration regression
git diff HEADempty, blob back to24c3c31c…, 9 passedNo rebuild was needed: the subject is reached by relative source specifier, not through the
package
exportsmap, so vitest compiles the mutated source directly — and the red/greenflip is itself the proof the mutation reached it.
Tests and gates below were run at
6c523862e0, the final commit.pnpm --filter @objectstack/core test— 47 files, 1156 passedpnpm --filter @objectstack/runtime exec vitest run src/error-envelope.conformance.test.ts— 52 passed (it imports
UNREGISTERED_CODE_SITES, the array this PR edits)pnpm --filter @objectstack/core build— exit 0, dts emitted 2/2node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack): 32 exit 0worktree does not have (a full workspace build), and each says so itself:
check-test-completenesscheck:dual-build-cjs-loadspnpm buildfirst. ⛔ This is NOT a pass"check:type-check-debt--re-measurecannot run: 52 workspace dependencies have no built type entry point"CI builds the closure and runs all three.
@objectstack/coredeclares notypecheckscript, so a--filter … typecheckwould match zero scripts and exit 0 having checked nothing. Type coverage was measured
directly instead:
tsc --noEmit --listFilesconfirms both new files are in the program(they are not excluded), and after correcting their import extensions and the mock context
they contribute zero errors to core's ledgered debt — which is what keeps CI's
shrink-only ratchet from moving.
Generated by Claude Code