From 8d189af8dba03326ff50e9b0ec2ab1d266cd73f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 15:51:20 +0000 Subject: [PATCH 1/6] 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 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- .../runtime/src/domain-handler-registry.ts | 36 ++ .../runtime/src/domains/activation-gate.ts | 36 +- packages/runtime/src/domains/keys.ts | 37 +- .../tenancy-posture-outage-gates.test.ts | 346 ++++++++++++++++++ packages/runtime/src/http-dispatcher.ts | 15 +- 5 files changed, 456 insertions(+), 14 deletions(-) create mode 100644 packages/runtime/src/domains/tenancy-posture-outage-gates.test.ts diff --git a/packages/runtime/src/domain-handler-registry.ts b/packages/runtime/src/domain-handler-registry.ts index 9d66ab48b7..898d7fd703 100644 --- a/packages/runtime/src/domain-handler-registry.ts +++ b/packages/runtime/src/domain-handler-registry.ts @@ -124,6 +124,42 @@ export interface DomainHandlerDeps { */ resolveService(context: HttpProtocolContext, name: K, environmentId?: string): Promise | undefined>; resolveService(context: HttpProtocolContext, name: string, environmentId?: string): any; + /** + * [#15900 · #13906 decision 1 option A] The CLASSIFIED sibling of + * `resolveService`, for a domain gate whose input is an authorization FACT + * rather than an optional capability. + * + * Same chain, same registries, same order — only what a REJECTION means + * differs: + * + * - branded "never registered" (`isServiceNotRegisteredError`, #13905) → + * `undefined`, quiet. The supported composition, whose behaviour is + * exactly what it was; + * - every other rejection (a factory that threw, a scoped registration + * resolved without a scope id, a circular service dependency) → + * re-raised, for the gate to answer as an OUTAGE rather than as an + * absent fact. + * + * ⚠️ `resolveService` above stays the contract for everything else, and + * that is a boundary rather than an oversight: it is a capability PROBE + * whose collapsed `undefined` is the right shape for "is this optional + * service installed", and rerouting a NAME through this method for every + * domain at once would change every gate that reads it in one stroke — + * option C on #15900, explicitly NOT ruled, because nobody has enumerated + * those gates. A gate opts IN, one call site at a time, and says at the + * call site why its input is not a capability question. + * + * ⛔ Do not reach for this because it reads as the stricter one. The + * classification is only meaningful where "the fact could not be read" and + * "the fact is absent" license DIFFERENT answers; where they license the + * same answer it buys an outage in place of a working deployment. + * + * Untyped by slot on purpose, exactly like `resolveService`'s second + * overload: its callers address `tenancy`, which has no written + * `ServiceSlotContracts` entry, and inventing one here would be a shape + * nothing verifies. + */ + resolveServiceOrLoud(context: HttpProtocolContext, name: string, environmentId?: string): Promise; /** * Unscoped service lookup on the current kernel, typed by the slot. * diff --git a/packages/runtime/src/domains/activation-gate.ts b/packages/runtime/src/domains/activation-gate.ts index 693ee3a7a2..b5fbb7b49e 100644 --- a/packages/runtime/src/domains/activation-gate.ts +++ b/packages/runtime/src/domains/activation-gate.ts @@ -79,7 +79,7 @@ // authorization RUNG off the execution context, which is where "platform // operator, NOT a tenant user role" (ADR-0068 D2) still means that. The // built-in identity NAME is deliberately no longer imported: see the doc block. -import { effectiveTenancyPosture } from '@objectstack/core'; +import { effectiveTenancyPosture, AuthzStoreUnavailableError } from '@objectstack/core'; import { postureEnforcesWall } from '@objectstack/spec/security'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps } from '../domain-handler-registry.js'; @@ -147,6 +147,10 @@ export const ACTION_ACTIVATION_SUBJECT: ActivationSubject = { * validation, so a refused caller writes nothing and learns nothing about the * contract. "Write first, refuse second" is the worst shape here — it is * #10243 with an audit trail. + * + * ⚠️ It has THREE exits, not two: a refusal, `undefined` to proceed, and a + * THROW. See the posture read below for the class that throws and why a caller + * must not absorb it into "no gate to enforce". */ export async function refuseUngrantedActivationWrite( deps: DomainHandlerDeps, @@ -156,11 +160,35 @@ export async function refuseUngrantedActivationWrite( const ec: any = context?.executionContext; if (ec?.isSystem) return undefined; + // [#15900, ruled 2026-09-06 — option A] The posture is an authorization + // INPUT here, so the two ways it can be missing are two different facts: + // + // - **never registered** ⇒ no posture, and no refusal. Unchanged, and load + // bearing: ADR-0093 D4/D5 makes a deployment with no tenancy service the + // same shape as `single`, where install-level and org-level are ONE + // scope and the org admin who already cleared `manage_metadata` is the + // right authority. Refusing here would lock every single-organization + // operator out of their own switch. + // - **registered and unable to answer** ⇒ how far this install-wide row + // reaches was never READ, so whether the operator is required was never + // DECIDED. Serving the write there is #13906 decision 1 option A's + // permissive direction at a second door — 「A posture that could not be + // READ is not a posture that is ABSENT.」 — so it is answered as an + // outage (503), never as a permit. + // + // Told apart by the REGISTRY's brand (#13905) inside `resolveServiceOrLoud`, + // never by message text; the branded class is already absorbed there, so + // anything reaching this `catch` is a `tenancy` that is wired and broke. + // + // ⛔ The plain `resolveService` probe is what this gate used to read, and it + // collapses the two: a factory that threw and a name nothing registered + // both arrived as the same absent posture, and this gate then returned + // `undefined` — no refusal — for both. let posture; try { - posture = effectiveTenancyPosture(await deps.resolveService(context, 'tenancy')); - } catch { - posture = undefined; + posture = effectiveTenancyPosture(await deps.resolveServiceOrLoud(context, 'tenancy')); + } catch (err) { + throw new AuthzStoreUnavailableError('tenancy', err); } if (!posture || !postureEnforcesWall(posture)) return undefined; diff --git a/packages/runtime/src/domains/keys.ts b/packages/runtime/src/domains/keys.ts index 1408cfb219..a23464f181 100644 --- a/packages/runtime/src/domains/keys.ts +++ b/packages/runtime/src/domains/keys.ts @@ -31,7 +31,7 @@ * organization is re-checked here, against `sys_member`, at mint time. */ -import { isGrantActive, effectiveTenancyPosture } from '@objectstack/core'; +import { isGrantActive, effectiveTenancyPosture, AuthzStoreUnavailableError } from '@objectstack/core'; import { postureEnforcesWall } from '@objectstack/spec/security'; import { generateApiKey } from '../security/api-key.js'; @@ -104,17 +104,38 @@ export async function handleKeysRequest( // The EFFECTIVE posture, from the kernel's `tenancy` service — what is // ENFORCED, not what `OS_TENANCY_POSTURE` requested (ADR-0093 D4/D5: a - // requested-but-unenforceable wall resolves to `single`). An absent service - // means we cannot tell, and the honest answer to that at MINT time is to - // mint: refusing would block key creation on a deployment that may have no - // wall at all. + // requested-but-unenforceable wall resolves to `single`). + // + // [#15900, ruled 2026-09-06 — option A] "No service" and "the service could + // not be built" are TWO facts, and only one of them licenses a mint: + // + // - **never registered** ⇒ no posture, and the honest answer at MINT time + // is to mint. That is the deliberate choice this comment has always + // recorded, and it is unchanged: refusing would block key creation on a + // deployment that may have no wall at all, and a no-tenancy composition + // is supported. ⚠️ It applies to THIS class only — it is not a statement + // about a posture that could not be read. + // - **registered and unable to answer** ⇒ the posture is an authorization + // INPUT that was never READ, so whether this key needs an organization + // was never DECIDED. Minting there hands back a long-lived credential on + // a question nobody answered — the permissive direction #13906 decision 1 + // option A ruled against at the identity seam, whose words govern here + // too: 「A posture that could not be READ is not a posture that is + // ABSENT.」 Answered as an outage (503), never as a mint. + // + // The split is taken from the REGISTRY's own brand (#13905), never from + // message text, and the two classes arrive here already told apart — + // `resolveServiceOrLoud` absorbs the branded "never registered" and answers + // `undefined`, so anything that REJECTS is a `tenancy` that is wired and + // broken. ⛔ Hence no `isServiceNotRegisteredError` re-test below: a second + // copy of the classification is a second thing to drift. let tenancyPosture; try { tenancyPosture = effectiveTenancyPosture( - await deps.resolveService(context, 'tenancy' as any, context.environmentId), + await deps.resolveServiceOrLoud(context, 'tenancy', context.environmentId), ); - } catch { - tenancyPosture = undefined; + } catch (err) { + throw new AuthzStoreUnavailableError('tenancy', err); } const walled = tenancyPosture ? postureEnforcesWall(tenancyPosture) : false; diff --git a/packages/runtime/src/domains/tenancy-posture-outage-gates.test.ts b/packages/runtime/src/domains/tenancy-posture-outage-gates.test.ts new file mode 100644 index 0000000000..8cefea1bd6 --- /dev/null +++ b/packages/runtime/src/domains/tenancy-posture-outage-gates.test.ts @@ -0,0 +1,346 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#15900] The two dispatcher DOMAIN gates that read the tenancy posture must +// tell "no tenancy service was ever registered" apart from "the tenancy service +// is wired and could not be built". +// +// ## The ruling these pins hold up +// +// Director seat, decision batch #55, 2026-09-06T13:51Z; maintainer's reply +// verbatim and untranslated: 「同意」. Option A — the narrow, two-site fix: +// +// - `./keys.ts` (the `POST /keys` minting gate) and `./activation-gate.ts` +// (the install-wide activation write gate) stop reading the posture through +// the collapsing `resolveService('tenancy')` capability probe and read it +// through the classified lookup instead; +// - **never registered ⇒ no posture** — today's answer is preserved exactly, +// so a single-organization deployment is never refused and an org-less key +// is still minted; +// - **registered but failed to build, or any other resolution failure ⇒ +// re-throw ⇒ 503** on the door, never a mint and never a permit. +// +// It is the SAME reading #13906 decision 1 option A already ruled at the +// authorization-input seam — 「A posture that could not be READ is not a posture +// that is ABSENT.」 — and that PR #15909 landed for the identity step. The +// severity these gates carried is the INCONSISTENCY: in one deployment, one +// failure made the identity step answer 503 while these two gates served the +// request, so "what is this deployment's state on the wall question" had two +// answers at once. +// +// ⛔ NOT ruled, and deliberately not here: rerouting `'tenancy'` for every +// domain through the loud lookup (option C). That would change every gate that +// reads it in one stroke, and nobody has enumerated those gates. +// +// ## Why these pins call the DOMAIN doors directly +// +// Driving `dispatcher.dispatch()` would prove nothing about these gates: the +// identity step runs FIRST on that path and already answers 503 for this exact +// fault (PR #15909, merged). A pin routed through `dispatch()` would go green on +// #15909's fix with these two gates left exactly as they were — a phantom +// check. So each pin enters at the door body (`handleKeys` / `handleActions`), +// which is where the gate it is about actually runs. +// +// ## Why the fixture builds a REAL kernel +// +// The two classes this file separates are produced by ONE place — the plugin +// loader — and only there: `serviceNotRegisteredError` is package-internal to +// `@objectstack/core`, so a hand-rolled brand in a double would be this file's +// opinion of the classification rather than the classification. In this tree +// `tenancy` is registered as an INSTANCE by `plugin-auth`, so "registered and +// failed to build" has no in-repo producer to lean on and has to be +// CONSTRUCTED: a real `ObjectKernel` with a real `registerServiceFactory` +// whose factory throws. Both legs then resolve through the real +// `PluginLoader.getService`, which is what makes the branded/unbranded split a +// measurement instead of a restatement. + +import { describe, it, expect, vi } from 'vitest'; + +import { ObjectKernel, isAuthzStoreUnavailableError } from '@objectstack/core'; +import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; + +import { HttpDispatcher } from '../http-dispatcher.js'; +import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; + +/** ADR-0112 envelope for the outage answer — the brand's own two fields. */ +const OUTAGE_STATUS = 503; +const OUTAGE_CODE = 'SERVICE_UNAVAILABLE'; + +/** The message a `tenancy` factory fails with. Never a classification signal. */ +const FACTORY_FAULT = 'tenancy factory: datasource unreachable'; + +type TenancyWiring = 'never-registered' | 'throwing-factory'; + +/** + * Register `tenancy` on a real kernel in the requested wiring. + * + * `never-registered` registers nothing, so `getServiceAsync('tenancy')` rejects + * with the loader's BRANDED rejection; `throwing-factory` registers a real + * singleton factory that throws, so it rejects UNBRANDED from below. Neither + * rejection is built here — both come out of `PluginLoader.getService`. + */ +function wireTenancy(kernel: ObjectKernel, wiring: TenancyWiring): void { + if (wiring === 'never-registered') return; + kernel.registerServiceFactory('tenancy', () => { + throw new Error(FACTORY_FAULT); + }); +} + +/** + * A kernel with no graceful-shutdown handlers: this suite constructs one per + * case, and the constructor's signal registration would otherwise accumulate + * process listeners across them. + */ +const bareKernel = (): ObjectKernel => + new ObjectKernel({ gracefulShutdown: false, skipSystemValidation: true }); + +// ─────────────────────────────────────────────────────────────────────────── +// Gate 1 — `POST /keys`, the mint path (`./keys.ts`) +// ─────────────────────────────────────────────────────────────────────────── + +/** + * The engine double the mint path reads: `sys_api_key` inserts and `sys_member` + * lookups, nothing else. + * + * The write verbs route through ObjectQL's OWN dispatch predicates + * (`check:engine-double-contract`) rather than a hand-written approximation, so + * this double cannot be looser than the producer. `find` REFUSES a combinator + * rather than answering it wrong, for the same reason its sibling in + * `http-dispatcher.keys.test.ts` does: without the throw, `Object.entries` + * reads `$or` as an ordinary field name and hands back an empty result set with + * nothing erroring. + */ +function keysEngine(members: any[]) { + const rows: any[] = []; + const ql = { + insert: async (_obj: string, data: any) => { + const id = `key_${rows.length + 1}`; + rows.push({ id, ...data }); + return { id }; + }, + find: async (obj: string, opts: any) => { + const where = opts?.where ?? {}; + const table = obj === 'sys_api_key' ? rows : obj === 'sys_member' ? members : []; + return table.filter((r: any) => Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return r[k] === v; + })); + }, + update: async (_obj: string, data: any, options?: any) => { + assertEngineUpdateDispatch(data, options); + return {}; + }, + delete: async (_obj: string, options?: any) => { + assertEngineDeleteDispatch(options); + return {}; + }, + }; + return { ql, rows }; +} + +function bootKeys(wiring: TenancyWiring) { + const { ql, rows } = keysEngine([]); + const kernel = bareKernel(); + kernel.registerService('objectql', ql); + wireTenancy(kernel, wiring); + return { + dispatcher: new HttpDispatcher(kernel as never, undefined, { enforceProjectMembership: false }), + rows, + }; +} + +/** A signed-in caller with NO active organization — the org-less mint. */ +const orgLessCaller = (): HttpProtocolContext => ({ + request: { headers: {} }, + response: {}, + environmentId: undefined, + executionContext: { + userId: 'u1', + isSystem: false, + positions: [], + permissions: [], + tenantId: undefined, + }, +} as unknown as HttpProtocolContext); + +/** + * `HttpDispatcherResult.response` is optional, so every read of it is a + * `possibly undefined` in a type-checked program — and this package's test layer + * IS type-checked. Narrow once, and narrow LOUDLY: a door that answered no + * response at all is a different defect from one that answered the wrong status. + */ +function responseOf(res: HttpDispatcherResult): NonNullable { + const { response } = res; + if (!response) throw new Error('the door answered no response at all'); + return response; +} + +describe('[#15900] `POST /keys` mint — a tenancy service that FAILED to build is not an absent posture', () => { + it('REFUSES loudly (503, outage brand) and mints nothing when the `tenancy` factory throws', async () => { + const { dispatcher, rows } = bootKeys('throwing-factory'); + + const err = await dispatcher + .handleKeys('POST', { name: 'agent' }, orgLessCaller()) + .then( + () => { throw new Error('the mint door answered instead of refusing'); }, + (e: unknown) => e, + ); + + // The ADR-0112 envelope, not the throw. `toThrow()` alone would go green + // on the pre-fix tree the moment ANY unrelated fault reached here, and + // stay green on a door that answered a bare `Error`. + expect(isAuthzStoreUnavailableError(err)).toBe(true); + expect((err as { status?: unknown }).status).toBe(OUTAGE_STATUS); + expect((err as { code?: unknown }).code).toBe(OUTAGE_CODE); + // The load-bearing half: refused BEFORE the secret exists. + expect(rows).toHaveLength(0); + }); + + it('renders that refusal as a 503 `SERVICE_UNAVAILABLE` on the door', async () => { + const { dispatcher } = bootKeys('throwing-factory'); + + const err = await dispatcher + .handleKeys('POST', { name: 'agent' }, orgLessCaller()) + .catch((e: unknown) => e); + // The dispatcher's OWN error exit — the one every domain throw takes on + // the wire — so this pin is about the answer a caller receives and not + // only about the shape of the rejection. + const rendered = (dispatcher as unknown as { + domainDeps: { errorFromThrown(e: unknown, fallbackStatus?: number): { status: number; body: any } }; + }).domainDeps.errorFromThrown(err, 500); + + expect(rendered.status).toBe(OUTAGE_STATUS); + expect(rendered.body?.error?.code).toBe(OUTAGE_CODE); + }); + + /** + * The control. Without it the case above cannot show that the two classes + * were SEPARATED — only that the door got louder about both. + */ + it('CONTROL — a deployment that never registered `tenancy` still mints, exactly as before', async () => { + const { dispatcher, rows } = bootKeys('never-registered'); + + const res = responseOf(await dispatcher.handleKeys('POST', { name: 'agent' }, orgLessCaller())); + + expect(res.status).toBe(201); + expect(rows).toHaveLength(1); + expect(rows[0].active_organization_id).toBeUndefined(); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// Gate 2 — the install-wide activation write (`./activation-gate.ts`) +// ─────────────────────────────────────────────────────────────────────────── + +const OBJECT = 'crm_lead'; +const ACTION = 'convert_lead'; +const DECLARATION = { + name: ACTION, + label: 'Convert Lead', + objectName: OBJECT, + type: 'script', + _packageId: 'crm', +}; + +function bootActivation(wiring: TenancyWiring) { + const setActionActive = vi.fn(async () => undefined); + const objectDef = { name: OBJECT, actions: [DECLARATION], _packageId: 'crm' }; + const objects = [objectDef]; + + const ql: any = { + executeAction: vi.fn(async () => ({ ran: 'script' })), + getSchema: (name: string) => objects.find((o) => o.name === name), + registry: { getObject: (name: string) => objects.find((o) => o.name === name), getItem: () => undefined }, + isActionEnabled: () => true, + describeDisabledAction: (n: string) => `Action '${n}' is disabled`, + setActionActive, + find: vi.fn(async () => []), + insert: vi.fn(), + update: async (_obj: string, data: any, options?: any) => { + assertEngineUpdateDispatch(data, options); + return {}; + }, + delete: async (_obj: string, options?: any) => { + assertEngineDeleteDispatch(options); + return {}; + }, + }; + const metadata: any = { + load: vi.fn(async () => null), + loadDiagnosed: vi.fn(async () => ({ data: null, degraded: false, errors: [] })), + loadMany: vi.fn(async () => []), + listObjects: vi.fn(async () => objects), + getObject: vi.fn(async () => objectDef), + }; + + const kernel = bareKernel(); + kernel.registerService('objectql', ql); + kernel.registerService('data', ql); + kernel.registerService('metadata', metadata); + wireTenancy(kernel, wiring); + + return { dispatcher: new HttpDispatcher(kernel as never), setActionActive }; +} + +/** + * A tenant org admin who DOES hold `manage_metadata`, so the capability tier in + * front of the §5 gate passes and the posture question is the only one left. + */ +const tenantAdmin = (): HttpProtocolContext => ({ + request: {}, + environmentId: 'platform', + executionContext: { + userId: 'u_northwind_owner', + positions: ['org_owner', 'org_admin'], + permissions: ['organization_admin'], + systemPermissions: ['manage_metadata'], + organizationId: 'org_northwind', + }, +} as unknown as HttpProtocolContext); + +const flip = (dispatcher: HttpDispatcher, ctx: HttpProtocolContext) => + dispatcher.handleActions(`/_activation/${OBJECT}/${ACTION}`, 'POST', { enabled: false }, ctx); + +describe('[#15900] the install-wide activation write — a tenancy service that FAILED to build is not an absent posture', () => { + it('REFUSES loudly (503, outage brand) and writes no activation row when the `tenancy` factory throws', async () => { + const { dispatcher, setActionActive } = bootActivation('throwing-factory'); + + const err = await flip(dispatcher, tenantAdmin()).then( + () => { throw new Error('the activation door answered instead of refusing'); }, + (e: unknown) => e, + ); + + expect(isAuthzStoreUnavailableError(err)).toBe(true); + expect((err as { status?: unknown }).status).toBe(OUTAGE_STATUS); + expect((err as { code?: unknown }).code).toBe(OUTAGE_CODE); + // Refused BEFORE the write — a gate that refuses afterwards is #10243 + // with an audit trail. + expect(setActionActive).not.toHaveBeenCalled(); + }); + + it('renders that refusal as a 503 `SERVICE_UNAVAILABLE` on the door', async () => { + const { dispatcher } = bootActivation('throwing-factory'); + + const err = await flip(dispatcher, tenantAdmin()).catch((e: unknown) => e); + const rendered = (dispatcher as unknown as { + domainDeps: { errorFromThrown(e: unknown, fallbackStatus?: number): { status: number; body: any } }; + }).domainDeps.errorFromThrown(err, 500); + + expect(rendered.status).toBe(OUTAGE_STATUS); + expect(rendered.body?.error?.code).toBe(OUTAGE_CODE); + }); + + /** + * The control — ADR-0093 D4/D5: no tenancy service behaves like `single`, + * where install-level and org-level are the same scope and the org admin who + * already cleared `manage_metadata` is the right authority. Refusing here + * would lock every single-tenant operator out of their own switch. + */ + it('CONTROL — a deployment that never registered `tenancy` still permits the org admin, exactly as before', async () => { + const { dispatcher, setActionActive } = bootActivation('never-registered'); + + const res = responseOf(await flip(dispatcher, tenantAdmin())); + + expect(res.status).toBe(200); + expect(setActionActive).toHaveBeenCalledWith({ name: ACTION, packageId: 'crm', active: false }); + }); +}); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 8d6c737bbb..7b457ec0aa 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -399,6 +399,14 @@ export class HttpDispatcher { // — there is no such thing here. The request carries its own. resolveService: (context: HttpProtocolContext, name: string, environmentId?: string) => this.resolveService(this.requestKernel(context), name, environmentId), + // [#15900] The classified lookup, reachable from a DOMAIN gate. The + // identity step has read `tenancy` this way since #15366/PR #15909; the + // two gates that decide a mint and an install-wide activation write ask + // the same question about the same fact, so they read it the same way. + // The probe above is untouched — this adds a second, opted-into path, + // it does not reroute a name for every domain (#15900 option C). + resolveServiceOrLoud: (context: HttpProtocolContext, name: string, environmentId?: string) => + this.resolveServiceOrLoud(this.requestKernel(context), name, environmentId), getService: (context: HttpProtocolContext, name: string) => this.getService(this.requestKernel(context), name as Parameters[1]), getObjectQL: (context, environmentId) => @@ -2207,8 +2215,11 @@ export class HttpDispatcher { /** * [#13906 decision 1 A] Resolve a service whose ABSENCE is a supported - * composition but whose FAILURE is an outage — today only the `tenancy` - * read the identity step feeds `resolveExecutionContext`. + * composition but whose FAILURE is an outage — today the `tenancy` read the + * identity step feeds `resolveExecutionContext`, and (via + * {@link DomainHandlerDeps.resolveServiceOrLoud}, #15900) the same read at + * the two domain gates that decide a `/keys` mint and an install-wide + * activation write. * * `resolveService` above is a capability probe: every step of its chain * absorbs every rejection and falls through, so a factory that threw and a From 4ae75fa4c982aa4c4d1a91117ddcee1131ac462c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 15:59:42 +0000 Subject: [PATCH 2/6] chore(changeset): the two domain gates classify a tenancy resolution failure Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- .../runtime-domain-gates-tenancy-posture-loud.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/runtime-domain-gates-tenancy-posture-loud.md diff --git a/.changeset/runtime-domain-gates-tenancy-posture-loud.md b/.changeset/runtime-domain-gates-tenancy-posture-loud.md new file mode 100644 index 0000000000..8c9e32756d --- /dev/null +++ b/.changeset/runtime-domain-gates-tenancy-posture-loud.md @@ -0,0 +1,11 @@ +--- +'@objectstack/runtime': patch +--- + +The `/keys` mint gate and the install-wide activation-write gate classify a tenancy resolution failure instead of reading it as "no wall" + +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. + +The identity step already read this fact through the classified lookup, so one failure made the same deployment answer 503 at the identity step while admitting at these two gates — "is this deployment walled" had two answers at once. 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. + +`resolveService` keeps its probe contract for every other name and every other domain; the classified read is a second, opted-into dependency the two named gates call, so no gate that was not named here changes behaviour. Patch rather than minor: no accept set widens, and a declared guard returns to enforced. From c34cae6e2cca712ec5d54906fdbf832710118be5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 16:15:06 +0000 Subject: [PATCH 3/6] 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 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- content/docs/permissions/system-context.mdx | 2 +- .../tenancy-posture-outage-gates.test.ts | 43 ++++++++----------- 2 files changed, 18 insertions(+), 27 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 7b0c0ec654..ce2e00f6fe 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -164,7 +164,7 @@ The largest single consumer — **17 of the 105 sites**. | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | | 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` | | 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:250`, `:283` | -| 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` | +| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:161`, `:239` | | 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` | | 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` | | 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` | diff --git a/packages/runtime/src/domains/tenancy-posture-outage-gates.test.ts b/packages/runtime/src/domains/tenancy-posture-outage-gates.test.ts index 8cefea1bd6..f1430e8a3d 100644 --- a/packages/runtime/src/domains/tenancy-posture-outage-gates.test.ts +++ b/packages/runtime/src/domains/tenancy-posture-outage-gates.test.ts @@ -56,7 +56,6 @@ import { describe, it, expect, vi } from 'vitest'; import { ObjectKernel, isAuthzStoreUnavailableError } from '@objectstack/core'; -import { assertEngineDeleteDispatch, assertEngineUpdateDispatch } from '@objectstack/metadata-core'; import { HttpDispatcher } from '../http-dispatcher.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; @@ -99,15 +98,21 @@ const bareKernel = (): ObjectKernel => /** * The engine double the mint path reads: `sys_api_key` inserts and `sys_member` - * lookups, nothing else. + * lookups, and NOTHING else. * - * The write verbs route through ObjectQL's OWN dispatch predicates - * (`check:engine-double-contract`) rather than a hand-written approximation, so - * this double cannot be looser than the producer. `find` REFUSES a combinator - * rather than answering it wrong, for the same reason its sibling in - * `http-dispatcher.keys.test.ts` does: without the throw, `Object.entries` - * reads `$or` as an ordinary field name and hands back an empty result set with - * nothing erroring. + * ⛔ Deliberately no `update` / `delete`. The mint path calls neither, and a + * double that declares a verb its subject never reaches is coverage nobody is + * getting: it would owe `check:engine-double-contract` a ledger row for a pin + * that can never fire. A path that grows into one of those verbs fails loudly + * here rather than meeting a stub. + * + * `find` REFUSES a combinator rather than answering it wrong, for the same + * reason its sibling in `http-dispatcher.keys.test.ts` does: without the throw, + * `Object.entries` reads `$or` as an ordinary field name, compares `row.$or` + * against the array, matches nothing, and hands the suite an empty result set + * with nothing erroring. It honours the caller's `limit` by PRESENCE and after + * the filter (`check:objectql-double-limit`), so it cannot pass a page the + * producer would not have returned. */ function keysEngine(members: any[]) { const rows: any[] = []; @@ -120,18 +125,11 @@ function keysEngine(members: any[]) { find: async (obj: string, opts: any) => { const where = opts?.where ?? {}; const table = obj === 'sys_api_key' ? rows : obj === 'sys_member' ? members : []; - return table.filter((r: any) => Object.entries(where).every(([k, v]) => { + const matched = table.filter((r: any) => Object.entries(where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return r[k] === v; })); - }, - update: async (_obj: string, data: any, options?: any) => { - assertEngineUpdateDispatch(data, options); - return {}; - }, - delete: async (_obj: string, options?: any) => { - assertEngineDeleteDispatch(options); - return {}; + return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched; }, }; return { ql, rows }; @@ -253,16 +251,9 @@ function bootActivation(wiring: TenancyWiring) { isActionEnabled: () => true, describeDisabledAction: (n: string) => `Action '${n}' is disabled`, setActionActive, + // Same rule as `keysEngine`: only the verbs this door actually reaches. find: vi.fn(async () => []), insert: vi.fn(), - update: async (_obj: string, data: any, options?: any) => { - assertEngineUpdateDispatch(data, options); - return {}; - }, - delete: async (_obj: string, options?: any) => { - assertEngineDeleteDispatch(options); - return {}; - }, }; const metadata: any = { load: vi.fn(async () => null), From 7eb955f1b8874e74d61862a63b2eec6cae081ecb Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 17:49:35 +0000 Subject: [PATCH 4/6] fix(runtime): the activation gate reads the tenancy posture in the request's own scope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- content/docs/permissions/system-context.mdx | 2 +- .../runtime/src/domains/activation-gate.ts | 15 +- .../tenancy-posture-outage-gates.test.ts | 257 +++++++++++++++++- 3 files changed, 264 insertions(+), 10 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index ce2e00f6fe..35716582d8 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -164,7 +164,7 @@ The largest single consumer — **17 of the 105 sites**. | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | | 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` | | 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:250`, `:283` | -| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:161`, `:239` | +| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:161`, `:252` | | 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` | | 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` | | 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` | diff --git a/packages/runtime/src/domains/activation-gate.ts b/packages/runtime/src/domains/activation-gate.ts index b5fbb7b49e..cf591fa05c 100644 --- a/packages/runtime/src/domains/activation-gate.ts +++ b/packages/runtime/src/domains/activation-gate.ts @@ -184,9 +184,22 @@ export async function refuseUngrantedActivationWrite( // collapses the two: a factory that threw and a name nothing registered // both arrived as the same absent posture, and this gate then returned // `undefined` — no refusal — for both. + // + // ⚠️ THE SCOPE ID IS PART OF THE READ, not an optimisation. `tenancy` may be + // registered `ServiceLifecycle.SCOPED`, and a scoped registration resolved + // without a scope id rejects UNBRANDED (`Scope ID required for scoped + // service 'tenancy'`) — which the classified lookup correctly re-raises, + // and this gate would then answer as a 503 it MANUFACTURED itself on a + // perfectly healthy deployment, locking the platform operator out of the + // switch that is theirs. `context.environmentId` is the same scope the + // identity step and `./keys.ts` already resolve this exact name with, so + // all three read one deployment's posture through one scope: an outage + // answered here is the service's, never this call site's omission. let posture; try { - posture = effectiveTenancyPosture(await deps.resolveServiceOrLoud(context, 'tenancy')); + posture = effectiveTenancyPosture( + await deps.resolveServiceOrLoud(context, 'tenancy', context.environmentId), + ); } catch (err) { throw new AuthzStoreUnavailableError('tenancy', err); } diff --git a/packages/runtime/src/domains/tenancy-posture-outage-gates.test.ts b/packages/runtime/src/domains/tenancy-posture-outage-gates.test.ts index f1430e8a3d..3552eade5d 100644 --- a/packages/runtime/src/domains/tenancy-posture-outage-gates.test.ts +++ b/packages/runtime/src/domains/tenancy-posture-outage-gates.test.ts @@ -40,6 +40,51 @@ // check. So each pin enters at the door body (`handleKeys` / `handleActions`), // which is where the gate it is about actually runs. // +// ## WHICH DOORS — three, because the gate body has three call sites +// +// `refuseUngrantedActivationWrite` is ONE gate with two callers, so the +// install-wide activation write has two doors: `./actions.ts:156` (`POST +// /actions/_activation/:object/:action`) and `./automation.ts:1050` (`POST +// /automation/:name/toggle`, through `refuseUngrantedFlowActivationWrite`). +// Both inherit the throw exit this change gives the gate, so both are pinned +// here alongside the `/keys` mint. A pin on one door is not evidence about the +// other: they differ in what runs in FRONT of the gate — the automation domain +// has an anonymous floor and its own authoring-write predicate — and what runs +// in front of a gate is exactly what a pin entering at the door body measures. +// +// ## THE SCOPE ID IS PART OF THE READ, not an optimisation +// +// `tenancy` may be registered `ServiceLifecycle.SCOPED`. Resolved WITHOUT a +// scope id a scoped registration rejects UNBRANDED — `Scope ID required for +// scoped service 'tenancy'` — and the classified lookup then re-raises it, +// correctly: it is not the branded "never registered". So a gate that drops the +// scope id it already holds answers **503 on a perfectly healthy deployment**, +// and the caller it locks out includes the platform operator, the one authority +// ADR-0126 §5 says the switch belongs to. That outage is manufactured by the +// CALL SITE, and it is the opposite of what this card is about. +// +// The `scoped-healthy` legs below are the pins for that class. They register a +// real scoped `tenancy` and require each door to READ the posture through the +// request's own environment — refuse the tenant org admin (403) and ADMIT the +// operator (200) — never to answer 503. Without them a pin file about outages +// cannot tell "loud on a broken service" from "loud on everything". +// +// ## POPULATION — stated because a pin proves only what it covers +// +// Covers: three wirings of `tenancy` (never registered · registered through a +// factory that throws · registered SCOPED and healthy, reporting `isolated`) +// across three doors (`POST /keys` mint · the actions activation write · the +// automation toggle), for the callers each door's decision turns on (an org-less +// minter; a tenant org admin who already holds `manage_metadata`; the +// `PLATFORM_ADMIN` operator). +// +// Does NOT cover: the posture-conditional refusal itself on a HEALTHY +// non-scoped service — that is `action-activation-posture-gate.test.ts` and +// `automation-activation-posture-gate.test.ts`, whose populations are their own +// and whose passing is not evidence about this file — nor the `manage_metadata` +// tier in front of the gate, nor the identity step (`http-dispatcher.ts`), which +// has read this same fact loudly since PR #15909. +// // ## Why the fixture builds a REAL kernel // // The two classes this file separates are produced by ONE place — the plugin @@ -55,10 +100,11 @@ import { describe, it, expect, vi } from 'vitest'; -import { ObjectKernel, isAuthzStoreUnavailableError } from '@objectstack/core'; +import { ObjectKernel, ServiceLifecycle, isAuthzStoreUnavailableError } from '@objectstack/core'; import { HttpDispatcher } from '../http-dispatcher.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; +import { ACTIVATION_DENY_STATUS, ACTIVATION_DENY_CODE } from './activation-gate.js'; /** ADR-0112 envelope for the outage answer — the brand's own two fields. */ const OUTAGE_STATUS = 503; @@ -67,18 +113,40 @@ const OUTAGE_CODE = 'SERVICE_UNAVAILABLE'; /** The message a `tenancy` factory fails with. Never a classification signal. */ const FACTORY_FAULT = 'tenancy factory: datasource unreachable'; -type TenancyWiring = 'never-registered' | 'throwing-factory'; +/** + * The environment the scoped legs resolve in — the value a request carries as + * `context.environmentId`, which is the scope every gate reading this name must + * pass down (`./keys.ts`, `./activation-gate.ts`, and the identity step). + */ +const SCOPE = 'platform'; + +/** A wall-enforcing posture, so the §5 operator test is the only question left. */ +const WALLED_POSTURE = 'isolated'; + +type TenancyWiring = 'never-registered' | 'throwing-factory' | 'scoped-healthy'; /** * Register `tenancy` on a real kernel in the requested wiring. * * `never-registered` registers nothing, so `getServiceAsync('tenancy')` rejects * with the loader's BRANDED rejection; `throwing-factory` registers a real - * singleton factory that throws, so it rejects UNBRANDED from below. Neither - * rejection is built here — both come out of `PluginLoader.getService`. + * singleton factory that throws, so it rejects UNBRANDED from below; + * `scoped-healthy` registers a real `ServiceLifecycle.SCOPED` factory that + * SUCCEEDS, which resolves only when the caller passes the scope id and rejects + * unbranded (`Scope ID required…`) when it does not. None of the three + * rejections is built here — all come out of `PluginLoader.getService`, which + * is what makes the classification a measurement rather than a restatement. */ function wireTenancy(kernel: ObjectKernel, wiring: TenancyWiring): void { if (wiring === 'never-registered') return; + if (wiring === 'scoped-healthy') { + kernel.registerServiceFactory( + 'tenancy', + () => ({ posture: WALLED_POSTURE }), + ServiceLifecycle.SCOPED, + ); + return; + } kernel.registerServiceFactory('tenancy', () => { throw new Error(FACTORY_FAULT); }); @@ -146,11 +214,18 @@ function bootKeys(wiring: TenancyWiring) { }; } -/** A signed-in caller with NO active organization — the org-less mint. */ -const orgLessCaller = (): HttpProtocolContext => ({ +/** + * A signed-in caller with NO active organization — the org-less mint. + * + * `environmentId` is a parameter because it is the SCOPE the mint gate resolves + * `tenancy` in: the outage legs below carry none (the shape a single-kernel + * deployment sends), the scoped-healthy leg carries the request's environment, + * and the difference between those two is the thing the scoped legs measure. + */ +const orgLessCaller = (environmentId?: string): HttpProtocolContext => ({ request: { headers: {} }, response: {}, - environmentId: undefined, + environmentId, executionContext: { userId: 'u1', isSystem: false, @@ -223,6 +298,24 @@ describe('[#15900] `POST /keys` mint — a tenancy service that FAILED to build expect(rows).toHaveLength(1); expect(rows[0].active_organization_id).toBeUndefined(); }); + + /** + * CONTROL for the OTHER direction: a HEALTHY service must not be answered as + * an outage. A scoped registration is resolvable only with the scope id the + * request carries, so this leg fails the moment the gate stops passing + * `context.environmentId` down — and it fails as a 503, which is precisely + * the failure a pin file about 503s must be able to see. + */ + it('reads a HEALTHY SCOPED `tenancy` through the request scope and refuses the org-less mint (400), not 503', async () => { + const { dispatcher, rows } = bootKeys('scoped-healthy'); + + const res = responseOf(await dispatcher.handleKeys('POST', { name: 'agent' }, orgLessCaller(SCOPE))); + + // The walled refusal, which means the posture was READ — an unread + // posture cannot produce it, and an outage answer is not it either. + expect(res.status).toBe(400); + expect(rows).toHaveLength(0); + }); }); // ─────────────────────────────────────────────────────────────────────────── @@ -278,7 +371,7 @@ function bootActivation(wiring: TenancyWiring) { */ const tenantAdmin = (): HttpProtocolContext => ({ request: {}, - environmentId: 'platform', + environmentId: SCOPE, executionContext: { userId: 'u_northwind_owner', positions: ['org_owner', 'org_admin'], @@ -288,6 +381,29 @@ const tenantAdmin = (): HttpProtocolContext => ({ }, } as unknown as HttpProtocolContext); +/** + * The PLATFORM OPERATOR — the authority ADR-0126 §5 says this install-wide row + * belongs to. Read as the posture RUNG (`ec.posture === 'PLATFORM_ADMIN'`, ADR-0095 + * D2/D3 · #15981), never as a position NAME. + * + * This caller is the load-bearing one for the scoped legs: every other caller + * this file drives is one a refusal is a correct answer for, so a gate that + * answered "no" to everything would still satisfy them. The operator is the only + * caller whose CORRECT answer is `200`, which makes them the only caller who can + * catch a gate that turned a healthy deployment into an outage. + */ +const operator = (): HttpProtocolContext => ({ + request: {}, + environmentId: SCOPE, + executionContext: { + userId: 'u_operator', + posture: 'PLATFORM_ADMIN', + positions: [], + permissions: [], + systemPermissions: ['manage_metadata'], + }, +} as unknown as HttpProtocolContext); + const flip = (dispatcher: HttpDispatcher, ctx: HttpProtocolContext) => dispatcher.handleActions(`/_activation/${OBJECT}/${ACTION}`, 'POST', { enabled: false }, ctx); @@ -334,4 +450,129 @@ describe('[#15900] the install-wide activation write — a tenancy service that expect(res.status).toBe(200); expect(setActionActive).toHaveBeenCalledWith({ name: ACTION, packageId: 'crm', active: false }); }); + + /** + * A HEALTHY scoped service is not an outage: the gate must READ the walled + * posture through the request's own environment and answer §5 — the tenant + * org admin refused with `403 PERMISSION_DENIED` and no row written. + */ + it('reads a HEALTHY SCOPED `tenancy` through the request scope and REFUSES the tenant org admin (403), not 503', async () => { + const { dispatcher, setActionActive } = bootActivation('scoped-healthy'); + + const res = responseOf(await flip(dispatcher, tenantAdmin())); + + expect(res.status).toBe(ACTIVATION_DENY_STATUS); + expect(res.body?.error?.code).toBe(ACTIVATION_DENY_CODE); + expect(setActionActive).not.toHaveBeenCalled(); + }); + + /** + * ⭐ The pin that catches a gate manufacturing its own outage. On a healthy + * deployment the operator is the sanctioned authority and their write must + * LAND. A gate that resolves the posture without the scope id answers 503 + * here instead, on a service that was never unwell — a lockout of the one + * caller ADR-0126 §5 exists to admit, dressed as this card's own fix. + */ + it('reads a HEALTHY SCOPED `tenancy` through the request scope and ADMITS the platform operator (200)', async () => { + const { dispatcher, setActionActive } = bootActivation('scoped-healthy'); + + const res = responseOf(await flip(dispatcher, operator())); + + expect(res.status).toBe(200); + expect(setActionActive).toHaveBeenCalledWith({ name: ACTION, packageId: 'crm', active: false }); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// Gate 3 — the SECOND door onto the same install-wide write: +// `POST /automation/:name/toggle` (`./automation.ts` → +// `refuseUngrantedFlowActivationWrite` → the gate above) +// ─────────────────────────────────────────────────────────────────────────── + +const FLOW = 'vendor_lead_router'; +const FLOW_DEFINITION = { name: FLOW, label: 'Vendor Lead Router', type: 'autolaunched', nodes: [], edges: [] }; + +/** + * The automation slot double, holding only what this door reaches: `getFlow` + * for the lookup and `toggleFlow` for the write, plus the `handlerReady: true` + * self-declaration the domain's #4058 serveability probe requires. Same rule as + * the two engine doubles above — a verb this door never calls would be coverage + * nobody is getting. + */ +function bootAutomation(wiring: TenancyWiring) { + const toggleFlow = vi.fn(async () => undefined); + const automation = { + handlerReady: true, + toggleFlow, + getFlow: vi.fn(async (name: string) => (name === FLOW ? FLOW_DEFINITION : undefined)), + }; + + const kernel = bareKernel(); + kernel.registerService('automation', automation); + wireTenancy(kernel, wiring); + + return { dispatcher: new HttpDispatcher(kernel as never), toggleFlow }; +} + +const toggle = (dispatcher: HttpDispatcher, ctx: HttpProtocolContext) => + dispatcher.handleAutomation(`/${FLOW}/toggle`, 'POST', { enabled: false }, ctx, undefined); + +describe('[#15900] the automation toggle — the same install-wide gate, reached through the OTHER door', () => { + it('REFUSES loudly (503, outage brand) and toggles nothing when the `tenancy` factory throws', async () => { + const { dispatcher, toggleFlow } = bootAutomation('throwing-factory'); + + const err = await toggle(dispatcher, tenantAdmin()).then( + () => { throw new Error('the toggle door answered instead of refusing'); }, + (e: unknown) => e, + ); + + expect(isAuthzStoreUnavailableError(err)).toBe(true); + expect((err as { status?: unknown }).status).toBe(OUTAGE_STATUS); + expect((err as { code?: unknown }).code).toBe(OUTAGE_CODE); + // Refused BEFORE the durable row — ADR-0126 made this switch survive a + // cold boot, so a refusal after the write is the #10243 leak with an + // audit trail. + expect(toggleFlow).not.toHaveBeenCalled(); + }); + + it('renders that refusal as a 503 `SERVICE_UNAVAILABLE` on the door', async () => { + const { dispatcher } = bootAutomation('throwing-factory'); + + const err = await toggle(dispatcher, tenantAdmin()).catch((e: unknown) => e); + const rendered = (dispatcher as unknown as { + domainDeps: { errorFromThrown(e: unknown, fallbackStatus?: number): { status: number; body: any } }; + }).domainDeps.errorFromThrown(err, 500); + + expect(rendered.status).toBe(OUTAGE_STATUS); + expect(rendered.body?.error?.code).toBe(OUTAGE_CODE); + }); + + /** The control, for this door's own population: absence still fails open. */ + it('CONTROL — a deployment that never registered `tenancy` still permits the org admin, exactly as before', async () => { + const { dispatcher, toggleFlow } = bootAutomation('never-registered'); + + const res = responseOf(await toggle(dispatcher, tenantAdmin())); + + expect(res.status).toBe(200); + expect(toggleFlow).toHaveBeenCalled(); + }); + + it('reads a HEALTHY SCOPED `tenancy` through the request scope and REFUSES the tenant org admin (403), not 503', async () => { + const { dispatcher, toggleFlow } = bootAutomation('scoped-healthy'); + + const res = responseOf(await toggle(dispatcher, tenantAdmin())); + + expect(res.status).toBe(ACTIVATION_DENY_STATUS); + expect(res.body?.error?.code).toBe(ACTIVATION_DENY_CODE); + expect(toggleFlow).not.toHaveBeenCalled(); + }); + + it('reads a HEALTHY SCOPED `tenancy` through the request scope and ADMITS the platform operator (200)', async () => { + const { dispatcher, toggleFlow } = bootAutomation('scoped-healthy'); + + const res = responseOf(await toggle(dispatcher, operator())); + + expect(res.status).toBe(200); + expect(toggleFlow).toHaveBeenCalled(); + }); }); From aa40e5382ac00b977cc908e2214492e0c6ef9c69 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 17:52:08 +0000 Subject: [PATCH 5/6] docs(runtime): name both doors this activation gate serves, and correct two sentences in 8d189af MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- content/docs/permissions/system-context.mdx | 2 +- packages/runtime/src/domain-handler-registry.ts | 10 ++++++++++ packages/runtime/src/domains/activation-gate.ts | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 35716582d8..8640ef74a9 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -164,7 +164,7 @@ The largest single consumer — **17 of the 105 sites**. | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | | 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` | | 55 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `domains/packages.ts:250`, `:283` | -| 56 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `activation-gate.ts:161`, `:252` | +| 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` | | 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` | | 58 | Audience-binding suggestion recording skipped | plugin-security | Lose: install-time suggestions are not recorded for system callers | `suggested-audience-bindings.ts:703` | | 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` | diff --git a/packages/runtime/src/domain-handler-registry.ts b/packages/runtime/src/domain-handler-registry.ts index 898d7fd703..166a7c42be 100644 --- a/packages/runtime/src/domain-handler-registry.ts +++ b/packages/runtime/src/domain-handler-registry.ts @@ -154,6 +154,16 @@ export interface DomainHandlerDeps { * "the fact is absent" license DIFFERENT answers; where they license the * same answer it buys an outage in place of a working deployment. * + * ⚠️ PASS THE SCOPE YOU HOLD. `environmentId` is optional in the signature + * and load bearing in use: a service registered `ServiceLifecycle.SCOPED` + * and resolved without one rejects UNBRANDED, so this method re-raises it + * and the caller answers 503 — on a service that is perfectly healthy. Under + * `resolveService` that same omission was invisible, because the probe + * absorbed it; opting a call site in without the scope converts a silent + * fallback into a manufactured outage for every caller of that door. A + * rejection out of this method should describe the SERVICE, never the call + * site's own omission. + * * Untyped by slot on purpose, exactly like `resolveService`'s second * overload: its callers address `tenancy`, which has no written * `ServiceSlotContracts` entry, and inventing one here would be a shape diff --git a/packages/runtime/src/domains/activation-gate.ts b/packages/runtime/src/domains/activation-gate.ts index cf591fa05c..088dbb18c1 100644 --- a/packages/runtime/src/domains/activation-gate.ts +++ b/packages/runtime/src/domains/activation-gate.ts @@ -151,6 +151,22 @@ export const ACTION_ACTIVATION_SUBJECT: ActivationSubject = { * ⚠️ It has THREE exits, not two: a refusal, `undefined` to proceed, and a * THROW. See the posture read below for the class that throws and why a caller * must not absorb it into "no gate to enforce". + * + * ⚠️ TWO DOORS, one gate body — so every exit above, the throw included, + * reaches BOTH of them and neither is "the" activation door: + * + * - `./actions.ts` — `POST /actions/_activation/:object/:action`, calling + * this function directly with {@link ACTION_ACTIVATION_SUBJECT}; + * - `./automation.ts` — `POST /automation/:name/toggle`, through its + * `refuseUngrantedFlowActivationWrite` wrapper and + * {@link FLOW_ACTIVATION_SUBJECT}. + * + * Both `await` the call, so the throw exit is a rejected promise the domain + * handler propagates and the dispatcher's error exit renders — nothing is + * unhandled at either door. Written down because a change to this body is a + * change to two routes: a claim about "the gate" that was measured at one door + * is a claim about half the surface, and both doors are pinned together in + * `./tenancy-posture-outage-gates.test.ts` for that reason. */ export async function refuseUngrantedActivationWrite( deps: DomainHandlerDeps, From 0795550f3574c35bd100ac3dbffdee4b522fb717 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 6 Sep 2026 17:53:47 +0000 Subject: [PATCH 6/6] =?UTF-8?q?chore(changeset):=20grade=20the=20runtime?= =?UTF-8?q?=20change=20minor=20=E2=80=94=20it=20grows=20a=20published=20in?= =?UTF-8?q?terface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01D47qPfEWVPmhguWgBZCi5N --- .changeset/runtime-domain-gates-tenancy-posture-loud.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/runtime-domain-gates-tenancy-posture-loud.md b/.changeset/runtime-domain-gates-tenancy-posture-loud.md index 8c9e32756d..053b8e64c7 100644 --- a/.changeset/runtime-domain-gates-tenancy-posture-loud.md +++ b/.changeset/runtime-domain-gates-tenancy-posture-loud.md @@ -1,11 +1,11 @@ --- -'@objectstack/runtime': patch +'@objectstack/runtime': minor --- The `/keys` mint gate and the install-wide activation-write gate classify a tenancy resolution failure instead of reading it as "no wall" 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. -The identity step already read this fact through the classified lookup, so one failure made the same deployment answer 503 at the identity step while admitting at these two gates — "is this deployment walled" had two answers at once. 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 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. -`resolveService` keeps its probe contract for every other name and every other domain; the classified read is a second, opted-into dependency the two named gates call, so no gate that was not named here changes behaviour. Patch rather than minor: no accept set widens, and a declared guard returns to enforced. +`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.