Skip to content

Commit 8d189af

Browse files
committed
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
1 parent a87163c commit 8d189af

5 files changed

Lines changed: 456 additions & 14 deletions

File tree

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,42 @@ 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+
* Untyped by slot on purpose, exactly like `resolveService`'s second
158+
* overload: its callers address `tenancy`, which has no written
159+
* `ServiceSlotContracts` entry, and inventing one here would be a shape
160+
* nothing verifies.
161+
*/
162+
resolveServiceOrLoud(context: HttpProtocolContext, name: string, environmentId?: string): Promise<any>;
127163
/**
128164
* Unscoped service lookup on the current kernel, typed by the slot.
129165
*

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

Lines changed: 32 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,10 @@ 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".
150154
*/
151155
export async function refuseUngrantedActivationWrite(
152156
deps: DomainHandlerDeps,
@@ -156,11 +160,35 @@ export async function refuseUngrantedActivationWrite(
156160
const ec: any = context?.executionContext;
157161
if (ec?.isSystem) return undefined;
158162

163+
// [#15900, ruled 2026-09-06 — option A] The posture is an authorization
164+
// INPUT here, so the two ways it can be missing are two different facts:
165+
//
166+
// - **never registered** ⇒ no posture, and no refusal. Unchanged, and load
167+
// bearing: ADR-0093 D4/D5 makes a deployment with no tenancy service the
168+
// same shape as `single`, where install-level and org-level are ONE
169+
// scope and the org admin who already cleared `manage_metadata` is the
170+
// right authority. Refusing here would lock every single-organization
171+
// operator out of their own switch.
172+
// - **registered and unable to answer** ⇒ how far this install-wide row
173+
// reaches was never READ, so whether the operator is required was never
174+
// DECIDED. Serving the write there is #13906 decision 1 option A's
175+
// permissive direction at a second door — 「A posture that could not be
176+
// READ is not a posture that is ABSENT.」 — so it is answered as an
177+
// outage (503), never as a permit.
178+
//
179+
// Told apart by the REGISTRY's brand (#13905) inside `resolveServiceOrLoud`,
180+
// never by message text; the branded class is already absorbed there, so
181+
// anything reaching this `catch` is a `tenancy` that is wired and broke.
182+
//
183+
// ⛔ The plain `resolveService` probe is what this gate used to read, and it
184+
// collapses the two: a factory that threw and a name nothing registered
185+
// both arrived as the same absent posture, and this gate then returned
186+
// `undefined` — no refusal — for both.
159187
let posture;
160188
try {
161-
posture = effectiveTenancyPosture(await deps.resolveService(context, 'tenancy'));
162-
} catch {
163-
posture = undefined;
189+
posture = effectiveTenancyPosture(await deps.resolveServiceOrLoud(context, 'tenancy'));
190+
} catch (err) {
191+
throw new AuthzStoreUnavailableError('tenancy', err);
164192
}
165193
if (!posture || !postureEnforcesWall(posture)) return undefined;
166194

‎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)