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
|
…ct two sentences in 8d189af Commit 8d189af on this branch says, of the classified read: "That made one failure answer 503 at the identity step and admit at these two gates in the same deployment, so 'what is this deployment's state on the wall question' had two answers at once." That is overstated for a WIRE caller and it would land in `main` verbatim, because this repository squashes. Driven at this head, `dispatch('POST', '/keys', …)` and `dispatch('POST', '/actions/_activation/…')` with a `tenancy` factory that throws both answer 503 SERVICE_UNAVAILABLE with `context.executionContext` never set, nothing minted and no activation row written: the identity step raises first and the request never reaches either gate. Since PR #15909 the collapse at these two gates is a DOOR-BODY fact — an embedder calling `handleKeys` / `handleActions` directly, which is exactly where this branch's pins enter — not a second answer a wire caller could observe for that class. The pin section of the PR body already said this correctly; the commit message did not. The severity argument is unchanged and does not rest on the overstatement: one deployment still holds two different readings of its own wall question, and the door-body reading is the permissive one. The same commit also says: "the classified read is a second, opted-into deps facility, so no gate that was not named here changes behaviour." True of `resolveService`'s other callers, false about the gate it edited: `refuseUngrantedActivationWrite` is ONE body with TWO doors — `./actions.ts` (`POST /actions/_activation/:object/:action`) and `./automation.ts` (`POST /automation/:name/toggle`, through `refuseUngrantedFlowActivationWrite`) — so the toggle door inherited the new throw exit while going unnamed and unpinned. Both `await` the call, so nothing was ever unhandled; what was missing was the statement and the coverage. Both doors are now named in the gate's own doc block and pinned together in `tenancy-posture-outage-gates.test.ts`, which is where a claim about "the gate" can be checked against both halves of the surface. `DomainHandlerDeps.resolveServiceOrLoud` also gains the caller rule the scope-id defect earned: pass the scope you hold, because a rejection out of the classified read must describe the SERVICE and never the call site's own omission. Under the plain probe that omission was invisible; under this one it is a 503 for every caller of the door. The census docs line moves with the anchors this edit shifted, by the gate's own `--fix`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N
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