Skip to content

Commit de75e40

Browse files
os-litantclaude
andauthored
fix(runtime): the /keys mint and install-wide activation gates classify a tenancy resolution failure instead of reading it as "no wall" (#16385)
* fix(runtime): read the tenancy posture loudly at the /keys mint and activation-write gates Both gates derived the effective tenancy posture through `DomainHandlerDeps.resolveService`, the dispatcher's capability PROBE, whose fallback chain absorbs every rejection at every step and answers `undefined`. So a `tenancy` service that was registered and FAILED to build arrived at both gates as the same value a deployment that never registered one produces, and both gates read that as "there is no wall": `POST /keys` minted an organization-less key it would otherwise refuse, and an organization administrator's install-wide activation write was served instead of refused. Measured on the pre-fix tree with a real `ObjectKernel` whose `tenancy` is registered through a throwing factory: the mint door answered 201 with one row written and the raw secret echoed once, and the activation door answered 200 with `setActionActive` called. The identity step already reads this fact through the classified lookup (`resolveServiceOrLoud`, #13906 decision 1 option A). 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. The gates now read the same classification: never registered stays quiet and behaves exactly as before, every other resolution failure is re-raised as `AuthzStoreUnavailableError` (503 `SERVICE_UNAVAILABLE`). `resolveService` keeps its probe contract for every other name and every other domain; the classified read is a second, opted-into deps facility, so no gate that was not named here changes behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * chore(changeset): the two domain gates classify a tenancy resolution failure Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * test(runtime): trim the new doubles to the verbs their doors reach, and honour the caller's bound `check:engine-double-contract` and `check:objectql-double-limit` both read the new fixture. The `update`/`delete` verbs were copied from a sibling and no door under test reaches either, so they were coverage nobody was getting and would have owed the retained ledger two rows for pins that can never fire; they are removed rather than pinned, and the ledger is untouched (744 rows held, none added). The `find` double now applies the caller's `limit` by presence and after the filter, so it cannot answer a page the producer would not have returned. `check:system-context-census` line rot from the gate edit is repaired by the gate's own `--fix`: two anchors in the elevation-read page move with the lines they cite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * fix(runtime): the activation gate reads the tenancy posture in the request's own scope The classified read this branch gave `refuseUngrantedActivationWrite` dropped the scope id: `deps.resolveServiceOrLoud(context, 'tenancy')`, where the mint gate in `./keys.ts` and the identity step in `../http-dispatcher.ts` both pass `context.environmentId`. A `tenancy` registered `ServiceLifecycle.SCOPED` and resolved without a scope id rejects UNBRANDED — `Scope ID required for scoped service 'tenancy'` — which the classified lookup then re-raises, correctly. So the gate answered 503 on a service that was never unwell, and it was the gate's own omission that produced the fault it reported. Driven at the previous head with a HEALTHY scoped `tenancy` reporting `isolated` and `environmentId = 'platform'`, entering at each door body: actions/_activation tenant org admin threw 503 (cause "Scope ID required…") actions/_activation PLATFORM_ADMIN threw 503 automation/:name/toggle both callers threw 503 keys mint org-less answered 400 (correct: keys passed the scope) So the branch converted one wrong admit AND one correct admit into outages: on the merge base the tenant org admin was wrongly served 200 with the row written (the collapse this card is about) and the platform OPERATOR was correctly served 200 — and the operator is the one authority ADR-0126 §5 says this install-wide switch belongs to. It also re-created the split this branch exists to remove: the identity step, which resolves with the scope, read that same deployment as healthy while the gate called it an outage. With `context.environmentId` passed, the same probe answers 403 PERMISSION_DENIED for the tenant org admin (no row written) and 200 for the operator (row written) on all three doors, and every throwing-factory leg still answers 503 with nothing written. Predicted in writing before the run; all fifteen probe legs matched. The pins grow a third wiring — `scoped-healthy`, a real `ServiceLifecycle.SCOPED` factory that SUCCEEDS — because a pin file about outages that never registers a HEALTHY service cannot tell "loud on a broken service" from "loud on everything". The operator leg is the load-bearing one: every other caller here is one a refusal is a correct answer for, so only the caller whose correct answer is 200 can catch a gate that manufactured an outage. The automation toggle door is pinned here too — see the following commit, which corrects what the earlier message said about it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N * docs(runtime): name both doors this activation gate serves, and correct 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 * chore(changeset): grade the runtime change minor — it grows a published interface The changeset graded `@objectstack/runtime` `patch` and argued it in one line: "Patch rather than minor: no accept set widens, and a declared guard returns to enforced." That reasoning answers a different question than the level asks. `DomainHandlerDeps` is a PUBLISHED type — `packages/runtime/src/index.ts` re-exports it and the package's `exports["."]` resolves to the emitted `dist/index.d.ts`, which carries the interface and the new REQUIRED member `resolveServiceOrLoud(…)`. A required member added to an exported interface is a new symbol on the published surface: every external `const deps: DomainHandlerDeps = {…}` stops compiling. This repository's own history grades that exact shape as minor — three entries under 17.0.0 / Minor Changes in packages/runtime/CHANGELOG.md say `DomainHandlerDeps` grows two lazily-read members, grows the shared facilities the body needs, and grows `getRequestKernelService` and `routeNotFound`. Nothing distinguishes this growth from those three. The earlier `patch` rested on an in-repo grep — one constructor, no implementer outside `packages/runtime` — which is evidence of presence and never evidence of absence. The mechanical floor exists so that likelihood is not the thing being argued. So: level `minor`, and the clause-② declaration in the PR body moves to `yes` with it — the LEVEL AXIS check makes `yes` plus `patch` a self-contradiction, and the two belong to one judgement. The justification sentence for the level no longer used is removed rather than left standing. The changeset text also picks up what round 2 measured: the activation gate is one body behind TWO routes, so three routes change, not two, and every gate now reads the posture in the request's own environment scope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d83d079 commit de75e40

7 files changed

Lines changed: 739 additions & 15 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
'@objectstack/runtime': minor
3+
---
4+
5+
The `/keys` mint gate and the install-wide activation-write gate classify a tenancy resolution failure instead of reading it as "no wall"
6+
7+
Both gates derived the effective tenancy posture through `DomainHandlerDeps.resolveService`, the dispatcher's capability **probe**: every step of its fallback chain absorbs every rejection and answers `undefined`. So a `tenancy` service that was registered and **failed to build** arrived at both gates as the same value a deployment that never registered one produces, and both read that as "there is no wall". Measured on the pre-fix tree against a real kernel whose `tenancy` is registered through a throwing factory: `POST /keys` answered **201** and minted an organization-less key, echoing the raw secret once, where a walled posture refuses one; and an organization administrator's install-wide activation write answered **200** and wrote the row, where ADR-0126 §5 requires the platform operator.
8+
9+
The identity step already read this fact through the classified lookup, so one deployment held two readings of its own wall question at once — 503 at the identity step, admitted at the door bodies these gates guard. The gates now read the same classification, taken from the registry's own brand and never from message text: a service that was **never registered** stays quiet and behaves exactly as before (an org-less key is still minted, and a single-organization deployment's own admin can still flip an install-wide switch — with no tenancy service, install-level and org-level are one scope under ADR-0093 D4/D5), while a service that is **registered and unable to answer** raises `AuthzStoreUnavailableError` — 503 `SERVICE_UNAVAILABLE` — instead of degrading to "no posture". Nothing is minted and nothing is permitted on a posture that was never read. The activation gate is one body behind **two** routes, so three routes change: `POST /keys`, `POST /actions/_activation/:object/:action` and `POST /automation/:name/toggle`. Every gate reads the posture in the request's own environment scope, as the identity step does, so a `tenancy` registered `ServiceLifecycle.SCOPED` is resolved rather than reported as an outage.
10+
11+
`resolveService` keeps its probe contract for every other name and every other domain: the classified read is a second, opted-into member — `DomainHandlerDeps.resolveServiceOrLoud` — that a gate calls one site at a time, so no gate outside the three routes above changes behaviour. **Minor** rather than patch: this grows the exported `DomainHandlerDeps` interface with a required member, which is a published-surface addition — the same shape the three `DomainHandlerDeps` growths in 17.0.0 shipped as minor changes.

content/docs/permissions/system-context.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ The largest single consumer — **17 of the 105 sites**.
164164
| 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` |
165165
| 54 | Package REST route capability gate bypassed | rest | Get: package read/write over REST without `manage_metadata` / `studio.access` / `setup.access` | `package-routes.ts:102` |
166166
| 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:250`, `:283` |
167-
| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:157`, `:211` |
167+
| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:177`, `:268` |
168168
| 57 | Automation run-state read, flow-authoring write and unrelated-screen read all pass | runtime | Get: run state, flow writes and screen reads with no grant | `domains/automation.ts:254`, `:545`, `:635` |
169169
| 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` |
170170
| 59 | Email-template / webhook provenance stamps skipped | plugin-email, plugin-webhooks | Lose: the row is not marked as an admin customization | `email-template-provenance.ts:77`, `webhook-provenance.ts:68` |

packages/runtime/src/domain-handler-registry.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,52 @@ export interface DomainHandlerDeps {
124124
*/
125125
resolveService<K extends keyof ServiceSlotContracts>(context: HttpProtocolContext, name: K, environmentId?: string): Promise<ServiceSlotContract<K> | undefined>;
126126
resolveService(context: HttpProtocolContext, name: string, environmentId?: string): any;
127+
/**
128+
* [#15900 · #13906 decision 1 option A] The CLASSIFIED sibling of
129+
* `resolveService`, for a domain gate whose input is an authorization FACT
130+
* rather than an optional capability.
131+
*
132+
* Same chain, same registries, same order — only what a REJECTION means
133+
* differs:
134+
*
135+
* - branded "never registered" (`isServiceNotRegisteredError`, #13905) →
136+
* `undefined`, quiet. The supported composition, whose behaviour is
137+
* exactly what it was;
138+
* - every other rejection (a factory that threw, a scoped registration
139+
* resolved without a scope id, a circular service dependency) →
140+
* re-raised, for the gate to answer as an OUTAGE rather than as an
141+
* absent fact.
142+
*
143+
* ⚠️ `resolveService` above stays the contract for everything else, and
144+
* that is a boundary rather than an oversight: it is a capability PROBE
145+
* whose collapsed `undefined` is the right shape for "is this optional
146+
* service installed", and rerouting a NAME through this method for every
147+
* domain at once would change every gate that reads it in one stroke —
148+
* option C on #15900, explicitly NOT ruled, because nobody has enumerated
149+
* those gates. A gate opts IN, one call site at a time, and says at the
150+
* call site why its input is not a capability question.
151+
*
152+
* ⛔ Do not reach for this because it reads as the stricter one. The
153+
* classification is only meaningful where "the fact could not be read" and
154+
* "the fact is absent" license DIFFERENT answers; where they license the
155+
* same answer it buys an outage in place of a working deployment.
156+
*
157+
* ⚠️ PASS THE SCOPE YOU HOLD. `environmentId` is optional in the signature
158+
* and load bearing in use: a service registered `ServiceLifecycle.SCOPED`
159+
* and resolved without one rejects UNBRANDED, so this method re-raises it
160+
* and the caller answers 503 — on a service that is perfectly healthy. Under
161+
* `resolveService` that same omission was invisible, because the probe
162+
* absorbed it; opting a call site in without the scope converts a silent
163+
* fallback into a manufactured outage for every caller of that door. A
164+
* rejection out of this method should describe the SERVICE, never the call
165+
* site's own omission.
166+
*
167+
* Untyped by slot on purpose, exactly like `resolveService`'s second
168+
* overload: its callers address `tenancy`, which has no written
169+
* `ServiceSlotContracts` entry, and inventing one here would be a shape
170+
* nothing verifies.
171+
*/
172+
resolveServiceOrLoud(context: HttpProtocolContext, name: string, environmentId?: string): Promise<any>;
127173
/**
128174
* Unscoped service lookup on the current kernel, typed by the slot.
129175
*

packages/runtime/src/domains/activation-gate.ts

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@
7979
// authorization RUNG off the execution context, which is where "platform
8080
// operator, NOT a tenant user role" (ADR-0068 D2) still means that. The
8181
// built-in identity NAME is deliberately no longer imported: see the doc block.
82-
import { effectiveTenancyPosture } from '@objectstack/core';
82+
import { effectiveTenancyPosture, AuthzStoreUnavailableError } from '@objectstack/core';
8383
import { postureEnforcesWall } from '@objectstack/spec/security';
8484
import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js';
8585
import type { DomainHandlerDeps } from '../domain-handler-registry.js';
@@ -147,6 +147,26 @@ export const ACTION_ACTIVATION_SUBJECT: ActivationSubject = {
147147
* validation, so a refused caller writes nothing and learns nothing about the
148148
* contract. "Write first, refuse second" is the worst shape here — it is
149149
* #10243 with an audit trail.
150+
*
151+
* ⚠️ It has THREE exits, not two: a refusal, `undefined` to proceed, and a
152+
* THROW. See the posture read below for the class that throws and why a caller
153+
* must not absorb it into "no gate to enforce".
154+
*
155+
* ⚠️ TWO DOORS, one gate body — so every exit above, the throw included,
156+
* reaches BOTH of them and neither is "the" activation door:
157+
*
158+
* - `./actions.ts` — `POST /actions/_activation/:object/:action`, calling
159+
* this function directly with {@link ACTION_ACTIVATION_SUBJECT};
160+
* - `./automation.ts` — `POST /automation/:name/toggle`, through its
161+
* `refuseUngrantedFlowActivationWrite` wrapper and
162+
* {@link FLOW_ACTIVATION_SUBJECT}.
163+
*
164+
* Both `await` the call, so the throw exit is a rejected promise the domain
165+
* handler propagates and the dispatcher's error exit renders — nothing is
166+
* unhandled at either door. Written down because a change to this body is a
167+
* change to two routes: a claim about "the gate" that was measured at one door
168+
* is a claim about half the surface, and both doors are pinned together in
169+
* `./tenancy-posture-outage-gates.test.ts` for that reason.
150170
*/
151171
export async function refuseUngrantedActivationWrite(
152172
deps: DomainHandlerDeps,
@@ -156,11 +176,48 @@ export async function refuseUngrantedActivationWrite(
156176
const ec: any = context?.executionContext;
157177
if (ec?.isSystem) return undefined;
158178

179+
// [#15900, ruled 2026-09-06 — option A] The posture is an authorization
180+
// INPUT here, so the two ways it can be missing are two different facts:
181+
//
182+
// - **never registered** ⇒ no posture, and no refusal. Unchanged, and load
183+
// bearing: ADR-0093 D4/D5 makes a deployment with no tenancy service the
184+
// same shape as `single`, where install-level and org-level are ONE
185+
// scope and the org admin who already cleared `manage_metadata` is the
186+
// right authority. Refusing here would lock every single-organization
187+
// operator out of their own switch.
188+
// - **registered and unable to answer** ⇒ how far this install-wide row
189+
// reaches was never READ, so whether the operator is required was never
190+
// DECIDED. Serving the write there is #13906 decision 1 option A's
191+
// permissive direction at a second door — 「A posture that could not be
192+
// READ is not a posture that is ABSENT.」 — so it is answered as an
193+
// outage (503), never as a permit.
194+
//
195+
// Told apart by the REGISTRY's brand (#13905) inside `resolveServiceOrLoud`,
196+
// never by message text; the branded class is already absorbed there, so
197+
// anything reaching this `catch` is a `tenancy` that is wired and broke.
198+
//
199+
// ⛔ The plain `resolveService` probe is what this gate used to read, and it
200+
// collapses the two: a factory that threw and a name nothing registered
201+
// both arrived as the same absent posture, and this gate then returned
202+
// `undefined` — no refusal — for both.
203+
//
204+
// ⚠️ THE SCOPE ID IS PART OF THE READ, not an optimisation. `tenancy` may be
205+
// registered `ServiceLifecycle.SCOPED`, and a scoped registration resolved
206+
// without a scope id rejects UNBRANDED (`Scope ID required for scoped
207+
// service 'tenancy'`) — which the classified lookup correctly re-raises,
208+
// and this gate would then answer as a 503 it MANUFACTURED itself on a
209+
// perfectly healthy deployment, locking the platform operator out of the
210+
// switch that is theirs. `context.environmentId` is the same scope the
211+
// identity step and `./keys.ts` already resolve this exact name with, so
212+
// all three read one deployment's posture through one scope: an outage
213+
// answered here is the service's, never this call site's omission.
159214
let posture;
160215
try {
161-
posture = effectiveTenancyPosture(await deps.resolveService(context, 'tenancy'));
162-
} catch {
163-
posture = undefined;
216+
posture = effectiveTenancyPosture(
217+
await deps.resolveServiceOrLoud(context, 'tenancy', context.environmentId),
218+
);
219+
} catch (err) {
220+
throw new AuthzStoreUnavailableError('tenancy', err);
164221
}
165222
if (!posture || !postureEnforcesWall(posture)) return undefined;
166223

packages/runtime/src/domains/keys.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
* organization is re-checked here, against `sys_member`, at mint time.
3232
*/
3333

34-
import { isGrantActive, effectiveTenancyPosture } from '@objectstack/core';
34+
import { isGrantActive, effectiveTenancyPosture, AuthzStoreUnavailableError } from '@objectstack/core';
3535
import { postureEnforcesWall } from '@objectstack/spec/security';
3636

3737
import { generateApiKey } from '../security/api-key.js';
@@ -104,17 +104,38 @@ export async function handleKeysRequest(
104104

105105
// The EFFECTIVE posture, from the kernel's `tenancy` service — what is
106106
// ENFORCED, not what `OS_TENANCY_POSTURE` requested (ADR-0093 D4/D5: a
107-
// requested-but-unenforceable wall resolves to `single`). An absent service
108-
// means we cannot tell, and the honest answer to that at MINT time is to
109-
// mint: refusing would block key creation on a deployment that may have no
110-
// wall at all.
107+
// requested-but-unenforceable wall resolves to `single`).
108+
//
109+
// [#15900, ruled 2026-09-06 — option A] "No service" and "the service could
110+
// not be built" are TWO facts, and only one of them licenses a mint:
111+
//
112+
// - **never registered** ⇒ no posture, and the honest answer at MINT time
113+
// is to mint. That is the deliberate choice this comment has always
114+
// recorded, and it is unchanged: refusing would block key creation on a
115+
// deployment that may have no wall at all, and a no-tenancy composition
116+
// is supported. ⚠️ It applies to THIS class only — it is not a statement
117+
// about a posture that could not be read.
118+
// - **registered and unable to answer** ⇒ the posture is an authorization
119+
// INPUT that was never READ, so whether this key needs an organization
120+
// was never DECIDED. Minting there hands back a long-lived credential on
121+
// a question nobody answered — the permissive direction #13906 decision 1
122+
// option A ruled against at the identity seam, whose words govern here
123+
// too: 「A posture that could not be READ is not a posture that is
124+
// ABSENT.」 Answered as an outage (503), never as a mint.
125+
//
126+
// The split is taken from the REGISTRY's own brand (#13905), never from
127+
// message text, and the two classes arrive here already told apart —
128+
// `resolveServiceOrLoud` absorbs the branded "never registered" and answers
129+
// `undefined`, so anything that REJECTS is a `tenancy` that is wired and
130+
// broken. ⛔ Hence no `isServiceNotRegisteredError` re-test below: a second
131+
// copy of the classification is a second thing to drift.
111132
let tenancyPosture;
112133
try {
113134
tenancyPosture = effectiveTenancyPosture(
114-
await deps.resolveService(context, 'tenancy' as any, context.environmentId),
135+
await deps.resolveServiceOrLoud(context, 'tenancy', context.environmentId),
115136
);
116-
} catch {
117-
tenancyPosture = undefined;
137+
} catch (err) {
138+
throw new AuthzStoreUnavailableError('tenancy', err);
118139
}
119140
const walled = tenancyPosture ? postureEnforcesWall(tenancyPosture) : false;
120141

0 commit comments

Comments
 (0)