fix(runtime): the tenancy posture seam tells "never registered" from "registered and failed" at the runtime door - #15909
Conversation
…e door (red against main) Pins for #13906 decision 1 option A on the runtime resolver and the dispatcher wiring: a tenancy service that is registered and fails to build must raise AuthzStoreUnavailableError (503), and a tenancy service that was never registered must keep resolving quietly with no posture. Source untouched in this commit so the red run measures origin/main bytes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…"registered and failed" at the runtime door resolveExecutionContext swallowed every rejection of the tenancy read into "no posture", and no posture skips both posture-conditional API-key refusals — so a tenancy service that was registered and failed to build read as a deployment with no wall. Apply #13906 decision 1 option A the way rest-server.ts already does: absorb only the registry's branded "never registered" rejection; re-raise everything else as AuthzStoreUnavailableError (503 SERVICE_UNAVAILABLE). Two nets between the resolver and the transport envelope are told the same thing: the dispatcher's service facade hands the resolver the classified rejection for 'tenancy' (resolveService is a capability probe that collapsed it to undefined), and resolveRequestScope's catch re-raises only the branded outage via rethrowAuthzStoreUnavailable, degrading everything else to anonymous as before. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
…ionContext always resolves It rejects with AuthzStoreUnavailableError (503) when an authorization input exists and could not be read — false since #13279 for a failed permission-store read, and now also for a tenancy service that is registered and failed to build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ARYe3yQTQCUFm5qPYNgKaJ
📓 Docs Drift CheckThis PR changes 1 package(s): 5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 24 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 3931c98acd2516926e45b438d7cff6e371a63189 && git checkout 3931c98acd2516926e45b438d7cff6e371a63189
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin abdceef8c68cf0f5a43b3bfb536c53d375c79c1b 3055f94ae1162f5cb7d1d36c4d77b07e99d374bc && git checkout -B drift-repro abdceef8c68cf0f5a43b3bfb536c53d375c79c1b && git merge --no-ff 3055f94ae1162f5cb7d1d36c4d77b07e99d374bc
node scripts/docs-audit/affected-docs.mjs --json abdceef8c68cf0f5a43b3bfb536c53d375c79c1b
|
Closes #15366
What this changes
resolveExecutionContextreads the effective tenancy posture from the kernel'stenancyservice, andresolveAuthzContextruns BOTH posture-conditional API-key refusals (organization_required,organization_membership_ended) only when that posture is present. Soundefinedthere is not neutral — it is "no wall". The seam resolved every rejection to it, which made atenancyservice that was registered and failed to build indistinguishable from a deployment with no tenancy at all.The fix applies #13906 decision 1 option A at the runtime door, in the spelling
rest-server.tsalready carries on both of its wirings — classification by the registry's own brand (#13905), never by message text:undefined. The supported no-tenancy composition; unchanged.AuthzStoreUnavailableError(503 SERVICE_UNAVAILABLE, an existing member of the closed ADR-0112 vocabulary). 「A posture that could not be READ is not a posture that is ABSENT.」The caller trace (Zone-2 A4), and why the site is three nets, not one
resolveExecutionContexthas exactly one production caller:HttpDispatcher.resolveRequestScope(viatimedResolveExecutionContext;measureServerTimingusestry/finallyand re-throws).resolveRequestScopeis reached fromdispatch()— which awaits it OUTSIDE its owntry, so a throw leavesdispatch()— and from the declarative-endpoint fallback indispatcher-plugin.ts. Everydispatch()call site (33 route handlers indispatcher-plugin.ts, the fallback included) sits intry { … } catch (err) { errorResponseBase(err, res, …) }, anderrorResponseBasereadserr.status ?? err.statusCode— so the runtime door DOES have a 503 envelope.package-routes.ts'soptions.resolveExecutionContextis REST's ownresolvePackageRouteExecutionContext, not this function.Measured on
origin/main, though, a re-raise at the card's site alone would have changed nothing in production, because two nets sit above it and both collapse the same fact:catch { tenancyPosture = undefined }— the card's site;getServicefacade,resolveService: a capability PROBE whose fallback chain absorbs every rejection at every step and answersundefined— the resolver's catch was never reached;resolveRequestScope'scatch { /* anonymous */ }around the whole identity step — a re-raised 503 would have been answered as an anonymous request, the "changed disguise"authz-store-unavailable.tsdocuments.All three are told the same thing, each in the shape
@objectstack/corealready prescribes:resolve-execution-context.ts— the catch discriminates by brand and re-raisesAuthzStoreUnavailableError('tenancy', err)(REST'scomputeExecCtxspelling). Its header no longer claims "never throws" — that sentence had been false since [finding] a permission-store read failure resolves as an AUTHENTICATED caller holding ZERO capabilities — the package door answers 403 FORBIDDEN, byte-identical to a genuine capability denial #13279 made a failed permission-store read throw.http-dispatcher.ts— a new privateresolveServiceOrLoudkeepsresolveService's chain ORDER (host-scoped lookup first, then the request's own kernel; a host with no async accessor keeps the quiet probe, exactly as REST's provider does forLiteKernel) and changes only what a rejection MEANS. The identity step's facade routes'tenancy'through it; every other name keeps the probe. The catch around the identity step now callsrethrowAuthzStoreUnavailable(err)— the.catchargument core prescribes for "every fail-closed seam betweenresolveAuthzContextand a door" — so everything else still degrades to anonymous.content/docs/protocol/kernel/index.mdx— the identity-step code sample no longer says the resolver "always resolves" (a sentence already false since [finding] a permission-store read failure resolves as an AUTHENTICATED caller holding ZERO capabilities — the package door answers 403 FORBIDDEN, byte-identical to a genuine capability denial #13279); it now names the one rejecting class. All 33 open PRs' file lists were intersected with this path: no in-flight holder.AuthzStoreUnavailableErrorfromtryFind) reaches this door as 503 instead of being served as an anonymous 401. Measured onorigin/main:http-dispatcher.tscontained zero references toAuthzStoreUnavailable, i.e. the runtime door was swallowing that ruled-loud outage too. It cannot be separated from this card's outage without inventing a third category, and it is the ruled direction, so it ships here and is named in the changeset. This is the bounded in-place fix the dispatch contract allows (same defect class · prescribed shape · no other claim on that catch · same gate family); the file surface is declared in the report.Measured on the wire (real routes through
createDispatcherPlugin, realerrorResponseBase), ex-member org-stamped API keyorigin/mainbytes, blob-hash verified)isolatedGET /automation401 UNAUTHENTICATED (membership refusal)GET /automation501 NOT_IMPLEMENTED (admitted, no automation service)GET /automation501 NOT_IMPLEMENTED — byte-identical to "never registered": admittedGET /health200POST /keyswas NOT usable as the wire pin: it answers its own503 Data service not availableagainst a fixture engine with noinsert, so it read 503 on the failed leg before the fix too.Red then green, both tests
Red on
origin/mainbytes (git hash-objectof both source files equal to theirBASE=6acb37eb9blobs, printed in the same command):RED_RUN_EXIT=1,Tests 6 failed | 39 passed (45)— the six failures are exactly the REPAIRED / COLLAPSE-ENDED /dispatch()-re-raises pins; every POSITIVE CONTROL (posture reaches the resolver on both facades), every SUPPORTED never-registered pin, the probe-facade pin and the "every other fault still degrades" pin were green on main.Green after the fix:
GREEN_RUN_EXIT=0,Tests 47 passed (47)(two wire pins were added after the route measurement above).Verification
3055f94ae; it differs from566e1558aonly undercontent/docs/(git diff 566e1558a 3055f94ae --stat -- packagesprints nothing), so the package tree the suite and the build measured is byte-identical to the head.@objectstack/runtimesuite on566e1558a(pnpm --filter @objectstack/runtime exec vitest run --maxWorkers=2, through the shared verify lock):Test Files 228 passed (228),Tests 3272 passed (3272),VERDICT command-exit 0. Dependency closure built first (pnpm --filter '@objectstack/runtime^...' build,VERDICT command-exit 0);pnpm --filter @objectstack/runtime buildafter the fix:VERDICT command-exit 0,check-dts-emitted 2/2.pnpm --filter @objectstack/runtime typecheckthrough the lock:VERDICT command-exit 0(tsc pluscheck:test-typecheck: 27 files / 191 errors / 69 pinned signatures held — the errors are pre-existing ledgered ones at lines 333–362 of the resolver test, not in the added lines;tsc -p tsconfig.test.json --listFileslists both test files).eslint.config.mjsenables no type-aware linting for any file (its own comment at line 328), so a verdict is per-file and this diff cannot move an untouched file's;--format jsonover the four touched TS files →files linted: 4, errors: 0, warnings: 0, exit 0. The repo-widepnpm lintis CI's.node scripts/pm/dispatch-gates.mjs(no path argument) at566e1558a— the ones whose population reads this diff, each run with its exit captured before any pipe and its own verdict line read:check:nul-bytes·check:dispatcher-error-vocabulary·check:cross-package-test-inputs·check:engine-double-contract·check:where-matcher·check:objectql-double-limit·check:query-options-erasure·check:type-check-coverage·check:test-source-alias·check:startup-registry-verdict·check:authz-resolver·check:tenant-chokepoint·check:slot-lookup·check:error-code-casing·check:error-status-conformance·check:optional-error-sink·check:logger-receiver-detach·check:init-service-contract·check:durability-log-level·check:changeset-gate-self-tests·check-adr-0087-registration --base origin/main·check-changeset-no-major --base origin/main·check-empty-changeset --base origin/main·check-changeset-fixed·check:doc-authoring·check:org-identifier·check:page-declaration-shape·check:type-source-resolution·check-undeclared-dep-imports·check:published-files·check:single-claim-paths·check-comment-mask-adoption·check:dts-closure— 34 gates, every one exit 0 (check:authz-resolver: "single shared authorization resolver intact; both entry points delegate";check:dispatcher-error-vocabulary: no unregistered code —SERVICE_UNAVAILABLEis aStandardErrorCode).3055f94aeafter the docs commit, exits captured the same way: the ratchet family (slot-lookup·query-options-erasure·engine-double-contract·where-matcher·objectql-double-limit·type-check-coverage) plusnul-bytes·doc-authoring·docs-image-tag·docs-audit/check-affected-docs·docs-audit/check-drift-comment·dispatcher-error-vocabulary·authz-resolver·startup-registry-verdict·cross-package-test-inputs·test-source-alias· the three changeset scripts — 19, all exit 0; and the docs-derived families the re-derivation added for the.mdxpath —doc-anchors·docs-single-h1·docs-redirects·role-word·corpus-claim-drift·docs-transcript-drift·docs-audit-scope·lint: doc-security-posture·lint: doc-formula-expressions·check-doc-frontmatter·check-docs-section-name·check-doc-route-spelling --advisory·check-section-landing-index·vendor-version-stamps— 14, all exit 0.check:dual-build-cjs-loadsexits 3 with its own line "Runpnpm buildfirst. ⛔ This is NOT a pass: nothing was measured" (whole-workspace build; CI's run);check:type-check-debt --re-measurelikewise needs the full closure built (CI's run). The repo-widepnpm lintis CI's (narrowing evidence above).packages/runtime/dist:index.d.tshas NO declaration ofresolveExecutionContext(its two mentions are TSDoc prose onHttpProtocolContext); the sole declaration-level delta isprivate resolveServiceOrLoud;onHttpDispatcher(opaque, not a usable face).content/docs/**mentionsresolveExecutionContextonce, inprotocol/kernel/index.mdx, as a code sample of the identity step's call shape (resolveExecutionContext({ getService, getQl, request })) — the call shape and the options keys are unchanged; what changes is that an unreadable authorization input now rejects instead of resolving as guest, the behaviour the REST door already documents for the same class.http-dispatcher.ts, fix(runtime): the dispatcher's scope strip matches/environments/, the prefix its own hint parser reads #15859 (claude/issue-15488-scope-strip-prefix, hunks at 560–575 and 2195+; its head4538d6289):git merge-tree --write-treeexit 0 — clean.Clause-② re-declaration: no
resolveExecutionContextis not exported from@objectstack/runtime's entry (src/index.tshas no reference); no new export, no new accepted key or value, no error type added —AuthzStoreUnavailableErrorandSERVICE_UNAVAILABLEalready exist and are already answered by the REST door. The only declaration-level delta is oneprivatemember onHttpDispatcher(see the dist reading above). Behaviour change on a security seam, not a published-face change (维护者 2026-08-28: 「运行时权限/安全行为变更不是条款② …… 条款②只指已发布契约面」).Open question for the PM (not blocking the fix)
resolveRequestScopereads the posture for EVERY request, credentialed or not, so under a failed tenancy buildGET /healthnow answers 503 (measured above), which a liveness probe would read as "restart me" for a configuration fault a restart cannot fix. REST has no/healthand reads the posture unconditionally too, so this is consistent with the ruling as applied there; whether liveness should be carved out (or the posture read made lazy for anonymous requests) is a decision, not something this card may invent. Left as-is on purpose.Out of scope, filed
resolveServiceprobe, so a tenancy service that failed to build reads as "no wall" at/keysmint and at the activation-write refusal #15900 —domains/keys.ts(mint) anddomains/activation-gate.tsread the posture through the same collapsing probe with a bare catch; same permissive direction, different gates, left untouched here (a domain-level decision). runtime: two dispatcher domain gates read the tenancy posture through the collapsingresolveServiceprobe, so a tenancy service that failed to build reads as "no wall" at/keysmint and at the activation-write refusal #15900 is not addressed here.Zone-2 readings that differ from the dispatch word
resolve-execution-context.tsbut not forhttp-dispatcher.ts: fix(runtime): the dispatcher's scope strip matches/environments/, the prefix its own hint parser reads #15859 holds it (draft). Merge measured clean; whichever lands second inherits a trivial rebase at most.tenancyis registered as an instance byplugin-auth(ctx.registerService('tenancy', …), one site), so the failed-build class is reachable via a factory registration by an embedder or the loader's other unbranded rejections — no evidence for the p0 escalation condition.Generated by Claude Code