From 8aead68697c4a5ab2c08199740efd9209d817f51 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 16:11:11 +0000 Subject: [PATCH 1/6] fix(rest): keep failed and unwired apart at the two computeExecCtx authz-input seams MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the #13906 measurement, implementing the maintainer ruling of 2026-09-02 (decision 1 = A + B', decision 2 = B). Both seams measured fail-OPEN: an absorbed FAILURE read as "this check does not apply", so an authorization refusal was skipped rather than produced. - tenancy posture (A): absorb only the registry's branded not-registered rejection; any other rejection raises AuthzStoreUnavailableError, the same loud answer wiredEngineOrLoud gives the engine seam. The wiring fact comes from `kernel`'s presence, never inferred from the returned value. - single-kernel wiring (B'): a configured wall-enforcing posture is refused loudly at boot, because that wiring never reads a posture at all. - ADR-0069 auth gate (B): fail closed in the measured window only — isAuthGateActive() answered true AND the session re-read then failed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- packages/rest/src/rest-api-plugin.ts | 83 +++++++++++++++++++- packages/rest/src/rest-server.ts | 109 ++++++++++++++++++++++++--- 2 files changed, 181 insertions(+), 11 deletions(-) diff --git a/packages/rest/src/rest-api-plugin.ts b/packages/rest/src/rest-api-plugin.ts index e0ef920871..0ee96e691c 100644 --- a/packages/rest/src/rest-api-plugin.ts +++ b/packages/rest/src/rest-api-plugin.ts @@ -1,6 +1,9 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { Plugin, PluginContext, IHttpServer, isServiceNotRegisteredError } from '@objectstack/core'; +import { Plugin, PluginContext, IHttpServer, isServiceNotRegisteredError, effectiveTenancyPosture } from '@objectstack/core'; +// [#13906] The wall predicate, from its single owner — the same vocabulary +// `resolveAuthzContext` compiles against for the Layer 0 refusals. +import { postureEnforcesWall } from '@objectstack/spec/security'; import { RestServer, RestKernelManager, RestProtocol, RestRequestEnvResolver, RestEnvRegistry } from './rest-server.js'; import { RestServerConfig } from '@objectstack/spec/api'; import { mountAndRecordDirectRoutes } from './direct-mount-composition.js'; @@ -175,6 +178,84 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { // Single-kernel deployment — fall back to the control protocol } + // [#13906 — maintainer ruling 2026-09-02, decision 1 option B′] + // REFUSE a wall-enforcing tenancy posture on the single-kernel + // provider wiring, loudly, at BOOT — because this wiring cannot + // enforce it and must not pretend to. + // + // ## The measurement this exists to answer + // + // `RestServer.computeExecCtx` reads the posture off the LOCAL + // `kernel` variable, and that variable is assigned on the + // kernelManager branches ONLY. With no kernelManager the transport + // resolves auth and data through the injected providers instead, so + // `kernel` stays `undefined` and the posture is never asked for — + // not merely absent on failure, NEVER READ. Driven on one real + // `ObjectKernel` carrying a healthy `isolated` tenancy behind a + // RECORDING factory, wired both ways, same ex-member org-stamped key: + // + // | wiring | door | tenancy factory invocations | + // |:--|:--|:--| + // | via `kernelManager` | 401 refused | 1 | + // | via the provider wiring | **200 served** | **0** | + // + // ⇒ a healthy, correctly-configured, wall-enforcing tenancy service + // enforces NOTHING on this wiring, and no failure is required to + // reach that state — it is the NORMAL state. That is why the ruling + // put it here rather than in the seam: there is no posture to fix at + // request time, so the honest answer is to refuse the composition. + // + // ⛔ Option B — wiring a tenancy provider into the single-kernel path + // — was NOT taken (it needs a product answer about whether these + // deployments should run walled postures at all). This is B′: the + // deployment is told, at boot, that what it configured is not being + // enforced, instead of discovering it from a served request. + // + // ⚠️ Positive knowledge is REQUIRED to refuse: only a posture we + // actually READ and that actually enforces a wall trips this. A + // tenancy service that is absent (the overwhelmingly common + // single-kernel shape) or unreadable cannot assert a configured + // wall, so it is logged and allowed to proceed — refusing there + // would break embedders that never asked for a wall. + if (!kernelManager) { + const localKernel: any = typeof ctx.getKernel === 'function' ? ctx.getKernel() : undefined; + let tenancySource: unknown; + if (localKernel && typeof localKernel.getServiceAsync === 'function') { + try { + tenancySource = await localKernel.getServiceAsync('tenancy'); + } catch (err) { + // Never registered is the supported shape and is quiet. + // Anything else means we could not READ the posture, so + // we cannot assert one is configured — loud in the log, + // but not a refusal. See the RESIDUE note on the card. + if (!isServiceNotRegisteredError(err)) { + ctx.logger.error( + '[security] RestApiPlugin: the `tenancy` service could not be read at boot, so it ' + + 'could not be checked against this single-kernel wiring, which carries NO tenancy ' + + 'posture into request authorization (#13906). If a wall-enforcing posture is ' + + 'configured here, it is NOT being enforced.', + err as any, + ); + } + tenancySource = undefined; + } + } + const bootPosture = effectiveTenancyPosture(tenancySource as any); + if (bootPosture && postureEnforcesWall(bootPosture)) { + throw new Error( + `[security] RestApiPlugin refuses to start: the kernel's \`tenancy\` service reports the ` + + `wall-enforcing posture \`${bootPosture}\`, but this deployment has no \`kernel-manager\` ` + + `service, so the REST transport resolves every request through the single-kernel ` + + `providers and NEVER reads a tenancy posture. The Layer 0 organization wall — including ` + + `the \`organization_required\` and \`organization_membership_ended\` API-key refusals — ` + + `would silently not be enforced (#13906, maintainer ruling 2026-09-02 decision 1 B'). ` + + `Fix by mounting a kernel-manager service (the multi-environment wiring that can carry a ` + + `posture), or by setting the tenancy posture to \`single\` if this deployment is not ` + + `meant to run an organization wall.`, + ); + } + } + // Optional — only present in runtime mode. When available, // RestServer will resolve hostname → environmentId on unscoped // routes so a remote runtime node can dispatch every request diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 1778442c8e..df951e998e 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -10,6 +10,10 @@ import { // takes the same loud answer rather than the quiet 403 it used to wear. AuthzStoreUnavailableError, effectiveTenancyPosture, + // [#13906] The REGISTRY's own "never registered" brand — the discriminator + // that lets the tenancy seam absorb the supported no-tenancy composition + // while every other rejection stays loud. Never message text (#13905). + isServiceNotRegisteredError, assembleExecutionContext, normalizeAuthGate, type AuthGate, shouldDenyAnonymous, ANONYMOUS_DENY_BODY, ANONYMOUS_DENY_STATUS, // [#7678] ADR-0090 D5/D9 suggested-binding `?status=` vocabulary — the one @@ -2346,13 +2350,60 @@ export class RestServer { }; // [#8287] The EFFECTIVE tenancy posture, from the kernel's `tenancy` // service — the same source plugin-security reconciles for the Layer 0 - // wall, so API-key admission and the wall agree. Absent ⇒ undefined ⇒ - // no posture-conditional refusal (behaviour unchanged). + // wall, so API-key admission and the wall agree. + // + // [#13906 — maintainer ruling 2026-09-02, decision 1 option A] The + // facts this seam used to answer with one `undefined` are now kept + // apart. Measured on a real `ObjectKernel` with a healthy `isolated` + // tenancy and an EX-MEMBER's org-stamped API key, the wiring + // differing ONLY in the tenancy service's health: + // + // | `tenancy` service | before | after | + // |:--|:--|:--| + // | healthy, wall-enforcing | 401 refused | 401 — unchanged | + // | never registered (supported) | 200 served | 200 — unchanged | + // | registered and FAILED to build | **200 served** | **503** | + // + // ⇒ the direction here was PERMISSIVE, and that is what makes this + // card's family different from its siblings: a tenancy service that + // could not be CONSTRUCTED skipped the Layer 0 + // `organization_membership_ended` refusal (and its + // `organization_required` sibling), so a FAILURE read as "this check + // does not apply". #13476 and #13904 answered an unknown with a + // REFUSAL; this one answered it with ADMISSION. + // + // The classification is the REGISTRY's, never message text — the + // same discriminator the shipped `objectQLProvider` already uses one + // layer down (`isServiceNotRegisteredError`, #13905): "never + // registered" is branded and stays quiet; every other rejection (a + // factory that threw, a scoped registration resolved without a scope + // id, a circular service dependency) is unbranded, and the set is + // closed with a LOUD default. + // + // ⚠️ The WIRING fact is taken from `kernel`'s PRESENCE, asked here + // once, and never inferred from what the read returned — the #13476 + // discipline. Without that guard the single-kernel provider path + // (where `kernel` is `undefined`) would raise a `TypeError` from the + // dereference and every embedder on that wiring would take the loud + // answer. That path carries no posture at all; its half of this + // ruling (decision 1 option B′) is refused at BOOT instead — see + // `rest-api-plugin.ts`. let tenancyPosture; - try { - tenancyPosture = effectiveTenancyPosture(await kernel.getServiceAsync('tenancy') as any); - } catch { - tenancyPosture = undefined; + if (kernel) { + try { + tenancyPosture = effectiveTenancyPosture(await kernel.getServiceAsync('tenancy') as any); + } catch (err) { + // Registered and unable to answer. The posture is an + // authorization INPUT, so admission was never decided — the + // same loud answer `wiredEngineOrLoud` gives the engine seam, + // carried to the door by the same nets. + if (!isServiceNotRegisteredError(err)) { + throw new AuthzStoreUnavailableError('tenancy', err); + } + // Never registered ⇒ the supported no-tenancy composition: + // quiet `undefined`, no posture-conditional refusal. + tenancyPosture = undefined; + } } const authz = await resolveAuthzContext({ ql, headers, getSession, tenancyPosture }); // [#6216] The anonymous contract IS the shared assembler's default @@ -2383,13 +2434,51 @@ export class RestServer { // (`{ code, message }`), so this is where the declaration is met — // a gate with a blank message no longer rides into a 403 body as // `undefined`. + // + // [#13906 — maintainer ruling 2026-09-02, decision 2 option B] The + // gate is best-effort NO LONGER in one precisely measured window: + // `isAuthGateActive()` answered `true` AND the gate's session + // re-read then FAILED. Measured before the repair, same fixture, + // the wiring differing only in how the gate faulted: + // + // | gate wiring | before | after | + // |:--|:--|:--| + // | INACTIVE (the common, correct case)| admitted | admitted — unchanged | + // | ACTIVE, healthy re-read, gated user| 403 code+message | 403 — unchanged | + // | `isAuthGateActive()` THROWS | admitted | admitted — unchanged | + // | ACTIVE, re-read FAILS | **admitted** | **503** | + // + // ⇒ an enforcement the deployment DECLARED active used to vanish + // with no wire trace, deep-equal to gate-off. A declared promise + // that disappears silently is the fail-OPEN this card measured. + // + // ⛔ The probe-throws row stays absorbed DELIBERATELY, and it is the + // narrowness the ruling asked for: a host whose probe faults never + // answered `true`, so it never declared a gate, and refusing on it + // would block deployments that never asked for one. let authGate: AuthGate | undefined; + let gateActive = false; try { - if (typeof authService.isAuthGateActive === 'function' && authService.isAuthGateActive()) { - const gatedSession: any = await getSession(headers).catch(() => undefined); - authGate = normalizeAuthGate(gatedSession?.user) ?? undefined; + gateActive = typeof authService.isAuthGateActive === 'function' + && authService.isAuthGateActive() === true; + } catch { + gateActive = false; + } + if (gateActive) { + let gatedSession: any; + try { + // ⛔ NOT the `getSession` closure above, and not its + // `.catch(() => undefined)`: both convert a THROW into the + // same `undefined` a gate-less user produces, which is + // precisely the collapse being repaired. A session that + // RESOLVES carrying no gate is not a failure — that user is + // simply not gated, and still admits. + gatedSession = await api.getSession({ headers }); + } catch (err) { + throw new AuthzStoreUnavailableError('auth_gate', err); } - } catch { /* gate is best-effort — never break context resolution */ } + authGate = normalizeAuthGate(gatedSession?.user) ?? undefined; + } // [#6216 — maintainer ruling 2026-08-08, Option A] The assembly of // the ExecutionContext itself is now the SINGLE shared one From b8370b50b61928a044ad9274f882be9a78ec5f80 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 16:16:28 +0000 Subject: [PATCH 2/6] test(rest): re-aim the #13906 pins onto the ruled behaviour, and drive B' The six phase-1 pins that recorded the PERMISSIVE answers go red by design under the repair; each is inverted IN PLACE with its superseded text quoted beside it, per the file's own standing instruction. Adds rest-api-plugin-tenancy-posture-boot-refusal.test.ts, which drives the ruling's own opening question for B' (can a walled posture be configured on the single-kernel wiring at all? yes) plus the refusal and four narrowness controls, and a narrowness control for decision 2 B. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- ...cctx-authz-input-seam-reachability.test.ts | 176 ++++++++--- ...lugin-tenancy-posture-boot-refusal.test.ts | 294 ++++++++++++++++++ 2 files changed, 421 insertions(+), 49 deletions(-) create mode 100644 packages/rest/src/rest-api-plugin-tenancy-posture-boot-refusal.test.ts diff --git a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts index a56a0af820..5867dc756c 100644 --- a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts +++ b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts @@ -5,17 +5,40 @@ * FAILURE into the same `undefined` an ABSENT wiring produces — and both feed * AUTHORIZATION inputs: the tenancy posture and the ADR-0069 auth gate. * - * ## ⛔ This is a MEASUREMENT file. It repairs nothing and proposes nothing. + * ## This file WAS a measurement. The ruling landed, and the pins are re-aimed. * - * #13906 records a code reading ("no severity asserted and no direction - * measured") and its dispatch order was explicit: the first deliverable is a - * READING, not a repair. Every assertion below pins what the tree DOES today, - * driven through the real supplier — including the permissive answers. - * ⛔ A pin on a permissive answer is a measurement, not an endorsement: - * whether any of it should CHANGE is a ruling this file deliberately does not - * take. If a later ruling repairs a seam, invert the pin IN PLACE and quote - * the superseded text beside it, as `package-door-execctx-fault-reachability` - * did for #13279/#13476. + * Phase 1 pinned what the tree DID — including the permissive answers — under + * a standing instruction: "if a later ruling repairs a seam, invert the pin IN + * PLACE and quote the superseded text beside it". ⭐ That is what happened, and + * every re-aimed assertion below carries its SUPERSEDED text in a comment, so + * the change is legible from this file alone rather than only from git. + * + * **Maintainer ruling, 2026-09-02** (director seat, summon #8; verbatim + * 「14324 等我发版,其他同意」 adopting the recommendation as presented): + * + * - **Decision 1 — tenancy posture: A + B′.** A: absorb ONLY the branded + * not-registered rejection (`isServiceNotRegisteredError`, the + * discriminator the shipped `objectQLProvider` already uses one layer + * down); every unbranded failure fails closed instead of collapsing into + * the absent-posture path. B′: on the single-kernel provider wiring a + * configured wall-enforcing posture is refused LOUDLY AT BOOT — that + * wiring cannot enforce it, so it must not pretend to. ⛔ Option B (wiring + * a tenancy provider into the single-kernel path) was NOT taken. + * - **Decision 2 — ADR-0069 auth gate: B.** Fail closed in the measured + * window ONLY: `isAuthGateActive()` answered `true` AND the gate re-read + * then failed. ⛔ The common inactive path, and a probe that throws, are + * untouched. + * + * ⚠️ Both halves are runtime authorization behaviour on the MANUAL FLOOR (the + * 2026-08-28 negative-boundary ruling); the ruling above is the control. ⛔ Do + * not widen either repair on the strength of this file — the narrowness + * controls below exist precisely to make widening fail. + * + * B′'s own half is driven in `rest-api-plugin-tenancy-posture-boot-refusal + * .test.ts`, because it is a BOOT refusal in the plugin rather than a + * request-time seam. ⚠️ Consequently §3 below still measures 200 on the + * provider wiring and that is CORRECT: B′ refuses the composition at boot, it + * does not change `computeExecCtx`, which still never reads a posture there. * * ## Why this card is not its siblings, and why the direction matters * @@ -123,18 +146,36 @@ describe('[#13906] §0 — the two seams are LIVE on today\'s tree, by symbol', const body = computeExecCtxBody(SOURCE); const sites = enumerateAbsorbSites(body); - it('KNOWN POSITIVE: the tenancy-posture seam still absorbs to undefined', () => { - // The card's quoted shape, re-derived: the posture read sits in a try - // whose catch assigns undefined, and it dereferences the local `kernel`. + it('REPAIRED [decision 1 A]: the tenancy-posture seam keeps not-registered apart from failed', () => { + // SUPERSEDED PIN, quoted so the change is legible rather than lost. + // Before the 2026-09-02 ruling this asserted the collapse: + // expect(body).toMatch(/catch\s*\{\s*\n\s*tenancyPosture = undefined;/); + // i.e. EVERY rejection became `undefined`. It now must not match, because + // only the branded not-registered rejection may take that path. expect(body).toMatch(/tenancyPosture = effectiveTenancyPosture\(await kernel\.getServiceAsync\('tenancy'\)/); - expect(body).toMatch(/catch\s*\{\s*\n\s*tenancyPosture = undefined;/); - expect(sites.some((s) => s.includes('tenancyPosture = undefined') || /catch/.test(s))).toBe(true); + expect(body).not.toMatch(/catch\s*\{\s*\n\s*tenancyPosture = undefined;/); + // The discriminator is the REGISTRY's brand, never message text (#13905). + expect(body).toMatch(/if \(!isServiceNotRegisteredError\(err\)\) \{\s*\n\s*throw new AuthzStoreUnavailableError\('tenancy', err\);/); + // ⛔ And the WIRING fact is asked of `kernel`'s presence, never inferred + // from the returned value — the #13476 discipline this repair inherits. + expect(body).toMatch(/let tenancyPosture;\s*\n\s*if \(kernel\) \{/); }); - it('KNOWN POSITIVE: the auth-gate seam still absorbs to undefined', () => { - expect(body).toMatch(/isAuthGateActive === 'function' && authService\.isAuthGateActive\(\)/); - // The comment-only swallow, verbatim class: gate is best-effort. - expect(body).toMatch(/catch\s*\{\s*\/\*\s*gate is best-effort/); + it('REPAIRED [decision 2 B]: the auth-gate seam fails closed in the measured window only', () => { + // SUPERSEDED PIN, quoted: + // expect(body).toMatch(/catch\s*\{\s*\/\*\s*gate is best-effort/); + // the comment-only swallow that made a FAILED re-read of an ACTIVE gate + // indistinguishable from gate-off. That swallow is gone. + expect(body).not.toMatch(/catch\s*\{\s*\/\*\s*gate is best-effort/); + expect(body).toMatch(/isAuthGateActive === 'function'\s*\n?\s*&& authService\.isAuthGateActive\(\) === true/); + // The re-read is loud, and it is the RAW api call — ⛔ not the swallowing + // `getSession` closure, which would re-collapse the very same two facts. + expect(body).toMatch(/gatedSession = await api\.getSession\(\{ headers \}\);/); + expect(body).toMatch(/throw new AuthzStoreUnavailableError\('auth_gate', err\);/); + // ⛔ NARROWNESS CONTROL: the probe-throws leg must STAY absorbed — a host + // whose probe faults never declared a gate. If this ever flips, the repair + // has widened past the window the ruling named. + expect(body).toMatch(/\} catch \{\s*\n\s*gateActive = false;\s*\n\s*\}/); }); it('KNOWN NEGATIVE: the engine seam #13476 repaired is NOT flagged as absorb', () => { @@ -409,14 +450,21 @@ describe('[#13906] §2 — the Layer 0 ex-member refusal, and what a failed post expect(authz.userId).toBeUndefined(); }); - it('⚠️ MEASURED PERMISSIVE: tenancy REGISTERED AND FAILING (factory throws) → the refusal is SKIPPED and the ex-member key is served 200', async () => { + it('REPAIRED [decision 1 A]: tenancy REGISTERED AND FAILING (factory throws) → 503 outage, no longer a served 200', async () => { + // SUPERSEDED PIN, quoted — this is the row the card was filed for: + // expect(captured.status).toBe(200); + // expect(captured.body?.success).toBe(true); + // A tenancy service that could not be CONSTRUCTED skipped the Layer 0 + // refusal, so an ex-member's org-stamped key was SERVED with full grants. + // // ONE condition changed from the positive control: the tenancy service - // fails to construct — the registry's own unbranded rejection reaches the - // seam's catch and the posture becomes `undefined`. + // fails to construct — the registry's own UNBRANDED rejection. const kernel = kernelWith({ ql: qlWith({ memberships: MEMBER_ROWS }), tenancy: 'factory-throws' }); const captured = await drive(mount(serverWith(viaKernelManager(kernel))), { 'x-api-key': RAW_EXMEMBER_KEY }); - expect(captured.status).toBe(200); - expect(captured.body?.success).toBe(true); + expect(captured.status).toBe(503); + // ⭐ And it answers as an OUTAGE, not as a permission denial — the + // distinction #13279 ruled on and this repair reuses rather than reinvents. + expect(captured.body?.success).not.toBe(true); }); it('⚠️ MEASURED PERMISSIVE (mechanism): with the posture absent the resolver ADMITS the ex-member as a full principal', async () => { @@ -435,19 +483,28 @@ describe('[#13906] §2 — the Layer 0 ex-member refusal, and what a failed post expect(authz.tenantId).toBe('org_A'); }); - it('THE COLLAPSE: "registered and failed" and "never registered" answer byte-identically at the door', async () => { + it('THE COLLAPSE IS ENDED: "registered and failed" and "never registered" no longer answer alike', async () => { + // SUPERSEDED PIN, quoted — the card's subject in one assertion: + // expect(a).toEqual(b); + // expect(a.status).toBe(200); const failed = kernelWith({ ql: qlWith({ memberships: MEMBER_ROWS }), tenancy: 'factory-throws' }); const absent = kernelWith({ ql: qlWith({ memberships: MEMBER_ROWS }), tenancy: 'unregistered' }); const a = await drive(mount(serverWith(viaKernelManager(failed))), { 'x-api-key': RAW_EXMEMBER_KEY }); const b = await drive(mount(serverWith(viaKernelManager(absent))), { 'x-api-key': RAW_EXMEMBER_KEY }); - // The `unregistered` half is the SUPPORTED single-tenant shape (no wall - // exists, an org-stamped key still working is by design). The `failed` - // half rides the same answer. That equality is the card's subject. - expect(a).toEqual(b); - expect(a.status).toBe(200); + expect(a).not.toEqual(b); + // Registered and FAILED ⇒ the outage it is. + expect(a.status).toBe(503); + // ⭐ THE OTHER HALF, and the one that keeps this repair honest: the + // `unregistered` leg is the SUPPORTED no-tenancy composition (no wall + // exists; an org-stamped key working there is by design) and it must be + // completely UNCHANGED. A repair that made this 503 too would have broken + // every single-tenant embedder — that is why the brand, not the catch, is + // the discriminator. + expect(b.status).toBe(200); + expect(b.body?.success).toBe(true); }); - it('SIBLING REFUSAL, same gate: the org-less-key refusal under `isolated` is also skipped when the probe fails', async () => { + it('SIBLING REFUSAL, same gate [decision 1 A]: the org-less-key refusal under `isolated` is no longer skipped when the probe FAILS', async () => { // `resolveApiKeyAdmission` refuses an org-less key under a walled, // non-union posture (`organization_required`) — also only when the // posture is PRESENT. Note api-key.ts DOCUMENTS absent-posture-admit as a @@ -459,9 +516,15 @@ describe('[#13906] §2 — the Layer 0 ex-member refusal, and what a failed post const refused = await drive(mount(serverWith(viaKernelManager(healthy))), { 'x-api-key': RAW_ORGLESS_KEY }); expect(refused.status).toBe(ANONYMOUS_DENY_STATUS); + // SUPERSEDED PIN, quoted: expect(admitted.status).toBe(200); + // The org-less key was ADMITTED when the posture probe failed. api-key.ts + // documents absent-posture-admit as deliberate for THIS refusal — and that + // reasoning still holds for a tenancy service that is genuinely ABSENT. + // What it never meant to cover is a service that FAILED, which is the only + // leg this repair moves. const failing = kernelWith({ ql: qlWith({ memberships: MEMBER_ROWS }), tenancy: 'factory-throws' }); - const admitted = await drive(mount(serverWith(viaKernelManager(failing))), { 'x-api-key': RAW_ORGLESS_KEY }); - expect(admitted.status).toBe(200); + const nowLoud = await drive(mount(serverWith(viaKernelManager(failing))), { 'x-api-key': RAW_ORGLESS_KEY }); + expect(nowLoud.status).toBe(503); }); }); @@ -570,7 +633,7 @@ describe('[#13906] §4 — auth gate: failed probe vs inactive gate', () => { .toEqual({ authGate: inactive.ctx?.authGate, blocked: inactive.blocked, wire: inactive.state }); }); - it('⚠️ MEASURED: a FAILED session re-read under an ACTIVE gate is indistinguishable from the inactive gate — the gated user is NOT blocked', async () => { + it('REPAIRED [decision 2 B]: a FAILED session re-read under an ACTIVE gate is now a 503 outage, not a silent admission', async () => { // The transient class: identity resolution succeeds (first read), the // gate's re-read fails (second read) — a session-backend fault between // the two reads of one request. @@ -585,21 +648,36 @@ describe('[#13906] §4 — auth gate: failed probe vs inactive gate', () => { }, }, }; - const inactive = await driveGate(gateWiring({ - isAuthGateActive: () => false, - api: { getSession: async () => ({ user: GATED_USER }) }, + // SUPERSEDED PINS, quoted — the measured fail-OPEN this card was filed for: + // expect(rereadFails.ctx?.authGate).toBeUndefined(); + // expect(rereadFails.blocked).toBe(false); + // expect({ authGate, blocked, wire }).toEqual({ …inactive… }); + // A gate the deployment DECLARED active vanished silently, deep-equal to + // gate-off, and the gated user was admitted with no trace on the wire. + await expect(driveGate(gateWiring(auth))).rejects.toMatchObject({ + code: 'SERVICE_UNAVAILABLE', + status: 503, + object: 'auth_gate', + }); + // The gate DID probe — the counter is what proves the re-read was reached + // and is the anti-vacuity control for this leg: without it a repair that + // never probed at all would also "pass". + expect(reads).toBeGreaterThan(1); + }); + + it('NARROWNESS CONTROL [decision 2 B]: a SUCCESSFUL re-read carrying no gate still admits — only the FAILED re-read is loud', async () => { + // The window the ruling named has two conditions, and this is the leg that + // proves the second one is really required rather than approximated. An + // ACTIVE gate whose re-read SUCCEEDS but returns a user with no gate is + // not a failure — that user is simply not gated — and must still be served. + // ⛔ A repair keyed on "active gate + no authGate value" instead of + // "active gate + the re-read THREW" would refuse this caller. It does not. + const r = await driveGate(gateWiring({ + isAuthGateActive: () => true, + api: { getSession: async () => ({ user: { id: 'u_ungated' } }) }, })); - const rereadFails = await driveGate(gateWiring(auth)); - expect(reads).toBeGreaterThan(1); // the gate DID probe — and its failure vanished - expect(rereadFails.ctx?.userId).toBe('u_gated'); - expect(rereadFails.ctx?.authGate).toBeUndefined(); - expect(rereadFails.blocked).toBe(false); - expect({ authGate: rereadFails.ctx?.authGate, blocked: rereadFails.blocked, wire: rereadFails.state }) - .toEqual({ authGate: inactive.ctx?.authGate, blocked: inactive.blocked, wire: inactive.state }); - // This user's SESSION says the policy gate applies (user.authGate is set, - // the gate is ACTIVE) — the block was lost to the re-read failure, and - // the wire carries no trace. The code comment names the design - // best-effort; whether that stands is a ruling, recorded on #13906, and - // deliberately not taken here. + expect(r.ctx?.userId).toBe('u_ungated'); + expect(r.ctx?.authGate).toBeUndefined(); + expect(r.blocked).toBe(false); }); }); diff --git a/packages/rest/src/rest-api-plugin-tenancy-posture-boot-refusal.test.ts b/packages/rest/src/rest-api-plugin-tenancy-posture-boot-refusal.test.ts new file mode 100644 index 0000000000..9f3cfae0f9 --- /dev/null +++ b/packages/rest/src/rest-api-plugin-tenancy-posture-boot-refusal.test.ts @@ -0,0 +1,294 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#13906 — maintainer ruling 2026-09-02, decision 1 option B′] The + * single-kernel provider wiring REFUSES a configured wall-enforcing tenancy + * posture at BOOT, because it cannot enforce one. + * + * ## Why a boot refusal and not a seam repair + * + * `RestServer.computeExecCtx` reads the tenancy posture off its local `kernel` + * variable, and that variable is assigned on the `kernelManager` branches ONLY. + * With no kernel-manager the transport resolves auth and data through the + * injected providers, `kernel` stays `undefined`, and the posture is never + * asked for. Phase 1 drove that on one real `ObjectKernel` carrying a healthy + * `isolated` tenancy behind a RECORDING factory, wired both ways, same + * ex-member org-stamped key: + * + * | wiring | door | tenancy factory invocations | + * |:--|:--|:--| + * | via `kernelManager` | 401 refused | 1 | + * | via the provider wiring | **200 served** | **0** | + * + * ⇒ NOT "absent on failure" — never read. A healthy, correctly-configured, + * wall-enforcing tenancy service enforces nothing there, and no failure is + * required to reach that state. There is therefore no posture to repair at + * request time, which is why the ruling put the answer at boot: tell the + * deployment that what it configured is not being enforced, instead of letting + * it find out from a served request. + * + * ⚠️ §3 of `execctx-authz-input-seam-reachability.test.ts` still measures 200 + * on the provider wiring, and that stays CORRECT: this refuses the + * COMPOSITION at boot, it does not change `computeExecCtx`. + * + * ⛔ Option B — wiring a tenancy provider into the single-kernel path — was NOT + * taken. It needs a product answer (should these deployments run walled + * postures at all?) that the ruling explicitly declined to pre-empt. + * + * ## §1 is the ruling's OWN opening question, driven + * + * The ruling made B′ conditional: *"B′ opens with a measurement: can a walled + * posture be configured on that wiring at all? If it cannot, B′ reduces to + * documenting that the single-kernel wiring carries no posture."* §1 answers + * it — YES, it can — which is what makes B′ a refusal rather than a doc note. + * ⛔ Do not delete §1 as redundant: it is the precondition of everything below. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ObjectKernel, ServiceLifecycle, effectiveTenancyPosture } from '@objectstack/core'; +import type { PluginContext } from '@objectstack/core'; +import { postureEnforcesWall } from '@objectstack/spec/security'; + +const captured = vi.hoisted(() => ({ ctorArgs: [] as unknown[][] })); + +// The same double the sibling plugin tests use: EXTENDS the real class so the +// composition root runs production code, and only suppresses route +// registration. Route registration is not what is under measurement here. +vi.mock('./rest-server.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + RestServer: class extends actual.RestServer { + constructor(...args: unknown[]) { + super(...(args as ConstructorParameters)); + captured.ctorArgs.push(args); + } + override registerRoutes(): void { + /* not under test */ + } + }, + }; +}); + +const { createRestApiPlugin } = await import('./rest-api-plugin.js'); + +function mockHttpServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), + use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), + close: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeObjectKernel(): ObjectKernel { + return new ObjectKernel({ + logger: { level: 'error' }, + gracefulShutdown: false, + skipSystemValidation: true, + }); +} + +/** + * Boot the REAL rest plugin on a real kernel. `wire` runs in a host plugin's + * init BEFORE the rest plugin starts — where a real composition registers its + * services. Returns whether a `RestServer` was constructed, which is the + * observable that says whether the door came up at all. + */ +async function bootRealPluginOn( + kernel: ObjectKernel, + wire?: (ctx: PluginContext) => void, +): Promise<{ booted: boolean; error?: unknown }> { + captured.ctorArgs.length = 0; + await kernel.use({ + name: 'test.host.wiring', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('http.server', mockHttpServer()); + ctx.registerService('protocol', {}); + wire?.(ctx); + }, + }); + await kernel.use(createRestApiPlugin()); + try { + await kernel.bootstrap(); + return { booted: captured.ctorArgs.length === 1 }; + } catch (error) { + return { booted: false, error }; + } +} + +/** The shape `effectiveTenancyPosture` reads — structural, as core declares it. */ +const isolatedTenancy = () => ({ posture: 'isolated', isolationActive: true }); +const singleTenancy = () => ({ posture: 'single', isolationActive: false }); + +/** Flatten a boot rejection to the text an operator would actually read. */ +function bootText(error: unknown): string { + const parts: string[] = []; + let cur: any = error; + for (let i = 0; cur && i < 5; i++) { + if (cur.message) parts.push(String(cur.message)); + cur = cur.cause; + } + return parts.join(' || '); +} + +// --------------------------------------------------------------------------- +// §1 — the ruling's opening measurement: CAN a walled posture be configured on +// this wiring at all? If not, B′ collapses to documentation. +// --------------------------------------------------------------------------- + +describe('[#13906] §1 — B′ opening measurement: is a walled posture configurable on the single-kernel wiring?', () => { + it('YES: a kernel with NO kernel-manager still carries a readable, wall-enforcing tenancy posture', async () => { + // Nothing about registering a tenancy service depends on a + // kernel-manager: the service is a kernel service like any other, and + // it reconciles to `isolated` exactly as it would on the multi-kernel + // wiring. So the misconfiguration B′ refuses is REACHABLE — the + // deployment can, and this proves it does, hold a wall it is not + // enforcing. + const kernel = makeObjectKernel(); + await kernel.use({ + name: 'test.tenancy.host', + version: '1.0.0', + init: async (ctx: PluginContext) => { ctx.registerService('tenancy', isolatedTenancy()); }, + }); + await kernel.bootstrap(); + + // Read it the way the transport would, through the same helper. + const tenancy = await kernel.getServiceAsync('tenancy'); + const posture = effectiveTenancyPosture(tenancy as any); + expect(posture).toBe('isolated'); + expect(postureEnforcesWall(posture!)).toBe(true); + + // ⛔ And no kernel-manager exists here — the condition that makes the + // transport blind to the posture just read. + await expect(kernel.getServiceAsync('kernel-manager')).rejects.toBeDefined(); + + await kernel.shutdown(); + }); + + it('POSITIVE CONTROL for the criterion: a `single` posture on the SAME wiring does NOT enforce a wall', async () => { + // Without this leg, "the posture enforces a wall" could be an artifact + // of the reader rather than a fact about the configuration. + const kernel = makeObjectKernel(); + await kernel.use({ + name: 'test.tenancy.host', + version: '1.0.0', + init: async (ctx: PluginContext) => { ctx.registerService('tenancy', singleTenancy()); }, + }); + await kernel.bootstrap(); + + const posture = effectiveTenancyPosture(await kernel.getServiceAsync('tenancy') as any); + expect(posture).toBe('single'); + expect(postureEnforcesWall(posture!)).toBe(false); + + await kernel.shutdown(); + }); +}); + +// --------------------------------------------------------------------------- +// §2 — the refusal itself. +// --------------------------------------------------------------------------- + +describe('[#13906] §2 — B′: the boot refusal', () => { + it('⭐ REFUSES: wall-enforcing posture + no kernel-manager → the plugin does not start and the door never comes up', async () => { + const kernel = makeObjectKernel(); + const r = await bootRealPluginOn(kernel, (ctx) => { + ctx.registerService('tenancy', isolatedTenancy()); + }); + + expect(r.booted).toBe(false); + expect(r.error).toBeDefined(); + // ⭐ The door must not have been constructed — a refusal that still + // built a serving RestServer would be a log line, not a refusal. + expect(captured.ctorArgs).toHaveLength(0); + + const text = bootText(r.error); + // The message has to carry the three things an operator needs: WHAT is + // configured, WHY it is not enforced, and HOW to fix it. + expect(text).toContain('isolated'); + expect(text).toContain('kernel-manager'); + expect(text).toMatch(/never reads a tenancy posture/i); + expect(text).toContain('#13906'); + + await kernel.shutdown().catch(() => undefined); + }); + + it('the refusal fires on a FACTORY-registered tenancy service too — the shape a real composition uses', async () => { + // Instance registration is the simplest case; a real host commonly + // registers a factory. Both must be seen, or the refusal is trivially + // evaded by the more realistic wiring. + const kernel = makeObjectKernel(); + kernel.registerServiceFactory('tenancy', () => isolatedTenancy(), ServiceLifecycle.SINGLETON); + const r = await bootRealPluginOn(kernel); + + expect(r.booted).toBe(false); + expect(bootText(r.error)).toContain('isolated'); + + await kernel.shutdown().catch(() => undefined); + }); +}); + +// --------------------------------------------------------------------------- +// §3 — narrowness controls. Each of these MUST still boot; together they are +// what stops B′ from becoming "the rest plugin refuses to start". +// --------------------------------------------------------------------------- + +describe('[#13906] §3 — B′ narrowness: what must keep booting', () => { + it('⭐ CONTROL: NO tenancy service at all — the overwhelmingly common single-kernel shape — boots untouched', async () => { + const kernel = makeObjectKernel(); + const r = await bootRealPluginOn(kernel); + + expect(r.error).toBeUndefined(); + expect(r.booted).toBe(true); + + await kernel.shutdown(); + }); + + it('CONTROL: a `single` tenancy posture boots — no wall is configured, so nothing is being pretended', async () => { + const kernel = makeObjectKernel(); + const r = await bootRealPluginOn(kernel, (ctx) => { + ctx.registerService('tenancy', singleTenancy()); + }); + + expect(r.error).toBeUndefined(); + expect(r.booted).toBe(true); + + await kernel.shutdown(); + }); + + it('⭐ CONTROL: an `isolated` posture WITH a kernel-manager boots — that wiring CAN carry a posture', async () => { + // This is the leg that proves the refusal keys on the WIRING and not + // merely on the posture. Same tenancy service, same posture, opposite + // answer, because `computeExecCtx` reads the posture on this path. + const kernel = makeObjectKernel(); + const r = await bootRealPluginOn(kernel, (ctx) => { + ctx.registerService('tenancy', isolatedTenancy()); + ctx.registerService('kernel-manager', { getOrCreate: async () => kernel }); + }); + + expect(r.error).toBeUndefined(); + expect(r.booted).toBe(true); + + await kernel.shutdown(); + }); + + it('CONTROL: a tenancy service that FAILS TO CONSTRUCT does not refuse — an unreadable posture cannot assert a configured wall', async () => { + // Deliberate, and named as residue on the card: positive knowledge is + // required to refuse. We could not read a posture, so we cannot claim + // one is configured, and refusing here would take down deployments on + // a guess. The condition is logged loudly instead. + const kernel = makeObjectKernel(); + kernel.registerServiceFactory( + 'tenancy', + () => { throw new Error('tenancy store handshake failed'); }, + ServiceLifecycle.SINGLETON, + ); + const r = await bootRealPluginOn(kernel); + + expect(r.error).toBeUndefined(); + expect(r.booted).toBe(true); + + await kernel.shutdown(); + }); +}); From e5347377b26801d9467cad150357bfb8c6524351 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 16:40:12 +0000 Subject: [PATCH 3/6] fix(rest): guard the tenancy seam on the async accessor, and triage one double Two follow-ons from driving the repair against the whole @objectstack/rest suite: - LiteKernel/KernelBase-shaped hosts have no `getServiceAsync`, so the bare dereference would raise an unbranded TypeError and turn "this host shape has no async registry" into a 503. The wiring fact now includes the accessor's presence, mirroring the shipped objectQLProvider's split. - ui-view-environment-ownership.test.ts's kernel double spelled ABSENT services as a bare Error. The real registry brands the never-registered rejection and reserves the unbranded one for a service that failed to construct, so the double was claiming every absent service had broken. It now resolves undefined, the spelling the seam contract itself names for absence. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- .../execctx-authz-input-seams-fail-closed.md | 46 +++++++++++++++++++ ...cctx-authz-input-seam-reachability.test.ts | 10 ++-- packages/rest/src/rest-server.ts | 12 ++++- .../src/ui-view-environment-ownership.test.ts | 25 ++++++++-- 4 files changed, 86 insertions(+), 7 deletions(-) create mode 100644 .changeset/execctx-authz-input-seams-fail-closed.md diff --git a/.changeset/execctx-authz-input-seams-fail-closed.md b/.changeset/execctx-authz-input-seams-fail-closed.md new file mode 100644 index 0000000000..aa0036db03 --- /dev/null +++ b/.changeset/execctx-authz-input-seams-fail-closed.md @@ -0,0 +1,46 @@ +--- +'@objectstack/rest': minor +--- + +REST no longer reads a FAILED authorization-input lookup as "this check does not apply" — the tenancy-posture and ADR-0069 auth-gate seams in `computeExecCtx` fail closed, and a wall-enforcing posture is refused at boot on wiring that cannot enforce it + +Two seams inside `RestServer.computeExecCtx` absorbed a FAILURE into the same `undefined` an +ABSENT wiring produces, and both feed authorization inputs. Unlike the sibling repairs in this +family — where an unknown was answered as a REFUSAL — these two pointed the other way: a failure +read as *permissive*, so a refusal was SKIPPED rather than produced. Driven on a real +`ObjectKernel`, each fault beside a positive control that is the same fixture with the one fault +removed: + +| wiring | before | after | +|:--|:--|:--| +| healthy `isolated` tenancy, ex-member's org-stamped API key | 401 refused | 401 — unchanged | +| `tenancy` never registered (supported no-tenancy composition) | 200 served | 200 — unchanged | +| `tenancy` registered and FAILED to construct | **200 served, full grants** | **503** | +| auth gate INACTIVE | admitted | admitted — unchanged | +| auth gate ACTIVE, healthy re-read, gated user | 403 | 403 — unchanged | +| `isAuthGateActive()` itself THROWS | admitted | admitted — unchanged | +| auth gate ACTIVE, session re-read FAILS | **admitted, no wire trace** | **503** | + +- **Tenancy posture.** Only the service registry's *branded* "never registered" rejection is + absorbed — the `isServiceNotRegisteredError` discriminator the shipped `objectQLProvider` + already uses one layer down. Every other rejection (a factory that threw, a scoped registration + resolved without a scope id, a circular service dependency) raises the same loud + `AuthzStoreUnavailableError` the data-engine seam raises, so the door answers a server-side + outage instead of serving the request. The classification is the registry's, never message text. + The WIRING fact is taken from the kernel's presence and never inferred from what the read + returned. +- **Single-kernel wiring.** On deployments with no `kernel-manager` service, `computeExecCtx` + never reads a tenancy posture at all — measured with a recording factory, invocation count 0 — + so a healthy, correctly-configured, wall-enforcing tenancy service enforced nothing there, with + no failure required. The REST plugin now **refuses to start** in exactly that composition, naming + the configured posture and how to fix it, rather than serving requests that silently skip the + Layer 0 organization wall. Deployments with no tenancy service, with a `single` posture, or with a + kernel-manager are untouched. +- **ADR-0069 auth gate.** Fails closed in one precisely measured window only: `isAuthGateActive()` + answered `true` **and** the gate's session re-read then failed. A gate the deployment declared + active no longer vanishes silently. The common inactive path, a probe that throws, and a + successful re-read carrying no gate all keep their existing behaviour. + +Operators running a wall-enforcing tenancy posture on a single-kernel REST deployment must mount a +kernel-manager service or set the posture to `single`; that composition was never enforcing the +wall it declared, and now says so at boot instead of at audit time. diff --git a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts index 5867dc756c..dcdbf0e6a5 100644 --- a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts +++ b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts @@ -156,9 +156,13 @@ describe('[#13906] §0 — the two seams are LIVE on today\'s tree, by symbol', expect(body).not.toMatch(/catch\s*\{\s*\n\s*tenancyPosture = undefined;/); // The discriminator is the REGISTRY's brand, never message text (#13905). expect(body).toMatch(/if \(!isServiceNotRegisteredError\(err\)\) \{\s*\n\s*throw new AuthzStoreUnavailableError\('tenancy', err\);/); - // ⛔ And the WIRING fact is asked of `kernel`'s presence, never inferred - // from the returned value — the #13476 discipline this repair inherits. - expect(body).toMatch(/let tenancyPosture;\s*\n\s*if \(kernel\) \{/); + // ⛔ And the WIRING fact is asked of `kernel`'s presence AND of the async + // accessor's — never inferred from the returned value (the #13476 + // discipline this repair inherits). The accessor half matters on its own: + // a `KernelBase`-shaped host (`LiteKernel`) has no `getServiceAsync`, and + // without this guard the dereference would raise an unbranded `TypeError` + // and turn that host shape into a 503. + expect(body).toMatch(/let tenancyPosture;\s*\n\s*if \(kernel && typeof kernel\.getServiceAsync === 'function'\) \{/); }); it('REPAIRED [decision 2 B]: the auth-gate seam fails closed in the measured window only', () => { diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index df951e998e..d6b2f2a748 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -2388,8 +2388,18 @@ export class RestServer { // answer. That path carries no posture at all; its half of this // ruling (decision 1 option B′) is refused at BOOT instead — see // `rest-api-plugin.ts`. + // + // ⚠️ The ASYNC ACCESSOR's presence is part of the wiring fact, for + // the same reason the shipped `objectQLProvider` splits on it: a + // `KernelBase`-shaped host (`LiteKernel`) has no `getServiceAsync` + // at all, so dereferencing it would raise a `TypeError` — unbranded, + // and therefore LOUD — turning "this host shape has no async + // registry" into an outage. Such a host also has no service + // factories (`registerServiceFactory` throws "not supported"), so + // absence is the only fault it could report anyway. It keeps the + // previous quiet answer, unchanged. let tenancyPosture; - if (kernel) { + if (kernel && typeof kernel.getServiceAsync === 'function') { try { tenancyPosture = effectiveTenancyPosture(await kernel.getServiceAsync('tenancy') as any); } catch (err) { diff --git a/packages/rest/src/ui-view-environment-ownership.test.ts b/packages/rest/src/ui-view-environment-ownership.test.ts index 79eaa58c75..db8e905f0a 100644 --- a/packages/rest/src/ui-view-environment-ownership.test.ts +++ b/packages/rest/src/ui-view-environment-ownership.test.ts @@ -116,9 +116,28 @@ function kernelManagerFor(spec: Record) { } if (name === 'objectql') return emptyQl() as T; if (name === 'protocol') return protocolFor(entry?.schema ?? null) as T; - // `i18n`, `tenancy`, `settings`, … — absent, which every - // caller in `computeExecCtx` treats as best-effort. - throw new Error(`no ${name} service`); + // `i18n`, `tenancy`, `settings`, … — ABSENT from this + // environment's kernel. + // + // [#13906] Absence is spelled as a RESOLVED `undefined`, + // ⛔ no longer as a bare `Error`. The seam contract names + // that spelling itself (`wiredEngineOrLoud`: "a provider + // that RESOLVES `undefined` still means no engine, quietly + // — that is the seam contract declaring absence, not + // failing"), and the double now says the fact it always + // MEANT rather than one the registry never produces for + // absence: a real `getServiceAsync` rejects for an + // unregistered name with a BRANDED rejection (#13905), and + // reserves the bare, unbranded rejection for a service that + // IS registered and FAILED TO CONSTRUCT. + // + // Under the old collapse the difference was invisible, so + // the inaccuracy was free. It is not free now: the tenancy + // seam classifies an unbranded rejection as the outage it + // is, and this double was claiming every absent service had + // broken. ⛔ Do not "restore" the throw — that reintroduces + // a fake reporting a fault it does not have. + return undefined as T; }, } as any; }, From 34a1fbce8c08b484b3f7ad3a50c268c2f118de19 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 17:25:12 +0000 Subject: [PATCH 4/6] chore(rest): keep tracker ids out of runtime strings, re-anchor the census MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit check:doc-authoring refuses `#NNNN` inside runtime prose (maintainer ruling 2026-08-12) — an operator reading a boot failure cannot resolve one. The ids move to adjacent source comments and the boot message asserts its REMEDY instead. check-system-context-census --fix re-anchors ten line citations in content/docs/permissions/system-context.mdx that this PR's insertions shifted. Pure line rot; no elevation behaviour changed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- content/docs/permissions/system-context.mdx | 8 ++++---- .../rest-api-plugin-tenancy-posture-boot-refusal.test.ts | 8 +++++++- packages/rest/src/rest-api-plugin.ts | 8 ++++++-- 3 files changed, 17 insertions(+), 7 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 3e311b82dc..f1610cdb76 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -64,7 +64,7 @@ not on any flag. ## How the flag is set `isSystem` is **server-constructed and never client-supplied**. Inbound HTTP -cannot set it (`packages/rest/src/rest-server.ts:1520`, `:1549`), and neither +cannot set it (`packages/rest/src/rest-server.ts:1524`, `:1553`), and neither can an action body (`packages/runtime/src/domains/actions.ts:404`). It is written by internal callers only, as an option on the engine call: @@ -103,7 +103,7 @@ that silently does not happen. | 14 | MCP stdio bridge skips the object API-exposure gate | mcp | Get: the bridge reaches objects whose `apiEnabled` / `apiMethods` would refuse an external caller | `stdio-data-bridge.ts:246` | | 15 | **Read-audit rows are not written** | plugin-audit | Lose: the "a person opened this record" trail. `sudo()` keeps the caller's `userId`, so this flag is the only thing separating a human read from a platform one | `read-audit.ts:556` | | 16 | Approval snapshot payload redaction skipped | plugin-approvals | Get: the whole snapshot on `find` / `findOne` — the audit/replay channel. Lose: field-visibility redaction over approval payloads | `payload-redaction-middleware.ts:115` | -| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1552` | +| 17 | REST anonymous-deny seam satisfied | rest | Get: `enforceAuth` passes with no `userId`. Not reachable from the wire — `isSystem` is never set on an inbound request | `rest-server.ts:1556` | ### 2. Write pipeline and data integrity @@ -158,7 +158,7 @@ The largest single consumer — **17 of the 106 sites**. |:--|:---|:---|:---|:---| | 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` | | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` | -| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4715`, `:6078`, `:6326`, `:6757`, `:6950` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4814`, `:6177`, `:6425`, `:6856`, `:7049` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | | 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:326`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | @@ -199,7 +199,7 @@ assuming `isSystem` covers it is a documented source of bugs. | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1537` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` | -| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1520`, `:1549`; `domains/actions.ts:404` | +| "A client can request it" | **No.** Never settable from inbound HTTP or from an action body | `rest-server.ts:1524`, `:1553`; `domains/actions.ts:404` | --- diff --git a/packages/rest/src/rest-api-plugin-tenancy-posture-boot-refusal.test.ts b/packages/rest/src/rest-api-plugin-tenancy-posture-boot-refusal.test.ts index 9f3cfae0f9..1136ebeb68 100644 --- a/packages/rest/src/rest-api-plugin-tenancy-posture-boot-refusal.test.ts +++ b/packages/rest/src/rest-api-plugin-tenancy-posture-boot-refusal.test.ts @@ -209,7 +209,13 @@ describe('[#13906] §2 — B′: the boot refusal', () => { expect(text).toContain('isolated'); expect(text).toContain('kernel-manager'); expect(text).toMatch(/never reads a tenancy posture/i); - expect(text).toContain('#13906'); + // ⛔ Deliberately NOT asserting a tracker id: `check:doc-authoring` + // forbids `#NNNN` in runtime strings (maintainer ruling 2026-08-12 — + // an operator reading a boot failure cannot resolve one). The id lives + // in the adjacent source comment instead. Assert the REMEDY instead, + // which is what the message owes its reader. + expect(text).toMatch(/organization wall/i); + expect(text).toMatch(/`single`/); await kernel.shutdown().catch(() => undefined); }); diff --git a/packages/rest/src/rest-api-plugin.ts b/packages/rest/src/rest-api-plugin.ts index 0ee96e691c..f27f2f5457 100644 --- a/packages/rest/src/rest-api-plugin.ts +++ b/packages/rest/src/rest-api-plugin.ts @@ -232,7 +232,7 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { ctx.logger.error( '[security] RestApiPlugin: the `tenancy` service could not be read at boot, so it ' + 'could not be checked against this single-kernel wiring, which carries NO tenancy ' - + 'posture into request authorization (#13906). If a wall-enforcing posture is ' + + 'posture into request authorization. If a wall-enforcing posture is ' + 'configured here, it is NOT being enforced.', err as any, ); @@ -242,13 +242,17 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { } const bootPosture = effectiveTenancyPosture(tenancySource as any); if (bootPosture && postureEnforcesWall(bootPosture)) { + // ⛔ The tracker id stays in THIS comment and out of the + // runtime string: an operator reading a boot failure cannot + // resolve `#NNNN` (maintainer ruling 2026-08-12). The + // reference is #13906 / the 2026-09-02 ruling, decision 1 B′. throw new Error( `[security] RestApiPlugin refuses to start: the kernel's \`tenancy\` service reports the ` + `wall-enforcing posture \`${bootPosture}\`, but this deployment has no \`kernel-manager\` ` + `service, so the REST transport resolves every request through the single-kernel ` + `providers and NEVER reads a tenancy posture. The Layer 0 organization wall — including ` + `the \`organization_required\` and \`organization_membership_ended\` API-key refusals — ` - + `would silently not be enforced (#13906, maintainer ruling 2026-09-02 decision 1 B'). ` + + `would silently not be enforced. ` + `Fix by mounting a kernel-manager service (the multi-environment wiring that can carry a ` + `posture), or by setting the tenancy posture to \`single\` if this deployment is not ` + `meant to run an organization wall.`, From 9de1b0ef34b25931ee63c828ce1db5d5a2652e24 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 20:31:18 +0000 Subject: [PATCH 5/6] chore(docs): re-anchor the system-context census after merging main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge deferred the generated `content/docs/permissions/system-context.mdx` to the merge driver (AGENTS.md §11); this commit discharges it by regenerating from the merged tree with the gate's own `--fix`. Line re-anchoring only — 20 anchors re-pointed, no elevation behaviour touched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016yfqQh2dBgPAymYd7xipza --- content/docs/permissions/system-context.mdx | 26 ++++++++++----------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index f1610cdb76..f5d5111a7a 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -109,18 +109,18 @@ that silently does not happen. | # | Behaviour when `isSystem` | Package | What you get / what you lose | Anchor | |:--|:---|:---|:---|:---| -| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11289` | -| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11472` | -| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10024` | +| 18 | **`readonly` strip bypassed — UPDATE, single row** | objectql | Get: a `readonly` field CAN be written. Lose: the protection that stops a caller seeding e.g. `approval_status` | `objectql/src/engine.ts:11290` | +| 19 | **`readonly` strip bypassed — UPDATE, bulk/predicate** | objectql | Same, on the multi-row path | `objectql/src/engine.ts:11473` | +| 20 | **`readonly` strip bypassed — INSERT (engine pass)** | objectql | Same, on create | `objectql/src/engine.ts:10025` | | 21 | **`readonly` strip bypassed — INSERT (protocol ingress)** | metadata-protocol | `isSystem` is the **only** exemption here. `preserveAudit` is deliberately not read on this path (#6640) — a non-system historical import is still stripped on create | `metadata-protocol/src/protocol.ts:1746` | -| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10072`, `readonly-strict-errors.ts:66` | -| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5891` | -| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3735`, `:3745`, `:3772` | +| 22 | Strict-drop refusal never fires | objectql | Lose: a caller that opted into loud refusal gets **silence** — strict refuses exactly what the strip would have taken, and the strip took nothing | `objectql/src/engine.ts:10073`, `readonly-strict-errors.ts:66` | +| 23 | **Referential-integrity check skipped** | objectql | Get: writes proceed against unreachable/unresolvable targets. Lose: an `isSystem` caller can write a **dangling reference** | `objectql/src/engine.ts:5892` | +| 24 | Tenant-audit warning silenced; `bypassTenantAudit` threaded to the driver | objectql | Get: unscoped system writes stop warning. Lose: the signal that would flag a genuine user-path scoping bug | `objectql/src/engine.ts:3736`, `:3746`, `:3773` | | 25 | Engine-owned / append-only write guard bypassed | plugin-security | Get: generic writes to `managedBy` engine-owned objects | `system-write-guard.ts:96`, `:120` | | 26 | Identity write guard bypassed (ADR-0092) | plugin-auth | Get: direct writes to identity tables through the generic data path | `identity-write-guard.ts:99` | -| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6589` | -| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12084` | -| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:12013` | +| 27 | Search-companion column **kept** in a read's rows when it was explicitly requested | objectql | Get: the internal companion column is readable. Lose: nothing for app code — this is the engine reading its own index | `objectql/src/engine.ts:6590` | +| 28 | Dependent-count disclosure on a blocked delete | objectql | Get: the count of blocking children. Nothing was elevated past the caller, so nothing is withheld | `objectql/src/engine.ts:12085` | +| 29 | Reference-cleanup log attributes the write to `'system'` | objectql | Get: an honest actor label instead of `anonymous` when the context carries neither `userId` nor `actor` | `objectql/src/engine.ts:12014` | ### 3. Sharing (`plugin-sharing`) @@ -158,7 +158,7 @@ The largest single consumer — **17 of the 106 sites**. |:--|:---|:---|:---|:---| | 48 | Object API-exposure gate bypassed (`apiEnabled` / `apiMethods`) | runtime | Get: internal self-writes ignore exposure declarations — these govern **external** exposure, not engine self-writes | `action-execution.ts:138` | | 49 | Action `requiredPermissions` bypassed | runtime | Get: engine self-invocation runs any action | `action-execution.ts:401` | -| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4814`, `:6177`, `:6425`, `:6856`, `:7049` | +| 50 | `manage_metadata` bypassed on metadata writes | runtime, rest | Get: schema writes without the capability | `domains/meta.ts:471`, `:874`, `rest-server.ts:4888`, `:6302`, `:6550`, `:6981`, `:7174` | | 51 | The shared metadata-write verdict itself returns `allowed` | metadata-core | Get: the one function all of row 50's doors consult answers yes before any capability is examined | `meta-write-capability.ts:134` | | 52 | Anonymous-deny seam satisfied on the domain dispatchers and the package/federation routes | runtime, rest | Get: passes with no `userId` | `domains/actions.ts:411`, `domains/ai.ts:60`, `domains/automation.ts:989`, `domains/meta.ts:232`, `domains/security.ts:78`, `domains/packages.ts:326`, `external-datasource-routes.ts:302`, `package-routes.ts:97` | | 53 | MCP principal check satisfied | runtime | Get: MCP surface reachable with no user | `domains/mcp.ts:61` | @@ -179,8 +179,8 @@ a reader tracing where elevation travels needs them. | # | Site | Package | What it does | |:--|:---|:---|:---| -| 62 | `objectql/src/engine.ts:3542` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | -| 63 | `objectql/src/engine.ts:14433` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | +| 62 | `objectql/src/engine.ts:3543` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes | +| 63 | `objectql/src/engine.ts:14463` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag | | 64 | `plugin-reports/src/report-service.ts:556` | plugin-reports | Threads the flag into the engine call that runs a report | | 65 | `body-runner.ts:279` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across | @@ -195,7 +195,7 @@ assuming `isSystem` covers it is a documented source of bugs. |:---|:---|:---| | "It suppresses triggers / record-change automation" | **No.** Only `skipTriggers` does. A bare `{ isSystem: true }` on a seed write re-fired automation on freshly seeded rows and wedged first boot | `metadata-protocol/src/seed-loader.ts:1971` (rationale at `:1881`–`1883`, #3760), `flow.zod.ts:702` | | "It skips the state machine" | **No.** That is `skipStateMachine`, carried by seed replay and by `treatAsHistorical` imports | `objectql/src/engine.ts` FSM gate; see [State Machine](/docs/protocol/objectql/state-machine) | -| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10007`–`10024` | +| "It skips validation rules" | **No.** Field shape, `format`, `script` and the rest still run. The `readonly` strip runs *before* validation precisely so a discarded value is not judged | `objectql/src/engine.ts:10008`–`10025` | | "It preserves a supplied `updated_at` / `updated_by`" | **No.** That is `preserveAudit`, a separate opt-in — and an UPDATE-path exemption only | `field.zod.ts:1537` (#3493 / #6640) | | "It stamps `created_by`" | **No.** Audit stamping reads `userId` from the context. A user-less system write stamps nothing — that is today's behaviour, not an error | `runtime-identity.ts:280`–`281` | | "It bypasses every guard" | **No.** The last-admin guard applies to **every** context, `isSystem` included — the deprovision path that actually locks an org out is the system one | `last-admin-guard.ts:299` | From 7c71ea64b61b76b48ec40309281982163bad83a4 Mon Sep 17 00:00:00 2001 From: Jack Zhuang <50353452+hotlong@users.noreply.github.com> Date: Fri, 4 Sep 2026 12:19:37 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(rest):=20withdraw=20the=20single-kernel?= =?UTF-8?q?=20boot=20refusal=20(B=E2=80=B2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decision 1 narrows to A alone, per the maintainer ruling of 2026-09-04 recorded on the card; the measurement B′ was asking for moves to #15163. `RestApiPlugin` no longer refuses to start when a wall-enforcing tenancy posture is configured on a deployment with no `kernel-manager` service. `packages/rest/src/rest-api-plugin.ts` is now byte-identical to `main`. Why the refusal goes, from the CI triage on this branch: the only registrar of a `kernel-manager` service in this repository was B′'s own narrowness control test, so the refusal fired on every real walled composition the open core can build — the `os serve` process under an `isolated` posture, the ADR-0105 `bootStack multiTenant` harness, and seven dogfood suites — for `group` as well as `isolated`. Its premise was also false wherever it fired: a wall-enforcing effective posture requires `org-scoping`, which is exactly what keeps the platform's `organization_id` row policies standing (ADR-0105 D3), so the Layer 0 row wall the message claimed was unenforced was in fact standing in every case the refusal could reach. Decision 1 A (a registered-but-failed `tenancy` service answers 503 in `computeExecCtx`) and decision 2 B (an active ADR-0069 auth gate whose session re-read fails answers 503) are untouched — both stand exactly as ruled on 2026-09-02. - delete the boot-refusal block and its two now-unused imports (`effectiveTenancyPosture`, `postureEnforcesWall`); `isServiceNotRegisteredError` stays, used by the pre-existing objectql provider one layer down - delete `rest-api-plugin-tenancy-posture-boot-refusal.test.ts` — every one of its 2 refusal tests and 4 narrowness controls measures a behaviour that no longer exists - re-aim the phase-1 pin file's header: the ruling it records is now A alone, the dangling pointer to the deleted file is replaced by the withdrawal note, and §3's "the provider wiring still measures 200" reading is restated as CORRECT and PINNED — it is #15163's subject, not a regression introduced here - drop the B′ paragraphs from the changeset; the 503 rows stay ⛔ Not done, deliberately: B′ is not replaced by a warning, a softer refusal, an env escape hatch, or a narrowed condition. The ruling moved the question to a measurement card; the code now says nothing about single-kernel posture at boot. Part of #13906 Co-Authored-By: Claude Fable 5.1 --- .../execctx-authz-input-seams-fail-closed.md | 16 +- ...cctx-authz-input-seam-reachability.test.ts | 36 ++- ...lugin-tenancy-posture-boot-refusal.test.ts | 300 ------------------ packages/rest/src/rest-api-plugin.ts | 87 +---- 4 files changed, 32 insertions(+), 407 deletions(-) delete mode 100644 packages/rest/src/rest-api-plugin-tenancy-posture-boot-refusal.test.ts diff --git a/.changeset/execctx-authz-input-seams-fail-closed.md b/.changeset/execctx-authz-input-seams-fail-closed.md index aa0036db03..2019ba3c0e 100644 --- a/.changeset/execctx-authz-input-seams-fail-closed.md +++ b/.changeset/execctx-authz-input-seams-fail-closed.md @@ -2,7 +2,7 @@ '@objectstack/rest': minor --- -REST no longer reads a FAILED authorization-input lookup as "this check does not apply" — the tenancy-posture and ADR-0069 auth-gate seams in `computeExecCtx` fail closed, and a wall-enforcing posture is refused at boot on wiring that cannot enforce it +REST no longer reads a FAILED authorization-input lookup as "this check does not apply" — the tenancy-posture and ADR-0069 auth-gate seams in `computeExecCtx` fail closed Two seams inside `RestServer.computeExecCtx` absorbed a FAILURE into the same `undefined` an ABSENT wiring produces, and both feed authorization inputs. Unlike the sibling repairs in this @@ -29,18 +29,12 @@ removed: outage instead of serving the request. The classification is the registry's, never message text. The WIRING fact is taken from the kernel's presence and never inferred from what the read returned. -- **Single-kernel wiring.** On deployments with no `kernel-manager` service, `computeExecCtx` - never reads a tenancy posture at all — measured with a recording factory, invocation count 0 — - so a healthy, correctly-configured, wall-enforcing tenancy service enforced nothing there, with - no failure required. The REST plugin now **refuses to start** in exactly that composition, naming - the configured posture and how to fix it, rather than serving requests that silently skip the - Layer 0 organization wall. Deployments with no tenancy service, with a `single` posture, or with a - kernel-manager are untouched. - **ADR-0069 auth gate.** Fails closed in one precisely measured window only: `isAuthGateActive()` answered `true` **and** the gate's session re-read then failed. A gate the deployment declared active no longer vanishes silently. The common inactive path, a probe that throws, and a successful re-read carrying no gate all keep their existing behaviour. -Operators running a wall-enforcing tenancy posture on a single-kernel REST deployment must mount a -kernel-manager service or set the posture to `single`; that composition was never enforcing the -wall it declared, and now says so at boot instead of at audit time. +Boot behaviour is unchanged: no composition that starts today stops starting. Single-kernel REST +deployments — the wiring with no `kernel-manager` service — keep their current behaviour exactly, +including the fact that `computeExecCtx` reads no tenancy posture there. What that wiring actually +skips is being measured separately and is not changed here. diff --git a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts index dcdbf0e6a5..ec60cd0775 100644 --- a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts +++ b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts @@ -16,14 +16,15 @@ * **Maintainer ruling, 2026-09-02** (director seat, summon #8; verbatim * 「14324 等我发版,其他同意」 adopting the recommendation as presented): * - * - **Decision 1 — tenancy posture: A + B′.** A: absorb ONLY the branded + * - **Decision 1 — tenancy posture: A.** Absorb ONLY the branded * not-registered rejection (`isServiceNotRegisteredError`, the * discriminator the shipped `objectQLProvider` already uses one layer * down); every unbranded failure fails closed instead of collapsing into - * the absent-posture path. B′: on the single-kernel provider wiring a - * configured wall-enforcing posture is refused LOUDLY AT BOOT — that - * wiring cannot enforce it, so it must not pretend to. ⛔ Option B (wiring - * a tenancy provider into the single-kernel path) was NOT taken. + * the absent-posture path. ⛔ Option B (wiring a tenancy provider into the + * single-kernel path) was NOT taken. ⭐ The 2026-09-02 ruling also carried + * a B′ half — a BOOT refusal of a wall-enforcing posture on the + * single-kernel wiring — and the **2026-09-04 ruling WITHDREW it**; see + * the note below. * - **Decision 2 — ADR-0069 auth gate: B.** Fail closed in the measured * window ONLY: `isAuthGateActive()` answered `true` AND the gate re-read * then failed. ⛔ The common inactive path, and a probe that throws, are @@ -34,11 +35,26 @@ * not widen either repair on the strength of this file — the narrowness * controls below exist precisely to make widening fail. * - * B′'s own half is driven in `rest-api-plugin-tenancy-posture-boot-refusal - * .test.ts`, because it is a BOOT refusal in the plugin rather than a - * request-time seam. ⚠️ Consequently §3 below still measures 200 on the - * provider wiring and that is CORRECT: B′ refuses the composition at boot, it - * does not change `computeExecCtx`, which still never reads a posture there. + * **Maintainer ruling, 2026-09-04** (live chat, verbatim + * 「按照你的建议,你帮我跟进处理15020」, recorded on the card): + * decision 1 narrows to **A alone**; **B′ is WITHDRAWN** and its plugin block + * and dedicated test file are removed. It supersedes only the B′ half of the + * 2026-09-02 ruling — A and decision 2 stand exactly as ruled there. + * + * Why: the only registrar of a `kernel-manager` service in this repository was + * B′'s own narrowness control, so the refusal fired on every real walled + * composition the open core can build (`os serve` under `isolated`, the + * ADR-0105 harness, seven dogfood suites), and its premise was false where it + * fired — a wall-enforcing effective posture REQUIRES `org-scoping`, which is + * exactly what keeps the platform's `organization_id` row policies standing + * (ADR-0105 D3). B′'s underlying question — what the single-kernel provider + * wiring actually skips — survives as its own measurement card, #15163. + * + * ⚠️ §3 below therefore measures 200 on the provider wiring, and that stays + * CORRECT and stays PINNED: withdrawing B′ returns that wiring to `main`'s + * behaviour, and neither ruling changed `computeExecCtx`, which still never + * reads a posture there. ⛔ That 200 is the subject of #15163, not a + * regression introduced here. * * ## Why this card is not its siblings, and why the direction matters * diff --git a/packages/rest/src/rest-api-plugin-tenancy-posture-boot-refusal.test.ts b/packages/rest/src/rest-api-plugin-tenancy-posture-boot-refusal.test.ts deleted file mode 100644 index 1136ebeb68..0000000000 --- a/packages/rest/src/rest-api-plugin-tenancy-posture-boot-refusal.test.ts +++ /dev/null @@ -1,300 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#13906 — maintainer ruling 2026-09-02, decision 1 option B′] The - * single-kernel provider wiring REFUSES a configured wall-enforcing tenancy - * posture at BOOT, because it cannot enforce one. - * - * ## Why a boot refusal and not a seam repair - * - * `RestServer.computeExecCtx` reads the tenancy posture off its local `kernel` - * variable, and that variable is assigned on the `kernelManager` branches ONLY. - * With no kernel-manager the transport resolves auth and data through the - * injected providers, `kernel` stays `undefined`, and the posture is never - * asked for. Phase 1 drove that on one real `ObjectKernel` carrying a healthy - * `isolated` tenancy behind a RECORDING factory, wired both ways, same - * ex-member org-stamped key: - * - * | wiring | door | tenancy factory invocations | - * |:--|:--|:--| - * | via `kernelManager` | 401 refused | 1 | - * | via the provider wiring | **200 served** | **0** | - * - * ⇒ NOT "absent on failure" — never read. A healthy, correctly-configured, - * wall-enforcing tenancy service enforces nothing there, and no failure is - * required to reach that state. There is therefore no posture to repair at - * request time, which is why the ruling put the answer at boot: tell the - * deployment that what it configured is not being enforced, instead of letting - * it find out from a served request. - * - * ⚠️ §3 of `execctx-authz-input-seam-reachability.test.ts` still measures 200 - * on the provider wiring, and that stays CORRECT: this refuses the - * COMPOSITION at boot, it does not change `computeExecCtx`. - * - * ⛔ Option B — wiring a tenancy provider into the single-kernel path — was NOT - * taken. It needs a product answer (should these deployments run walled - * postures at all?) that the ruling explicitly declined to pre-empt. - * - * ## §1 is the ruling's OWN opening question, driven - * - * The ruling made B′ conditional: *"B′ opens with a measurement: can a walled - * posture be configured on that wiring at all? If it cannot, B′ reduces to - * documenting that the single-kernel wiring carries no posture."* §1 answers - * it — YES, it can — which is what makes B′ a refusal rather than a doc note. - * ⛔ Do not delete §1 as redundant: it is the precondition of everything below. - */ - -import { describe, it, expect, vi } from 'vitest'; -import { ObjectKernel, ServiceLifecycle, effectiveTenancyPosture } from '@objectstack/core'; -import type { PluginContext } from '@objectstack/core'; -import { postureEnforcesWall } from '@objectstack/spec/security'; - -const captured = vi.hoisted(() => ({ ctorArgs: [] as unknown[][] })); - -// The same double the sibling plugin tests use: EXTENDS the real class so the -// composition root runs production code, and only suppresses route -// registration. Route registration is not what is under measurement here. -vi.mock('./rest-server.js', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - RestServer: class extends actual.RestServer { - constructor(...args: unknown[]) { - super(...(args as ConstructorParameters)); - captured.ctorArgs.push(args); - } - override registerRoutes(): void { - /* not under test */ - } - }, - }; -}); - -const { createRestApiPlugin } = await import('./rest-api-plugin.js'); - -function mockHttpServer() { - return { - get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), - use: vi.fn(), - listen: vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - }; -} - -function makeObjectKernel(): ObjectKernel { - return new ObjectKernel({ - logger: { level: 'error' }, - gracefulShutdown: false, - skipSystemValidation: true, - }); -} - -/** - * Boot the REAL rest plugin on a real kernel. `wire` runs in a host plugin's - * init BEFORE the rest plugin starts — where a real composition registers its - * services. Returns whether a `RestServer` was constructed, which is the - * observable that says whether the door came up at all. - */ -async function bootRealPluginOn( - kernel: ObjectKernel, - wire?: (ctx: PluginContext) => void, -): Promise<{ booted: boolean; error?: unknown }> { - captured.ctorArgs.length = 0; - await kernel.use({ - name: 'test.host.wiring', - version: '1.0.0', - init: async (ctx: PluginContext) => { - ctx.registerService('http.server', mockHttpServer()); - ctx.registerService('protocol', {}); - wire?.(ctx); - }, - }); - await kernel.use(createRestApiPlugin()); - try { - await kernel.bootstrap(); - return { booted: captured.ctorArgs.length === 1 }; - } catch (error) { - return { booted: false, error }; - } -} - -/** The shape `effectiveTenancyPosture` reads — structural, as core declares it. */ -const isolatedTenancy = () => ({ posture: 'isolated', isolationActive: true }); -const singleTenancy = () => ({ posture: 'single', isolationActive: false }); - -/** Flatten a boot rejection to the text an operator would actually read. */ -function bootText(error: unknown): string { - const parts: string[] = []; - let cur: any = error; - for (let i = 0; cur && i < 5; i++) { - if (cur.message) parts.push(String(cur.message)); - cur = cur.cause; - } - return parts.join(' || '); -} - -// --------------------------------------------------------------------------- -// §1 — the ruling's opening measurement: CAN a walled posture be configured on -// this wiring at all? If not, B′ collapses to documentation. -// --------------------------------------------------------------------------- - -describe('[#13906] §1 — B′ opening measurement: is a walled posture configurable on the single-kernel wiring?', () => { - it('YES: a kernel with NO kernel-manager still carries a readable, wall-enforcing tenancy posture', async () => { - // Nothing about registering a tenancy service depends on a - // kernel-manager: the service is a kernel service like any other, and - // it reconciles to `isolated` exactly as it would on the multi-kernel - // wiring. So the misconfiguration B′ refuses is REACHABLE — the - // deployment can, and this proves it does, hold a wall it is not - // enforcing. - const kernel = makeObjectKernel(); - await kernel.use({ - name: 'test.tenancy.host', - version: '1.0.0', - init: async (ctx: PluginContext) => { ctx.registerService('tenancy', isolatedTenancy()); }, - }); - await kernel.bootstrap(); - - // Read it the way the transport would, through the same helper. - const tenancy = await kernel.getServiceAsync('tenancy'); - const posture = effectiveTenancyPosture(tenancy as any); - expect(posture).toBe('isolated'); - expect(postureEnforcesWall(posture!)).toBe(true); - - // ⛔ And no kernel-manager exists here — the condition that makes the - // transport blind to the posture just read. - await expect(kernel.getServiceAsync('kernel-manager')).rejects.toBeDefined(); - - await kernel.shutdown(); - }); - - it('POSITIVE CONTROL for the criterion: a `single` posture on the SAME wiring does NOT enforce a wall', async () => { - // Without this leg, "the posture enforces a wall" could be an artifact - // of the reader rather than a fact about the configuration. - const kernel = makeObjectKernel(); - await kernel.use({ - name: 'test.tenancy.host', - version: '1.0.0', - init: async (ctx: PluginContext) => { ctx.registerService('tenancy', singleTenancy()); }, - }); - await kernel.bootstrap(); - - const posture = effectiveTenancyPosture(await kernel.getServiceAsync('tenancy') as any); - expect(posture).toBe('single'); - expect(postureEnforcesWall(posture!)).toBe(false); - - await kernel.shutdown(); - }); -}); - -// --------------------------------------------------------------------------- -// §2 — the refusal itself. -// --------------------------------------------------------------------------- - -describe('[#13906] §2 — B′: the boot refusal', () => { - it('⭐ REFUSES: wall-enforcing posture + no kernel-manager → the plugin does not start and the door never comes up', async () => { - const kernel = makeObjectKernel(); - const r = await bootRealPluginOn(kernel, (ctx) => { - ctx.registerService('tenancy', isolatedTenancy()); - }); - - expect(r.booted).toBe(false); - expect(r.error).toBeDefined(); - // ⭐ The door must not have been constructed — a refusal that still - // built a serving RestServer would be a log line, not a refusal. - expect(captured.ctorArgs).toHaveLength(0); - - const text = bootText(r.error); - // The message has to carry the three things an operator needs: WHAT is - // configured, WHY it is not enforced, and HOW to fix it. - expect(text).toContain('isolated'); - expect(text).toContain('kernel-manager'); - expect(text).toMatch(/never reads a tenancy posture/i); - // ⛔ Deliberately NOT asserting a tracker id: `check:doc-authoring` - // forbids `#NNNN` in runtime strings (maintainer ruling 2026-08-12 — - // an operator reading a boot failure cannot resolve one). The id lives - // in the adjacent source comment instead. Assert the REMEDY instead, - // which is what the message owes its reader. - expect(text).toMatch(/organization wall/i); - expect(text).toMatch(/`single`/); - - await kernel.shutdown().catch(() => undefined); - }); - - it('the refusal fires on a FACTORY-registered tenancy service too — the shape a real composition uses', async () => { - // Instance registration is the simplest case; a real host commonly - // registers a factory. Both must be seen, or the refusal is trivially - // evaded by the more realistic wiring. - const kernel = makeObjectKernel(); - kernel.registerServiceFactory('tenancy', () => isolatedTenancy(), ServiceLifecycle.SINGLETON); - const r = await bootRealPluginOn(kernel); - - expect(r.booted).toBe(false); - expect(bootText(r.error)).toContain('isolated'); - - await kernel.shutdown().catch(() => undefined); - }); -}); - -// --------------------------------------------------------------------------- -// §3 — narrowness controls. Each of these MUST still boot; together they are -// what stops B′ from becoming "the rest plugin refuses to start". -// --------------------------------------------------------------------------- - -describe('[#13906] §3 — B′ narrowness: what must keep booting', () => { - it('⭐ CONTROL: NO tenancy service at all — the overwhelmingly common single-kernel shape — boots untouched', async () => { - const kernel = makeObjectKernel(); - const r = await bootRealPluginOn(kernel); - - expect(r.error).toBeUndefined(); - expect(r.booted).toBe(true); - - await kernel.shutdown(); - }); - - it('CONTROL: a `single` tenancy posture boots — no wall is configured, so nothing is being pretended', async () => { - const kernel = makeObjectKernel(); - const r = await bootRealPluginOn(kernel, (ctx) => { - ctx.registerService('tenancy', singleTenancy()); - }); - - expect(r.error).toBeUndefined(); - expect(r.booted).toBe(true); - - await kernel.shutdown(); - }); - - it('⭐ CONTROL: an `isolated` posture WITH a kernel-manager boots — that wiring CAN carry a posture', async () => { - // This is the leg that proves the refusal keys on the WIRING and not - // merely on the posture. Same tenancy service, same posture, opposite - // answer, because `computeExecCtx` reads the posture on this path. - const kernel = makeObjectKernel(); - const r = await bootRealPluginOn(kernel, (ctx) => { - ctx.registerService('tenancy', isolatedTenancy()); - ctx.registerService('kernel-manager', { getOrCreate: async () => kernel }); - }); - - expect(r.error).toBeUndefined(); - expect(r.booted).toBe(true); - - await kernel.shutdown(); - }); - - it('CONTROL: a tenancy service that FAILS TO CONSTRUCT does not refuse — an unreadable posture cannot assert a configured wall', async () => { - // Deliberate, and named as residue on the card: positive knowledge is - // required to refuse. We could not read a posture, so we cannot claim - // one is configured, and refusing here would take down deployments on - // a guess. The condition is logged loudly instead. - const kernel = makeObjectKernel(); - kernel.registerServiceFactory( - 'tenancy', - () => { throw new Error('tenancy store handshake failed'); }, - ServiceLifecycle.SINGLETON, - ); - const r = await bootRealPluginOn(kernel); - - expect(r.error).toBeUndefined(); - expect(r.booted).toBe(true); - - await kernel.shutdown(); - }); -}); diff --git a/packages/rest/src/rest-api-plugin.ts b/packages/rest/src/rest-api-plugin.ts index f27f2f5457..e0ef920871 100644 --- a/packages/rest/src/rest-api-plugin.ts +++ b/packages/rest/src/rest-api-plugin.ts @@ -1,9 +1,6 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. -import { Plugin, PluginContext, IHttpServer, isServiceNotRegisteredError, effectiveTenancyPosture } from '@objectstack/core'; -// [#13906] The wall predicate, from its single owner — the same vocabulary -// `resolveAuthzContext` compiles against for the Layer 0 refusals. -import { postureEnforcesWall } from '@objectstack/spec/security'; +import { Plugin, PluginContext, IHttpServer, isServiceNotRegisteredError } from '@objectstack/core'; import { RestServer, RestKernelManager, RestProtocol, RestRequestEnvResolver, RestEnvRegistry } from './rest-server.js'; import { RestServerConfig } from '@objectstack/spec/api'; import { mountAndRecordDirectRoutes } from './direct-mount-composition.js'; @@ -178,88 +175,6 @@ export function createRestApiPlugin(config: RestApiPluginConfig = {}): Plugin { // Single-kernel deployment — fall back to the control protocol } - // [#13906 — maintainer ruling 2026-09-02, decision 1 option B′] - // REFUSE a wall-enforcing tenancy posture on the single-kernel - // provider wiring, loudly, at BOOT — because this wiring cannot - // enforce it and must not pretend to. - // - // ## The measurement this exists to answer - // - // `RestServer.computeExecCtx` reads the posture off the LOCAL - // `kernel` variable, and that variable is assigned on the - // kernelManager branches ONLY. With no kernelManager the transport - // resolves auth and data through the injected providers instead, so - // `kernel` stays `undefined` and the posture is never asked for — - // not merely absent on failure, NEVER READ. Driven on one real - // `ObjectKernel` carrying a healthy `isolated` tenancy behind a - // RECORDING factory, wired both ways, same ex-member org-stamped key: - // - // | wiring | door | tenancy factory invocations | - // |:--|:--|:--| - // | via `kernelManager` | 401 refused | 1 | - // | via the provider wiring | **200 served** | **0** | - // - // ⇒ a healthy, correctly-configured, wall-enforcing tenancy service - // enforces NOTHING on this wiring, and no failure is required to - // reach that state — it is the NORMAL state. That is why the ruling - // put it here rather than in the seam: there is no posture to fix at - // request time, so the honest answer is to refuse the composition. - // - // ⛔ Option B — wiring a tenancy provider into the single-kernel path - // — was NOT taken (it needs a product answer about whether these - // deployments should run walled postures at all). This is B′: the - // deployment is told, at boot, that what it configured is not being - // enforced, instead of discovering it from a served request. - // - // ⚠️ Positive knowledge is REQUIRED to refuse: only a posture we - // actually READ and that actually enforces a wall trips this. A - // tenancy service that is absent (the overwhelmingly common - // single-kernel shape) or unreadable cannot assert a configured - // wall, so it is logged and allowed to proceed — refusing there - // would break embedders that never asked for a wall. - if (!kernelManager) { - const localKernel: any = typeof ctx.getKernel === 'function' ? ctx.getKernel() : undefined; - let tenancySource: unknown; - if (localKernel && typeof localKernel.getServiceAsync === 'function') { - try { - tenancySource = await localKernel.getServiceAsync('tenancy'); - } catch (err) { - // Never registered is the supported shape and is quiet. - // Anything else means we could not READ the posture, so - // we cannot assert one is configured — loud in the log, - // but not a refusal. See the RESIDUE note on the card. - if (!isServiceNotRegisteredError(err)) { - ctx.logger.error( - '[security] RestApiPlugin: the `tenancy` service could not be read at boot, so it ' - + 'could not be checked against this single-kernel wiring, which carries NO tenancy ' - + 'posture into request authorization. If a wall-enforcing posture is ' - + 'configured here, it is NOT being enforced.', - err as any, - ); - } - tenancySource = undefined; - } - } - const bootPosture = effectiveTenancyPosture(tenancySource as any); - if (bootPosture && postureEnforcesWall(bootPosture)) { - // ⛔ The tracker id stays in THIS comment and out of the - // runtime string: an operator reading a boot failure cannot - // resolve `#NNNN` (maintainer ruling 2026-08-12). The - // reference is #13906 / the 2026-09-02 ruling, decision 1 B′. - throw new Error( - `[security] RestApiPlugin refuses to start: the kernel's \`tenancy\` service reports the ` - + `wall-enforcing posture \`${bootPosture}\`, but this deployment has no \`kernel-manager\` ` - + `service, so the REST transport resolves every request through the single-kernel ` - + `providers and NEVER reads a tenancy posture. The Layer 0 organization wall — including ` - + `the \`organization_required\` and \`organization_membership_ended\` API-key refusals — ` - + `would silently not be enforced. ` - + `Fix by mounting a kernel-manager service (the multi-environment wiring that can carry a ` - + `posture), or by setting the tenancy posture to \`single\` if this deployment is not ` - + `meant to run an organization wall.`, - ); - } - } - // Optional — only present in runtime mode. When available, // RestServer will resolve hostname → environmentId on unscoped // routes so a remote runtime node can dispatch every request