Skip to content

fix(runtime): the tenancy posture seam tells "never registered" from "registered and failed" at the runtime door - #15909

Merged
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-15366-runtime-tenancy-posture-failure-discrimination
Sep 5, 2026
Merged

fix(runtime): the tenancy posture seam tells "never registered" from "registered and failed" at the runtime door#15909
zhuangjianguo merged 4 commits into
mainfrom
claude/issue-15366-runtime-tenancy-posture-failure-discrimination

Conversation

@zhuangjianguo

Copy link
Copy Markdown
Collaborator

Closes #15366

What this changes

resolveExecutionContext reads the effective tenancy posture from the kernel's tenancy service, and resolveAuthzContext runs BOTH posture-conditional API-key refusals (organization_required, organization_membership_ended) only when that posture is present. So undefined there is not neutral — it is "no wall". The seam resolved every rejection to it, which made a tenancy service 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.ts already carries on both of its wirings — classification by the registry's own brand (#13905), never by message text:

  • never registered → branded → quiet undefined. The supported no-tenancy composition; unchanged.
  • registered and failed to build → unbranded → re-raised as 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

resolveExecutionContext has exactly one production caller: HttpDispatcher.resolveRequestScope (via timedResolveExecutionContext; measureServerTiming uses try/finally and re-throws). resolveRequestScope is reached from dispatch() — which awaits it OUTSIDE its own try, so a throw leaves dispatch() — and from the declarative-endpoint fallback in dispatcher-plugin.ts. Every dispatch() call site (33 route handlers in dispatcher-plugin.ts, the fallback included) sits in try { … } catch (err) { errorResponseBase(err, res, …) }, and errorResponseBase reads err.status ?? err.statusCode — so the runtime door DOES have a 503 envelope. package-routes.ts's options.resolveExecutionContext is REST's own resolvePackageRouteExecutionContext, 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:

  1. the resolver's bare catch { tenancyPosture = undefined } — the card's site;
  2. the dispatcher's getService facade, resolveService: a capability PROBE whose fallback chain absorbs every rejection at every step and answers undefined — the resolver's catch was never reached;
  3. resolveRequestScope's catch { /* anonymous */ } around the whole identity step — a re-raised 503 would have been answered as an anonymous request, the "changed disguise" authz-store-unavailable.ts documents.

All three are told the same thing, each in the shape @objectstack/core already prescribes:

⚠️ Stated consequence of net 3: the loud error is one brand, so the same net now also lets the #13279 class through — a permission-store read that FAILS (AuthzStoreUnavailableError from tryFind) reaches this door as 503 instead of being served as an anonymous 401. Measured on origin/main: http-dispatcher.ts contained zero references to AuthzStoreUnavailable, 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, real errorResponseBase), ex-member org-stamped API key

tenancy service before (origin/main bytes, blob-hash verified) after
healthy isolated GET /automation 401 UNAUTHENTICATED (membership refusal) 401 — unchanged
never registered GET /automation 501 NOT_IMPLEMENTED (admitted, no automation service) 501 — unchanged
registered and FAILED GET /automation 501 NOT_IMPLEMENTED — byte-identical to "never registered": admitted 503 SERVICE_UNAVAILABLE
registered and FAILED, no credential GET /health 200 503 — see open question

POST /keys was NOT usable as the wire pin: it answers its own 503 Data service not available against a fixture engine with no insert, so it read 503 on the failed leg before the fix too.

Red then green, both tests

Red on origin/main bytes (git hash-object of both source files equal to their BASE=6acb37eb9 blobs, 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

  • Branch head is 3055f94ae; it differs from 566e1558a only under content/docs/ (git diff 566e1558a 3055f94ae --stat -- packages prints nothing), so the package tree the suite and the build measured is byte-identical to the head.
  • Full @objectstack/runtime suite on 566e1558a (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 build after the fix: VERDICT command-exit 0, check-dts-emitted 2/2.
  • pnpm --filter @objectstack/runtime typecheck through the lock: VERDICT command-exit 0 (tsc plus check: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 --listFiles lists both test files).
  • ESLint, narrowed (a measurement, not a skip): eslint.config.mjs enables 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 json over the four touched TS files → files linted: 4, errors: 0, warnings: 0, exit 0. The repo-wide pnpm lint is CI's.
  • Gate families derived by node scripts/pm/dispatch-gates.mjs (no path argument) at 566e1558a — 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-closure34 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_UNAVAILABLE is a StandardErrorCode).
  • Re-run on the branch head 3055f94ae after 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) plus nul-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 .mdx path — 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.
  • NOT MEASURED locally, deliberately declared: check:dual-build-cjs-loads exits 3 with its own line "Run pnpm build first. ⛔ This is NOT a pass: nothing was measured" (whole-workspace build; CI's run); check:type-check-debt --re-measure likewise needs the full closure built (CI's run). The repo-wide pnpm lint is CI's (narrowing evidence above).
  • Published-face reading against built packages/runtime/dist: index.d.ts has NO declaration of resolveExecutionContext (its two mentions are TSDoc prose on HttpProtocolContext); the sole declaration-level delta is private resolveServiceOrLoud; on HttpDispatcher (opaque, not a usable face). content/docs/** mentions resolveExecutionContext once, in protocol/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.
  • Merge probe against the one in-flight holder of 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 head 4538d6289): git merge-tree --write-tree exit 0 — clean.

Clause-② re-declaration: no

resolveExecutionContext is not exported from @objectstack/runtime's entry (src/index.ts has no reference); no new export, no new accepted key or value, no error type added — AuthzStoreUnavailableError and SERVICE_UNAVAILABLE already exist and are already answered by the REST door. The only declaration-level delta is one private member on HttpDispatcher (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)

resolveRequestScope reads the posture for EVERY request, credentialed or not, so under a failed tenancy build GET /health now answers 503 (measured above), which a liveness probe would read as "restart me" for a configuration fault a restart cannot fix. REST has no /health and 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

Zone-2 readings that differ from the dispatch word

  • A1 names one site; the measured site is three nets (above). Fixing only the resolver's catch would have been a green test with zero production effect.
  • A5 held for resolve-execution-context.ts but not for http-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.
  • Sizing: in this tree tenancy is registered as an instance by plugin-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

…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
…"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
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 5, 2026
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/runtime, touching 4 documentable anchor(s).

5 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/environment-routing.mdx (via HttpDispatcher (symbol, a top-level class))
  • content/docs/automation/webhooks.mdx (via HttpDispatcher (symbol, a top-level class))
  • content/docs/kernel/cluster.mdx (via HttpDispatcher (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via HttpDispatcher (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via resolveExecutionContext (symbol, a top-level function))
What this run could not see
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 24 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json abdceef8c68cf0f5a43b3bfb536c53d375c79c1bpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 3931c98acd2516926e45b438d7cff6e371a63189 — the merge of head 3055f94ae1162f5cb7d1d36c4d77b07e99d374bc into base abdceef8c68cf0f5a43b3bfb536c53d375c79c1b, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs abdceef8c68cf0f5a43b3bfb536c53d375c79c1b → pass the list as
args.docs, on the commit named under Which tree this was computed on.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants