From 1f3f87f4f1b1d2b7de10f36d3d341e74bfa6ba9d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:39:52 +0000 Subject: [PATCH 01/25] wip(security): walled catalog refusal at the /meta doors and the data door (ADR-0131 D3) Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude --- .../src/meta-write-capability.ts | 101 +++++++++++++++++ .../plugin-security/src/security-plugin.ts | 16 +++ .../src/walled-catalog-data-door.ts | 88 ++++++++++++++ packages/rest/src/rest-server.ts | 107 ++++++++++++++++++ .../runtime/src/domains/activation-gate.ts | 33 ++++-- packages/runtime/src/domains/meta.ts | 26 +++++ 6 files changed, 363 insertions(+), 8 deletions(-) create mode 100644 packages/plugins/plugin-security/src/walled-catalog-data-door.ts diff --git a/packages/metadata-core/src/meta-write-capability.ts b/packages/metadata-core/src/meta-write-capability.ts index ed62187c0e0..0a07e04efb5 100644 --- a/packages/metadata-core/src/meta-write-capability.ts +++ b/packages/metadata-core/src/meta-write-capability.ts @@ -47,6 +47,9 @@ * about this one; the sentence varies only on the door's verb. */ +import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/security'; +import { canonicalMetaUrlType } from '@objectstack/spec/meta-spelling'; + /** ADR-0066 D1's platform-wide metadata authoring capability. */ export const METADATA_AUTHORING_CAPABILITY = 'manage_metadata'; @@ -91,3 +94,101 @@ export function metaWriteCapabilityVerdict(input: { message: `${OPERATION_SUBJECT[input.operation]} requires the \`manage_metadata\` capability.`, }; } + +// ───────────────────────────────────────────────────────────────────────────── +// [ADR-0131 D3] The walled catalog refusal +// ───────────────────────────────────────────────────────────────────────────── + +/** + * The security catalog (ADR-0131 D3): the three metadata types whose items are + * definitions every organization resolves BY NAME, from the one environment + * registry. A walled deployment's organizations share them, so a write to one + * reaches every organization that holds it. + */ +export const SECURITY_CATALOG_TYPES: readonly string[] = Object.freeze(['position', 'permission', 'capability']); + +/** + * Is `type` — in any URL spelling `saveMetaItem` accepts — a security catalog + * type? A door asks this before it pays for the tenancy posture read that + * {@link walledCatalogWriteVerdict} needs, so a write of any other type reads + * no posture at all. + */ +export function isSecurityCatalogType(type: string): boolean { + return SECURITY_CATALOG_TYPES.includes(canonicalMetaUrlType(type)); +} + +/** The noun the refusal names, per catalog type. */ +const CATALOG_NOUN: Readonly> = { + position: 'a position', + permission: 'a permission set', + capability: 'a capability', +}; + +/** + * The ADR-0095 D2/D3 posture rung that IS the platform operator (ADR-0068 D2: + * "Platform operator (SaaS admin). NOT a tenant user role"). The activation + * gate (`@objectstack/runtime` `domains/activation-gate.ts`) reads the same + * rung for the same reason: it is derived from the platform-admin anchors and + * never from a held capability or a position name, so a tenant holding + * `manage_metadata` inside its organization does not reach it. + */ +export const PLATFORM_OPERATOR_POSTURE = 'PLATFORM_ADMIN'; + +/** + * [ADR-0131 D3] May this caller write this item of the security catalog, on a + * deployment running this tenancy posture? + * + * ADR-0131 D3, verbatim: "Shared-DB multi-tenant (`group` / `isolated`): tenant + * admins cannot create or edit catalog items; the operator defines the catalog + * for every tenant (managed packages, Studio). Creation through Setup or the + * API is refused with a message naming the posture and the capability." The + * catalog is environment-level and organizations resolve it by name, so under + * a wall a write by one organization's principal changes what every other + * organization's members resolve. That was measured, not argued: an + * organization-scoped `manage_metadata` holder edited a permission set another + * organization's member holds, and that member's next resolution carried the + * edit (`wall-catalog-refusal.dogfood.test.ts`). + * + * This is the ONE predicate for that refusal. Every door that writes a catalog + * item asks it, after its own capability gate: the `/meta` item write doors on + * both transports (save, reset, publish, rollback) and the security plugin's + * data door on the catalog objects. Under `single` it is inert: the + * organization is the environment, and the capability gate decides alone + * (#22621 → A: catalog writes stay with `manage_metadata` holders). + * + * Admitted under a wall: `isSystem` (the engine's own writes: seeders, + * installers, migrations) and the platform operator rung. Refused: everyone + * else, whatever they hold. An ABSENT posture (no `tenancy` service) is inert, + * as at the activation gate: ADR-0093 D4/D5 resolve a requested-but-absent wall + * to `single`, so absent and `single` are the same deployment shape. + * + * Assignments are not catalog writes and never reach this predicate: + * `sys_user_position` and `sys_user_permission_set` rows are organization rows + * (ADR-0131 D3, "Assignments are organization rows, in every posture"). + */ +export function walledCatalogWriteVerdict(input: { + /** The metadata type written. A type outside {@link SECURITY_CATALOG_TYPES} is always admitted. */ + type: string; + /** The deployment's EFFECTIVE tenancy posture; `undefined` when no `tenancy` service answers. */ + tenancyPosture: TenancyPosture | undefined; + isSystem?: boolean; + /** The caller's ADR-0095 posture rung, off its execution context. */ + callerPosture?: unknown; +}): MetaWriteCapabilityVerdict { + // The fold `saveMetaItem` applies at its own request boundary, so a URL + // spelling the protocol accepts (a plural) is judged as the type it writes. + if (!isSecurityCatalogType(input.type)) return { allowed: true }; + const type = canonicalMetaUrlType(input.type); + if (!input.tenancyPosture || !postureEnforcesWall(input.tenancyPosture)) return { allowed: true }; + if (input.isSystem === true) return { allowed: true }; + if (input.callerPosture === PLATFORM_OPERATOR_POSTURE) return { allowed: true }; + return { + allowed: false, + message: + `Creating, changing or deleting ${CATALOG_NOUN[type]} requires the platform operator: this ` + + `deployment runs the '${input.tenancyPosture}' tenancy posture, where the security catalog is shared ` + + `by every organization (ADR-0131 D3). Holding the \`manage_metadata\` capability inside an ` + + `organization does not admit it. An organization's administrators assign the catalog's positions ` + + `and permission sets to their own members instead.`, + }; +} diff --git a/packages/plugins/plugin-security/src/security-plugin.ts b/packages/plugins/plugin-security/src/security-plugin.ts index 12449d326a2..cdb8d1e7c8d 100644 --- a/packages/plugins/plugin-security/src/security-plugin.ts +++ b/packages/plugins/plugin-security/src/security-plugin.ts @@ -42,6 +42,7 @@ import { } from './grant-permission-set-name.js'; import { runOneTimeGrantPermissionSetNameBackfill } from './grant-permission-set-name-backfill.js'; import { createPositionWriteThrough } from './position-write-through.js'; +import { createWalledCatalogDataDoorRefusal, WALLED_CATALOG_OBJECT_TYPES } from './walled-catalog-data-door.js'; import { runOneTimePositionEnvironmentBackfill } from './position-environment-backfill.js'; import { reportCatalogReferences } from './catalog-reference-report.js'; import { @@ -4342,6 +4343,21 @@ export class SecurityPlugin implements Plugin { ); } + // [ADR-0131 D3] Under a wall the security catalog is the platform + // operator's: a non-system write on `sys_position`, `sys_permission_set` or + // `sys_capability` by anyone below the `PLATFORM_ADMIN` rung is refused + // `403 PERMISSION_DENIED`, with the metadata door's own verdict and + // sentence. Registered AFTER the security middleware (a caller who may not + // write the table keeps its CRUD refusal) and BEFORE the permission-set + // write-through below (a refused write is never translated into a metadata + // save). Inert under `single`. See `walled-catalog-data-door.ts`. + { + const walledCatalogDataDoor = createWalledCatalogDataDoorRefusal({ getPosture: () => this.tenancyPosture }); + for (const object of Object.keys(WALLED_CATALOG_OBJECT_TYPES)) { + ql.registerMiddleware(walledCatalogDataDoor, { object }); + } + } + // [ADR-0094] Data-door write-through: every non-system CRUD write on // `sys_permission_set` is redirected into the metadata store (the ONE // authoritative store for definitions); the record is projector-owned. diff --git a/packages/plugins/plugin-security/src/walled-catalog-data-door.ts b/packages/plugins/plugin-security/src/walled-catalog-data-door.ts new file mode 100644 index 00000000000..d10cc2a76ee --- /dev/null +++ b/packages/plugins/plugin-security/src/walled-catalog-data-door.ts @@ -0,0 +1,88 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3] The walled catalog refusal at the DATA door: a non-system + * insert, update or delete on `sys_position`, `sys_permission_set` or + * `sys_capability`, under a posture that enforces an organization wall, by a + * caller who is not the platform operator, is refused `403 PERMISSION_DENIED`. + * + * ## Why the data door needs it + * + * ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins + * cannot create or edit catalog items … Creation through Setup or the API is + * refused". Setup writes the catalog through these three objects. Under a wall + * the stock organization administrator is already refused there by its own + * grants (the anti-escalation deny in `objects/default-permission-sets.ts`), + * but any other organization-scoped principal whose sets admit the table was + * not: + * + * - a `sys_permission_set` write is redirected into the environment ledger + * (`createPermissionSetWriteThrough`, ADR-0094 D3) in every posture, so an + * organization-scoped principal created an ENVIRONMENT permission set every + * organization resolves by name — measured on a booted walled stack; + * - a `sys_position` write landed as a row of the writer's organization, a + * position with no registry home (the `single`-only write-through stands + * down under a wall) — measured in this package's harness. + * + * The verdict and its sentence are the metadata door's own + * (`walledCatalogWriteVerdict`, `@objectstack/metadata-core`), so the data + * door and the `/meta` doors refuse the same callers with the same words. + * + * ## Where it runs + * + * Registered AFTER the security middleware, so it runs INSIDE it: a caller who + * may not write the table at all keeps its CRUD refusal and learns nothing + * here. Registered BEFORE the permission-set write-through, so a refused write + * is never translated into a metadata save. + * + * ## What it does not judge + * + * - **Assignments.** `sys_user_position` and `sys_user_permission_set` rows + * are organization rows in every posture (ADR-0131 D3); an organization's + * administrators keep assigning the catalog to their own members. + * - **System writes** (`isSystem`): the seeders, the package door, migrations. + * - **The platform operator** (the ADR-0095 `PLATFORM_ADMIN` rung on the + * context). A hand-built context that carries no rung is not the operator. + * - **`single`**: inert; the organization is the environment. + */ + +import { walledCatalogWriteVerdict } from '@objectstack/metadata-core'; +import type { TenancyPosture } from '@objectstack/spec/security'; +import { PermissionDeniedError } from './errors.js'; + +/** The catalog objects this guard judges, and the metadata type each one writes. */ +export const WALLED_CATALOG_OBJECT_TYPES: Readonly> = Object.freeze({ + sys_position: 'position', + sys_permission_set: 'permission', + sys_capability: 'capability', +}); + +/** The engine operations that change a catalog row. */ +const WRITE_OPERATIONS: ReadonlySet = new Set(['insert', 'update', 'delete']); + +export interface WalledCatalogDataDoorDeps { + /** The deployment's effective tenancy posture, as the security plugin resolved it. */ + getPosture: () => TenancyPosture | undefined; +} + +/** The engine middleware. Registered once per catalog object (see {@link WALLED_CATALOG_OBJECT_TYPES}). */ +export function createWalledCatalogDataDoorRefusal( + deps: WalledCatalogDataDoorDeps, +): (opCtx: any, next: () => Promise) => Promise { + return async (opCtx: any, next: () => Promise): Promise => { + const object = opCtx?.object; + const type = typeof object === 'string' ? WALLED_CATALOG_OBJECT_TYPES[object] : undefined; + if (!type || !WRITE_OPERATIONS.has(opCtx?.operation)) return next(); + const context = opCtx?.context ?? {}; + const verdict = walledCatalogWriteVerdict({ + type, + tenancyPosture: deps.getPosture(), + isSystem: context.isSystem === true, + callerPosture: context.posture, + }); + if (!verdict.allowed) { + throw new PermissionDeniedError(verdict.message, { operation: opCtx.operation, object }); + } + return next(); + }; +} diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 80f0e594db9..c1443c9f525 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -15,6 +15,7 @@ import { // every other rejection stays loud. ⛔ The two wirings themselves are NOT // the helper's — see `computeExecCtx`. classifyAdmissionTenancyPosture, + effectiveTenancyPosture, // The registry's "never registered" brand, asked directly by the public // form doors' tenancy read (see `registerFormEndpoints`). isServiceNotRegisteredError, @@ -72,6 +73,9 @@ import { // environment-wide and every read resolves environment → code. metaWriteCapabilityVerdict, type MetaWriteCapabilityVerdict, + // [ADR-0131 D3] The walled catalog refusal the same four doors ask next. + isSecurityCatalogType, + walledCatalogWriteVerdict, // Which form candidates the anonymous form doors serve — the one rule the // metadata protocol also judges organization-scoped `view` writes by. anonymousFormIntakeCandidates, @@ -3621,6 +3625,33 @@ export class RestServer { */ private static readonly STORED_VERSION_DOOR_POLICY: MetaReadGatePolicy = metaReadGate.STORED_VERSION_DOOR_POLICY; + /** + * [ADR-0131 D3] The walled catalog refusal for one `/meta` item write: the + * shared `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), asked + * by this transport's four write doors (save, reset, publish, rollback) + * after {@link metaSaveVerdict}'s capability gate. A type outside the + * security catalog is admitted without reading the posture. The posture is + * read through {@link resolveAnonymousFormTenancy}, the wiring that mirrors + * `resolveProtocol`, so the posture judged and the protocol that writes + * always come from the same kernel; a `tenancy` service that is registered + * and cannot answer raises `AuthzStoreUnavailableError` (the door's 503), + * never a permit. + */ + private async walledCatalogRefusal( + type: string, + ctx: any, + environmentId: string | undefined, + req: any, + ): Promise { + if (!isSecurityCatalogType(type)) return { allowed: true }; + return walledCatalogWriteVerdict({ + type, + tenancyPosture: effectiveTenancyPosture(await this.resolveAnonymousFormTenancy(environmentId, req)), + isSystem: ctx?.isSystem === true, + callerPosture: ctx?.posture, + }); + } + /** * [#12702 · #20156] May this caller SAVE `:type/:name`? The admission of * `PUT /meta/:type/:name`, spelled ONCE: that door asks it, and so does the @@ -6933,6 +6964,25 @@ export class RestServer { return; } } + // [ADR-0131 D3] The walled catalog refusal, after the + // capability gate and before the protocol is resolved: under + // `isolated` / `group` the security catalog is shared by every + // organization, so only the platform operator rung (or the + // engine itself) writes it. Inert under `single`, and no + // posture is read for any other type. See + // {@link walledCatalogRefusal}. + { + const walled = await this.walledCatalogRefusal(req.params.type, ctx, environmentId, req); + if (!walled.allowed) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: walled.message, + }, + }); + return; + } + } const p = await this.resolveProtocol(environmentId, req); if (!p.saveMetaItem) { // [#7035] ADR-0112 envelope: the semantic code lives at @@ -7131,6 +7181,25 @@ export class RestServer { return; } } + // [ADR-0131 D3] The walled catalog refusal, after the + // capability gate and before the protocol is resolved: under + // `isolated` / `group` the security catalog is shared by every + // organization, so only the platform operator rung (or the + // engine itself) writes it. Inert under `single`, and no + // posture is read for any other type. See + // {@link walledCatalogRefusal}. + { + const walled = await this.walledCatalogRefusal(req.params.type, ctx, environmentId, req); + if (!walled.allowed) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: walled.message, + }, + }); + return; + } + } const p = await this.resolveProtocol(environmentId, req); if (!p.deleteMetaItem) { // [#7035] ADR-0112 envelope. This site was the worst of @@ -7567,6 +7636,25 @@ export class RestServer { return; } } + // [ADR-0131 D3] The walled catalog refusal, after the + // capability gate and before the protocol is resolved: under + // `isolated` / `group` the security catalog is shared by every + // organization, so only the platform operator rung (or the + // engine itself) writes it. Inert under `single`, and no + // posture is read for any other type. See + // {@link walledCatalogRefusal}. + { + const walled = await this.walledCatalogRefusal(req.params.type, ctx, environmentId, req); + if (!walled.allowed) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: walled.message, + }, + }); + return; + } + } const p = await this.resolveProtocol(environmentId, req); if (!p.publishMetaItem) { res.status(501).json({ @@ -7729,6 +7817,25 @@ export class RestServer { return; } } + // [ADR-0131 D3] The walled catalog refusal, after the + // capability gate and before the protocol is resolved: under + // `isolated` / `group` the security catalog is shared by every + // organization, so only the platform operator rung (or the + // engine itself) writes it. Inert under `single`, and no + // posture is read for any other type. See + // {@link walledCatalogRefusal}. + { + const walled = await this.walledCatalogRefusal(req.params.type, ctx, environmentId, req); + if (!walled.allowed) { + res.status(403).json({ + error: { + code: 'FORBIDDEN', + message: walled.message, + }, + }); + return; + } + } const p = await this.resolveProtocol(environmentId, req); if (!(p as any).rollbackMetaItem) { res.status(501).json({ diff --git a/packages/runtime/src/domains/activation-gate.ts b/packages/runtime/src/domains/activation-gate.ts index 0e2e2a4473a..3724549bfa4 100644 --- a/packages/runtime/src/domains/activation-gate.ts +++ b/packages/runtime/src/domains/activation-gate.ts @@ -219,6 +219,30 @@ export function readActivationBody( return { enabled: (toggleBody as { enabled?: boolean }).enabled ?? true }; } +/** + * The deployment's EFFECTIVE tenancy posture for this request, read the way + * {@link refuseUngrantedActivationWrite} reads it (the reasoning lives in that + * function's body): `undefined` when no `tenancy` service is registered — the + * same deployment shape as `single` — and a loud + * `AuthzStoreUnavailableError('tenancy')` when one is registered and cannot + * answer, because the posture is an authorization input and was never decided. + * [ADR-0131 D3] The metadata door's walled catalog refusal reads it through + * this same function, so the two posture-conditional gates of this transport + * cannot read two different postures. + */ +export async function readEffectiveTenancyPosture( + deps: DomainHandlerDeps, + context: HttpProtocolContext, +): Promise> { + try { + return effectiveTenancyPosture( + await deps.resolveServiceOrLoud(context, 'tenancy', context.environmentId), + ); + } catch (err) { + throw new AuthzStoreUnavailableError('tenancy', err); + } +} + /** * The §5 gate itself. * @@ -298,14 +322,7 @@ export async function refuseUngrantedActivationWrite( // identity step and `./keys.ts` already resolve this exact name with, so // all three read one deployment's posture through one scope: an outage // answered here is the service's, never this call site's omission. - let posture; - try { - posture = effectiveTenancyPosture( - await deps.resolveServiceOrLoud(context, 'tenancy', context.environmentId), - ); - } catch (err) { - throw new AuthzStoreUnavailableError('tenancy', err); - } + const posture = await readEffectiveTenancyPosture(deps, context); if (!posture || !postureEnforcesWall(posture)) return undefined; // [ADR-0095 D2/D3 · #15981] The platform-operator test is the posture RUNG. diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 513c7aa0667..ba9d99286bf 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -31,6 +31,10 @@ import { // too. No `/meta` branch here carries an organization into a metadata // write or read: the per-organization overlay axis is retired. metaWriteCapabilityVerdict, + // [ADR-0131 D3] …and, for the security catalog under a wall, the platform + // operator alone: the walled catalog refusal every catalog write door asks. + isSecurityCatalogType, + walledCatalogWriteVerdict, } from '@objectstack/metadata-core'; // [#15238] The DECLARED protocol contracts this domain's request literals are // compiled against. Imported, never restated: a second hand-written @@ -90,6 +94,7 @@ import { type MetaReadGatePolicy, } from '@objectstack/rest'; import { buildApiError } from '../error-envelope.js'; +import { readEffectiveTenancyPosture } from './activation-gate.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; @@ -1301,6 +1306,27 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin }; } + // [ADR-0131 D3] The walled catalog refusal, right after the + // capability gate and before anything is read or written: under + // `isolated` / `group` a position, permission set or capability is + // shared by every organization, so only the platform operator rung + // (or the engine itself) saves one. Inert under `single`. The + // posture is read only for a catalog type, so no other save pays + // for it. Same predicate as `RestServer`'s four write doors and the + // security plugin's data door. + if (isSecurityCatalogType(type)) { + const ec: any = _context.executionContext; + const walled = walledCatalogWriteVerdict({ + type, + tenancyPosture: await readEffectiveTenancyPosture(deps, _context), + isSystem: ec?.isSystem === true, + callerPosture: ec?.posture, + }); + if (!walled.allowed) { + return { handled: true, response: deps.error(walled.message, 403) }; + } + } + // [#22141] The ADR-0008 precondition and the ADR-0033 lifecycle, // read off THIS request through the one mapping `RestServer`'s // `PUT` door reads them through (`metaSaveRequestOptions`, From d039e4d88d576e950da97c4a7c4f7d6b4d222258 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:52:46 +0000 Subject: [PATCH 02/25] test(security): pin the walled catalog refusal on isolated and group, with single and assignment controls (ADR-0131 D3) Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude --- .../src/meta-write-capability.test.ts | 68 ++++++ .../src/builtin-positions.boot.test.ts | 20 +- .../src/position-write-through.test.ts | 20 +- .../src/walled-catalog-data-door.test.ts | 227 ++++++++++++++++++ .../test/wall-catalog-refusal.dogfood.test.ts | 225 +++++++++++++++++ 5 files changed, 551 insertions(+), 9 deletions(-) create mode 100644 packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts create mode 100644 packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts diff --git a/packages/metadata-core/src/meta-write-capability.test.ts b/packages/metadata-core/src/meta-write-capability.test.ts index eb6a837b27b..6ff950d546e 100644 --- a/packages/metadata-core/src/meta-write-capability.test.ts +++ b/packages/metadata-core/src/meta-write-capability.test.ts @@ -23,6 +23,10 @@ import * as metadataCore from './index.js'; import { METADATA_AUTHORING_CAPABILITY, metaWriteCapabilityVerdict, + isSecurityCatalogType, + PLATFORM_OPERATOR_POSTURE, + SECURITY_CATALOG_TYPES, + walledCatalogWriteVerdict, type MetaWriteOperation, } from './meta-write-capability.js'; @@ -92,3 +96,67 @@ describe('metaWriteCapabilityVerdict — who is admitted', () => { expect(metaWriteCapabilityVerdict({ isSystem: false, operation: 'rollback' }).allowed).toBe(false); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// [ADR-0131 D3] The walled catalog refusal +// ───────────────────────────────────────────────────────────────────────────── + +describe('[ADR-0131 D3] walledCatalogWriteVerdict — under a wall the security catalog is the platform operator\'s', () => { + const TENANT = { isSystem: false, callerPosture: 'TENANT_ADMIN' }; + + it('refuses a non-operator write of each catalog type under `isolated` and `group`', () => { + for (const tenancyPosture of ['isolated', 'group'] as const) { + for (const type of SECURITY_CATALOG_TYPES) { + const v = walledCatalogWriteVerdict({ type, tenancyPosture, ...TENANT }); + expect(v.allowed, `${type} under ${tenancyPosture}`).toBe(false); + } + } + }); + + it('the sentence names the posture and the capability, and nothing about the caller', () => { + const v = walledCatalogWriteVerdict({ type: 'permission', tenancyPosture: 'group', ...TENANT }); + expect(v.allowed).toBe(false); + const message = (v as { message: string }).message; + expect(message.split(': ')[0]).toBe('Creating, changing or deleting a permission set requires the platform operator'); + expect(message).toContain("'group' tenancy posture"); + expect(message).toContain('`manage_metadata`'); + expect(message).not.toContain('TENANT_ADMIN'); + }); + + it('a URL spelling the protocol folds to a catalog type is judged as that type', () => { + for (const type of ['positions', 'permissions', 'capabilities']) { + expect(isSecurityCatalogType(type), type).toBe(true); + expect(walledCatalogWriteVerdict({ type, tenancyPosture: 'isolated', ...TENANT }).allowed, type).toBe(false); + } + }); + + it('admits the platform operator rung and a system write', () => { + expect(walledCatalogWriteVerdict({ type: 'position', tenancyPosture: 'isolated', callerPosture: PLATFORM_OPERATOR_POSTURE })) + .toEqual({ allowed: true }); + expect(walledCatalogWriteVerdict({ type: 'position', tenancyPosture: 'isolated', isSystem: true })) + .toEqual({ allowed: true }); + }); + + it('a caller with no rung at all is not the operator', () => { + expect(walledCatalogWriteVerdict({ type: 'position', tenancyPosture: 'isolated' }).allowed).toBe(false); + }); + + it('is inert under `single` and with no posture (no `tenancy` service): the capability gate decides alone', () => { + for (const tenancyPosture of ['single', undefined] as const) { + for (const type of SECURITY_CATALOG_TYPES) { + expect(walledCatalogWriteVerdict({ type, tenancyPosture, ...TENANT })).toEqual({ allowed: true }); + } + } + }); + + it('admits every other type under a wall — the refusal is the catalog\'s, not the metadata door\'s', () => { + for (const type of ['object', 'view', 'flow', 'app']) { + expect(isSecurityCatalogType(type), type).toBe(false); + expect(walledCatalogWriteVerdict({ type, tenancyPosture: 'isolated', ...TENANT })).toEqual({ allowed: true }); + } + }); + + it('the catalog is exactly positions, permission sets and capabilities', () => { + expect([...SECURITY_CATALOG_TYPES]).toEqual(['position', 'permission', 'capability']); + }); +}); diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index a0291771532..11adcea9334 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -515,12 +515,13 @@ for (const name of Object.keys(SCENARIOS) as ScenarioName[]) { } /** - * The tenant position writes — the same in both postures. A name the security + * The tenant position writes. A name the security * catalog does not hold, a dangling one or a row with no definition alike, is * the position catalog refusal's envelope (`reference_not_found` at * `position`); a stack-declared position is assigned with no row; the two * built-in rows an existing database holds are refused on their `platform` - * provenance. + * provenance. Under a wall the tenant's position create is refused as well + * ({@link refusalGoldenFor}). */ const REFUSAL_GOLDEN: Record = { 'assign a position the security catalog does not hold': { @@ -551,10 +552,19 @@ const REFUSAL_GOLDEN: Record = { }, }; -/** {@link REFUSAL_GOLDEN} for a scenario: the built-in row cases need an existing database's rows. */ +/** + * {@link REFUSAL_GOLDEN} for a scenario: the built-in row cases need an existing + * database's rows. [ADR-0131 D3] Under a wall the tenant's position CREATE is + * refused (`walled-catalog-data-door.ts`): the catalog is the platform + * operator's, and a tenant may only assign it. The assignment cases do not + * move, and the built-in row refusals keep their `403 PERMISSION_DENIED`. + */ function refusalGoldenFor(scenario: Scenario): Record { - if (scenario.existingRows) return REFUSAL_GOLDEN; - const { 'delete the everyone row': _d, 'relabel the platform_admin row': _r, ...fresh } = REFUSAL_GOLDEN; + const golden: Record = scenario.posture === 'single' + ? REFUSAL_GOLDEN + : { ...REFUSAL_GOLDEN, 'control: create a position row': { ok: false, code: 'PERMISSION_DENIED', status: 403 } }; + if (scenario.existingRows) return golden; + const { 'delete the everyone row': _d, 'relabel the platform_admin row': _r, ...fresh } = golden; return fresh; } diff --git a/packages/plugins/plugin-security/src/position-write-through.test.ts b/packages/plugins/plugin-security/src/position-write-through.test.ts index 6de62903f68..bbea2fce923 100644 --- a/packages/plugins/plugin-security/src/position-write-through.test.ts +++ b/packages/plugins/plugin-security/src/position-write-through.test.ts @@ -712,11 +712,17 @@ describe('[ADR-0131 D3] Q3 = A — a package cannot register a name a Setup posi }); describe('[ADR-0131 D3] controls — what the write-through leaves exactly as it was', () => { - it('a walled posture: an organization administrator\'s create lands its organization row, and no definition', async () => { + // [ADR-0131 D3] Under a wall the catalog is the platform operator's: an + // organization administrator's Setup create is refused at the data door + // (`walled-catalog-data-door.ts`) before any row or definition exists. It + // used to land a row of the organization with no definition; the refusal + // and its assignment control are pinned in `walled-catalog-data-door.test.ts`. + it('a walled posture: an organization administrator\'s create is refused 403, with no row and no definition', async () => { const b = await boot({ posture: 'isolated' }); const saves = vi.spyOn(b.protocol, 'saveMetaItem'); - await create(b, { name: 'plant_lead', label: 'Plant lead' }); - expect((await b.rows('plant_lead')).map((r) => r.organization_id)).toEqual([ORG]); + expect(envelope(await refusalOf(create(b, { name: 'plant_lead', label: 'Plant lead' })))) + .toEqual({ code: 'PERMISSION_DENIED', status: 403 }); + expect(await b.rows('plant_lead')).toEqual([]); expect(saves).not.toHaveBeenCalled(); expect(await b.envRows('plant_lead')).toEqual([]); }); @@ -725,9 +731,15 @@ describe('[ADR-0131 D3] controls — what the write-through leaves exactly as it // under a wall an organization's Setup position is a row with no definition, // so it grants nothing; the metadata door takes an environment definition // under the same name, and that definition is what the catalog then serves. + // + // The row-only position is the population a wall's Setup created before the + // walled catalog refusal (#15196 Q3 = A), so it is seeded here as that + // existing row, a system write into the organization: a tenant can no longer + // create one. it('a walled posture: an organization\'s row-only position is promoted by an environment definition saved at the metadata door', async () => { const b = await boot({ posture: 'isolated' }); - await create(b, { name: 'plant_auditor', label: 'Plant auditor' }); + await b.engine.insert('sys_position', { name: 'plant_auditor', label: 'Plant auditor' }, + { context: { isSystem: true, tenantId: ORG } } as any); expect(await b.catalogResolves('plant_auditor')).toBe(false); await (b.protocol as any).saveMetaItem({ type: 'position', name: 'plant_auditor', item: { name: 'plant_auditor', label: 'Plant auditor', permissionSets: ['plant_tools'] }, diff --git a/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts b/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts new file mode 100644 index 00000000000..ee23104f024 --- /dev/null +++ b/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3] "Tenants are refused creation and may only assign" — the + * walled catalog refusal at the data door (`walled-catalog-data-door.ts`). + * + * Measured before it on this same harness shape (a real `ObjectQL` over a real + * SQL driver, the real `SecurityPlugin`, the `isolated` posture with the + * organization-scoping service): an organization-bound administrator holding + * the superuser wildcard created a position at the data door, and it landed + * as a row of that organization. Nothing judged a catalog create by posture. + * + * Pinned here, per posture: + * - `isolated` and `group`: the tenant's create, update and delete of a + * position, a permission set and a capability are refused with the ADR-0112 + * envelope `403 PERMISSION_DENIED`, and nothing is stored; + * - the CONTROL: the same tenant still assigns a catalog position to a member + * of its organization; + * - the platform operator rung and a system write are admitted; + * - `single`: the same tenant's create is accepted, unchanged. + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { resolveThrownHttpError } from '@objectstack/types'; +import type { PermissionSet } from '@objectstack/spec/security'; + +import { SecurityPlugin } from './security-plugin.js'; +import { SysUser, SysMember } from '@objectstack/platform-objects/identity'; +import { SysMetadataActivation } from '@objectstack/platform-objects/system'; +import { SysPosition } from './objects/sys-position.object.js'; +import { SysUserPosition } from './objects/sys-user-position.object.js'; +import { SysPermissionSet } from './objects/sys-permission-set.object.js'; +import { SysPositionPermissionSet } from './objects/sys-position-permission-set.object.js'; +import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { SysCapability } from './objects/sys-capability.object.js'; +import { WALLED_CATALOG_OBJECT_TYPES } from './walled-catalog-data-door.js'; + +const ORG = 'org_a'; + +/** The fallback every authenticated caller resolves: it grants nothing here. */ +const MEMBER_DEFAULT = { name: 'member_default', label: 'Member', objects: {} } as unknown as PermissionSet; + +/** An organization-bound administrator: the superuser wildcard, so CRUD admits every catalog table. */ +const QA_ADMIN = { + name: 'qa_admin', + label: 'QA Admin', + objects: { + '*': { + allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true, + viewAllRecords: true, modifyAllRecords: true, + }, + }, +} as unknown as PermissionSet; + +type Posture = 'single' | 'isolated' | 'group'; + +const engines: ObjectQL[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + while (engines.length) { + try { await engines.pop()?.destroy(); } catch { /* noop */ } + } +}); + +async function boot(posture: Posture) { + const engine = new ObjectQL(); + engine.registerDriver( + new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }), + true, + ); + await engine.init(); + engine.registerApp({ + id: 'com.objectstack.qa.walled-catalog-data-door', + name: 'Walled catalog data door', + version: '1.0.0', + type: 'plugin', + scope: 'system', + objects: [ + SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, + SysCapability, SysMember, SysMetadataActivation, + ], + } as any); + await engine.syncSchemas(); + for (const o of [SysUser, SysMember, SysPosition, SysUserPosition, SysPermissionSet, SysUserPermissionSet, SysPositionPermissionSet]) { + if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as never, 'qa.authz-read-set'); + } + engines.push(engine); + + const services: Record = { + manifest: { register: vi.fn() }, + objectql: engine, + metadata: { + get: async (type: string, name: string) => + type === 'permission' + ? ([MEMBER_DEFAULT, QA_ADMIN] as Array<{ name?: string }>).find((s) => s?.name === name) ?? null + : engine.getSchema(name) ?? null, + list: async () => [MEMBER_DEFAULT, QA_ADMIN], + }, + ...(posture === 'single' + ? {} + : { 'org-scoping': { name: 'com.objectstack.org-scoping' }, tenancy: { posture } }), + }; + const ctx: any = { + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + hook: vi.fn(), + registerService: vi.fn(), + getService: (name: string) => { + if (!(name in services)) throw new Error(`service not registered: ${name}`); + return services[name]; + }, + }; + const plugin = new SecurityPlugin({ fallbackPermissionSet: 'member_default' }); + await plugin.init(ctx); + await plugin.start(ctx); + vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); + + const walled = posture !== 'single'; + const system = walled ? { isSystem: true, tenantId: ORG } : { isSystem: true }; + // The catalog the tenant may assign: one position, declared in the registry. + engine.registry.registerItem('position', { name: 'qa_auditor', label: 'Auditor', permissionSets: [] } as any, + 'name' as any, 'com.objectstack.qa.walled-catalog-data-door'); + // Rows the tenant will try to change: one per catalog object. + await engine.insert('sys_position', { id: 'pos_existing', name: 'qa_existing', label: 'Existing' }, { context: system } as any); + await engine.insert('sys_permission_set', { + id: 'ps_existing', name: 'qa_existing_set', label: 'Existing set', + object_permissions: '{}', field_permissions: '{}', system_permissions: '[]', active: true, + }, { context: system } as any); + await engine.insert('sys_capability', { id: 'cap_existing', name: 'qa.existing', label: 'Existing capability' }, { context: system } as any); + // The member an assignment names: a member of the organization it is scoped to. + await engine.insert('sys_member', { user_id: 'u_member', organization_id: ORG, role: 'member' }, + { context: { isSystem: true, tenantId: ORG } } as any); + + const tenant = walled + ? { userId: 'u_admin', positions: [], permissions: ['qa_admin'], tenantId: ORG, accessible_org_ids: [ORG] } + : { userId: 'u_admin', positions: [], permissions: ['qa_admin'] }; + return { engine, tenant, system }; +} + +async function outcomeOf(run: Promise): Promise<{ ok: true } | { ok: false; code: string; status: number }> { + try { + await run; + return { ok: true }; + } catch (e) { + const r = resolveThrownHttpError(e); + return { ok: false, code: r.code, status: r.status }; + } +} + +const REFUSED = { ok: false, code: 'PERMISSION_DENIED', status: 403 } as const; + +async function storedNames(engine: ObjectQL, object: string, name: string): Promise { + const rows = await engine.find(object, { where: { name }, context: { isSystem: true } } as any); + return Array.isArray(rows) ? rows.length : 0; +} + +for (const posture of ['isolated', 'group'] as const) { + describe(`[ADR-0131 D3] ${posture}: tenants are refused catalog writes and may only assign`, () => { + it('a create of a position, a permission set or a capability is refused 403 PERMISSION_DENIED, and nothing is stored', async () => { + const { engine, tenant } = await boot(posture); + expect(await outcomeOf(engine.insert('sys_position', { name: 'plant_lead', label: 'Plant lead' }, { context: tenant } as any))) + .toEqual(REFUSED); + expect(await outcomeOf(engine.insert('sys_permission_set', { name: 'plant_set', label: 'Plant set' }, { context: tenant } as any))) + .toEqual(REFUSED); + expect(await outcomeOf(engine.insert('sys_capability', { name: 'plant.cap', label: 'Plant cap' }, { context: tenant } as any))) + .toEqual(REFUSED); + expect(await storedNames(engine, 'sys_position', 'plant_lead')).toBe(0); + expect(await storedNames(engine, 'sys_permission_set', 'plant_set')).toBe(0); + expect(await storedNames(engine, 'sys_capability', 'plant.cap')).toBe(0); + }); + + it('an update and a delete of a catalog row are refused, and the row is untouched', async () => { + const { engine, tenant } = await boot(posture); + expect(await outcomeOf(engine.update('sys_position', { id: 'pos_existing', label: 'Renamed' }, { context: tenant } as any))) + .toEqual(REFUSED); + expect(await outcomeOf(engine.delete('sys_permission_set', { where: { id: 'ps_existing' }, context: tenant } as any))) + .toEqual(REFUSED); + const [row] = await engine.find('sys_position', { where: { id: 'pos_existing' }, context: { isSystem: true } } as any) as any[]; + expect(row?.label).toBe('Existing'); + expect(await storedNames(engine, 'sys_permission_set', 'qa_existing_set')).toBe(1); + }); + + it('CONTROL: the same tenant still assigns a catalog position to a member of its organization', async () => { + const { engine, tenant } = await boot(posture); + expect(await outcomeOf(engine.insert('sys_user_position', { user_id: 'u_member', position: 'qa_auditor' }, { context: tenant } as any))) + .toEqual({ ok: true }); + }); + + it('the platform operator rung and a system write are admitted', async () => { + const { engine, tenant, system } = await boot(posture); + const operator = { ...tenant, userId: 'u_operator', posture: 'PLATFORM_ADMIN' }; + expect(await outcomeOf(engine.insert('sys_position', { name: 'op_lead', label: 'Operator lead' }, { context: operator } as any))) + .toEqual({ ok: true }); + expect(await outcomeOf(engine.insert('sys_position', { name: 'sys_lead', label: 'System lead' }, { context: system } as any))) + .toEqual({ ok: true }); + }); + + it('a tenant rung below the operator is refused like a context that carries none', async () => { + const { engine, tenant } = await boot(posture); + const tenantAdmin = { ...tenant, posture: 'TENANT_ADMIN' }; + expect(await outcomeOf(engine.insert('sys_position', { name: 'ta_lead', label: 'Tenant admin lead' }, { context: tenantAdmin } as any))) + .toEqual(REFUSED); + }); + }); +} + +describe('[ADR-0131 D3] single: the data door is unchanged (control)', () => { + it('the same administrator creates a position and a permission set', async () => { + const { engine, tenant } = await boot('single'); + expect(await outcomeOf(engine.insert('sys_position', { name: 'plant_lead', label: 'Plant lead' }, { context: tenant } as any))) + .toEqual({ ok: true }); + expect(await outcomeOf(engine.insert('sys_permission_set', { name: 'plant_set', label: 'Plant set' }, { context: tenant } as any))) + .toEqual({ ok: true }); + expect(await storedNames(engine, 'sys_position', 'plant_lead')).toBe(1); + }); +}); + +describe('the judged objects', () => { + it('are exactly the three security catalog objects — assignment objects are never judged', () => { + expect(WALLED_CATALOG_OBJECT_TYPES).toEqual({ + sys_position: 'position', + sys_permission_set: 'permission', + sys_capability: 'capability', + }); + }); +}); diff --git a/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts b/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts new file mode 100644 index 00000000000..7df2a89ed89 --- /dev/null +++ b/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts @@ -0,0 +1,225 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0131 D3] The walled catalog refusal, on a REAL booted stack. +// +// ADR-0131 D3: under `group` / `isolated` "tenant admins cannot create or edit +// catalog items; the operator defines the catalog for every tenant … Creation +// through Setup or the API is refused with a message naming the posture and +// the capability." The catalog (positions, permission sets, capabilities) is +// environment-level, and every organization resolves it by name. +// +// What this file measured before the refusal, on this same harness, under both +// walled postures: organization A's owner, granted `manage_metadata` inside +// organization A only (an organization-scoped grant, never the platform +// operator rung), saved `PUT /meta/permission/:name` over an environment +// permission set organization B's member holds, adding `manage_users`; B's +// member's next resolution carried `manage_users`. One organization's write +// changed what another organization's members resolve. The same principal +// created and deleted catalog items at `/meta`, and a holder of CRUD on +// `sys_permission_set` created an environment permission set through the data +// door's write-through. +// +// Pinned here: +// - walled: org A's catalog writes at `/meta` (save, create, delete) answer +// `403 FORBIDDEN`, and B's resolution does not move; +// - walled: the data door's catalog creates answer `403 PERMISSION_DENIED`, +// and nothing reaches the environment ledger; +// - walled CONTROLS: org A still assigns a catalog permission set to its own +// member, and the platform operator still saves the catalog; +// - `single` CONTROL: a `manage_metadata` holder who is not the platform +// administrator still saves a position (#22621 → A, unchanged). +// +// The boot is `multiTenant: 'posture-only'`: a real, non-degraded walled +// posture. The refusal keys on the posture and the caller's rung, never on row +// isolation, so the stand-in's lack of a row wall measures nothing away here. +// `OS_TENANCY_POSTURE` selects `group`; the harness declares the seeded admin +// as `OS_PLATFORM_OWNER_EMAIL`, so that admin is the platform operator. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import crmStack from '@objectstack/example-crm'; +import { resolveUserAuthzGrants } from '@objectstack/core'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const SYS = { isSystem: true, positions: [], permissions: [] }; + +interface Engine { + find(object: string, options?: unknown): Promise>>; + insert(object: string, data: Record, options?: unknown): Promise; +} + +/** The ADR-0112 envelope a refusal answered: `error.code` and the HTTP status. */ +async function envelopeOf(res: Response): Promise<{ status: number; code: unknown }> { + const body = (await res.clone().json().catch(() => ({}))) as { error?: { code?: unknown }; code?: unknown }; + return { status: res.status, code: body?.error?.code ?? body?.code }; +} + +const META_AUTHOR = 'w_meta_author'; +const SHARED = 'w_shared'; +const RBAC_WRITER = 'w_rbac_writer'; + +for (const posture of ['isolated', 'group'] as const) { + describe(`[ADR-0131 D3] ${posture}: the security catalog is the platform operator's`, () => { + let stack: VerifyStack; + let ql: Engine; + let operator: string; + let tokA: string; + let orgA: string; + let orgB: string; + let userA: string; + let userB: string; + const previous = process.env.OS_TENANCY_POSTURE; + + const idOf = async (email: string) => + String((await ql.find('sys_user', { where: { email }, limit: 1, context: SYS }))[0]?.id); + const bSystemPermissions = async () => + (await resolveUserAuthzGrants(ql as never, userB, { tenantId: orgB })).systemPermissions; + const definitionOf = async (type: string, name: string) => { + const res = await stack.apiAs(operator, 'GET', `/meta/${type}/${name}`); + return res.status === 200 ? ((await res.json()) as any) : null; + }; + + beforeAll(async () => { + process.env.OS_TENANCY_POSTURE = posture; + stack = await bootStack(crmStack as never, { multiTenant: 'posture-only' }); + ql = (await stack.kernel.getServiceAsync('objectql')) as unknown as Engine; + operator = await stack.signIn(); + tokA = await stack.signUp(`w-a-${posture}@wall.test`); + const tokB = await stack.signUp(`w-b-${posture}@wall.test`); + for (const [tok, slug] of [[tokA, `w-a-${posture}`], [tokB, `w-b-${posture}`]] as const) { + const created = await stack.apiAs(tok, 'POST', '/auth/organization/create', { name: slug, slug }); + expect(created.status, await created.clone().text()).toBe(200); + const id = ((await created.json()) as { id: string }).id; + if (tok === tokA) orgA = id; else orgB = id; + const active = await stack.apiAs(tok, 'POST', '/auth/organization/set-active', { organizationSlug: slug }); + expect(active.status, await active.clone().text()).toBe(200); + } + userA = await idOf(`w-a-${posture}@wall.test`); + userB = await idOf(`w-b-${posture}@wall.test`); + + // The operator authors the catalog, and grants inside each organization: + // A holds `manage_metadata` and CRUD on the catalog and assignment tables + // in org A only (the stock `organization_admin` denies those tables, so + // without this grant the data door would refuse A on CRUD alone); + // B's member holds the shared set in org B. + const rbac = { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true }; + for (const body of [ + { name: META_AUTHOR, label: 'Meta author', objects: {}, systemPermissions: ['manage_metadata'] }, + { name: SHARED, label: 'Shared', objects: {}, systemPermissions: [] }, + { name: RBAC_WRITER, label: 'Catalog table writer', systemPermissions: [], objects: { sys_permission_set: rbac, sys_position: rbac, sys_user_permission_set: rbac, sys_user_position: rbac } }, + ]) { + const saved = await stack.apiAs(operator, 'PUT', `/meta/permission/${body.name}`, body); + expect(saved.status, await saved.clone().text()).toBe(200); + } + for (const name of [META_AUTHOR, RBAC_WRITER]) { + await ql.insert('sys_user_permission_set', { user_id: userA, permission_set: name, organization_id: orgA }, + { context: { ...SYS, tenantId: orgA } }); + } + await ql.insert('sys_user_permission_set', { user_id: userB, permission_set: SHARED, organization_id: orgB }, + { context: { ...SYS, tenantId: orgB } }); + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + if (previous === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = previous; + }); + + it('guard the guard: a real walled posture; A holds `manage_metadata` in org A and is not the platform operator', async () => { + const tenancy = await stack.kernel.getServiceAsync<{ posture: string; degraded: boolean }>('tenancy'); + expect(tenancy.posture).toBe(posture); + expect(tenancy.degraded).toBe(false); + expect(orgA).toBeTruthy(); + expect(orgB).toBeTruthy(); + expect(orgA).not.toBe(orgB); + const a = await resolveUserAuthzGrants(ql as never, userA, { tenantId: orgA }); + expect(a.systemPermissions).toContain('manage_metadata'); + expect(a.posture).not.toBe('PLATFORM_ADMIN'); + const session = (await (await stack.apiAs(tokA, 'GET', '/auth/get-session')).json()) as any; + expect(session.user?.isPlatformAdmin).toBe(false); + expect(session.session?.activeOrganizationId).toBe(orgA); + }); + + it("org A's edit of a permission set org B's member holds is refused 403 FORBIDDEN, and B's resolution does not move", async () => { + const before = await bSystemPermissions(); + expect(before).not.toContain('manage_users'); + const res = await stack.apiAs(tokA, 'PUT', `/meta/permission/${SHARED}`, { + name: SHARED, label: 'Edited by organization A', objects: {}, systemPermissions: ['manage_users'], + }); + expect(await envelopeOf(res)).toEqual({ status: 403, code: 'FORBIDDEN' }); + expect(await bSystemPermissions()).toEqual(before); + expect((await definitionOf('permission', SHARED))?.item?.label).toBe('Shared'); + }); + + it('org A cannot create a position, a permission set or a capability at `/meta`, nor delete one', async () => { + for (const [type, name, body] of [ + ['position', 'w_tenant_pos', { name: 'w_tenant_pos', label: 'Tenant position', permissionSets: [SHARED] }], + ['permission', 'w_tenant_set', { name: 'w_tenant_set', label: 'Tenant set', objects: {} }], + ['capability', 'w.tenant', { name: 'w.tenant', label: 'Tenant capability', scope: 'org' }], + ] as const) { + const res = await stack.apiAs(tokA, 'PUT', `/meta/${type}/${name}`, body); + expect(await envelopeOf(res), `${type} ${name}`).toEqual({ status: 403, code: 'FORBIDDEN' }); + } + expect((await definitionOf('position', 'w_tenant_pos'))?.item).toBeFalsy(); + expect((await definitionOf('permission', 'w_tenant_set'))?.item).toBeFalsy(); + + const del = await stack.apiAs(tokA, 'DELETE', `/meta/permission/${SHARED}`); + expect(await envelopeOf(del)).toEqual({ status: 403, code: 'FORBIDDEN' }); + expect((await definitionOf('permission', SHARED))?.item?.name).toBe(SHARED); + }); + + it('the data door refuses org A a catalog create 403 PERMISSION_DENIED, and nothing reaches the environment ledger', async () => { + const set = await stack.apiAs(tokA, 'POST', '/data/sys_permission_set', { name: 'w_dd_set', label: 'Data door set' }); + expect(await envelopeOf(set)).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect((await definitionOf('permission', 'w_dd_set'))?.item).toBeFalsy(); + + const position = await stack.apiAs(tokA, 'POST', '/data/sys_position', { name: 'w_dd_pos', label: 'Data door position' }); + expect(await envelopeOf(position)).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect(await ql.find('sys_position', { where: { name: 'w_dd_pos' }, context: SYS })).toEqual([]); + }); + + it('CONTROL: org A still assigns a catalog permission set to its own member', async () => { + const res = await stack.apiAs(tokA, 'POST', '/data/sys_user_permission_set', { user_id: userA, permission_set: SHARED }); + expect(res.status, await res.clone().text()).toBe(201); + }); + + it('CONTROL: the platform operator still saves the catalog', async () => { + const res = await stack.apiAs(operator, 'PUT', '/meta/position/w_operator_pos', { + name: 'w_operator_pos', label: 'Operator position', permissionSets: [SHARED], + }); + expect(res.status, await res.clone().text()).toBe(200); + expect((await definitionOf('position', 'w_operator_pos'))?.item?.name).toBe('w_operator_pos'); + }); + }); +} + +describe('[ADR-0131 D3] single: a `manage_metadata` holder still authors the catalog (control)', () => { + let stack: VerifyStack; + let ql: Engine; + let author: string; + + beforeAll(async () => { + stack = await bootStack(crmStack as never, {}); + ql = (await stack.kernel.getServiceAsync('objectql')) as unknown as Engine; + const operator = await stack.signIn(); + author = await stack.signUp('w-single-author@wall.test'); + const saved = await stack.apiAs(operator, 'PUT', `/meta/permission/${META_AUTHOR}`, { + name: META_AUTHOR, label: 'Meta author', objects: {}, systemPermissions: ['manage_metadata'], + }); + expect(saved.status, await saved.clone().text()).toBe(200); + const [user] = await ql.find('sys_user', { where: { email: 'w-single-author@wall.test' }, limit: 1, context: SYS }); + await ql.insert('sys_user_permission_set', { user_id: String(user.id), permission_set: META_AUTHOR }, { context: SYS }); + }, 180_000); + + afterAll(async () => { + await stack?.stop?.(); + }); + + it('saves a position: 200, unchanged by the walled refusal', async () => { + const tenancy = await stack.kernel.getServiceAsync<{ posture: string }>('tenancy').catch(() => undefined); + expect(tenancy?.posture ?? 'single').toBe('single'); + const session = (await (await stack.apiAs(author, 'GET', '/auth/get-session')).json()) as any; + expect(session.user?.isPlatformAdmin).toBe(false); + const res = await stack.apiAs(author, 'PUT', '/meta/position/w_single_pos', { name: 'w_single_pos', label: 'Single position' }); + expect(res.status, await res.clone().text()).toBe(200); + }); +}); From 8d4d5ad9b311e462835118292bc2cd8c768e0646 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 19:53:43 +0000 Subject: [PATCH 03/25] chore(security): changeset and ADR-0131 anchors for the walled catalog refusal Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude --- .changeset/15204-walled-catalog-refusal.md | 25 +++++++++++++++++++ ...a-core__src__meta-write-capability.ts.json | 7 ++++++ ...ity__src__walled-catalog-data-door.ts.json | 7 ++++++ 3 files changed, 39 insertions(+) create mode 100644 .changeset/15204-walled-catalog-refusal.md create mode 100644 scripts/adr-anchors/packages__metadata-core__src__meta-write-capability.ts.json create mode 100644 scripts/adr-anchors/packages__plugins__plugin-security__src__walled-catalog-data-door.ts.json diff --git a/.changeset/15204-walled-catalog-refusal.md b/.changeset/15204-walled-catalog-refusal.md new file mode 100644 index 00000000000..42018e93296 --- /dev/null +++ b/.changeset/15204-walled-catalog-refusal.md @@ -0,0 +1,25 @@ +--- +'@objectstack/metadata-core': minor +'@objectstack/rest': minor +'@objectstack/runtime': minor +'@objectstack/plugin-security': minor +--- + +Under a walled posture the security catalog is the platform operator's: tenants are refused creating, changing or deleting positions, permission sets and capabilities, and may only assign them + +Clause-②: yes (narrowing) + + + +**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant … Creation through Setup or the API is refused with a message naming the posture and the capability."). + +**Why.** The catalog is environment-level, and every organization resolves it by name. Measured on a booted `isolated` and `group` stack: an organization owner granted `manage_metadata` inside their own organization saved `PUT /meta/permission/:name` over a permission set another organization's member holds, and that member's next resolution carried the change. One organization's write changed what another organization's members resolve. + +**FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + +- `PUT /meta/:type/:name`, `DELETE /meta/:type/:name`, `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` for `position`, `permission` or `capability`: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher's `PUT` answers `403 PERMISSION_DENIED`). The message names the posture and the capability. +- A data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability`: **FROM** admitted to any caller whose grants admit the table (a `sys_permission_set` create landed in the environment ledger; a `sys_position` create landed as an organization row with no definition) **TO** `403 PERMISSION_DENIED`, before anything is written. + +**Unchanged.** Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows that an organization's administrators write for their own members. The platform operator and the engine's own writes (seeders, installers, migrations) write the catalog as before. Every other metadata type is untouched. Under `single` (and with no `tenancy` service), catalog writes stay with `manage_metadata` holders, exactly as before. The catalog activation door already required the platform operator under a wall. + +**The remedy.** A tenant that needs a position or permission set asks the platform operator to define it in Studio, or through a managed package. The tenant's administrators then assign it to their members. `@objectstack/metadata-core` exports the one predicate every door asks, `walledCatalogWriteVerdict`, beside `isSecurityCatalogType`, `SECURITY_CATALOG_TYPES` and `PLATFORM_OPERATOR_POSTURE`. A custom door that writes catalog items asks it after `metaWriteCapabilityVerdict`. diff --git a/scripts/adr-anchors/packages__metadata-core__src__meta-write-capability.ts.json b/scripts/adr-anchors/packages__metadata-core__src__meta-write-capability.ts.json new file mode 100644 index 00000000000..467bdcac6a9 --- /dev/null +++ b/scripts/adr-anchors/packages__metadata-core__src__meta-write-capability.ts.json @@ -0,0 +1,7 @@ +{ + "file": "packages/metadata-core/src/meta-write-capability.ts", + "adrs": [ + "ADR-0131" + ], + "invariant": "`walledCatalogWriteVerdict` is the ONE predicate for ADR-0131 D3's walled catalog refusal: under `group` / `isolated` a write of a position, permission set or capability is admitted only for `isSystem` or the platform operator rung, never for an organization-scoped `manage_metadata` holder, because one organization's write would change what every other organization resolves. Every `/meta` write door and the security plugin's data door ask it; `single` is inert." +} diff --git a/scripts/adr-anchors/packages__plugins__plugin-security__src__walled-catalog-data-door.ts.json b/scripts/adr-anchors/packages__plugins__plugin-security__src__walled-catalog-data-door.ts.json new file mode 100644 index 00000000000..0f79c307ca6 --- /dev/null +++ b/scripts/adr-anchors/packages__plugins__plugin-security__src__walled-catalog-data-door.ts.json @@ -0,0 +1,7 @@ +{ + "file": "packages/plugins/plugin-security/src/walled-catalog-data-door.ts", + "adrs": [ + "ADR-0131" + ], + "invariant": "Under a walled posture (`group` / `isolated`) the security catalog — positions, permission sets, capabilities — is environment-level and every organization resolves it by name, so a data-door create, update or delete of a catalog row is the platform operator's alone (ADR-0131 D3: tenants are refused creation and may only assign). Assignment rows are never judged here; `single` is inert." +} From 70f4febfad453184d8ae70e516f7ca3df3c56aab Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 20:11:59 +0000 Subject: [PATCH 04/25] test(runtime): pin the dispatcher PUT's walled catalog refusal (ADR-0131 D3) Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude --- .../meta-walled-catalog-refusal.test.ts | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 packages/runtime/src/domains/meta-walled-catalog-refusal.test.ts diff --git a/packages/runtime/src/domains/meta-walled-catalog-refusal.test.ts b/packages/runtime/src/domains/meta-walled-catalog-refusal.test.ts new file mode 100644 index 00000000000..fec22974c79 --- /dev/null +++ b/packages/runtime/src/domains/meta-walled-catalog-refusal.test.ts @@ -0,0 +1,100 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D3] The dispatcher's `PUT /meta/:type/:name` asks the walled + * catalog refusal (`walledCatalogWriteVerdict`, `@objectstack/metadata-core`) + * right after its capability gate: under `isolated` / `group` a position, a + * permission set or a capability is saved only by the platform operator rung + * or the engine itself. `RestServer`'s four write doors ask the same predicate; + * the booted proof of both transports' shared behaviour, including the + * cross-organization read-back, is `wall-catalog-refusal.dogfood.test.ts`. + * + * Pinned here, on this transport: the refusal's envelope + * (`403 PERMISSION_DENIED`, this transport's spelling), that nothing reaches + * `saveMetaItem`, and the controls (the operator, `single`, no `tenancy` + * service, and a type outside the catalog). + */ + +import { describe, it, expect, vi } from 'vitest'; +import { HttpDispatcher } from '../http-dispatcher.js'; + +function boot(posture: 'single' | 'isolated' | 'group' | null) { + const saveMetaItem = vi.fn(async ({ type, name }: any) => ({ success: true, type, name })); + const getMetaItem = vi.fn(async () => null); + const protocol = { saveMetaItem, getMetaItem }; + const services: Record = { protocol }; + if (posture) services.tenancy = { posture }; + const resolve = (name: string): unknown => services[name] ?? null; + const kernel = { + getService: resolve, + getServiceAsync: async (name: string) => resolve(name), + context: { getService: resolve }, + }; + return { dispatcher: new HttpDispatcher(kernel as never), saveMetaItem }; +} + +const ctx = (executionContext: any): any => ({ request: {}, environmentId: 'platform', executionContext }); + +/** An organization-scoped `manage_metadata` holder: the capability gate admits it, and it is not the operator. */ +const TENANT_AUTHOR = { userId: 'u_tenant', tenantId: 'org_a', systemPermissions: ['manage_metadata'], posture: 'TENANT_ADMIN' }; +const OPERATOR = { userId: 'u_operator', systemPermissions: ['manage_metadata'], posture: 'PLATFORM_ADMIN' }; + +const BODIES: Record> = { + position: { name: 'plant_lead', label: 'Plant lead' }, + permission: { name: 'plant_set', label: 'Plant set', objects: {} }, + capability: { name: 'plant.cap', label: 'Plant cap', scope: 'org' }, +}; + +describe('[ADR-0131 D3] dispatcher PUT /meta — the walled catalog refusal', () => { + for (const posture of ['isolated', 'group'] as const) { + it(`${posture}: a tenant's save of each catalog type is refused 403 PERMISSION_DENIED, and nothing is saved`, async () => { + for (const [type, body] of Object.entries(BODIES)) { + const stack = boot(posture); + const res = await stack.dispatcher.handleMetadata(`/${type}/${body.name}`, ctx(TENANT_AUTHOR), 'PUT', body); + expect(res.response?.status, type).toBe(403); + expect(res.response?.body?.error?.code, type).toBe('PERMISSION_DENIED'); + expect(String(res.response?.body?.error?.message), type).toContain(`'${posture}' tenancy posture`); + expect(stack.saveMetaItem, type).not.toHaveBeenCalled(); + } + }); + + it(`${posture}: a plural URL spelling is judged as the catalog type it writes`, async () => { + const stack = boot(posture); + const res = await stack.dispatcher.handleMetadata('/permissions/plant_set', ctx(TENANT_AUTHOR), 'PUT', BODIES.permission); + expect(res.response?.status).toBe(403); + expect(stack.saveMetaItem).not.toHaveBeenCalled(); + }); + + it(`${posture}: CONTROL — the platform operator rung saves`, async () => { + const stack = boot(posture); + await stack.dispatcher.handleMetadata('/position/plant_lead', ctx(OPERATOR), 'PUT', BODIES.position); + expect(stack.saveMetaItem).toHaveBeenCalledTimes(1); + }); + + it(`${posture}: CONTROL — a type outside the catalog is not judged`, async () => { + const stack = boot(posture); + await stack.dispatcher.handleMetadata('/view/plant_view', ctx(TENANT_AUTHOR), 'PUT', { name: 'plant_view', label: 'Plant view' }); + expect(stack.saveMetaItem).toHaveBeenCalledTimes(1); + }); + } + + it('single: CONTROL — the same `manage_metadata` holder saves a position, unchanged', async () => { + const stack = boot('single'); + await stack.dispatcher.handleMetadata('/position/plant_lead', ctx(TENANT_AUTHOR), 'PUT', BODIES.position); + expect(stack.saveMetaItem).toHaveBeenCalledTimes(1); + }); + + it('no `tenancy` service behaves like `single` (ADR-0093 D4/D5)', async () => { + const stack = boot(null); + await stack.dispatcher.handleMetadata('/position/plant_lead', ctx(TENANT_AUTHOR), 'PUT', BODIES.position); + expect(stack.saveMetaItem).toHaveBeenCalledTimes(1); + }); + + it('the capability gate still answers first: a caller without `manage_metadata` gets its sentence, not the posture one', async () => { + const stack = boot('isolated'); + const res = await stack.dispatcher.handleMetadata('/position/plant_lead', ctx({ userId: 'u_x', systemPermissions: [] }), 'PUT', BODIES.position); + expect(res.response?.status).toBe(403); + expect(String(res.response?.body?.error?.message)).toContain('requires the `manage_metadata` capability'); + expect(stack.saveMetaItem).not.toHaveBeenCalled(); + }); +}); From de89dd413be3bb0c4186738fc5562b0fe99faf9a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 20:45:49 +0000 Subject: [PATCH 05/25] chore(spec,security): register the walled catalog refusal as an ADR-0087 semantic entry; census row 55b Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude --- .changeset/15204-walled-catalog-refusal.md | 5 ++- content/docs/permissions/system-context.mdx | 21 ++++----- .../src/walled-catalog-data-door.test.ts | 4 +- ...18.walled-catalog-tenant-writes-retired.ts | 43 +++++++++++++++++++ 4 files changed, 59 insertions(+), 14 deletions(-) create mode 100644 packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts diff --git a/.changeset/15204-walled-catalog-refusal.md b/.changeset/15204-walled-catalog-refusal.md index 42018e93296..9f40050b5d0 100644 --- a/.changeset/15204-walled-catalog-refusal.md +++ b/.changeset/15204-walled-catalog-refusal.md @@ -3,13 +3,14 @@ '@objectstack/rest': minor '@objectstack/runtime': minor '@objectstack/plugin-security': minor +'@objectstack/spec': minor --- Under a walled posture the security catalog is the platform operator's: tenants are refused creating, changing or deleting positions, permission sets and capabilities, and may only assign them Clause-②: yes (narrowing) - + **BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant … Creation through Setup or the API is refused with a message naming the posture and the capability."). @@ -22,4 +23,4 @@ Clause-②: yes (narrowing) **Unchanged.** Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows that an organization's administrators write for their own members. The platform operator and the engine's own writes (seeders, installers, migrations) write the catalog as before. Every other metadata type is untouched. Under `single` (and with no `tenancy` service), catalog writes stay with `manage_metadata` holders, exactly as before. The catalog activation door already required the platform operator under a wall. -**The remedy.** A tenant that needs a position or permission set asks the platform operator to define it in Studio, or through a managed package. The tenant's administrators then assign it to their members. `@objectstack/metadata-core` exports the one predicate every door asks, `walledCatalogWriteVerdict`, beside `isSecurityCatalogType`, `SECURITY_CATALOG_TYPES` and `PLATFORM_OPERATOR_POSTURE`. A custom door that writes catalog items asks it after `metaWriteCapabilityVerdict`. +**The remedy.** A tenant that needs a position or permission set asks the platform operator to define it in Studio, or through a managed package. The tenant's administrators then assign it to their members. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-tenant-writes-retired`. `@objectstack/metadata-core` exports the one predicate every door asks, `walledCatalogWriteVerdict`, beside `isSecurityCatalogType`, `SECURITY_CATALOG_TYPES` and `PLATFORM_OPERATOR_POSTURE`. A custom door that writes catalog items asks it after `metaWriteCapabilityVerdict`. diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 0b52f085e4d..edbef7d2a2a 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a service self-write, a migration. This page is **the authority** for what that flag actually does. It exists -because the flag is not one concept: it is a single boolean read at **123 +because the flag is not one concept: it is a single boolean read at **127 distinct sites across 20 packages**, and knowing three of those behaviours gives no hint that the other hundred-and-four exist. Every documented app-side bug traced to `isSystem` had the same shape — the metadata was complete and correct, @@ -142,7 +142,7 @@ that silently does not happen. ### 3. Sharing (`plugin-sharing`) -The largest single consumer — **17 of the 123 sites**. +The largest single consumer — **17 of the 127 sites**. | # | Behaviour when `isSystem` | What you get / what you lose | Anchor | |:--|:---|:---|:---| @@ -181,6 +181,7 @@ The largest single consumer — **17 of the 123 sites**. | 53 | Package REST route capability gate bypassed | rest | Get: a marketplace publish over REST (`POST /packages/publish`, the one route the REST registrar mounts since #14503) without `manage_metadata`; the package read cohort (`studio.access` / `setup.access`) is enforced by the dispatcher `/packages` domain's own read gate, where the reads are served | `packages/rest/src/package-routes.ts#refusePackageRequest` | | 54 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability` | | 55 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite`, `#refuseUngrantedActivationAuthoring` | +| 55b | Walled catalog refusal does not fire | metadata-core, rest, plugin-security | Get: under `isolated` / `group`, a create, change or delete of a position, a permission set or a capability, at the `/meta` write doors (save, reset, publish, rollback) and at the data door on `sys_position`, `sys_permission_set` and `sys_capability`. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3), because one organization's write would change what every organization resolves by name. The seeders, the package door and migrations write the catalog this way. Under `single` the refusal is inert for every caller | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict`, `packages/rest/src/rest-server.ts#walledCatalogRefusal`, `packages/plugins/plugin-security/src/walled-catalog-data-door.ts#createWalledCatalogDataDoorRefusal` | | 56 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A). The three doors ask one predicate, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#isRunStarterOrRunStateReader`, `#refuseUngrantedRunLifecycleWrite`, `packages/runtime/src/flow-start-admission.ts#refusesElevatedSelfTriggeredStart` | | 57 | Audience-binding suggestion authority gate passes | plugin-security | Get: a system caller lists, accepts and dismisses suggestions without `manage_metadata` and, under a wall, without the platform-operator rung | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertDeploymentAuthority` | | 58 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6), so the seeds, the boot sweep and the projection of a Studio save are the only writers. Lose (`sys_webhook`): the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` | @@ -285,7 +286,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are independent decisions, and a seed loader plausibly wants the first two but not the third. The concept is nevertheless **staying as one boolean**: -- **Shipped semantics.** `isSystem` is a published contract with 123 read sites +- **Shipped semantics.** `isSystem` is a published contract with 127 read sites in 20 packages. Splitting it is a breaking contract change across all of them. (The ruling was taken when the census read 80 sites in 18 packages; the count has grown, which strengthens rather than weakens the argument.) @@ -357,18 +358,18 @@ still holds equal to the census on every pull request: | — in tests | 1013 | — | | — in non-test sources | 798 | — | | Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | -| — parsed as a declaration | 28 | ✅ | +| — parsed as a declaration | 29 | ✅ | | — parsed as an object-literal / type key (producers and option objects) | 310 | — | -| — parsed as a property **read** | 129 | ✅ | +| — parsed as a property **read** | 133 | ✅ | | — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | | — the remainder: text inside comments and string literals | 358 | — | | Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ | -| Of those reads: reads of `ExecutionContext.isSystem` | **123** | ✅ | -| — behaviour-bearing (rows 1–61 above) | 120 | ✅ | +| Of those reads: reads of `ExecutionContext.isSystem` | **127** | ✅ | +| — behaviour-bearing (rows 1–61 above) | 124 | ✅ | | — carry the flag onward only (rows 62–64 above) | 3 | ✅ | | Packages containing at least one elevation read | **20** | ✅ | -| Files containing at least one elevation read | 57 | ✅ | -| — the distinct symbols those reads live in — what this page anchors | 105 | ✅ | +| Files containing at least one elevation read | 58 | ✅ | +| — the distinct symbols those reads live in — what this page anchors | 108 | ✅ | | — of those files, the ones holding more than one read in one symbol | 8 | ✅ | The six rows marked — are a **dated decomposition, not a live claim**: they were @@ -432,7 +433,7 @@ same resolver, and the same registration shape, that holds `docs/adr/**`. Renaming a symbol is now a loud red instead of a silent misdirection. ⚠️ **The precision that costs, priced here rather than buried.** A symbol anchor -cannot say WHICH read inside a function it means, and **8** of the **57** +cannot say WHICH read inside a function it means, and **8** of the **58** anchored files hold more than one read inside a single symbol. So the population check runs per file at symbol granularity: every file the census finds a read in must be anchored, and the set of symbols this page cites into that file must diff --git a/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts b/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts index ee23104f024..003a885f3f6 100644 --- a/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts +++ b/packages/plugins/plugin-security/src/walled-catalog-data-door.test.ts @@ -151,7 +151,7 @@ async function outcomeOf(run: Promise): Promise<{ ok: true } | { ok: fa const REFUSED = { ok: false, code: 'PERMISSION_DENIED', status: 403 } as const; async function storedNames(engine: ObjectQL, object: string, name: string): Promise { - const rows = await engine.find(object, { where: { name }, context: { isSystem: true } } as any); + const rows = await engine.find(object, { where: { name }, context: { isSystem: true } }); return Array.isArray(rows) ? rows.length : 0; } @@ -176,7 +176,7 @@ for (const posture of ['isolated', 'group'] as const) { .toEqual(REFUSED); expect(await outcomeOf(engine.delete('sys_permission_set', { where: { id: 'ps_existing' }, context: tenant } as any))) .toEqual(REFUSED); - const [row] = await engine.find('sys_position', { where: { id: 'pos_existing' }, context: { isSystem: true } } as any) as any[]; + const [row] = (await engine.find('sys_position', { where: { id: 'pos_existing' }, context: { isSystem: true } })) as any[]; expect(row?.label).toBe('Existing'); expect(await storedNames(engine, 'sys_permission_set', 'qa_existing_set')).toBe(1); }); diff --git a/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts new file mode 100644 index 00000000000..78af500a9d9 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0131 D3 (stage W of the C3 card) — a runtime-door narrowing with no +// authorable key: under a walled tenancy posture the security catalog's write +// doors admit only the platform operator. Registered because a caller that +// wrote catalog items before is refused now, and an operator must be told who +// keeps that authority and what a tenant does instead. +export const entry: SemanticMigration = { + id: 'walled-catalog-tenant-writes-retired', + // No backticks in `surface`: build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'on a group or isolated deployment, a write of a position, permission set or capability by a ' + + 'caller below the platform operator: PUT, DELETE, publish and rollback of /meta/position, ' + + '/meta/permission and /meta/capability on @objectstack/rest and the runtime dispatcher, and a ' + + 'data-door insert, update or delete on sys_position, sys_permission_set and sys_capability ' + + 'through @objectstack/plugin-security', + replacement: + 'the platform operator (the `PLATFORM_ADMIN` rung, the deployment\'s declared ' + + '`OS_PLATFORM_OWNER_EMAIL` owner) defines the catalog, in Studio or through a managed package; ' + + 'an organization\'s administrators assign it to their own members through ' + + '`sys_user_position` and `sys_user_permission_set`, which are unchanged. A custom door that ' + + 'writes catalog items asks `walledCatalogWriteVerdict` (`@objectstack/metadata-core`) after ' + + '`metaWriteCapabilityVerdict`', + reason: + 'ADR-0131 D3: on a shared-database multi-tenant deployment tenant admins cannot create or edit ' + + 'catalog items, and the operator defines the catalog for every tenant. The catalog is ' + + 'environment-level and every organization resolves it by name, so before this change a ' + + 'manage_metadata holder scoped to one organization could save over a permission set another ' + + 'organization\'s members hold and change what those members resolve.', + acceptanceCriteria: + 'On an isolated or group deployment, an organization owner holding manage_metadata inside ' + + 'their own organization saves PUT /api/v1/meta/permission/: the answer is 403 FORBIDDEN ' + + '(403 PERMISSION_DENIED on the runtime dispatcher), the message names the posture and the ' + + 'manage_metadata capability, and another organization\'s holder of that set resolves exactly ' + + 'what it resolved before. A data-door create on sys_position or sys_permission_set by the same ' + + 'caller answers 403 PERMISSION_DENIED and stores nothing. The platform operator saves the same ' + + 'item with 200, the same organization owner still assigns a catalog permission set to its own ' + + 'member with 201, and on a single deployment a manage_metadata holder saves catalog items ' + + 'exactly as before. Items saved before this change keep their bytes.', +}; From 26c70abf88d3fd928df59d61f25ad2e6ed74632a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 21:16:58 +0000 Subject: [PATCH 06/25] fix(runtime): the permission-set clone door asks the walled catalog refusal (ADR-0131 D3) Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude --- .../test/wall-catalog-refusal.dogfood.test.ts | 18 ++++++++ .../src/domains/permission-set-clone.test.ts | 44 +++++++++++++++++++ .../src/domains/permission-set-clone.ts | 24 +++++++++- 3 files changed, 85 insertions(+), 1 deletion(-) diff --git a/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts b/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts index 7df2a89ed89..2f38d83196e 100644 --- a/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/wall-catalog-refusal.dogfood.test.ts @@ -24,6 +24,8 @@ // `403 FORBIDDEN`, and B's resolution does not move; // - walled: the data door's catalog creates answer `403 PERMISSION_DENIED`, // and nothing reaches the environment ledger; +// - walled: the permission-set clone door refuses org A a clone +// `403 PERMISSION_DENIED`, and nothing is saved; // - walled CONTROLS: org A still assigns a catalog permission set to its own // member, and the platform operator still saves the catalog; // - `single` CONTROL: a `manage_metadata` holder who is not the platform @@ -177,6 +179,22 @@ for (const posture of ['isolated', 'group'] as const) { expect(await ql.find('sys_position', { where: { name: 'w_dd_pos' }, context: SYS })).toEqual([]); }); + it("the clone door refuses org A a clone of an environment set 403 PERMISSION_DENIED, and nothing is saved", async () => { + const res = await stack.apiAs(tokA, 'POST', `/security/permission-sets/${SHARED}/clone`, { + name: 'w_tenant_clone', label: 'Tenant clone', + }); + expect(await envelopeOf(res)).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect((await definitionOf('permission', 'w_tenant_clone'))?.item).toBeFalsy(); + }); + + it('CONTROL: the platform operator still clones a catalog set', async () => { + const res = await stack.apiAs(operator, 'POST', `/security/permission-sets/${SHARED}/clone`, { + name: 'w_operator_clone', label: 'Operator clone', + }); + expect(res.status, await res.clone().text()).toBe(200); + expect((await definitionOf('permission', 'w_operator_clone'))?.item?.name).toBe('w_operator_clone'); + }); + it('CONTROL: org A still assigns a catalog permission set to its own member', async () => { const res = await stack.apiAs(tokA, 'POST', '/data/sys_user_permission_set', { user_id: userA, permission_set: SHARED }); expect(res.status, await res.clone().text()).toBe(201); diff --git a/packages/runtime/src/domains/permission-set-clone.test.ts b/packages/runtime/src/domains/permission-set-clone.test.ts index 90f43a4a3ba..40426c165aa 100644 --- a/packages/runtime/src/domains/permission-set-clone.test.ts +++ b/packages/runtime/src/domains/permission-set-clone.test.ts @@ -59,6 +59,8 @@ interface BootOptions { noProtocol?: boolean; saveError?: Error; catalogError?: Error; + /** The `tenancy` service's posture; absent ⇒ no `tenancy` service (the `single` shape). */ + posture?: 'single' | 'isolated' | 'group'; } function boot(opts: BootOptions = {}) { @@ -83,6 +85,7 @@ function boot(opts: BootOptions = {}) { }; const services: Record = { objectql: engine, data: engine }; if (!opts.noProtocol) services.protocol = protocol; + if (opts.posture) services.tenancy = { posture: opts.posture }; const resolve = (name: string): unknown => services[name] ?? null; const kernel = { getService: resolve, @@ -260,3 +263,44 @@ describe('the permission-set clone door — refusals', () => { expect(statusOf(res)).toBe(501); }); }); + +// [ADR-0131 D3] The walled catalog refusal: a clone creates an environment +// permission set, so under a wall it is the platform operator's. Same predicate +// and envelope as the dispatcher's `PUT /meta` (`meta-walled-catalog-refusal.test.ts`). +describe('the permission-set clone door — the walled catalog refusal (ADR-0131 D3)', () => { + const TENANT_HOLDER = () => ctx({ userId: 'u_tenant', tenantId: 'org_a', positions: [], systemPermissions: ['manage_metadata'], posture: 'TENANT_ADMIN' }); + const OPERATOR = () => ctx({ userId: 'u_operator', positions: [], systemPermissions: ['manage_metadata'], posture: 'PLATFORM_ADMIN' }); + + for (const posture of ['isolated', 'group'] as const) { + it(`${posture}: a tenant manage_metadata holder is refused 403 PERMISSION_DENIED, and nothing is saved`, async () => { + const h = boot({ posture }); + const res = await clone(h, TENANT_HOLDER(), PACKAGED.name, { name: 'my_sales_rep', label: 'My Sales Rep' }); + expect(statusOf(res)).toBe(403); + expect(codeOf(res)).toBe('PERMISSION_DENIED'); + expect(String((res as any)?.response?.body?.error?.message)).toContain(`'${posture}' tenancy posture`); + expect(h.saves).toHaveLength(0); + }); + + it(`${posture}: CONTROL — the platform operator rung clones`, async () => { + const h = boot({ posture }); + const res = await clone(h, OPERATOR(), PACKAGED.name, { name: 'my_sales_rep', label: 'My Sales Rep' }); + expect(statusOf(res)).toBe(200); + expect(h.saves).toHaveLength(1); + }); + } + + it('single: CONTROL — the same tenant holder clones, unchanged', async () => { + const h = boot({ posture: 'single' }); + const res = await clone(h, TENANT_HOLDER(), PACKAGED.name, { name: 'my_sales_rep', label: 'My Sales Rep' }); + expect(statusOf(res)).toBe(200); + expect(h.saves).toHaveLength(1); + }); + + it('the capability gate still answers first under a wall', async () => { + const h = boot({ posture: 'isolated' }); + const res = await clone(h, NON_HOLDER(), PACKAGED.name, { name: 'my_sales_rep', label: 'My Sales Rep' }); + expect(statusOf(res)).toBe(403); + expect(String((res as any)?.response?.body?.error?.message)).toContain('`manage_metadata`'); + expect(h.saves).toHaveLength(0); + }); +}); diff --git a/packages/runtime/src/domains/permission-set-clone.ts b/packages/runtime/src/domains/permission-set-clone.ts index 7b7e9e24fbe..245d8297954 100644 --- a/packages/runtime/src/domains/permission-set-clone.ts +++ b/packages/runtime/src/domains/permission-set-clone.ts @@ -68,11 +68,18 @@ * stay with `manage_metadata` holders). ⛔ No new gate, and ⛔ not the ADR-0126 * §5 activation gate: a clone creates a new artifact and takes nothing away * from any tenant, exactly as the flow clone door reasons. + * + * Then, as at every catalog write door, the walled catalog refusal + * (`walledCatalogWriteVerdict`, ADR-0131 D3): under `isolated` / `group` the + * catalog is shared by every organization, so the clone is the platform + * operator's. That is D3's own refusal ("the server refuses anyway"), not a new + * gate; under `single` it is inert. */ import { METADATA_READ_DECORATIONS, MetadataProtectionFields } from '@objectstack/spec/kernel'; import { SnakeCaseIdentifierSchema } from '@objectstack/spec/shared'; -import { metaWriteCapabilityVerdict } from '@objectstack/metadata-core'; +import { metaWriteCapabilityVerdict, walledCatalogWriteVerdict } from '@objectstack/metadata-core'; +import { readEffectiveTenancyPosture } from './activation-gate.js'; import { securityCatalogReaderOf, type SecurityCatalogEntry } from '@objectstack/core'; import type { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { validationFailure } from '../validation-failure.js'; @@ -236,6 +243,21 @@ export async function handlePermissionSetCloneWrite( if (!verdict.allowed) { return { handled: true, response: deps.error(verdict.message, 403) }; } + // [ADR-0131 D3] The walled catalog refusal, the metadata door's own and + // asked right after its capability gate, exactly as the dispatcher's + // `PUT /meta` asks it: a clone creates an environment permission set, so + // under `isolated` / `group` only the platform operator rung (or the engine + // itself) makes one. Inert under `single`. Before any read, so a refused + // caller learns nothing about the source or the target name. + const walled = walledCatalogWriteVerdict({ + type: PERMISSION_METADATA_TYPE, + tenancyPosture: await readEffectiveTenancyPosture(deps, context), + isSystem: ec?.isSystem === true, + callerPosture: ec?.posture, + }); + if (!walled.allowed) { + return { handled: true, response: deps.error(walled.message, 403) }; + } try { // ── 2. shape ──────────────────────────────────────────────────────── From 763c52bcc0268fafa8acbab426e3aa5beed6981f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 21:26:06 +0000 Subject: [PATCH 07/25] docs(permissions): census counts after the clone door's walled check; row 55b names the clone door Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude --- content/docs/permissions/system-context.mdx | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index e46e53751be..c4b9b4f9f98 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a service self-write, a migration. This page is **the authority** for what that flag actually does. It exists -because the flag is not one concept: it is a single boolean read at **128 +because the flag is not one concept: it is a single boolean read at **129 distinct sites across 20 packages**, and knowing three of those behaviours gives no hint that the other hundred-and-four exist. Every documented app-side bug traced to `isSystem` had the same shape — the metadata was complete and correct, @@ -142,7 +142,7 @@ that silently does not happen. ### 3. Sharing (`plugin-sharing`) -The largest single consumer — **17 of the 128 sites**. +The largest single consumer — **17 of the 129 sites**. | # | Behaviour when `isSystem` | What you get / what you lose | Anchor | |:--|:---|:---|:---| @@ -181,7 +181,7 @@ The largest single consumer — **17 of the 128 sites**. | 53 | Package REST route capability gate bypassed | rest | Get: a marketplace publish over REST (`POST /packages/publish`, the one route the REST registrar mounts since #14503) without `manage_metadata`; the package read cohort (`studio.access` / `setup.access`) is enforced by the dispatcher `/packages` domain's own read gate, where the reads are served | `packages/rest/src/package-routes.ts#refusePackageRequest` | | 54 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability` | | 55 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite`, `#refuseUngrantedActivationAuthoring` | -| 55b | Walled catalog refusal does not fire | metadata-core, rest, plugin-security | Get: under `isolated` / `group`, a create, change or delete of a position, a permission set or a capability, at the `/meta` write doors (save, reset, publish, rollback) and at the data door on `sys_position`, `sys_permission_set` and `sys_capability`. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3), because one organization's write would change what every organization resolves by name. The seeders, the package door and migrations write the catalog this way. Under `single` the refusal is inert for every caller | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict`, `packages/rest/src/rest-server.ts#walledCatalogRefusal`, `packages/plugins/plugin-security/src/walled-catalog-data-door.ts#createWalledCatalogDataDoorRefusal` | +| 55b | Walled catalog refusal does not fire | metadata-core, rest, plugin-security | Get: under `isolated` / `group`, a create, change or delete of a position, a permission set or a capability, at the `/meta` write doors (save, reset, publish, rollback), at the permission-set clone door, and at the data door on `sys_position`, `sys_permission_set` and `sys_capability`. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3), because one organization's write would change what every organization resolves by name. The seeders, the package door and migrations write the catalog this way. Under `single` the refusal is inert for every caller | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict`, `packages/rest/src/rest-server.ts#walledCatalogRefusal`, `packages/plugins/plugin-security/src/walled-catalog-data-door.ts#createWalledCatalogDataDoorRefusal` | | 56 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A). The three doors ask one predicate, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#isRunStarterOrRunStateReader`, `#refuseUngrantedRunLifecycleWrite`, `packages/runtime/src/flow-start-admission.ts#refusesElevatedSelfTriggeredStart` | | 57 | Audience-binding suggestion authority gate passes | plugin-security | Get: a system caller lists, accepts and dismisses suggestions without `manage_metadata` and, under a wall, without the platform-operator rung | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertDeploymentAuthority` | | 58 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6), so the seeds, the boot sweep and the projection of a Studio save are the only writers. Lose (`sys_webhook`): the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` | @@ -286,7 +286,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are independent decisions, and a seed loader plausibly wants the first two but not the third. The concept is nevertheless **staying as one boolean**: -- **Shipped semantics.** `isSystem` is a published contract with 128 read sites +- **Shipped semantics.** `isSystem` is a published contract with 129 read sites in 20 packages. Splitting it is a breaking contract change across all of them. (The ruling was taken when the census read 80 sites in 18 packages; the count has grown, which strengthens rather than weakens the argument.) @@ -360,17 +360,17 @@ still holds equal to the census on every pull request: | Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | | — parsed as a declaration | 29 | ✅ | | — parsed as an object-literal / type key (producers and option objects) | 310 | — | -| — parsed as a property **read** | 134 | ✅ | +| — parsed as a property **read** | 135 | ✅ | | — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | | — the remainder: text inside comments and string literals | 358 | — | | Of those reads: reads of one of the unrelated metadata fields | 6 | ✅ | -| Of those reads: reads of `ExecutionContext.isSystem` | **128** | ✅ | -| — behaviour-bearing (rows 1–61 above) | 125 | ✅ | +| Of those reads: reads of `ExecutionContext.isSystem` | **129** | ✅ | +| — behaviour-bearing (rows 1–61 above) | 126 | ✅ | | — carry the flag onward only (rows 62–64 above) | 3 | ✅ | | Packages containing at least one elevation read | **20** | ✅ | | Files containing at least one elevation read | 59 | ✅ | | — the distinct symbols those reads live in — what this page anchors | 109 | ✅ | -| — of those files, the ones holding more than one read in one symbol | 8 | ✅ | +| — of those files, the ones holding more than one read in one symbol | 9 | ✅ | The six rows marked — are a **dated decomposition, not a live claim**: they were measured on 2026-08-29 at `ca1965f2b5` and CI does not re-derive them. They count @@ -433,7 +433,7 @@ same resolver, and the same registration shape, that holds `docs/adr/**`. Renaming a symbol is now a loud red instead of a silent misdirection. ⚠️ **The precision that costs, priced here rather than buried.** A symbol anchor -cannot say WHICH read inside a function it means, and **8** of the **59** +cannot say WHICH read inside a function it means, and **9** of the **59** anchored files hold more than one read inside a single symbol. So the population check runs per file at symbol granularity: every file the census finds a read in must be anchored, and the set of symbols this page cites into that file must From 5354b160163455c1558da61a18a66c905b4a310e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 21:26:23 +0000 Subject: [PATCH 08/25] chore(spec): the walled catalog refusal's changeset and ADR-0087 entry name the clone door Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude --- .changeset/15204-walled-catalog-refusal.md | 1 + .../semantic/18.walled-catalog-tenant-writes-retired.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.changeset/15204-walled-catalog-refusal.md b/.changeset/15204-walled-catalog-refusal.md index 9f40050b5d0..62823b94c0c 100644 --- a/.changeset/15204-walled-catalog-refusal.md +++ b/.changeset/15204-walled-catalog-refusal.md @@ -19,6 +19,7 @@ Clause-②: yes (narrowing) **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): - `PUT /meta/:type/:name`, `DELETE /meta/:type/:name`, `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` for `position`, `permission` or `capability`: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher's `PUT` answers `403 PERMISSION_DENIED`). The message names the posture and the capability. +- `POST /security/permission-sets/:name/clone` (the permission-set clone door): **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before the source is read. - A data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability`: **FROM** admitted to any caller whose grants admit the table (a `sys_permission_set` create landed in the environment ledger; a `sys_position` create landed as an organization row with no definition) **TO** `403 PERMISSION_DENIED`, before anything is written. **Unchanged.** Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows that an organization's administrators write for their own members. The platform operator and the engine's own writes (seeders, installers, migrations) write the catalog as before. Every other metadata type is untouched. Under `single` (and with no `tenancy` service), catalog writes stay with `manage_metadata` holders, exactly as before. The catalog activation door already required the platform operator under a wall. diff --git a/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts index 78af500a9d9..0f67b6f66b5 100644 --- a/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts @@ -14,7 +14,8 @@ export const entry: SemanticMigration = { surface: 'on a group or isolated deployment, a write of a position, permission set or capability by a ' + 'caller below the platform operator: PUT, DELETE, publish and rollback of /meta/position, ' - + '/meta/permission and /meta/capability on @objectstack/rest and the runtime dispatcher, and a ' + + '/meta/permission and /meta/capability on @objectstack/rest and the runtime dispatcher, the ' + + 'permission-set clone door (POST /security/permission-sets/:name/clone), and a ' + 'data-door insert, update or delete on sys_position, sys_permission_set and sys_capability ' + 'through @objectstack/plugin-security', replacement: From 2243159ea1a913d72590a77cd0bed2d69c35528b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 22:38:34 +0000 Subject: [PATCH 09/25] chore(spec): the walled catalog refusal's changeset and ADR-0087 surface: exact doors, capability's unchanged accept set, the D12 delegate Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude --- .changeset/15204-walled-catalog-refusal.md | 5 +++-- .../18.walled-catalog-tenant-writes-retired.ts | 10 +++++----- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/.changeset/15204-walled-catalog-refusal.md b/.changeset/15204-walled-catalog-refusal.md index 62823b94c0c..d22ed5c368b 100644 --- a/.changeset/15204-walled-catalog-refusal.md +++ b/.changeset/15204-walled-catalog-refusal.md @@ -18,9 +18,10 @@ Clause-②: yes (narrowing) **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): -- `PUT /meta/:type/:name`, `DELETE /meta/:type/:name`, `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` for `position`, `permission` or `capability`: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher's `PUT` answers `403 PERMISSION_DENIED`). The message names the posture and the capability. +- `PUT /meta/:type/:name`, `DELETE /meta/:type/:name`, `POST /meta/:type/:name/publish` and `POST /meta/:type/:name/rollback` on `@objectstack/rest`, and `PUT /meta/:type/:name` on the runtime dispatcher, for `position` or `permission`: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN` (the dispatcher's `PUT` answers `403 PERMISSION_DENIED`). The message names the posture and the capability. +- The same doors for `capability`: admitted where they were admitted. `capability` is a code-only type, so these doors already refused its writes before this change; only the sentence the caller reads changes, to the posture refusal. For `capability`, the accept set narrows at the data door on `sys_capability`, below. - `POST /security/permission-sets/:name/clone` (the permission-set clone door): **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before the source is read. -- A data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability`: **FROM** admitted to any caller whose grants admit the table (a `sys_permission_set` create landed in the environment ledger; a `sys_position` create landed as an organization row with no definition) **TO** `403 PERMISSION_DENIED`, before anything is written. +- A data-door insert, update or delete on `sys_position`, `sys_permission_set` or `sys_capability`: **FROM** admitted to any caller whose grants admit the table (a `sys_permission_set` create landed in the environment ledger; a `sys_position` create landed as an organization row with no definition) **TO** `403 PERMISSION_DENIED`, before anything is written. This includes an ADR-0090 D12 delegate whose `adminScope` carries `authorEnvironmentSets`: under a wall it can no longer author environment permission sets at the data door. Its assignment and binding paths are untouched. **Unchanged.** Assignments: `sys_user_position` and `sys_user_permission_set` rows stay organization rows that an organization's administrators write for their own members. The platform operator and the engine's own writes (seeders, installers, migrations) write the catalog as before. Every other metadata type is untouched. Under `single` (and with no `tenancy` service), catalog writes stay with `manage_metadata` holders, exactly as before. The catalog activation door already required the platform operator under a wall. diff --git a/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts index 0f67b6f66b5..9452e6b4d95 100644 --- a/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-tenant-writes-retired.ts @@ -13,11 +13,11 @@ export const entry: SemanticMigration = { // code span AND a table cell. surface: 'on a group or isolated deployment, a write of a position, permission set or capability by a ' - + 'caller below the platform operator: PUT, DELETE, publish and rollback of /meta/position, ' - + '/meta/permission and /meta/capability on @objectstack/rest and the runtime dispatcher, the ' - + 'permission-set clone door (POST /security/permission-sets/:name/clone), and a ' - + 'data-door insert, update or delete on sys_position, sys_permission_set and sys_capability ' - + 'through @objectstack/plugin-security', + + 'caller below the platform operator (an ADR-0090 D12 delegate whose scope authors environment ' + + 'sets included): PUT, DELETE, publish and rollback of /meta/position, /meta/permission and ' + + '/meta/capability on @objectstack/rest, and PUT on the runtime dispatcher; the permission-set ' + + 'clone door (POST /security/permission-sets/:name/clone); and a data-door insert, update or ' + + 'delete on sys_position, sys_permission_set and sys_capability through @objectstack/plugin-security', replacement: 'the platform operator (the `PLATFORM_ADMIN` rung, the deployment\'s declared ' + '`OS_PLATFORM_OWNER_EMAIL` owner) defines the catalog, in Studio or through a managed package; ' From a337a2fa625a03a247b4a172a0cc8ee445db1469 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 22:55:10 +0000 Subject: [PATCH 10/25] docs(permissions,spec): the walled caveat on authorEnvironmentSets, the clone door and the Setup env-door write (ADR-0131 D3) Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude --- content/docs/permissions/authorization.mdx | 4 +++- content/docs/permissions/delegated-administration.mdx | 2 +- content/docs/permissions/permission-sets.mdx | 4 +++- packages/spec/src/security/permission.zod.ts | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/content/docs/permissions/authorization.mdx b/content/docs/permissions/authorization.mdx index 8d5d9470c32..e4cfcd9426c 100644 --- a/content/docs/permissions/authorization.mdx +++ b/content/docs/permissions/authorization.mdx @@ -242,7 +242,9 @@ one of two doors, each writing only what it owns: CRUD) is transparently redirected into an env-scope metadata save (`saveMetaItem`), and the data record is a **pure projection** the platform re-derives on every metadata mutation (awaited — no staleness window) and at - boot. Deleting an artifact-backed set through this door **resets** it to its + boot. Under a `group` or `isolated` tenancy posture that Setup write is the + platform operator's: anyone else is refused `403 PERMISSION_DENIED` before the + redirect (ADR-0131 D3). Deleting an artifact-backed set through this door **resets** it to its declared body rather than removing it; renaming through the data door is rejected (the name is the metadata identity — clone instead). Subject assignments remain plain config rows, unchanged. diff --git a/content/docs/permissions/delegated-administration.mdx b/content/docs/permissions/delegated-administration.mdx index ba00c335783..5fe22db80e1 100644 --- a/content/docs/permissions/delegated-administration.mdx +++ b/content/docs/permissions/delegated-administration.mdx @@ -58,7 +58,7 @@ Writes to the governed tables (`sys_user_position`, | Tenant admins pass through | The superuser wildcard reaches the ordinary CRUD/RLS checks — delegation constrains *delegates*, not HQ | | Subtree anchoring | Assignments a delegate creates must be anchored (`sys_user_position.business_unit_id`) inside their subtree, and the target user must sit inside it | | Allowlist, no self-escalation | Every set reached by the write — bound to the assigned position, or granted directly — must be on `assignablePermissionSets`. Granting an un-allowlisted set is refused **for anyone, including the delegate themselves** | -| Action flags | `manageAssignments` / `manageBindings` / `authorEnvironmentSets` gate their table classes independently; re-composing a position (`manageBindings`) additionally requires every current holder to sit inside the subtree | +| Action flags | `manageAssignments` / `manageBindings` / `authorEnvironmentSets` gate their table classes independently; re-composing a position (`manageBindings`) additionally requires every current holder to sit inside the subtree. Under a `group` or `isolated` tenancy posture `authorEnvironmentSets` admits nothing at the data door: the platform operator authors the security catalog there (ADR-0131 D3). It still applies under `single` | | Strict containment | Granting or authoring a set that itself carries an `adminScope` requires a held scope that **strictly contains** it — handing your own exact scope to a peer is refused (no lateral propagation) | | Single-row writes | Delegates write single rows by id only — a broad filter-write cannot be boundary-checked | | Audit stamp | Every assignment a delegate creates is `granted_by`-stamped automatically | diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index d40d7c0bdc2..173313b77d6 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -417,7 +417,9 @@ it was copied from, so changes to the source never reach the copy. - **The copy is held by nobody** until you add it to a position or assign it. - **Who may call it** is who may save a permission set through the metadata API: a holder of `manage_metadata`. Anyone else is refused `403` - `PERMISSION_DENIED`. + `PERMISSION_DENIED`. Under a `group` or `isolated` tenancy posture the clone + is the platform operator's alone (ADR-0131 D3): a `manage_metadata` holder + inside one organization is refused `403` `PERMISSION_DENIED` too. ### Declared ≠ enforced — diagnosing a frozen package set diff --git a/packages/spec/src/security/permission.zod.ts b/packages/spec/src/security/permission.zod.ts index a09d39f0898..fbc81c70005 100644 --- a/packages/spec/src/security/permission.zod.ts +++ b/packages/spec/src/security/permission.zod.ts @@ -691,7 +691,7 @@ export const AdminScopeSchema = lazySchema(() => strictObject( /** May create/delete `sys_position_permission_set` bindings whose blast radius lies within the boundary. */ manageBindings: z.boolean().default(false).describe('Manage position↔permission-set bindings within the subtree'), /** May author environment-owned permission sets (package-managed rows stay publish-only per ADR-0086). */ - authorEnvironmentSets: z.boolean().default(false).describe('Author environment-owned permission sets'), + authorEnvironmentSets: z.boolean().default(false).describe('Author environment-owned permission sets (single posture only: under group or isolated the platform operator authors the catalog, ADR-0131 D3)'), /** * The anti-self-escalation core: permission-set NAMES the delegate may * assign/bind. Every set reached by a delegated write must be in this list; From 637f1269b2ccbf52ffe706a0389f43b16f75b860 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 11 Oct 2026 23:01:32 +0000 Subject: [PATCH 11/25] docs(references): regenerate the permission reference for the authorEnvironmentSets describe Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP Co-authored-by: Claude --- content/docs/references/security/permission.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/security/permission.mdx b/content/docs/references/security/permission.mdx index 224659ec51f..e3895711ec6 100644 --- a/content/docs/references/security/permission.mdx +++ b/content/docs/references/security/permission.mdx @@ -32,7 +32,7 @@ const result = AdminScopeSchema.parse(data); | **includeSubtree** | `boolean` | optional (default: `true`) | Cover descendant business units too (default true) | | **manageAssignments** | `boolean` | optional (default: `false`) | Manage user↔position assignments within the subtree | | **manageBindings** | `boolean` | optional (default: `false`) | Manage position↔permission-set bindings within the subtree | -| **authorEnvironmentSets** | `boolean` | optional (default: `false`) | Author environment-owned permission sets | +| **authorEnvironmentSets** | `boolean` | optional (default: `false`) | Author environment-owned permission sets (single posture only: under group or isolated the platform operator authors the catalog, ADR-0131 D3) | | **assignablePermissionSets** | `string[]` | optional (default: `[]`) | Allowlist of permission-set names the delegate may hand out | @@ -183,7 +183,7 @@ const result = AdminScopeSchema.parse(data); | **includeSubtree** | `boolean` | optional (default: `true`) | Cover descendant business units too (default true) | | **manageAssignments** | `boolean` | optional (default: `false`) | Manage user↔position assignments within the subtree | | **manageBindings** | `boolean` | optional (default: `false`) | Manage position↔permission-set bindings within the subtree | -| **authorEnvironmentSets** | `boolean` | optional (default: `false`) | Author environment-owned permission sets | +| **authorEnvironmentSets** | `boolean` | optional (default: `false`) | Author environment-owned permission sets (single posture only: under group or isolated the platform operator authors the catalog, ADR-0131 D3) | | **assignablePermissionSets** | `string[]` | optional (default: `[]`) | Allowlist of permission-set names the delegate may hand out | ### Nested Shape: `PermissionSet.protection` From 9dbf6bc6ee4379a9e55489491fe0e0812a91b164 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 02:24:57 +0000 Subject: [PATCH 12/25] feat(security): refuse catalog-carrying package writes under a wall (ADR-0131 D3) Under group/isolated, below the platform operator rung, install-local refuses an install whose incoming version declares a position, permission set or capability, an upgrade where either version declares one, and an uninstall of a package whose installed version declares one; DELETE /packages/:id refuses a package holding stored catalog rows. Both ask walledCatalogWriteVerdict once per declared type. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP --- content/docs/permissions/capabilities.mdx | 7 + packages/cloud-connection/package.json | 1 + .../src/marketplace-install-local-plugin.ts | 153 +++++++++- packages/objectql/src/index.ts | 10 + .../test/wall-package-refusal.dogfood.test.ts | 281 ++++++++++++++++++ packages/runtime/src/domains/packages.ts | 75 ++++- pnpm-lock.yaml | 3 + 7 files changed, 526 insertions(+), 4 deletions(-) create mode 100644 packages/qa/dogfood/test/wall-package-refusal.dogfood.test.ts diff --git a/content/docs/permissions/capabilities.mdx b/content/docs/permissions/capabilities.mdx index 2ac0247ee4d..ce917cd917d 100644 --- a/content/docs/permissions/capabilities.mdx +++ b/content/docs/permissions/capabilities.mdx @@ -242,6 +242,13 @@ has no counterpart in it — nothing in code would ever *require* the name, so t result is an unreferenced grant target sitting in the live authorization namespace. `job` and `agent` carry the same pair for the same reason. +Under a `group` or `isolated` tenancy posture, a caller below the platform +operator reads a different refusal, with the same `403` status: the security +catalog refusal (ADR-0131 D3), whose message names the posture, with the code +`FORBIDDEN` on the REST door and `PERMISSION_DENIED` on the runtime dispatcher. +It runs before the code-only refusal, so the block above is the answer the +platform operator reads, and every caller under `single`. + Two things this does **not** close: - **The package-declaration channel is untouched.** `AppPlugin` registers stack diff --git a/packages/cloud-connection/package.json b/packages/cloud-connection/package.json index f2757402ae2..bfd482cc1f5 100644 --- a/packages/cloud-connection/package.json +++ b/packages/cloud-connection/package.json @@ -27,6 +27,7 @@ "@objectstack/core": "workspace:*", "@objectstack/metadata-core": "workspace:*", "@objectstack/metadata-protocol": "workspace:*", + "@objectstack/objectql": "workspace:*", "@objectstack/runtime": "workspace:*", "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*" diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 10b51ce6aae..2b444ae5265 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -121,7 +121,19 @@ import { protocolIncompatibleAnswer, ProtocolIncompatibleError, type ProtocolIncompatibleDiagnostic, + // [#15204 W2] ADR-0131 D3's one predicate, asked here per declared catalog + // type: the same sentence, the same rung, as every catalog write door. + walledCatalogWriteVerdict, + SECURITY_CATALOG_TYPES, } from '@objectstack/metadata-core'; +// [#15204 W2] The engine's own reading of what a manifest declares, and of +// what the running registry holds under a package, so this door judges the +// declarations the engine registers and not a second reading of them. +import { + declaredSecurityCatalogNames, + findPackageHeldSecurityCatalogNames, + type DeclaredSecurityCatalogName, +} from '@objectstack/objectql'; import { resolveCloudUrl } from './cloud-url.js'; import { resolveMarketplacePublicBaseUrl } from './marketplace-public-url.js'; import { join } from 'node:path'; @@ -400,6 +412,20 @@ function organizationWallActive(ctx: PluginContext): boolean { const NO_ACTIVE_ORGANIZATION_CODE = 'PERMISSION_DENIED'; const NO_ACTIVE_ORGANIZATION_STATUS = 403; +/** + * [#15204 W2] What an admitted mutating request carries past + * `requireInstallCapability`: the acting user, plus the two inputs ADR-0131 + * D3's refusal needs, both off the admission's single authorization read. + */ +interface InstallAdmission { + ok: true; + userId: string; + /** The caller's ADR-0095 posture rung. */ + callerPosture: unknown; + /** The EFFECTIVE tenancy posture; `undefined` when no `tenancy` service is registered. */ + tenancyPosture: TenancyPosture | undefined; +} + /** The sample-data doors that seed or purge under the caller's active organization. */ type SampleDataDoor = 'install' | 'reseed' | 'purge'; @@ -1115,6 +1141,19 @@ export class MarketplaceInstallLocalPlugin implements Plugin { const manifestId = declaredId.data; if (inlineManifest) packageId = manifestId; + // [#15204 W2] ADR-0131 D3: under a wall, a package whose incoming or + // installed version carries a catalog item is the operator's to + // install, upgrade or replace. Asked before any later step of this + // install reads or writes anything. + { + const installed = this.ledger.read(manifestId).entry?.manifest; + const walled = this.refuseWalledCatalogPackage( + c, ctx, 'Installing', manifestId, admission, + installed === undefined ? [manifest] : [manifest, installed], + ); + if (walled) return walled; + } + // 1c. [#21762] ⭐ ADR-0087 D1'S PROTOCOL HANDSHAKE, before anything is // registered, written or synced: the same `assertProtocolCompat` // the other package-install door (`POST /api/v1/packages`) and the @@ -1637,6 +1676,16 @@ export class MarketplaceInstallLocalPlugin implements Plugin { if (!this.ledger.has(manifestId)) { return c.json({ success: false, error: { code: 'RESOURCE_NOT_FOUND', message: `No marketplace install for ${manifestId}.` } }, 404); } + // [#15204 W2] ADR-0131 D3: under a wall, uninstalling a package that + // carries a catalog item is the operator's: it removes those items + // from every organization's catalog. Asked before the ledger is touched. + { + const walled = this.refuseWalledCatalogPackage( + c, ctx, 'Uninstalling', manifestId, admission, + [this.ledger.read(manifestId).entry?.manifest], + ); + if (walled) return walled; + } try { this.ledger.remove(manifestId); } catch (err: any) { @@ -2800,7 +2849,14 @@ export class MarketplaceInstallLocalPlugin implements Plugin { private resolveInstallPrincipal = async ( c: any, ctx: PluginContext, - ): Promise<{ userId: string; systemPermissions: string[] } | null> => { + ): Promise<{ + userId: string; + systemPermissions: string[]; + /** [#15204 W2] The ADR-0095 posture rung the resolver derived. */ + posture?: unknown; + /** [#15204 W2] The EFFECTIVE tenancy posture this admission resolved. */ + tenancyPosture?: TenancyPosture; + } | null> => { const headers = c?.req?.raw?.headers; if (!headers) return null; try { @@ -2841,6 +2897,8 @@ export class MarketplaceInstallLocalPlugin implements Plugin { return { userId: String(authz.userId), systemPermissions: Array.isArray(authz.systemPermissions) ? authz.systemPermissions : [], + posture: authz.posture, + tenancyPosture, }; } catch (err) { // [commit 6a180e42d] `null` here means "nobody is authenticated", which is @@ -2889,7 +2947,7 @@ export class MarketplaceInstallLocalPlugin implements Plugin { c: any, ctx: PluginContext, action: string, - ): Promise<{ ok: true; userId: string } | { ok: false; response: Response }> => { + ): Promise => { const principal = await this.resolveInstallPrincipal(c, ctx); if (!principal) return { ok: false, response: this.refuseUnauthenticated(c) }; if (!principal.systemPermissions.includes(INSTALL_LOCAL_CAPABILITY)) { @@ -2908,7 +2966,96 @@ export class MarketplaceInstallLocalPlugin implements Plugin { }, 403), }; } - return { ok: true, userId: principal.userId }; + return { + ok: true, + userId: principal.userId, + callerPosture: principal.posture, + tenancyPosture: principal.tenancyPosture, + }; + }; + + /** + * [#15204 W2] ADR-0131 D3 at the package door: under a wall the security + * catalog is the platform operator's, and a package that declares a + * position, a permission set or a capability is the operator's instrument. + * + * Measured on `main` before this refusal: an organization's + * `manage_metadata` holder (rung `TENANT_ADMIN`) upgraded an + * operator-installed package at a new version, adding `manage_users` to a + * permission set another organization's member held, and that member + * resolved `manage_users` next; the same holder's uninstall took the set + * away from them. `refuseSecurityCatalogNameConflicts` refuses only a + * SHADOW of a name another holder has, never a write by the package that + * holds it. + * + * So the WHOLE package is refused, before any write, when any of + * `manifests` (the incoming version, the installed one, or both) declares + * a catalog item, or the running registry holds one under `manifestId`. + * A partial install is not well-formed: a package is registered whole. + * The decision is {@link walledCatalogWriteVerdict}'s, asked once per + * declared type and never restated: under `single`, with no `tenancy` + * service, or for the `PLATFORM_ADMIN` rung it admits, and a package with + * no catalog item never reaches it. The answer is the sibling refusals' + * envelope, `403 FORBIDDEN`. + */ + private refuseWalledCatalogPackage = ( + c: any, + ctx: PluginContext, + action: string, + manifestId: string, + admission: InstallAdmission, + manifests: readonly unknown[], + ): Response | null => { + const declared = this.catalogItemsOf(ctx, manifestId, manifests); + if (declared.length === 0) return null; + for (const type of SECURITY_CATALOG_TYPES) { + if (!declared.some((item) => item.type === type)) continue; + const verdict = walledCatalogWriteVerdict({ + type, + tenancyPosture: admission.tenancyPosture, + callerPosture: admission.callerPosture, + }); + if (verdict.allowed) continue; + ctx.logger?.warn?.( + `[MarketplaceInstallLocal] refused ${action} ${manifestId} for ${admission.userId}: ` + + `the package carries security catalog items under the '${admission.tenancyPosture}' posture (ADR-0131 D3)`, + ); + return c.json({ + success: false, + error: { + code: 'FORBIDDEN', + message: `${action} "${manifestId}" is refused: the package carries security catalog items. ${verdict.message}`, + details: { manifestId, catalogItems: declared }, + }, + }, 403); + } + return null; + }; + + /** + * [#15204 W2] Every catalog item `manifests` declare, plus every position + * and permission-set name the running registry holds under `manifestId` + * (a package the registry holds outside this door's ledger, such as a + * writable runtime package an operator authored items into). Read-only. + */ + private catalogItemsOf = ( + ctx: PluginContext, + manifestId: string, + manifests: readonly unknown[], + ): DeclaredSecurityCatalogName[] => { + const out = new Map(); + const add = (item: DeclaredSecurityCatalogName) => out.set(`${item.type}:${item.name}`, item); + for (const manifest of manifests) for (const item of declaredSecurityCatalogNames(manifest)) add(item); + let registry: Parameters[0] | undefined; + try { + registry = (ctx.getService('objectql') as { registry?: typeof registry } | undefined)?.registry; + } catch { /* no data engine: the manifests are the whole reading */ } + if (registry) { + for (const held of findPackageHeldSecurityCatalogNames(registry)) { + if (held.packageIds.includes(manifestId)) add({ type: held.catalogType, name: held.name }); + } + } + return [...out.values()]; }; /** diff --git a/packages/objectql/src/index.ts b/packages/objectql/src/index.ts index d3a216a6cd3..658c8ad2810 100644 --- a/packages/objectql/src/index.ts +++ b/packages/objectql/src/index.ts @@ -94,6 +94,16 @@ export { // stay private to the engine. export { findPackageHeldSecurityCatalogNames } from './registry.js'; +// [#15204 W2] Every `(type, name)` a manifest declares, read from the sources +// the engine's registration seams read. Exported for the install-local door +// (`@objectstack/cloud-connection`), which refuses a catalog-carrying package +// under a wall (ADR-0131 D3) and must judge the declarations the engine +// registers, not a second reading of the manifest. +export { + declaredSecurityCatalogNames, + type DeclaredSecurityCatalogName, +} from './security-catalog-namespace.js'; + // [#14553] The navigation-contribution group diagnostic (ADR-0029 D7, // ADR-0112 D6c). Exported because `os build` is the SECOND door that has to diff --git a/packages/qa/dogfood/test/wall-package-refusal.dogfood.test.ts b/packages/qa/dogfood/test/wall-package-refusal.dogfood.test.ts new file mode 100644 index 00000000000..93405081169 --- /dev/null +++ b/packages/qa/dogfood/test/wall-package-refusal.dogfood.test.ts @@ -0,0 +1,281 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0131 D3 · #15204 W2] The walled catalog refusal at the PACKAGE doors, on +// a REAL booted stack. +// +// ADR-0131 D3: under `group` / `isolated` "tenant admins cannot create or edit +// catalog items; the operator defines the catalog for every tenant (managed +// packages, Studio)". A package that declares a position, a permission set or +// a capability is the operator's instrument. +// +// What this harness measured before the refusal, under both walled postures: +// organization A's owner, granted `manage_metadata` inside organization A only +// (rung `TENANT_ADMIN`, never the platform operator): +// - re-installed an operator-installed package at a new version through +// `POST /api/v1/marketplace/install-local`, adding `manage_users` to a +// permission set organization B's member holds: `200`, and B's member +// resolved `manage_users` next; +// - uninstalled it through `DELETE /api/v1/marketplace/install-local/:id`: +// `200`, the set left the catalog and B's member lost it; +// - deleted, through `DELETE /api/v1/packages/:id`, a writable runtime +// package the operator had saved a permission set into +// (`PUT /meta/permission/:name?package=`): `200`, the same loss for B. +// `install-local` also registered A's own catalog-carrying package +// environment-wide. +// +// Pinned here, under each walled posture: +// - an install whose incoming version declares a catalog item, an upgrade +// where either version declares one, and an uninstall of a package whose +// installed version declares one answer `403 FORBIDDEN`, and B's +// resolution does not move; +// - the `/packages` delete of a package holding stored catalog items answers +// `403 PERMISSION_DENIED`, and B's resolution does not move; +// - CONTROLS: A installs, upgrades and uninstalls a catalog-free package and +// deletes a catalog-free runtime package as before; the platform operator +// upgrades, uninstalls and deletes the catalog-carrying ones. +// And under `single` (CONTROL): the same `manage_metadata` holder, who is not +// the platform operator, does all of it as before. +// +// The boot is `multiTenant: 'posture-only'`: a real, non-degraded walled +// posture. The refusal keys on the posture and the caller's rung, never on row +// isolation. The harness declares the seeded admin as `OS_PLATFORM_OWNER_EMAIL`, +// so that admin is the platform operator. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import crmStack from '@objectstack/example-crm'; +import { resolveUserAuthzGrants } from '@objectstack/core'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { MarketplaceInstallLocalPlugin } from '@objectstack/cloud-connection'; +import { mkdtempSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { buildShapedArtifact } from './build-shaped-artifact.js'; + +const SYS = { isSystem: true, positions: [], permissions: [] }; +const INSTALL_LOCAL = '/marketplace/install-local'; + +interface Engine { + find(object: string, options?: unknown): Promise>>; + insert(object: string, data: Record, options?: unknown): Promise; +} + +/** The ADR-0112 envelope a refusal answered: `error.code`, the message and the HTTP status. */ +async function envelopeOf(res: Response): Promise<{ status: number; code: unknown; message: string }> { + const body = (await res.clone().json().catch(() => ({}))) as { error?: { code?: unknown; message?: unknown } }; + return { status: res.status, code: body?.error?.code, message: String(body?.error?.message ?? '') }; +} + +/** An install-local body for a tiny app stack, with the catalog items it declares. */ +function packageBody(id: string, version: string, catalog: { permissions?: unknown[]; positions?: unknown[] } = {}) { + const stackDef = { + manifest: { id, name: id, version, type: 'app' }, + permissions: catalog.permissions ?? [], + positions: catalog.positions ?? [], + }; + const { artifact } = buildShapedArtifact(stackDef as unknown as Record); + return { manifest: { ...artifact, id, version } }; +} + +const set = (name: string, systemPermissions: string[]) => ({ name, label: name, objects: {}, systemPermissions }); + +const META_AUTHOR = 'w2_meta_author'; +const OP_PKG = 'com.w2.operator'; +const OP_SET = 'w2_operator_set'; +const RT_PKG = 'com.w2.runtime'; +const RT_SET = 'w2_runtime_set'; + +interface Harness { + stack: VerifyStack; + ql: Engine; + operator: string; + tokA: string; + userA: string; + userB: string; + orgA?: string; + orgB?: string; +} + +/** + * Boot, sign in the operator, A and B; under a wall each gets an organization. + * The operator installs `OP_PKG` (carrying `OP_SET`), creates the writable + * runtime package `RT_PKG` and saves `RT_SET` into it; B holds both sets; A + * holds `manage_metadata` (inside org A under a wall). + */ +async function boot(posture: 'isolated' | 'group' | 'single'): Promise { + const walled = posture !== 'single'; + if (walled) process.env.OS_TENANCY_POSTURE = posture; + else delete process.env.OS_TENANCY_POSTURE; + const storageDir = mkdtempSync(join(tmpdir(), `w2-wall-package-${posture}-`)); + const stack = await bootStack(crmStack as never, { + ...(walled ? { multiTenant: 'posture-only' as const } : {}), + extraPlugins: [new MarketplaceInstallLocalPlugin({ controlPlaneUrl: 'off', storageDir })], + } as never); + const ql = (await stack.kernel.getServiceAsync('objectql')) as unknown as Engine; + const operator = await stack.signIn(); + const tokA = await stack.signUp(`w2-a-${posture}@wall.test`); + const tokB = await stack.signUp(`w2-b-${posture}@wall.test`); + const h: Harness = { stack, ql, operator, tokA, userA: '', userB: '' }; + if (walled) { + for (const [tok, slug] of [[tokA, `w2-a-${posture}`], [tokB, `w2-b-${posture}`]] as const) { + const created = await stack.apiAs(tok, 'POST', '/auth/organization/create', { name: slug, slug }); + expect(created.status, await created.clone().text()).toBe(200); + const id = ((await created.json()) as { id: string }).id; + if (tok === tokA) h.orgA = id; else h.orgB = id; + const active = await stack.apiAs(tok, 'POST', '/auth/organization/set-active', { organizationSlug: slug }); + expect(active.status, await active.clone().text()).toBe(200); + } + } + const idOf = async (email: string) => + String((await ql.find('sys_user', { where: { email }, limit: 1, context: SYS }))[0]?.id); + h.userA = await idOf(`w2-a-${posture}@wall.test`); + h.userB = await idOf(`w2-b-${posture}@wall.test`); + + const saved = await stack.apiAs(operator, 'PUT', `/meta/permission/${META_AUTHOR}`, set(META_AUTHOR, ['manage_metadata'])); + expect(saved.status, await saved.clone().text()).toBe(200); + const installed = await stack.apiAs(operator, 'POST', INSTALL_LOCAL, packageBody(OP_PKG, '1.0.0', { permissions: [set(OP_SET, [])] })); + expect(installed.status, await installed.clone().text()).toBe(200); + const created = await stack.apiAs(operator, 'POST', '/packages', { id: RT_PKG, name: 'w2 runtime', version: '1.0.0', type: 'app' }); + expect(created.status, await created.clone().text()).toBe(201); + const authored = await stack.apiAs(operator, 'PUT', `/meta/permission/${RT_SET}?package=${RT_PKG}`, set(RT_SET, [])); + expect(authored.status, await authored.clone().text()).toBe(200); + + const grant = (user: string, permissionSet: string, org: string | undefined) => + ql.insert('sys_user_permission_set', { user_id: user, permission_set: permissionSet, ...(org ? { organization_id: org } : {}) }, + { context: { ...SYS, ...(org ? { tenantId: org } : {}) } }); + await grant(h.userA, META_AUTHOR, h.orgA); + await grant(h.userB, OP_SET, h.orgB); + await grant(h.userB, RT_SET, h.orgB); + return h; +} + +const grantsOf = (h: Harness, user: string, org: string | undefined) => + resolveUserAuthzGrants(h.ql as never, user, org ? { tenantId: org } : {}); + +for (const posture of ['isolated', 'group'] as const) { + describe(`[ADR-0131 D3 · #15204 W2] ${posture}: a catalog-carrying package is the platform operator's`, () => { + let h: Harness; + const previous = process.env.OS_TENANCY_POSTURE; + const bGrants = () => grantsOf(h, h.userB, h.orgB); + + beforeAll(async () => { h = await boot(posture); }, 180_000); + afterAll(async () => { + await h?.stack?.stop?.(); + if (previous === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = previous; + }); + + it('guard the guard: a real walled posture; A holds `manage_metadata` in org A and is not the platform operator; B holds both sets', async () => { + const tenancy = await h.stack.kernel.getServiceAsync<{ posture: string; degraded: boolean }>('tenancy'); + expect(tenancy.posture).toBe(posture); + expect(tenancy.degraded).toBe(false); + expect(h.orgA).toBeTruthy(); + expect(h.orgA).not.toBe(h.orgB); + const a = await grantsOf(h, h.userA, h.orgA); + expect(a.systemPermissions).toContain('manage_metadata'); + expect(a.posture).not.toBe('PLATFORM_ADMIN'); + const b = await bGrants(); + expect(b.permissions).toEqual(expect.arrayContaining([OP_SET, RT_SET])); + expect(b.systemPermissions).not.toContain('manage_users'); + }); + + it('install: A installing a package whose incoming version declares a permission set is refused 403 FORBIDDEN, naming the posture', async () => { + const res = await h.stack.apiAs(h.tokA, 'POST', INSTALL_LOCAL, + packageBody('com.w2.tenant', '1.0.0', { permissions: [set('w2_tenant_set', ['manage_users'])] })); + const env = await envelopeOf(res); + expect({ status: env.status, code: env.code }).toEqual({ status: 403, code: 'FORBIDDEN' }); + expect(env.message).toContain(`'${posture}'`); + expect(env.message).toContain('ADR-0131 D3'); + const listed = await h.stack.apiAs(h.operator, 'GET', '/meta/permission/w2_tenant_set'); + expect(listed.status).toBe(404); + }); + + it('install: a position alone in the incoming version is refused the same way', async () => { + const res = await h.stack.apiAs(h.tokA, 'POST', INSTALL_LOCAL, + packageBody('com.w2.tenantpos', '1.0.0', { positions: [{ name: 'w2_tenant_pos', label: 'tenant pos' }] })); + expect(await envelopeOf(res)).toMatchObject({ status: 403, code: 'FORBIDDEN' }); + }); + + it('upgrade (the measured edge): A re-installing the operator\'s package with `manage_users` added is refused, and B does not gain it', async () => { + const res = await h.stack.apiAs(h.tokA, 'POST', INSTALL_LOCAL, + packageBody(OP_PKG, '1.1.0', { permissions: [set(OP_SET, ['manage_users'])] })); + expect(await envelopeOf(res)).toMatchObject({ status: 403, code: 'FORBIDDEN' }); + expect((await bGrants()).systemPermissions).not.toContain('manage_users'); + }); + + it('upgrade: a catalog-free incoming version over an installed catalog-carrying one is refused too, and B keeps the set', async () => { + const res = await h.stack.apiAs(h.tokA, 'POST', INSTALL_LOCAL, packageBody(OP_PKG, '1.2.0')); + expect(await envelopeOf(res)).toMatchObject({ status: 403, code: 'FORBIDDEN' }); + expect((await bGrants()).permissions).toContain(OP_SET); + }); + + it('uninstall (the measured edge): A uninstalling the operator\'s package is refused, and B keeps the set', async () => { + const res = await h.stack.apiAs(h.tokA, 'DELETE', `${INSTALL_LOCAL}/${OP_PKG}`); + expect(await envelopeOf(res)).toMatchObject({ status: 403, code: 'FORBIDDEN' }); + expect((await bGrants()).permissions).toContain(OP_SET); + }); + + it('/packages delete (the measured edge): A deleting a runtime package holding the operator\'s set is refused 403 PERMISSION_DENIED, and B keeps the set', async () => { + const res = await h.stack.apiAs(h.tokA, 'DELETE', `/packages/${RT_PKG}`); + const env = await envelopeOf(res); + expect({ status: env.status, code: env.code }).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect(env.message).toContain(`'${posture}'`); + expect((await bGrants()).permissions).toContain(RT_SET); + const still = await h.stack.apiAs(h.operator, 'GET', `/packages/${RT_PKG}`); + expect(still.status).toBe(200); + }); + + it('CONTROL: A installs, upgrades and uninstalls a catalog-free package, and deletes a catalog-free runtime package, as before', async () => { + for (const version of ['1.0.0', '1.1.0']) { + const res = await h.stack.apiAs(h.tokA, 'POST', INSTALL_LOCAL, packageBody('com.w2.free', version)); + expect(res.status, await res.clone().text()).toBe(200); + } + const removed = await h.stack.apiAs(h.tokA, 'DELETE', `${INSTALL_LOCAL}/com.w2.free`); + expect(removed.status, await removed.clone().text()).toBe(200); + const created = await h.stack.apiAs(h.operator, 'POST', '/packages', { id: 'com.w2.freert', name: 'free', version: '1.0.0', type: 'app' }); + expect(created.status, await created.clone().text()).toBe(201); + const deleted = await h.stack.apiAs(h.tokA, 'DELETE', '/packages/com.w2.freert'); + expect(deleted.status, await deleted.clone().text()).toBe(200); + }); + + it('CONTROL: the platform operator upgrades, deletes and uninstalls the catalog-carrying packages', async () => { + const upgraded = await h.stack.apiAs(h.operator, 'POST', INSTALL_LOCAL, + packageBody(OP_PKG, '1.1.0', { permissions: [set(OP_SET, ['manage_users'])] })); + expect(upgraded.status, await upgraded.clone().text()).toBe(200); + expect((await bGrants()).systemPermissions).toContain('manage_users'); + const deleted = await h.stack.apiAs(h.operator, 'DELETE', `/packages/${RT_PKG}`); + expect(deleted.status, await deleted.clone().text()).toBe(200); + const removed = await h.stack.apiAs(h.operator, 'DELETE', `${INSTALL_LOCAL}/${OP_PKG}`); + expect(removed.status, await removed.clone().text()).toBe(200); + expect((await bGrants()).permissions).not.toEqual(expect.arrayContaining([OP_SET])); + }); + }); +} + +describe('[ADR-0131 D3 · #15204 W2] single (CONTROL): the refusal is inert', () => { + let h: Harness; + const previous = process.env.OS_TENANCY_POSTURE; + + beforeAll(async () => { h = await boot('single'); }, 180_000); + afterAll(async () => { + await h?.stack?.stop?.(); + if (previous === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = previous; + }); + + it('a `manage_metadata` holder below the operator installs, upgrades and uninstalls catalog-carrying packages, and deletes one at /packages', async () => { + const a = await grantsOf(h, h.userA, undefined); + expect(a.systemPermissions).toContain('manage_metadata'); + expect(a.posture).not.toBe('PLATFORM_ADMIN'); + const installed = await h.stack.apiAs(h.tokA, 'POST', INSTALL_LOCAL, + packageBody('com.w2.tenant', '1.0.0', { permissions: [set('w2_tenant_set', [])] })); + expect(installed.status, await installed.clone().text()).toBe(200); + const upgraded = await h.stack.apiAs(h.tokA, 'POST', INSTALL_LOCAL, + packageBody(OP_PKG, '1.1.0', { permissions: [set(OP_SET, ['manage_users'])] })); + expect(upgraded.status, await upgraded.clone().text()).toBe(200); + expect((await grantsOf(h, h.userB, undefined)).systemPermissions).toContain('manage_users'); + const removed = await h.stack.apiAs(h.tokA, 'DELETE', `${INSTALL_LOCAL}/${OP_PKG}`); + expect(removed.status, await removed.clone().text()).toBe(200); + const deleted = await h.stack.apiAs(h.tokA, 'DELETE', `/packages/${RT_PKG}`); + expect(deleted.status, await deleted.clone().text()).toBe(200); + }); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index c854d1ee036..321b3d6df17 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -32,6 +32,11 @@ import { // `OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES` — same value it read before, // no re-ruling of the package cohort as a side effect of #7020. import { OBJECT_SCHEMA_READ_ONLY_EXEMPT_CAPABILITIES } from '@objectstack/metadata-core'; +// [#15204 W2] ADR-0131 D3's one predicate, asked by the package delete below +// per stored catalog type, never restated here. +import { SECURITY_CATALOG_TYPES, isSecurityCatalogType, walledCatalogWriteVerdict } from '@objectstack/metadata-core'; +import { readEffectiveTenancyPosture } from './activation-gate.js'; +import type { IObjectQLEngine } from '@objectstack/spec/contracts'; // [#21727] ADR-0087 D1's protocol handshake and the brand predicate for its // refusal. The install branch asks the predicate ahead of its generic catch, // and its no-protocol fallback runs the same handshake. @@ -404,6 +409,68 @@ function requireReadCapability(deps: DomainHandlerDeps, context: HttpProtocolCon * id still falls through to the route's own 404 rather than being re-labelled * 422 (and so this gate never becomes an existence oracle of its own). */ +/** + * [#15204 W2] ADR-0131 D3 at `DELETE /packages/:id`: under a wall the security + * catalog is the platform operator's, so deleting a package that carries a + * position or a permission set is too. + * + * Measured before this refusal, under `isolated` and `group`: the operator + * created a writable runtime package and saved a permission set into it + * (`PUT /meta/permission/:name?package=`); another organization's member held + * that set; an organization's `manage_metadata` holder (rung `TENANT_ADMIN`) + * deleted the package, `200`, and the set left the catalog and the member's + * grants. The capability gate above checks no rung. + * + * The items are read where the delete reads them: `deletePackage` removes the + * `sys_metadata` rows bound to the package, and those rows are not held under + * the package in the running registry, so the stored rows are what is asked. + * The decision is {@link walledCatalogWriteVerdict}'s, once per stored catalog + * type: under `single`, with no `tenancy` service, for `isSystem` or for the + * `PLATFORM_ADMIN` rung it admits, and a package without catalog rows never + * reaches it. The answer is this domain's `403` (`PERMISSION_DENIED`). + */ +async function refuseWalledCatalogPackageDelete( + deps: DomainHandlerDeps, + context: HttpProtocolContext, + qlService: Pick | null | undefined, + packageId: string, +): Promise { + if (typeof qlService?.find !== 'function') return null; + let rows: unknown; + try { + rows = await qlService.find('sys_metadata', { where: { package_id: packageId }, context: { isSystem: true } }); + } catch (e) { + return { handled: true, response: deps.errorFromThrown(e, 500) }; + } + const storedTypes = new Set(); + for (const row of Array.isArray(rows) ? rows : []) { + const type = (row as { type?: unknown } | null)?.type; + if (typeof type === 'string' && isSecurityCatalogType(type)) storedTypes.add(type); + } + if (storedTypes.size === 0) return null; + const ec: any = context?.executionContext; + const tenancyPosture = await readEffectiveTenancyPosture(deps, context); + for (const type of SECURITY_CATALOG_TYPES) { + if (!storedTypes.has(type)) continue; + const verdict = walledCatalogWriteVerdict({ + type, + tenancyPosture, + isSystem: ec?.isSystem === true, + callerPosture: ec?.posture, + }); + if (!verdict.allowed) { + return { + handled: true, + response: deps.error( + `Deleting package "${packageId}" is refused: the package carries security catalog items. ${verdict.message}`, + 403, + ), + }; + } + } + return null; +} + function requireWritablePackage( deps: DomainHandlerDeps, engine: unknown, @@ -2033,7 +2100,13 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // the next restart. const readOnly = requireWritablePackage(deps, qlService, id, 'delete'); if (readOnly) return readOnly; // [ADR-0131 D6/D12] No organization: an uninstall is environment-wide - // by construction, and who may uninstall is the operator gate above. + // by construction. The gate above checks the `manage_metadata` + // capability (or `isSystem`) and no posture rung, so under a wall an + // organization's holder passes it. [#15204 W2] What makes the delete + // the operator's under a wall is the catalog refusal below (ADR-0131 + // D3), for a package that carries a position or a permission set. + const walled = await refuseWalledCatalogPackageDelete(deps, _context, qlService, id); + if (walled) return walled; const protocol = await resolveProtocol(deps, _context); const persists = Boolean(protocol && typeof protocol.deletePackage === 'function'); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0717ad84049..3d207cecd40 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -766,6 +766,9 @@ importers: '@objectstack/metadata-protocol': specifier: workspace:* version: link:../metadata-protocol + '@objectstack/objectql': + specifier: workspace:* + version: link:../objectql '@objectstack/runtime': specifier: workspace:* version: link:../runtime From 6d7837edb7b44cb97f7e016ba0977f4dc046e785 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 02:28:01 +0000 Subject: [PATCH 13/25] test(dogfood): give the walled package refusal's operator control its own packages Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP --- .../test/wall-package-refusal.dogfood.test.ts | 26 ++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/packages/qa/dogfood/test/wall-package-refusal.dogfood.test.ts b/packages/qa/dogfood/test/wall-package-refusal.dogfood.test.ts index 93405081169..dabfca26122 100644 --- a/packages/qa/dogfood/test/wall-package-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/wall-package-refusal.dogfood.test.ts @@ -237,16 +237,30 @@ for (const posture of ['isolated', 'group'] as const) { expect(deleted.status, await deleted.clone().text()).toBe(200); }); - it('CONTROL: the platform operator upgrades, deletes and uninstalls the catalog-carrying packages', async () => { + it('CONTROL: the platform operator installs, upgrades and uninstalls a catalog-carrying package, and deletes one at /packages', async () => { + // Its own packages, so this control reads the same whatever the refusal + // pins above did to OP_PKG and RT_PKG. + const pkg = 'com.w2.operator2'; + const opSet = 'w2_operator2_set'; + const installed = await h.stack.apiAs(h.operator, 'POST', INSTALL_LOCAL, packageBody(pkg, '1.0.0', { permissions: [set(opSet, [])] })); + expect(installed.status, await installed.clone().text()).toBe(200); + await h.ql.insert('sys_user_permission_set', { user_id: h.userB, permission_set: opSet, organization_id: h.orgB }, + { context: { ...SYS, tenantId: h.orgB } }); const upgraded = await h.stack.apiAs(h.operator, 'POST', INSTALL_LOCAL, - packageBody(OP_PKG, '1.1.0', { permissions: [set(OP_SET, ['manage_users'])] })); + packageBody(pkg, '1.1.0', { permissions: [set(opSet, ['manage_users'])] })); expect(upgraded.status, await upgraded.clone().text()).toBe(200); expect((await bGrants()).systemPermissions).toContain('manage_users'); - const deleted = await h.stack.apiAs(h.operator, 'DELETE', `/packages/${RT_PKG}`); - expect(deleted.status, await deleted.clone().text()).toBe(200); - const removed = await h.stack.apiAs(h.operator, 'DELETE', `${INSTALL_LOCAL}/${OP_PKG}`); + const removed = await h.stack.apiAs(h.operator, 'DELETE', `${INSTALL_LOCAL}/${pkg}`); expect(removed.status, await removed.clone().text()).toBe(200); - expect((await bGrants()).permissions).not.toEqual(expect.arrayContaining([OP_SET])); + expect((await bGrants()).permissions).not.toContain(opSet); + + const rt = 'com.w2.runtime2'; + const created = await h.stack.apiAs(h.operator, 'POST', '/packages', { id: rt, name: 'w2 runtime 2', version: '1.0.0', type: 'app' }); + expect(created.status, await created.clone().text()).toBe(201); + const authored = await h.stack.apiAs(h.operator, 'PUT', `/meta/permission/w2_runtime2_set?package=${rt}`, set('w2_runtime2_set', [])); + expect(authored.status, await authored.clone().text()).toBe(200); + const deleted = await h.stack.apiAs(h.operator, 'DELETE', `/packages/${rt}`); + expect(deleted.status, await deleted.clone().text()).toBe(200); }); }); } From df8411bf6e8536a92a93c71fcad7d2d67d5124d6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 02:30:38 +0000 Subject: [PATCH 14/25] chore(changeset): the walled package refusal, registered as walled-catalog-package-writes-retired Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP --- .../15204-walled-catalog-package-refusal.md | 31 +++++++++++++ ...8.walled-catalog-package-writes-retired.ts | 44 +++++++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 .changeset/15204-walled-catalog-package-refusal.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.walled-catalog-package-writes-retired.ts diff --git a/.changeset/15204-walled-catalog-package-refusal.md b/.changeset/15204-walled-catalog-package-refusal.md new file mode 100644 index 00000000000..43a7d02a38e --- /dev/null +++ b/.changeset/15204-walled-catalog-package-refusal.md @@ -0,0 +1,31 @@ +--- +'@objectstack/cloud-connection': minor +'@objectstack/runtime': minor +'@objectstack/objectql': minor +'@objectstack/spec': minor +--- + +Under a walled posture a package that carries security catalog items is the platform operator's: tenants are refused installing, upgrading, uninstalling or deleting it + +Clause-②: yes (narrowing) + + + +**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant (managed packages, Studio)."). + +**Why.** A package registers its positions, permission sets and capabilities environment-wide, and every organization resolves them by name. Measured on a booted `isolated` and `group` stack, with an organization owner granted `manage_metadata` inside their own organization (never the platform operator): +- the owner re-installed an operator-installed package at a new version through `install-local`, adding `manage_users` to a permission set another organization's member holds, and that member's next resolution carried `manage_users`; +- the owner's `install-local` uninstall of that package took the set away from that member; +- the owner's `DELETE /api/v1/packages/:id` of a runtime package the operator had saved a permission set into did the same. + +The existing name-conflict refusal stops only a package that shadows a name another holder has, never a write by the package that holds it. + +**FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner): + +- `POST /api/v1/marketplace/install-local` (`@objectstack/cloud-connection`) of a package whose incoming version declares a position, permission set or capability, or, on an upgrade, whose installed version does: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`, before the package is registered or the ledger written. The whole package is refused, since a package installs whole. The message names the posture, and `error.details.catalogItems` lists the items. +- `DELETE /api/v1/marketplace/install-local/:id` of a package whose installed version declares one: **FROM** admitted to any `manage_metadata` holder **TO** `403 FORBIDDEN`, before the ledger is touched. +- `DELETE /api/v1/packages/:id` on the runtime dispatcher, of a writable package holding stored positions or permission sets: **FROM** admitted to any `manage_metadata` holder **TO** `403 PERMISSION_DENIED`, before anything is deleted. + +**Unchanged.** A package with no catalog item installs, upgrades and uninstalls as before, for the same callers. The platform operator does all of the above as before. Under `single` (and with no `tenancy` service) nothing changes. Disabling a package is unchanged: it was measured not to change what any organization resolves. + +**The remedy.** The platform operator installs, upgrades and removes catalog-carrying packages. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-package-writes-retired`. Both doors ask the one predicate the catalog's other write doors ask, `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), once per catalog type the package declares. `@objectstack/objectql` now exports `declaredSecurityCatalogNames`, the engine's own reading of what a manifest declares, so a package door judges the declarations the engine registers. diff --git a/packages/spec/src/migrations/entries/semantic/18.walled-catalog-package-writes-retired.ts b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-package-writes-retired.ts new file mode 100644 index 00000000000..6933ebc27d8 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-package-writes-retired.ts @@ -0,0 +1,44 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0131 D3 (stage W2 of the C3 card) — a runtime-door narrowing with no +// authorable key: under a walled tenancy posture a package that carries +// security catalog items is the platform operator's to install, upgrade, +// uninstall or delete. Registered because a caller that did so before is +// refused now, and an operator must be told who keeps that authority. +export const entry: SemanticMigration = { + id: 'walled-catalog-package-writes-retired', + // No backticks in `surface`: build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'on a group or isolated deployment, by a caller below the platform operator: ' + + 'POST /api/v1/marketplace/install-local of a package whose incoming version, or whose installed ' + + 'version on an upgrade, declares a position, permission set or capability; ' + + 'DELETE /api/v1/marketplace/install-local/:id of a package whose installed version declares one ' + + '(@objectstack/cloud-connection); and DELETE /api/v1/packages/:id of a package holding stored ' + + 'positions or permission sets on the runtime dispatcher', + replacement: + 'the platform operator (the `PLATFORM_ADMIN` rung, the deployment\'s declared ' + + '`OS_PLATFORM_OWNER_EMAIL` owner) installs, upgrades, uninstalls and deletes those packages; ' + + 'a package with no catalog item installs, upgrades and uninstalls exactly as before. A custom ' + + 'package door asks `walledCatalogWriteVerdict` (`@objectstack/metadata-core`) once per catalog ' + + 'type the package declares, after its capability gate', + reason: + 'ADR-0131 D3: on a shared-database multi-tenant deployment tenant admins cannot create or edit ' + + 'catalog items, and the operator defines the catalog for every tenant, including through ' + + 'managed packages. A package registers its catalog items environment-wide, so before this ' + + 'change a manage_metadata holder scoped to one organization could upgrade, uninstall or delete ' + + 'a package holding a permission set another organization\'s members hold, and change or remove ' + + 'what those members resolve.', + acceptanceCriteria: + 'On an isolated or group deployment, an organization owner holding manage_metadata inside their ' + + 'own organization re-installs, at a new version, an operator-installed package whose permission ' + + 'set another organization\'s member holds: the answer is 403 FORBIDDEN, the message names the ' + + 'posture, and the member resolves exactly what it resolved before. The same owner\'s uninstall ' + + 'of that package answers 403 FORBIDDEN, and DELETE /api/v1/packages/ of a runtime package ' + + 'holding a permission set answers 403 PERMISSION_DENIED; the member keeps the set. The same owner ' + + 'installs, upgrades and uninstalls a package with no catalog item with 200. The platform operator ' + + 'does all of it with 200, and on a single deployment a manage_metadata holder does all of it ' + + 'exactly as before.', +}; From 1ce9bb8be8f4b83f90cbd1a49797473b95d67e34 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 02:31:11 +0000 Subject: [PATCH 15/25] docs(permissions): census row 55b names the /packages delete refusal Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP --- content/docs/permissions/system-context.mdx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index c2eadfc588e..3035a6b67dd 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -10,7 +10,7 @@ the seed loader replaying package fixtures, a plugin's boot reconciler, a service self-write, a migration. This page is **the authority** for what that flag actually does. It exists -because the flag is not one concept: it is a single boolean read at **129 +because the flag is not one concept: it is a single boolean read at **130 distinct sites across 20 packages**, and knowing three of those behaviours gives no hint that the other hundred-and-four exist. Every documented app-side bug traced to `isSystem` had the same shape — the metadata was complete and correct, @@ -142,7 +142,7 @@ that silently does not happen. ### 3. Sharing (`plugin-sharing`) -The largest single consumer — **17 of the 129 sites**. +The largest single consumer — **17 of the 130 sites**. | # | Behaviour when `isSystem` | What you get / what you lose | Anchor | |:--|:---|:---|:---| @@ -181,7 +181,7 @@ The largest single consumer — **17 of the 129 sites**. | 53 | Package REST route capability gate bypassed | rest | Get: a marketplace publish over REST (`POST /packages/publish`, the one route the REST registrar mounts since #14503) without `manage_metadata`; the package read cohort (`studio.access` / `setup.access`) is enforced by the dispatcher `/packages` domain's own read gate, where the reads are served | `packages/rest/src/package-routes.ts#refusePackageRequest` | | 54 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability` | | 55 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite`, `#refuseUngrantedActivationAuthoring` | -| 55b | Walled catalog refusal does not fire | metadata-core, rest, plugin-security | Get: under `isolated` / `group`, a create, change or delete of a position, a permission set or a capability, at the `/meta` write doors (save, reset, publish, rollback), at the permission-set clone door, and at the data door on `sys_position`, `sys_permission_set` and `sys_capability`. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3), because one organization's write would change what every organization resolves by name. The seeders, the package door and migrations write the catalog this way. Under `single` the refusal is inert for every caller | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict`, `packages/rest/src/rest-server.ts#walledCatalogRefusal`, `packages/plugins/plugin-security/src/walled-catalog-data-door.ts#createWalledCatalogDataDoorRefusal` | +| 55b | Walled catalog refusal does not fire | metadata-core, rest, plugin-security, runtime | Get: under `isolated` / `group`, a create, change or delete of a position, a permission set or a capability, at the `/meta` write doors (save, reset, publish, rollback), at the permission-set clone door, at the data door on `sys_position`, `sys_permission_set` and `sys_capability`, and at the `/packages` delete of a package holding stored positions or permission sets. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3), because one organization's write would change what every organization resolves by name. The seeders, the package door and migrations write the catalog this way. Under `single` the refusal is inert for every caller | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict`, `packages/rest/src/rest-server.ts#walledCatalogRefusal`, `packages/plugins/plugin-security/src/walled-catalog-data-door.ts#createWalledCatalogDataDoorRefusal`, `packages/runtime/src/domains/packages.ts#refuseWalledCatalogPackageDelete` | | 56 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A). The three doors ask one predicate, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#isRunStarterOrRunStateReader`, `#refuseUngrantedRunLifecycleWrite`, `packages/runtime/src/flow-start-admission.ts#refusesElevatedSelfTriggeredStart` | | 57 | Audience-binding suggestion authority gate passes | plugin-security | Get: a system caller lists, accepts and dismisses suggestions without `manage_metadata` and, under a wall, without the platform-operator rung | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertDeploymentAuthority` | | 58 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6); the plugin itself no longer writes the table (templates resolve from the registry), so the v18 migration ceremony's promotion is the one remaining writer. Lose (`sys_webhook`): the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` | @@ -286,7 +286,7 @@ Ownership injection, `readonly` bypass and sharing materialisation are independent decisions, and a seed loader plausibly wants the first two but not the third. The concept is nevertheless **staying as one boolean**: -- **Shipped semantics.** `isSystem` is a published contract with 129 read sites +- **Shipped semantics.** `isSystem` is a published contract with 130 read sites in 20 packages. Splitting it is a breaking contract change across all of them. (The ruling was taken when the census read 80 sites in 18 packages; the count has grown, which strengthens rather than weakens the argument.) @@ -360,16 +360,16 @@ still holds equal to the census on every pull request: | Appearances of the bare identifier `isSystem` in non-test sources | 813 | — | | — parsed as a declaration | 29 | ✅ | | — parsed as an object-literal / type key (producers and option objects) | 310 | — | -| — parsed as a property **read** | 134 | ✅ | +| — parsed as a property **read** | 135 | ✅ | | — parsed in some other syntactic position (a local, a cast, a conditional) | 9 | ✅ | | — the remainder: text inside comments and string literals | 358 | — | | Of those reads: reads of one of the unrelated metadata fields | 5 | ✅ | -| Of those reads: reads of `ExecutionContext.isSystem` | **129** | ✅ | -| — behaviour-bearing (rows 1–61 above) | 126 | ✅ | +| Of those reads: reads of `ExecutionContext.isSystem` | **130** | ✅ | +| — behaviour-bearing (rows 1–61 above) | 127 | ✅ | | — carry the flag onward only (rows 62–64 above) | 3 | ✅ | | Packages containing at least one elevation read | **20** | ✅ | | Files containing at least one elevation read | 59 | ✅ | -| — the distinct symbols those reads live in — what this page anchors | 109 | ✅ | +| — the distinct symbols those reads live in — what this page anchors | 110 | ✅ | | — of those files, the ones holding more than one read in one symbol | 9 | ✅ | The six rows marked — are a **dated decomposition, not a live claim**: they were From f4d6a8f1047576e029a344cfdd7d066a2629f130 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 02:59:06 +0000 Subject: [PATCH 16/25] fix(security): ask the walled verdict before reading a package's catalog items; alias objectql in cloud-connection tests Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP --- .../src/marketplace-install-local-plugin.ts | 25 +++++++++------- packages/cloud-connection/vitest.config.ts | 9 ++++++ packages/runtime/src/domains/packages.ts | 29 +++++++++++-------- 3 files changed, 41 insertions(+), 22 deletions(-) diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 2b444ae5265..c87f409f713 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -2992,11 +2992,14 @@ export class MarketplaceInstallLocalPlugin implements Plugin { * `manifests` (the incoming version, the installed one, or both) declares * a catalog item, or the running registry holds one under `manifestId`. * A partial install is not well-formed: a package is registered whole. - * The decision is {@link walledCatalogWriteVerdict}'s, asked once per - * declared type and never restated: under `single`, with no `tenancy` - * service, or for the `PLATFORM_ADMIN` rung it admits, and a package with - * no catalog item never reaches it. The answer is the sibling refusals' - * envelope, `403 FORBIDDEN`. + * The decision is {@link walledCatalogWriteVerdict}'s, never restated. It + * is asked FIRST, for the first catalog type: its answer does not vary + * across catalog types, so when it admits this caller (under `single`, + * with no `tenancy` service, or for the `PLATFORM_ADMIN` rung) nothing is + * read and the door behaves exactly as before. Only a refused caller's + * package is read, and the refusal names the first type it declares. A + * package with no catalog item is admitted. The answer is the sibling + * refusals' envelope, `403 FORBIDDEN`. */ private refuseWalledCatalogPackage = ( c: any, @@ -3006,15 +3009,17 @@ export class MarketplaceInstallLocalPlugin implements Plugin { admission: InstallAdmission, manifests: readonly unknown[], ): Response | null => { + const verdictFor = (type: string) => walledCatalogWriteVerdict({ + type, + tenancyPosture: admission.tenancyPosture, + callerPosture: admission.callerPosture, + }); + if (verdictFor(SECURITY_CATALOG_TYPES[0]!).allowed) return null; const declared = this.catalogItemsOf(ctx, manifestId, manifests); if (declared.length === 0) return null; for (const type of SECURITY_CATALOG_TYPES) { if (!declared.some((item) => item.type === type)) continue; - const verdict = walledCatalogWriteVerdict({ - type, - tenancyPosture: admission.tenancyPosture, - callerPosture: admission.callerPosture, - }); + const verdict = verdictFor(type); if (verdict.allowed) continue; ctx.logger?.warn?.( `[MarketplaceInstallLocal] refused ${action} ${manifestId} for ${admission.userId}: ` diff --git a/packages/cloud-connection/vitest.config.ts b/packages/cloud-connection/vitest.config.ts index 0d1d4fe56bc..9206035c576 100644 --- a/packages/cloud-connection/vitest.config.ts +++ b/packages/cloud-connection/vitest.config.ts @@ -50,6 +50,15 @@ export default defineConfig({ find: /^@objectstack\/metadata-core$/, replacement: path.resolve(__dirname, '../metadata-core/src/index.ts'), }, + // [#15204 W2] The install-local door reads a manifest's catalog + // declarations through `declaredSecurityCatalogNames`, so every suite + // that imports the plugin judges THIS checkout's engine reading, not an + // `objectql/dist` that may be behind it. Anchored: `@objectstack/objectql` + // exports a `./core` subpath. + { + find: /^@objectstack\/objectql$/, + replacement: path.resolve(__dirname, '../objectql/src/index.ts'), + }, ], }, // Discovery is untouched — but this file is NOT "the alias and nothing else". diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 321b3d6df17..8686032d0d5 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -424,10 +424,13 @@ function requireReadCapability(deps: DomainHandlerDeps, context: HttpProtocolCon * The items are read where the delete reads them: `deletePackage` removes the * `sys_metadata` rows bound to the package, and those rows are not held under * the package in the running registry, so the stored rows are what is asked. - * The decision is {@link walledCatalogWriteVerdict}'s, once per stored catalog - * type: under `single`, with no `tenancy` service, for `isSystem` or for the - * `PLATFORM_ADMIN` rung it admits, and a package without catalog rows never - * reaches it. The answer is this domain's `403` (`PERMISSION_DENIED`). + * The decision is {@link walledCatalogWriteVerdict}'s, never restated. It is + * asked FIRST, for the first catalog type: its answer does not vary across + * catalog types, so when it admits this caller (under `single`, with no + * `tenancy` service, for `isSystem` or for the `PLATFORM_ADMIN` rung) nothing + * is read and the delete behaves exactly as before. Only a refused caller's + * package is read, and a package without catalog rows is admitted. The answer + * is this domain's `403` (`PERMISSION_DENIED`). */ async function refuseWalledCatalogPackageDelete( deps: DomainHandlerDeps, @@ -435,6 +438,15 @@ async function refuseWalledCatalogPackageDelete( qlService: Pick | null | undefined, packageId: string, ): Promise { + const ec: any = context?.executionContext; + const tenancyPosture = await readEffectiveTenancyPosture(deps, context); + const verdictFor = (type: string) => walledCatalogWriteVerdict({ + type, + tenancyPosture, + isSystem: ec?.isSystem === true, + callerPosture: ec?.posture, + }); + if (verdictFor(SECURITY_CATALOG_TYPES[0]!).allowed) return null; if (typeof qlService?.find !== 'function') return null; let rows: unknown; try { @@ -448,16 +460,9 @@ async function refuseWalledCatalogPackageDelete( if (typeof type === 'string' && isSecurityCatalogType(type)) storedTypes.add(type); } if (storedTypes.size === 0) return null; - const ec: any = context?.executionContext; - const tenancyPosture = await readEffectiveTenancyPosture(deps, context); for (const type of SECURITY_CATALOG_TYPES) { if (!storedTypes.has(type)) continue; - const verdict = walledCatalogWriteVerdict({ - type, - tenancyPosture, - isSystem: ec?.isSystem === true, - callerPosture: ec?.posture, - }); + const verdict = verdictFor(type); if (!verdict.allowed) { return { handled: true, From b7e2eaf5f06bf17e4495ff5c8c152d5bdd42faa8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 04:06:39 +0000 Subject: [PATCH 17/25] docs(permissions): the walled package refusal on the package delete and permission-set provenance pages Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP --- content/docs/api/metadata-api.mdx | 4 +++- content/docs/permissions/permission-sets.mdx | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/content/docs/api/metadata-api.mdx b/content/docs/api/metadata-api.mdx index 895ffccdb17..0315e6b0ec7 100644 --- a/content/docs/api/metadata-api.mdx +++ b/content/docs/api/metadata-api.mdx @@ -123,7 +123,9 @@ Get package details. ### `DELETE /packages/:id` -Uninstall a package. +Uninstall a package. Under a `group` or `isolated` tenancy posture, a package +holding positions or permission sets is the platform operator's to delete: any +other caller is refused `403 PERMISSION_DENIED` (ADR-0131 D3). ### `PATCH /packages/:id/enable` diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 173313b77d6..1a53465c142 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -348,7 +348,11 @@ position/user bindings, and its pending audience-binding suggestions in the same request (no ghost grants); the uninstall response reports the revocation under `data.persisted.cleanups` (the dispatcher's `/packages` domain answers the route, and it nests the protocol's uninstall report under `persisted`). -Environment-authored sets and other packages' rows survive. +Environment-authored sets and other packages' rows survive. Under a `group` or +`isolated` tenancy posture, removing a package that carries positions or +permission sets is the platform operator's (ADR-0131 D3): that delete, and an +`install-local` install, upgrade or uninstall of such a package, refuse any +other caller `403` before anything changes. ## One authoritative store — the record is a projection (ADR-0094) From 54b82a08b2d2c30c54cdc9b8f942f57cbae1ed80 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 04:34:09 +0000 Subject: [PATCH 18/25] feat(security): refuse the remaining /packages catalog writes under a wall (ADR-0131 D3) publish-drafts, revert, rollback, commits/:commitId/revert, duplicate and adopt-orphans ask walledCatalogWriteVerdict through one helper generalising the W2 delete refusal, each judging the items its verb acts on. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP --- content/docs/permissions/system-context.mdx | 2 +- ...wall-package-doors-refusal.dogfood.test.ts | 281 ++++++++++++++++++ packages/runtime/src/domains/packages.ts | 196 +++++++++--- 3 files changed, 438 insertions(+), 41 deletions(-) create mode 100644 packages/qa/dogfood/test/wall-package-doors-refusal.dogfood.test.ts diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 6db22ecc29a..983b531c75b 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -182,7 +182,7 @@ The largest single consumer — **17 of the 131 sites**. | 53 | Package REST route capability gate bypassed | rest | Get: a marketplace publish over REST (`POST /packages/publish`, the one route the REST registrar mounts since #14503) without `manage_metadata`; the package read cohort (`studio.access` / `setup.access`) is enforced by the dispatcher `/packages` domain's own read gate, where the reads are served | `packages/rest/src/package-routes.ts#refusePackageRequest` | | 54 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability` | | 55 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite`, `#refuseUngrantedActivationAuthoring` | -| 55b | Walled catalog refusal does not fire | metadata-core, rest, plugin-security, runtime | Get: under `isolated` / `group`, a create, change or delete of a position, a permission set or a capability, at the `/meta` write doors (save, reset, publish, rollback), at the permission-set clone door, at the data door on `sys_position`, `sys_permission_set` and `sys_capability`, and at the `/packages` delete of a package holding stored positions or permission sets. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3), because one organization's write would change what every organization resolves by name. The seeders, the package door and migrations write the catalog this way. Under `single` the refusal is inert for every caller | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict`, `packages/rest/src/rest-server.ts#walledCatalogRefusal`, `packages/plugins/plugin-security/src/walled-catalog-data-door.ts#createWalledCatalogDataDoorRefusal`, `packages/runtime/src/domains/packages.ts#refuseWalledCatalogPackageDelete` | +| 55b | Walled catalog refusal does not fire | metadata-core, rest, plugin-security, runtime | Get: under `isolated` / `group`, a create, change or delete of a position, a permission set or a capability, at the `/meta` write doors (save, reset, publish, rollback), at the permission-set clone door, at the data door on `sys_position`, `sys_permission_set` and `sys_capability`, and at the `/packages` write doors (delete, `publish-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`) when the items they act on include a position or a permission set. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3), because one organization's write would change what every organization resolves by name. The seeders, the package door and migrations write the catalog this way. Under `single` the refusal is inert for every caller | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict`, `packages/rest/src/rest-server.ts#walledCatalogRefusal`, `packages/plugins/plugin-security/src/walled-catalog-data-door.ts#createWalledCatalogDataDoorRefusal`, `packages/runtime/src/domains/packages.ts#refuseWalledCatalogPackageWrite` | | 56 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A). The three doors ask one predicate, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#isRunStarterOrRunStateReader`, `#refuseUngrantedRunLifecycleWrite`, `packages/runtime/src/flow-start-admission.ts#refusesElevatedSelfTriggeredStart` | | 57 | Audience-binding suggestion authority gate passes | plugin-security | Get: a system caller lists, accepts and dismisses suggestions without `manage_metadata` and, under a wall, without the platform-operator rung | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertDeploymentAuthority` | | 58 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6); the plugin itself no longer writes the table (templates resolve from the registry), so the v18 migration ceremony's promotion is the one remaining writer. Lose (`sys_webhook`): the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` | diff --git a/packages/qa/dogfood/test/wall-package-doors-refusal.dogfood.test.ts b/packages/qa/dogfood/test/wall-package-doors-refusal.dogfood.test.ts new file mode 100644 index 00000000000..aa66d51b726 --- /dev/null +++ b/packages/qa/dogfood/test/wall-package-doors-refusal.dogfood.test.ts @@ -0,0 +1,281 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [ADR-0131 D3 · #15204 W3] The walled catalog refusal at the remaining +// `/packages` write doors, on a REAL booted stack. +// +// What this harness measured before the refusal, under both walled postures +// (6117740670): organization A's owner holds `manage_metadata` inside +// organization A only (rung `TENANT_ADMIN`). The operator authored a permission +// set in a writable runtime package and published it; organization B's member +// holds that set. Then A: +// - `POST /packages/:id/publish-drafts` published the operator's pending +// draft adding `manage_users`: `200`, and B's member gained `manage_users`; +// - `POST /packages/:id/rollback` and `POST /packages/:id/commits/:c/revert` +// undid the operator's published change: `200`, and B's member lost it; +// - `POST /packages/:id/duplicate` copied the set, under the same name, into +// A's own package, and `GET /meta` then served A's copy; +// - `POST /packages/:id/revert` discarded the operator's pending draft; +// - `POST /packages/:id/adopt-orphans` moved the environment's catalog rows +// into A's package. +// +// Pinned here, under each walled posture: every one of those answers +// `403 PERMISSION_DENIED`, nothing moves, and B's resolution stays. The commit +// revert is judged by the commit's own items, whatever package the URL names. +// CONTROLS: A runs every one of these doors on its own catalog-free package as +// before, and the platform operator runs them on the catalog-carrying one. +// And under `single` (CONTROL): the same `manage_metadata` holder runs them on +// the operator's package as before. +// +// The boot is `multiTenant: 'posture-only'`: a real, non-degraded walled +// posture. The harness declares the seeded admin as `OS_PLATFORM_OWNER_EMAIL`, +// so that admin is the platform operator. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import crmStack from '@objectstack/example-crm'; +import { resolveUserAuthzGrants } from '@objectstack/core'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; + +const SYS = { isSystem: true, positions: [], permissions: [] }; + +interface Engine { + find(object: string, options?: unknown): Promise>>; + insert(object: string, data: Record, options?: unknown): Promise; +} + +/** The ADR-0112 envelope a refusal answered: `error.code`, the message and the HTTP status. */ +async function envelopeOf(res: Response): Promise<{ status: number; code: unknown; message: string }> { + const body = (await res.clone().json().catch(() => ({}))) as { error?: { code?: unknown; message?: unknown } }; + return { status: res.status, code: body?.error?.code, message: String(body?.error?.message ?? '') }; +} + +const set = (name: string, systemPermissions: string[]) => ({ name, label: name, objects: {}, systemPermissions }); +const dashboard = (name: string, label: string) => ({ name, label, columns: 12, widgets: [] }); + +const META_AUTHOR = 'w3_meta_author'; +const OP_PKG = 'com.w3.operator'; +const OP_SET = 'w3_operator_set'; +const ENV_SET = 'w3_env_set'; + +interface Harness { + stack: VerifyStack; + ql: Engine; + operator: string; + tokA: string; + userA: string; + userB: string; + orgA?: string; + orgB?: string; + /** The operator's first publish of `OP_SET` (systemPermissions []). */ + c1: string; + /** The operator's second publish of `OP_SET` (adds `manage_users`). */ + c2: string; +} + +async function call(stack: VerifyStack, token: string, method: string, path: string, body?: unknown) { + return stack.apiAs(token, method, path, body); +} + +/** The commit ids of a package, newest first. */ +async function commitIds(stack: VerifyStack, token: string, pkg: string): Promise { + const res = await call(stack, token, 'GET', `/packages/${pkg}/commits`); + expect(res.status, await res.clone().text()).toBe(200); + const json = (await res.json()) as { data?: { commits?: Array<{ id: string }> } }; + return (json.data?.commits ?? []).map((c) => c.id); +} + +/** + * Boot, sign in the operator, A and B; under a wall each gets an organization. + * The operator creates `OP_PKG`, publishes `OP_SET` twice (c1 with no + * capability, c2 adding `manage_users`) and leaves a third draft pending + * (adding `manage_metadata`); B holds `OP_SET`, so B resolves `manage_users`. + * The operator also saves `ENV_SET` with no package (an orphan). A holds + * `manage_metadata` (inside org A under a wall). + */ +async function boot(posture: 'isolated' | 'group' | 'single'): Promise { + const walled = posture !== 'single'; + if (walled) process.env.OS_TENANCY_POSTURE = posture; + else delete process.env.OS_TENANCY_POSTURE; + const stack = await bootStack(crmStack as never, walled ? { multiTenant: 'posture-only' } : {}); + const ql = (await stack.kernel.getServiceAsync('objectql')) as unknown as Engine; + const operator = await stack.signIn(); + const tokA = await stack.signUp(`w3-a-${posture}@wall.test`); + const tokB = await stack.signUp(`w3-b-${posture}@wall.test`); + const h: Harness = { stack, ql, operator, tokA, userA: '', userB: '', c1: '', c2: '' }; + if (walled) { + for (const [tok, slug] of [[tokA, `w3-a-${posture}`], [tokB, `w3-b-${posture}`]] as const) { + const created = await call(stack, tok, 'POST', '/auth/organization/create', { name: slug, slug }); + expect(created.status, await created.clone().text()).toBe(200); + const id = ((await created.json()) as { id: string }).id; + if (tok === tokA) h.orgA = id; else h.orgB = id; + const active = await call(stack, tok, 'POST', '/auth/organization/set-active', { organizationSlug: slug }); + expect(active.status, await active.clone().text()).toBe(200); + } + } + const idOf = async (email: string) => + String((await ql.find('sys_user', { where: { email }, limit: 1, context: SYS }))[0]?.id); + h.userA = await idOf(`w3-a-${posture}@wall.test`); + h.userB = await idOf(`w3-b-${posture}@wall.test`); + + const ok = async (res: Response, status = 200) => expect(res.status, await res.clone().text()).toBe(status); + await ok(await call(stack, operator, 'POST', '/packages', { id: OP_PKG, name: 'w3 operator', version: '1.0.0', type: 'app' }), 201); + // META_AUTHOR lives in the operator's package, so the orphan adoption below + // reads ENV_SET alone as the environment's catalog orphan this file adds. + await ok(await call(stack, operator, 'PUT', `/meta/permission/${META_AUTHOR}?package=${OP_PKG}`, set(META_AUTHOR, ['manage_metadata']))); + await ok(await call(stack, operator, 'PUT', `/meta/permission/${OP_SET}?package=${OP_PKG}&mode=draft`, set(OP_SET, []))); + await ok(await call(stack, operator, 'POST', `/packages/${OP_PKG}/publish-drafts`, {})); + h.c1 = (await commitIds(stack, operator, OP_PKG))[0]!; + await ok(await call(stack, operator, 'PUT', `/meta/permission/${OP_SET}?package=${OP_PKG}&mode=draft`, set(OP_SET, ['manage_users']))); + await ok(await call(stack, operator, 'POST', `/packages/${OP_PKG}/publish-drafts`, {})); + h.c2 = (await commitIds(stack, operator, OP_PKG))[0]!; + await ok(await call(stack, operator, 'PUT', `/meta/permission/${OP_SET}?package=${OP_PKG}&mode=draft`, set(OP_SET, ['manage_users', 'manage_metadata']))); + await ok(await call(stack, operator, 'PUT', `/meta/permission/${ENV_SET}`, set(ENV_SET, []))); + + const grant = (user: string, permissionSet: string, org: string | undefined) => + ql.insert('sys_user_permission_set', { user_id: user, permission_set: permissionSet, ...(org ? { organization_id: org } : {}) }, + { context: { ...SYS, ...(org ? { tenantId: org } : {}) } }); + await grant(h.userA, META_AUTHOR, h.orgA); + await grant(h.userB, OP_SET, h.orgB); + return h; +} + +const grantsOf = (h: Harness, user: string, org: string | undefined) => + resolveUserAuthzGrants(h.ql as never, user, org ? { tenantId: org } : {}); + +const rowsOf = (h: Harness, where: Record) => h.ql.find('sys_metadata', { where, context: SYS }); + +for (const posture of ['isolated', 'group'] as const) { + describe(`[ADR-0131 D3 · #15204 W3] ${posture}: the /packages write doors refuse a tenant the operator's catalog items`, () => { + let h: Harness; + const previous = process.env.OS_TENANCY_POSTURE; + const bGrants = () => grantsOf(h, h.userB, h.orgB); + const refused = async (res: Response) => { + const env = await envelopeOf(res); + expect({ status: env.status, code: env.code }, env.message).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + expect(env.message).toContain(`'${posture}'`); + expect(env.message).toContain('ADR-0131 D3'); + }; + + beforeAll(async () => { h = await boot(posture); }, 180_000); + afterAll(async () => { + await h?.stack?.stop?.(); + if (previous === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = previous; + }); + + it('guard the guard: a real walled posture; A holds `manage_metadata` in org A, below the operator; B resolves `manage_users` through the operator\'s set, with a draft pending', async () => { + const tenancy = await h.stack.kernel.getServiceAsync<{ posture: string; degraded: boolean }>('tenancy'); + expect(tenancy.posture).toBe(posture); + expect(tenancy.degraded).toBe(false); + const a = await grantsOf(h, h.userA, h.orgA); + expect(a.systemPermissions).toContain('manage_metadata'); + expect(a.posture).not.toBe('PLATFORM_ADMIN'); + const b = await bGrants(); + expect(b.permissions).toContain(OP_SET); + expect(b.systemPermissions).toContain('manage_users'); + expect(b.systemPermissions).not.toContain('manage_metadata'); + expect(h.c1).not.toBe(h.c2); + expect(await rowsOf(h, { package_id: OP_PKG, type: 'permission', name: OP_SET, state: 'draft' })).toHaveLength(1); + }); + + it('publish-drafts: A publishing the operator\'s pending catalog draft is refused, and B does not gain `manage_metadata`', async () => { + await refused(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/publish-drafts`, {})); + expect((await bGrants()).systemPermissions).not.toContain('manage_metadata'); + expect(await rowsOf(h, { package_id: OP_PKG, type: 'permission', name: OP_SET, state: 'draft' })).toHaveLength(1); + }); + + it('revert: A discarding the operator\'s pending catalog draft is refused, and the draft stays', async () => { + await refused(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/revert`, {})); + expect(await rowsOf(h, { package_id: OP_PKG, type: 'permission', name: OP_SET, state: 'draft' })).toHaveLength(1); + }); + + it('rollback: A rolling the operator\'s package back past a catalog commit is refused, and B keeps `manage_users`', async () => { + await refused(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/rollback`, { commitId: h.c1 })); + expect((await bGrants()).systemPermissions).toContain('manage_users'); + }); + + it('commits/:commitId/revert: A reverting the operator\'s catalog commit is refused, and B keeps `manage_users`', async () => { + await refused(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/commits/${h.c2}/revert`, {})); + expect((await bGrants()).systemPermissions).toContain('manage_users'); + }); + + it('commits/:commitId/revert: the commit is judged by its own items, whatever package the URL names', async () => { + const own = await call(h.stack, h.tokA, 'POST', '/packages', { id: 'com.w3.a.url', name: 'url', version: '1.0.0', type: 'app' }); + expect(own.status, await own.clone().text()).toBe(201); + await refused(await call(h.stack, h.tokA, 'POST', `/packages/com.w3.a.url/commits/${h.c2}/revert`, {})); + expect((await bGrants()).systemPermissions).toContain('manage_users'); + }); + + it('duplicate: A copying the operator\'s catalog-carrying package is refused, and nothing is copied', async () => { + await refused(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/duplicate`, { targetPackageId: 'com.w3.dup' })); + expect(await rowsOf(h, { package_id: 'com.w3.dup' })).toHaveLength(0); + }); + + it('adopt-orphans: A adopting the environment\'s catalog orphans into its own package is refused, and the orphan stays unbound', async () => { + const own = await call(h.stack, h.tokA, 'POST', '/packages', { id: 'com.w3.a.adopt', name: 'adopt', version: '1.0.0', type: 'app' }); + expect(own.status, await own.clone().text()).toBe(201); + await refused(await call(h.stack, h.tokA, 'POST', '/packages/com.w3.a.adopt/adopt-orphans', {})); + const env = await rowsOf(h, { type: 'permission', name: ENV_SET }); + expect(env.map((r) => r.package_id ?? null)).not.toContain('com.w3.a.adopt'); + }); + + it('CONTROL: A runs every one of these doors on its own catalog-free package as before', async () => { + const pkg = 'com.w3.a.free'; + const dash = 'w3_free_dash'; + const ok = async (res: Response) => expect(res.status, await res.clone().text()).toBe(200); + expect((await call(h.stack, h.tokA, 'POST', '/packages', { id: pkg, name: 'free', version: '1.0.0', type: 'app' })).status).toBe(201); + await ok(await call(h.stack, h.tokA, 'PUT', `/meta/dashboard/${dash}?package=${pkg}&mode=draft`, dashboard(dash, 'v1'))); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/publish-drafts`, {})); + const first = (await commitIds(h.stack, h.tokA, pkg))[0]!; + await ok(await call(h.stack, h.tokA, 'PUT', `/meta/dashboard/${dash}?package=${pkg}&mode=draft`, dashboard(dash, 'v2'))); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/publish-drafts`, {})); + const second = (await commitIds(h.stack, h.tokA, pkg))[0]!; + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/commits/${second}/revert`, {})); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/rollback`, { commitId: first })); + await ok(await call(h.stack, h.tokA, 'PUT', `/meta/dashboard/${dash}?package=${pkg}&mode=draft`, dashboard(dash, 'v3'))); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/revert`, {})); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/duplicate`, { targetPackageId: 'com.w3.a.free.copy' })); + }); + + it('CONTROL: the platform operator runs every one of these doors on its catalog-carrying package', async () => { + const ok = async (res: Response) => expect(res.status, await res.clone().text()).toBe(200); + await ok(await call(h.stack, h.operator, 'POST', `/packages/${OP_PKG}/duplicate`, { targetPackageId: 'com.w3.op.copy' })); + await ok(await call(h.stack, h.operator, 'POST', `/packages/${OP_PKG}/revert`, {})); + await ok(await call(h.stack, h.operator, 'POST', `/packages/${OP_PKG}/commits/${h.c2}/revert`, {})); + expect((await bGrants()).systemPermissions).not.toContain('manage_users'); + await ok(await call(h.stack, h.operator, 'PUT', `/meta/permission/${OP_SET}?package=${OP_PKG}&mode=draft`, set(OP_SET, ['manage_users']))); + await ok(await call(h.stack, h.operator, 'POST', `/packages/${OP_PKG}/publish-drafts`, {})); + expect((await bGrants()).systemPermissions).toContain('manage_users'); + await ok(await call(h.stack, h.operator, 'POST', `/packages/${OP_PKG}/rollback`, { commitId: h.c1 })); + expect((await bGrants()).systemPermissions).not.toContain('manage_users'); + const own = await call(h.stack, h.operator, 'POST', '/packages', { id: 'com.w3.op.oporphans', name: 'adopt', version: '1.0.0', type: 'app' }); + expect(own.status, await own.clone().text()).toBe(201); + await ok(await call(h.stack, h.operator, 'POST', '/packages/com.w3.op.oporphans/adopt-orphans', {})); + }); + }); +} + +describe('[ADR-0131 D3 · #15204 W3] single (CONTROL): the refusal is inert', () => { + let h: Harness; + const previous = process.env.OS_TENANCY_POSTURE; + + beforeAll(async () => { h = await boot('single'); }, 180_000); + afterAll(async () => { + await h?.stack?.stop?.(); + if (previous === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = previous; + }); + + it('a `manage_metadata` holder below the operator runs every one of these doors on the operator\'s package as before', async () => { + const a = await grantsOf(h, h.userA, undefined); + expect(a.systemPermissions).toContain('manage_metadata'); + expect(a.posture).not.toBe('PLATFORM_ADMIN'); + const ok = async (res: Response) => expect(res.status, await res.clone().text()).toBe(200); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/duplicate`, { targetPackageId: 'com.w3.dup' })); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/publish-drafts`, {})); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/rollback`, { commitId: h.c1 })); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/commits/${h.c1}/revert`, {})); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/revert`, {})); + expect((await call(h.stack, h.tokA, 'POST', '/packages', { id: 'com.w3.a.adopt', name: 'adopt', version: '1.0.0', type: 'app' })).status).toBe(201); + await ok(await call(h.stack, h.tokA, 'POST', '/packages/com.w3.a.adopt/adopt-orphans', {})); + }); +}); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 8686032d0d5..805458ea682 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -410,33 +410,45 @@ function requireReadCapability(deps: DomainHandlerDeps, context: HttpProtocolCon * 422 (and so this gate never becomes an existence oracle of its own). */ /** - * [#15204 W2] ADR-0131 D3 at `DELETE /packages/:id`: under a wall the security - * catalog is the platform operator's, so deleting a package that carries a - * position or a permission set is too. - * - * Measured before this refusal, under `isolated` and `group`: the operator - * created a writable runtime package and saved a permission set into it - * (`PUT /meta/permission/:name?package=`); another organization's member held - * that set; an organization's `manage_metadata` holder (rung `TENANT_ADMIN`) - * deleted the package, `200`, and the set left the catalog and the member's - * grants. The capability gate above checks no rung. - * - * The items are read where the delete reads them: `deletePackage` removes the - * `sys_metadata` rows bound to the package, and those rows are not held under - * the package in the running registry, so the stored rows are what is asked. - * The decision is {@link walledCatalogWriteVerdict}'s, never restated. It is - * asked FIRST, for the first catalog type: its answer does not vary across - * catalog types, so when it admits this caller (under `single`, with no - * `tenancy` service, for `isSystem` or for the `PLATFORM_ADMIN` rung) nothing - * is read and the delete behaves exactly as before. Only a refused caller's - * package is read, and a package without catalog rows is admitted. The answer - * is this domain's `403` (`PERMISSION_DENIED`). + * [#15204 W2 · W3] ADR-0131 D3 at the `/packages` write doors: under a wall the + * security catalog is the platform operator's, so a package write that + * creates, changes, restores or removes a position or a permission set is the + * operator's too. + * + * Measured before these refusals, under `isolated` and `group`. An + * organization's `manage_metadata` holder (rung `TENANT_ADMIN`) acted on a + * writable runtime package the operator had saved a permission set into, and + * another organization's member held that set: + * - `DELETE /packages/:id` answered `200`; the set left the catalog and the + * member's grants (W2); + * - `publish-drafts` published the operator's pending draft adding + * `manage_users`, and the member gained it; + * - `rollback` and `commits/:commitId/revert` undid the operator's published + * change, and the member lost it; + * - `duplicate` copied the set, under the same name, into the holder's own + * package, and `GET /meta` then served that copy; + * - `revert` discarded the operator's pending catalog draft; + * - `adopt-orphans` moved environment catalog rows, the operator's included, + * into the holder's own package. + * The capability gate before each of them checks no rung. + * + * Each door passes `readTypes`: the metadata types of the items its verb acts + * on, read where the verb reads them (stored `sys_metadata` rows, or a + * commit's items; the running registry holds neither under the package). The + * decision is {@link walledCatalogWriteVerdict}'s, never restated. It is asked + * FIRST, for the first catalog type: its answer does not vary across catalog + * types, so when it admits this caller (under `single`, with no `tenancy` + * service, for `isSystem` or for the `PLATFORM_ADMIN` rung) `readTypes` is + * never called and the door behaves exactly as before. A write that touches no + * catalog item is admitted. The answer is this domain's `403` + * (`PERMISSION_DENIED`). */ -async function refuseWalledCatalogPackageDelete( +async function refuseWalledCatalogPackageWrite( deps: DomainHandlerDeps, context: HttpProtocolContext, - qlService: Pick | null | undefined, - packageId: string, + /** The refused write, as the message names it, e.g. `Deleting package "x"`. */ + action: string, + readTypes: () => Promise>, ): Promise { const ec: any = context?.executionContext; const tenancyPosture = await readEffectiveTenancyPosture(deps, context); @@ -447,35 +459,68 @@ async function refuseWalledCatalogPackageDelete( callerPosture: ec?.posture, }); if (verdictFor(SECURITY_CATALOG_TYPES[0]!).allowed) return null; - if (typeof qlService?.find !== 'function') return null; - let rows: unknown; + let types: Iterable; try { - rows = await qlService.find('sys_metadata', { where: { package_id: packageId }, context: { isSystem: true } }); + types = await readTypes(); } catch (e) { return { handled: true, response: deps.errorFromThrown(e, 500) }; } - const storedTypes = new Set(); - for (const row of Array.isArray(rows) ? rows : []) { - const type = (row as { type?: unknown } | null)?.type; - if (typeof type === 'string' && isSecurityCatalogType(type)) storedTypes.add(type); - } - if (storedTypes.size === 0) return null; - for (const type of SECURITY_CATALOG_TYPES) { - if (!storedTypes.has(type)) continue; + for (const type of types) { + if (typeof type !== 'string' || !isSecurityCatalogType(type)) continue; const verdict = verdictFor(type); if (!verdict.allowed) { return { handled: true, - response: deps.error( - `Deleting package "${packageId}" is refused: the package carries security catalog items. ${verdict.message}`, - 403, - ), + response: deps.error(`${action} is refused: it touches security catalog items. ${verdict.message}`, 403), }; } } return null; } +/** + * [#15204 W3] The metadata types of the `sys_metadata` rows `where` selects, + * read with the system context the package verbs read them with. Empty when + * no data engine is reachable: the verb then has nothing to act on either. + */ +function storedRowTypes( + qlService: Pick | null | undefined, + where: Record, + keep: (row: Record) => boolean = () => true, +): () => Promise { + return async () => { + if (typeof qlService?.find !== 'function') return []; + const rows = await qlService.find('sys_metadata', { where, context: { isSystem: true } } as any); + return (Array.isArray(rows) ? rows : []) + .filter((row) => row && typeof row === 'object' && keep(row as Record)) + .map((row) => (row as { type?: unknown }).type); + }; +} + +/** + * [#15204 W3] The `sys_metadata_commit` row `commitId` names, or `undefined`. + * Read directly because `revertCommit` and `rollbackToPackageCommit` take the + * commit id alone: the URL's package segment is not what they act on. + */ +async function readCommitRow( + qlService: Pick | null | undefined, + commitId: string, +): Promise | undefined> { + if (typeof qlService?.find !== 'function') return undefined; + const rows = await qlService.find('sys_metadata_commit', { where: { id: commitId }, context: { isSystem: true } } as any); + const row = Array.isArray(rows) ? rows[0] : undefined; + return row && typeof row === 'object' ? (row as Record) : undefined; +} + +/** [#15204 W3] The metadata types a commit's `items` name (stored as JSON text, or already parsed). */ +function commitItemTypes(items: unknown): unknown[] { + let list: unknown = items; + if (typeof list === 'string') { + try { list = JSON.parse(list); } catch { return []; } + } + return Array.isArray(list) ? list.map((item) => (item as { type?: unknown } | null)?.type) : []; +} + function requireWritablePackage( deps: DomainHandlerDeps, engine: unknown, @@ -1496,6 +1541,13 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 2 && parts[1] === 'publish-drafts' && m === 'POST') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); + // [#15204 W3] ADR-0131 D3: publishing a catalog draft is the + // operator's under a wall. The drafts this verb promotes. + const walled = await refuseWalledCatalogPackageWrite( + deps, _context, `Publishing the drafts of package "${id}"`, + storedRowTypes(qlService, { package_id: id, state: 'draft' }), + ); + if (walled) return walled; const protocol = await resolveProtocol(deps, _context); if (protocol && typeof protocol.publishPackageDrafts === 'function') { try { @@ -1855,6 +1907,17 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 4 && parts[1] === 'commits' && parts[3] === 'revert' && m === 'POST') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const commitId = decodeURIComponent(parts[2]); + // [#15204 W3] ADR-0131 D3: reverting a commit that touched a + // catalog item is the operator's under a wall. Judged by the + // commit's own items: `revertCommit` takes the commit id alone, so + // the URL's package segment names nothing it acts on. A commit + // that does not exist reads as touching nothing; the verb answers + // its own 404. + const walled = await refuseWalledCatalogPackageWrite( + deps, _context, `Reverting commit "${commitId}"`, + async () => commitItemTypes((await readCommitRow(qlService, commitId))?.items), + ); + if (walled) return walled; const protocol = await resolveProtocol(deps, _context); if (protocol && typeof protocol.revertCommit === 'function') { try { @@ -1879,6 +1942,30 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (!body?.commitId) { return { handled: true, response: deps.error('Body { commitId } is required', 400) }; } + // [#15204 W3] ADR-0131 D3: a rollback reverts every `apply` + // commit of the target's package newer than the target, so it + // is the operator's under a wall when any of them touched a + // catalog item. Read as `rollbackToPackageCommit` reads them: + // the target row, then that package's commits. Every `apply` + // commit at or after the target's instant other than the + // target itself is read, a superset of the reverted ones. + const targetCommitId = String(body.commitId); + const walled = await refuseWalledCatalogPackageWrite( + deps, _context, `Rolling back to commit "${targetCommitId}"`, + async () => { + const target = await readCommitRow(qlService, targetCommitId); + if (!target || typeof protocol.listCommits !== 'function') return []; + const commits: any[] = await protocol.listCommits({ packageId: target.package_id }); + const at = Date.parse(String( + commits.find((c) => c?.id === targetCommitId)?.createdAt ?? target.created_at, + )); + return commits + .filter((c) => c?.id !== targetCommitId && c?.operation === 'apply' + && !(Date.parse(String(c?.createdAt)) < at)) + .flatMap((c) => commitItemTypes(c?.items)); + }, + ); + if (walled) return walled; try { const result = await protocol.rollbackToPackageCommit({ commitId: String(body.commitId), @@ -1896,6 +1983,13 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 2 && parts[1] === 'revert' && m === 'POST') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); + // [#15204 W3] ADR-0131 D3: discarding a catalog draft is the + // operator's under a wall. The drafts this verb discards. + const walled = await refuseWalledCatalogPackageWrite( + deps, _context, `Reverting package "${id}"`, + storedRowTypes(qlService, { package_id: id, state: 'draft' }), + ); + if (walled) return walled; // [#22090] A package's members live in one of two places, and each // is reverted by the service that holds it. A Studio-authored // package is `sys_metadata` rows bound by `package_id` — the rows @@ -1952,6 +2046,16 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 2 && parts[1] === 'adopt-orphans' && m === 'POST') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); + // [#15204 W3] ADR-0131 D3: rebinding environment catalog rows into + // a package is the operator's under a wall. The orphans exactly as + // `reassignOrphanedMetadata` selects them: organization-less rows + // with no package (null, empty, or the `sys_metadata` sentinel). + const walled = await refuseWalledCatalogPackageWrite( + deps, _context, `Adopting orphaned metadata into package "${id}"`, + storedRowTypes(qlService, { organization_id: null }, + (row) => row.package_id == null || row.package_id === '' || row.package_id === 'sys_metadata'), + ); + if (walled) return walled; const protocol = await resolveProtocol(deps, _context); if (!protocol || typeof protocol.reassignOrphanedMetadata !== 'function') { return { handled: true, response: deps.error('Orphan adoption not supported', 501) }; @@ -1994,6 +2098,15 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // the target package record (`installPackage`) ahead of its copy // loop, so a refusal any later still leaves the empty shell behind. const notABase = requireDuplicableSource(deps, qlService, id); if (notABase) return notABase; + // [#15204 W3] ADR-0131 D3: a duplicate copies the source's items + // into a new package, and a copied catalog item is a catalog + // creation (its name is not re-namespaced), the operator's under + // a wall. The source's stored rows. + const walled = await refuseWalledCatalogPackageWrite( + deps, _context, `Duplicating package "${id}"`, + storedRowTypes(qlService, { package_id: id }), + ); + if (walled) return walled; try { const result = await protocol.duplicatePackage({ sourcePackageId: id, @@ -2110,7 +2223,10 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // organization's holder passes it. [#15204 W2] What makes the delete // the operator's under a wall is the catalog refusal below (ADR-0131 // D3), for a package that carries a position or a permission set. - const walled = await refuseWalledCatalogPackageDelete(deps, _context, qlService, id); + const walled = await refuseWalledCatalogPackageWrite( + deps, _context, `Deleting package "${id}"`, + storedRowTypes(qlService, { package_id: id }), + ); if (walled) return walled; const protocol = await resolveProtocol(deps, _context); const persists = Boolean(protocol && typeof protocol.deletePackage === 'function'); From 4c653ef8c3e1550a3417ee0a9b8130adc1ffa6a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 04:40:55 +0000 Subject: [PATCH 19/25] test(dogfood): each walled /packages door pin reads only its own request Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP --- ...wall-package-doors-refusal.dogfood.test.ts | 22 +++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/packages/qa/dogfood/test/wall-package-doors-refusal.dogfood.test.ts b/packages/qa/dogfood/test/wall-package-doors-refusal.dogfood.test.ts index aa66d51b726..2520d4c6949 100644 --- a/packages/qa/dogfood/test/wall-package-doors-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/wall-package-doors-refusal.dogfood.test.ts @@ -148,6 +148,13 @@ for (const posture of ['isolated', 'group'] as const) { let h: Harness; const previous = process.env.OS_TENANCY_POSTURE; const bGrants = () => grantsOf(h, h.userB, h.orgB); + /** B's resolution, as one comparable value. */ + const bSnapshot = async () => { + const g = await bGrants(); + return JSON.stringify({ sys: [...g.systemPermissions].sort(), sets: [...g.permissions].sort() }); + }; + // Each pin below compares B's resolution across its OWN request, so a pin + // reads only its own door: an earlier pin's outcome cannot redden it. const refused = async (res: Response) => { const env = await envelopeOf(res); expect({ status: env.status, code: env.code }, env.message).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); @@ -178,31 +185,38 @@ for (const posture of ['isolated', 'group'] as const) { }); it('publish-drafts: A publishing the operator\'s pending catalog draft is refused, and B does not gain `manage_metadata`', async () => { + const before = await bSnapshot(); await refused(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/publish-drafts`, {})); + expect(await bSnapshot()).toBe(before); expect((await bGrants()).systemPermissions).not.toContain('manage_metadata'); - expect(await rowsOf(h, { package_id: OP_PKG, type: 'permission', name: OP_SET, state: 'draft' })).toHaveLength(1); }); it('revert: A discarding the operator\'s pending catalog draft is refused, and the draft stays', async () => { + const drafted = await call(h.stack, h.operator, 'PUT', `/meta/permission/${OP_SET}?package=${OP_PKG}&mode=draft`, + set(OP_SET, ['manage_users', 'manage_metadata'])); + expect(drafted.status, await drafted.clone().text()).toBe(200); await refused(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/revert`, {})); expect(await rowsOf(h, { package_id: OP_PKG, type: 'permission', name: OP_SET, state: 'draft' })).toHaveLength(1); }); it('rollback: A rolling the operator\'s package back past a catalog commit is refused, and B keeps `manage_users`', async () => { + const before = await bSnapshot(); await refused(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/rollback`, { commitId: h.c1 })); - expect((await bGrants()).systemPermissions).toContain('manage_users'); + expect(await bSnapshot()).toBe(before); }); it('commits/:commitId/revert: A reverting the operator\'s catalog commit is refused, and B keeps `manage_users`', async () => { + const before = await bSnapshot(); await refused(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/commits/${h.c2}/revert`, {})); - expect((await bGrants()).systemPermissions).toContain('manage_users'); + expect(await bSnapshot()).toBe(before); }); it('commits/:commitId/revert: the commit is judged by its own items, whatever package the URL names', async () => { const own = await call(h.stack, h.tokA, 'POST', '/packages', { id: 'com.w3.a.url', name: 'url', version: '1.0.0', type: 'app' }); expect(own.status, await own.clone().text()).toBe(201); + const before = await bSnapshot(); await refused(await call(h.stack, h.tokA, 'POST', `/packages/com.w3.a.url/commits/${h.c2}/revert`, {})); - expect((await bGrants()).systemPermissions).toContain('manage_users'); + expect(await bSnapshot()).toBe(before); }); it('duplicate: A copying the operator\'s catalog-carrying package is refused, and nothing is copied', async () => { From fadbef54dcc9c49ad30c36b9b7256e8affcfc93a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 04:46:54 +0000 Subject: [PATCH 20/25] chore(changeset): the walled /packages verbs, registered as walled-catalog-package-verbs-retired; docs Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP --- .../15204-walled-catalog-package-verbs.md | 33 ++++++++++++++ content/docs/api/metadata-api.mdx | 13 ++++++ content/docs/permissions/permission-sets.mdx | 5 ++- ...18.walled-catalog-package-verbs-retired.ts | 45 +++++++++++++++++++ 4 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 .changeset/15204-walled-catalog-package-verbs.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.walled-catalog-package-verbs-retired.ts diff --git a/.changeset/15204-walled-catalog-package-verbs.md b/.changeset/15204-walled-catalog-package-verbs.md new file mode 100644 index 00000000000..a0db4398c07 --- /dev/null +++ b/.changeset/15204-walled-catalog-package-verbs.md @@ -0,0 +1,33 @@ +--- +'@objectstack/runtime': minor +'@objectstack/spec': minor +--- + +Under a walled posture the `/packages` verbs that publish, discard, roll back, revert, duplicate or adopt security catalog items are the platform operator's + +Clause-②: yes (narrowing) + + + +**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D3: "Shared-DB multi-tenant (`group` / `isolated`): tenant admins cannot create or edit catalog items; the operator defines the catalog for every tenant"). + +**Why.** A package's positions and permission sets are environment-wide, and every organization resolves them by name. Measured on a booted `isolated` and `group` stack: the operator published a permission set in a writable package, and another organization's member held it. An organization owner granted `manage_metadata` inside their own organization (never the platform operator) then: +- published the operator's pending draft adding `manage_users`, and that member's next resolution carried it; +- rolled the package back, and the member lost it; +- reverted the operator's commit, and the member lost it; +- copied the set, under the same name, into a package of their own, and `GET /meta` then served their copy; +- discarded the operator's pending draft; +- adopted the environment's catalog rows into their own package. + +**FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner). Every door is on the runtime dispatcher, and each **FROM** is: admitted to any `manage_metadata` holder. Each **TO** is `403 PERMISSION_DENIED`, naming the posture, before the verb runs: + +- `POST /api/v1/packages/:id/publish-drafts`, when the package has a pending position or permission-set draft. +- `POST /api/v1/packages/:id/revert`, when the package has such a draft. +- `POST /api/v1/packages/:id/rollback`, when a commit it would undo touched a position or permission set. +- `POST /api/v1/packages/:id/commits/:commitId/revert`, when that commit touched one. The commit's own items are judged: the verb takes the commit id alone, so the URL's package segment changes nothing. +- `POST /api/v1/packages/:id/duplicate`, when the source package holds one. +- `POST /api/v1/packages/:id/adopt-orphans`, when the environment's orphaned rows include one. + +**Unchanged.** A package whose drafts, commits or rows carry no catalog item is handled as before, for the same callers. The platform operator runs every verb as before. Under `single` (and with no `tenancy` service) nothing changes, and no extra read happens: the verdict is asked before the items are read. + +**The remedy.** The platform operator publishes, rolls back, reverts, duplicates and adopts catalog-carrying packages. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-package-verbs-retired`. Every door asks the one predicate the catalog's other write doors ask, `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), through the same helper as the package delete. diff --git a/content/docs/api/metadata-api.mdx b/content/docs/api/metadata-api.mdx index 0315e6b0ec7..23d06667338 100644 --- a/content/docs/api/metadata-api.mdx +++ b/content/docs/api/metadata-api.mdx @@ -127,6 +127,19 @@ Uninstall a package. Under a `group` or `isolated` tenancy posture, a package holding positions or permission sets is the platform operator's to delete: any other caller is refused `403 PERMISSION_DENIED` (ADR-0131 D3). +The package's other write verbs follow the same rule under a wall, judged by +the items each one acts on: +- `POST /packages/:id/publish-drafts` and `POST /packages/:id/revert`, by the + package's pending drafts; +- `POST /packages/:id/rollback`, by the commits it would undo; +- `POST /packages/:id/commits/:commitId/revert`, by that commit's own items, + whatever package the URL names; +- `POST /packages/:id/duplicate`, by the source's items; +- `POST /packages/:id/adopt-orphans`, by the environment's orphaned rows. + +When those items include a position or a permission set, only the platform +operator may run the verb. + ### `PATCH /packages/:id/enable` Enable a disabled package. diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 1a53465c142..220c73ca424 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -352,7 +352,10 @@ Environment-authored sets and other packages' rows survive. Under a `group` or `isolated` tenancy posture, removing a package that carries positions or permission sets is the platform operator's (ADR-0131 D3): that delete, and an `install-local` install, upgrade or uninstall of such a package, refuse any -other caller `403` before anything changes. +other caller `403` before anything changes. So do the package verbs that +publish, discard, roll back, revert, duplicate or adopt catalog items +(`publish-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, +`duplicate`, `adopt-orphans`). ## One authoritative store — the record is a projection (ADR-0094) diff --git a/packages/spec/src/migrations/entries/semantic/18.walled-catalog-package-verbs-retired.ts b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-package-verbs-retired.ts new file mode 100644 index 00000000000..18ca0c689bc --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-package-verbs-retired.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// ADR-0131 D3 (stage W3 of the C3 card) — a runtime-door narrowing with no +// authorable key: under a walled tenancy posture the `/packages` verbs that +// publish, discard, roll back, revert, duplicate or adopt security catalog +// items are the platform operator's. Registered because a caller that ran them +// before is refused now, and an operator must be told who keeps that authority. +export const entry: SemanticMigration = { + id: 'walled-catalog-package-verbs-retired', + // No backticks in `surface`: build-upgrade-guide.ts renders it inside a + // code span AND a table cell. + surface: + 'on a group or isolated deployment, by a caller below the platform operator, on the runtime ' + + 'dispatcher: POST /api/v1/packages/:id/publish-drafts and /revert when the package has a pending ' + + 'position or permission-set draft; /rollback when a commit it would undo touched one; ' + + '/commits/:commitId/revert when that commit touched one, whatever package the URL names; ' + + '/duplicate when the source holds one; and /adopt-orphans when the orphaned rows include one', + replacement: + 'the platform operator (the `PLATFORM_ADMIN` rung, the deployment\'s declared ' + + '`OS_PLATFORM_OWNER_EMAIL` owner) runs those verbs on catalog-carrying packages; a package whose ' + + 'drafts, commits or rows carry no catalog item is handled exactly as before. A custom package ' + + 'door asks `walledCatalogWriteVerdict` (`@objectstack/metadata-core`) for the catalog types its ' + + 'verb acts on, after its capability gate', + reason: + 'ADR-0131 D3: on a shared-database multi-tenant deployment tenant admins cannot create or edit ' + + 'catalog items, and the operator defines the catalog for every tenant. A package\'s catalog ' + + 'items are environment-wide, so before this change a manage_metadata holder scoped to one ' + + 'organization could publish an operator\'s pending catalog draft, roll back or revert the ' + + 'operator\'s published catalog change, discard the operator\'s draft, copy the operator\'s ' + + 'catalog items into a package of its own, or adopt the environment\'s catalog rows, and so ' + + 'change what another organization\'s members resolve.', + acceptanceCriteria: + 'On an isolated or group deployment, the operator publishes a permission set in a writable ' + + 'package twice (the second adding manage_users) and leaves a third draft pending; another ' + + 'organization\'s member holds the set. An organization owner holding manage_metadata inside ' + + 'their own organization then calls publish-drafts, revert, rollback to the first commit, ' + + 'commits//revert (also under a URL naming their own package), duplicate and, from a ' + + 'package of their own, adopt-orphans: each answers 403 PERMISSION_DENIED naming the posture, ' + + 'the draft stays pending, nothing is copied or rebound, and the member resolves exactly what it ' + + 'resolved before. The same owner runs every verb on a package of their own whose items carry no ' + + 'catalog item with 200; the platform operator runs every verb on the catalog-carrying package ' + + 'with 200; and on a single deployment a manage_metadata holder runs them exactly as before.', +}; From ae374774f08cc3a960a1e6d068db307dd6af37ed Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 05:19:12 +0000 Subject: [PATCH 21/25] fix(runtime): type the walled package readers' query options Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP --- packages/runtime/src/domains/packages.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 805458ea682..2b06ab8340c 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -490,7 +490,7 @@ function storedRowTypes( ): () => Promise { return async () => { if (typeof qlService?.find !== 'function') return []; - const rows = await qlService.find('sys_metadata', { where, context: { isSystem: true } } as any); + const rows = await qlService.find('sys_metadata', { where, context: { isSystem: true } }); return (Array.isArray(rows) ? rows : []) .filter((row) => row && typeof row === 'object' && keep(row as Record)) .map((row) => (row as { type?: unknown }).type); @@ -507,7 +507,7 @@ async function readCommitRow( commitId: string, ): Promise | undefined> { if (typeof qlService?.find !== 'function') return undefined; - const rows = await qlService.find('sys_metadata_commit', { where: { id: commitId }, context: { isSystem: true } } as any); + const rows = await qlService.find('sys_metadata_commit', { where: { id: commitId }, context: { isSystem: true } }); const row = Array.isArray(rows) ? rows[0] : undefined; return row && typeof row === 'object' ? (row as Record) : undefined; } From 3eaad0fa0492f000a4136b513d26e4c2c3cc5548 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 07:00:07 +0000 Subject: [PATCH 22/25] ci(drift): the shard timing-drift check exit-grades on `schedule` alone `--check-drift` takes the run's event (`--event`), required. On `schedule`, the profile scripts/test-shard-timings.json is measured on, a DRIFT exits 1 as before; on every other event it prints the same text, raises one `::warning::` annotation and exits 0. A missing or empty event is refused. Claude-Session: https://claude.ai/code/session_012yK1ddAdAqfzwigRHZq3r2 Co-authored-by: Claude --- scripts/partition-test-shards.mjs | 218 ++++++++++++++++++++++++++---- 1 file changed, 188 insertions(+), 30 deletions(-) diff --git a/scripts/partition-test-shards.mjs b/scripts/partition-test-shards.mjs index 52581cb6eaf..2c1bcf62605 100644 --- a/scripts/partition-test-shards.mjs +++ b/scripts/partition-test-shards.mjs @@ -75,7 +75,7 @@ // node scripts/partition-test-shards.mjs --shard N/M \ // [--exclude ]... // node scripts/partition-test-shards.mjs --check-drift ... \ -// [--label ] +// --event [--label ] // node scripts/partition-test-shards.mjs --self-test // // `--check-drift` is the half that keeps the dataset honest AFTER it is @@ -83,7 +83,8 @@ // run it just finished has written the measured truth to `.turbo/runs/`; this // mode reads that back, compares it to what this script PREDICTED for the same // packages, raises a `::warning::` past WARN_MEASURED_OVER_PREDICTED, and reds -// past MAX_MEASURED_OVER_PREDICTED. Without it the dataset +// past MAX_MEASURED_OVER_PREDICTED -- on a `schedule` run; on every other event +// that red is a `::warning::` and exits 0 (TIMING_GRADED_EVENT, C2). Without it the dataset // rots silently in one direction and the only instrument that notices is a // shard killed by the job timeout -- which is a shard that produced NO reading // while the rollup read green. @@ -200,6 +201,54 @@ export const MAX_MEASURED_OVER_PREDICTED = 1.5; // the warning cannot be made unreachable by moving either end. export const WARN_MEASURED_OVER_PREDICTED = 1.3; +// ── WHERE THE TWO TIMING GATES EXIT-GRADE: `schedule` ALONE (C2) ────────── +// +// Ruling (letter C2, for both timing steps): +// https://github.com/objectstack-ai/objectstack/issues/22949#issuecomment-6119186511 +// "a timing gate exit-grades only on the run profile its baseline was measured +// on; everywhere else it reports." +// +// Both timing gates read one baseline: the weights `--check-drift` predicts +// from, and the ceilings scripts/check-test-suite-ceilings.mjs grades against. +// The refresh lane measures it on ci.yml's hourly `schedule` runs and no others +// (DEFAULT_RUN_EVENT in scripts/ci/select-shard-timings-run.mjs). A +// `merge_group` or `pull_request` build executes far more of the workspace on +// the same runners. On the morning the refreshed dataset went live this step +// redded 3 of 19 queue builds and the heads of three PRs (#22953, #22954, +// #22959, filed as #22966) at 1.56-1.62x, on packages none of them touched, +// while every hourly run read green. So both gates take the run's event +// (`--event "$GITHUB_EVENT_NAME"` in ci.yml) and: +// +// schedule exit-graded, exactly as before: a red exits 1 +// every other REPORT ONLY (merge_group, pull_request, push, +// event workflow_dispatch): the same table prints, each red becomes a +// `::warning::` annotation, and the step exits 0 +// +// A real slowdown merged to `main` reds the next hourly run. A missing or empty +// `--event` is refused, never defaulted: a step that lost its event would +// otherwise grade everywhere or nowhere, and say nothing either way. +export const TIMING_GRADED_EVENT = 'schedule'; + +/** 'grade' on TIMING_GRADED_EVENT, 'report' on any other event; a missing event is refused. */ +export function timingGateMode(event) { + if (typeof event !== 'string' || event.trim() === '') { + throw new Error( + `the run's event is ${JSON.stringify(event ?? null)}. The timing gates exit-grade on ` + + `\`${TIMING_GRADED_EVENT}\` alone and report on every other event, so they need it: pass ` + + '`--event "$GITHUB_EVENT_NAME"`. A missing event is refused rather than guessed.' + ); + } + return event === TIMING_GRADED_EVENT ? 'grade' : 'report'; +} + +/** The one line both timing gates print to say which of the two modes this run is in. */ +export function timingModeNote(event) { + return timingGateMode(event) === 'grade' + ? `exit-graded: a \`${event}\` run is the profile the baseline was measured on` + : `report only: a \`${event}\` run is not the profile the baseline was measured on ` + + `(\`${TIMING_GRADED_EVENT}\`), so a red reading is a warning here and the step exits 0`; +} + // ── SHARDING ONE PACKAGE BELOW PACKAGE GRANULARITY (#16173) ──────────────── // // The floor argument at the top of this file is not a caveat, it is a wall: a @@ -1082,7 +1131,12 @@ export function escapeWorkflowCommandMessage(text) { return String(text).replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A'); } -export function renderDriftVerdict(report, label) { +// +// `event` is the run's (ci.yml passes `$GITHUB_EVENT_NAME`): off +// TIMING_GRADED_EVENT a DRIFT prints the same text, becomes a `::warning::` +// annotation, and exits 0 (C2, see timingGateMode). +export function renderDriftVerdict(report, label, event) { + const mode = timingGateMode(event); const skipped = report.unpredicted.length === 0 ? '' @@ -1115,25 +1169,37 @@ export function renderDriftVerdict(report, label) { `(${ratioOf(r)}${r.overshoot >= 0 ? '+' : ''}${r.overshoot.toFixed(1)}s)` ) .join('\n'); + const note = `\n ${timingModeNote(event)}.`; if (report.drifted) { - return { - verdict: 'DRIFT', - exitCode: 1, - out: [], - err: [ - `shard-timing-drift: DRIFT -- ${label}, ${head}.${skipped}\n` + - ' Heaviest overshoots:\n' + - `${worst}\n` + - ' scripts/test-shard-timings.json no longer describes this workspace, so the shard split\n' + - ' is balancing a quantity that is not the runtime. Refresh it -- see\n' + - ' scripts/measure-test-shard-timings.mjs for the two refresh paths -- and ⛔ do NOT\n' + - ' hand-edit the dataset or raise this bound to absorb the gap. Expect the refresh to red\n' + - " this script's own balance pins if a single suite has outgrown the acceptance bound:\n" + - ' that is those pins working, and the remedy they name is splitting that suite below\n' + - ' package granularity, never a different shard count.', - ], - }; + const text = + `shard-timing-drift: DRIFT -- ${label}, ${head}.${skipped}\n` + + ' Heaviest overshoots:\n' + + `${worst}\n` + + ' scripts/test-shard-timings.json no longer describes this workspace, so the shard split\n' + + ' is balancing a quantity that is not the runtime. Refresh it -- see\n' + + ' scripts/measure-test-shard-timings.mjs for the two refresh paths -- and ⛔ do NOT\n' + + ' hand-edit the dataset or raise this bound to absorb the gap. Expect the refresh to red\n' + + " this script's own balance pins if a single suite has outgrown the acceptance bound:\n" + + ' that is those pins working, and the remedy they name is splitting that suite below\n' + + ` package granularity, never a different shard count.${note}`; + if (mode === 'report') { + const top = report.rows[0]; + return { + verdict: 'DRIFT', + exitCode: 0, + out: [ + `::warning title=Test Core shard timing drift::${escapeWorkflowCommandMessage( + `${label}: ${head}, past the red bound. Heaviest overshoot: ${top.name}, predicted ` + + `${top.predicted.toFixed(1)}s, measured ${top.measured.toFixed(1)}s. Report only on a \`${event}\` run: ` + + `this step exit-grades on \`${TIMING_GRADED_EVENT}\` runs alone, the profile ` + + 'scripts/test-shard-timings.json was measured on, so a drift that is real reds the next hourly run on main.' + )}`, + ], + err: [text], + }; + } + return { verdict: 'DRIFT', exitCode: 1, out: [], err: [text] }; } if (report.warned) { @@ -1149,7 +1215,7 @@ export function renderDriftVerdict(report, label) { 'Not a failure: this step stays green until the red bound.' )}`, ], - err: [`shard-timing-drift: WARN -- ${label}, ${head}.${skipped}\n Heaviest overshoots:\n${worst}`], + err: [`shard-timing-drift: WARN -- ${label}, ${head}.${skipped}\n Heaviest overshoots:\n${worst}${note}`], }; } @@ -1157,7 +1223,7 @@ export function renderDriftVerdict(report, label) { verdict: 'OK', exitCode: 0, out: [], - err: [`shard-timing-drift: OK -- ${label}, ${head}.${skipped}`], + err: [`shard-timing-drift: OK -- ${label}, ${head}.${skipped}${note}`], }; } @@ -1249,11 +1315,12 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'file-level slice items (#16173)': 20, 'file-level slices reach vitest through OS_TEST_SHARD (#19278)': 11, 'density cap and the per-run slice decision (#22075)': 14, + 'the drift step exit-grades on `schedule` alone, and reports everywhere else (C2)': 8, }); // DELETING an entry silences that battery's floor exactly as effectively as // zeroing it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 12; +const SELF_TEST_BATTERY_FLOOR = 13; // The key an assertion is filed under when no battery is open. It is not a // declared battery, so it reds by the same set difference rather than silently @@ -1898,7 +1965,7 @@ function selfTest() { // with a workflow command would mint a real annotation on every lint run). const label = 'Test Core (9/9)'; check(() => { - const v = renderDriftVerdict(tierOf(131), label); + const v = renderDriftVerdict(tierOf(131), label, TIMING_GRADED_EVENT); const commands = v.out.filter((line) => line.startsWith('::')); if (v.verdict !== 'WARN' || v.exitCode !== 0 || commands.length !== 1) { throw new Error( @@ -1910,19 +1977,19 @@ function selfTest() { } }); check(() => { - const v = renderDriftVerdict(tierOf(151), label); + const v = renderDriftVerdict(tierOf(151), label, TIMING_GRADED_EVENT); if (v.verdict !== 'DRIFT' || v.exitCode !== 1 || v.out.some((line) => line.startsWith('::'))) { throw new Error(`drift warning: a red rendered ${v.verdict} exit ${v.exitCode}, ${v.out.length} stdout line(s)`); } }); check(() => { for (const [seconds, expected] of [[118, 'OK'], [WARN_MEASURED_OVER_PREDICTED * 100, 'OK']]) { - const v = renderDriftVerdict(tierOf(seconds), label); + const v = renderDriftVerdict(tierOf(seconds), label, TIMING_GRADED_EVENT); if (v.verdict !== expected || v.exitCode !== 0 || v.out.length !== 0) { throw new Error(`drift warning: ${seconds / 100}x rendered ${v.verdict} with ${v.out.length} stdout line(s)`); } } - const nm = renderDriftVerdict(driftReport(replayed.samples, driftTimings), label); + const nm = renderDriftVerdict(driftReport(replayed.samples, driftTimings), label, TIMING_GRADED_EVENT); if (nm.verdict !== 'NOT MEASURED' || nm.exitCode !== 0 || nm.out.length !== 0) { throw new Error(`drift warning: a replay-only shard rendered ${nm.verdict}`); } @@ -1936,6 +2003,85 @@ function selfTest() { } }); + // -- EXIT-GRADED ON `schedule` ALONE (C2) -------------------------------- + // + // The baseline is measured on hourly `schedule` runs, so only those exit 1 + // on a DRIFT; every other event prints the same text, annotates a warning + // and exits 0. Read as rendered text, like the tier above: printing a + // `::warning::` from here would mint a real annotation on every lint run. + battery('the drift step exit-grades on `schedule` alone, and reports everywhere else (C2)'); + const offSchedule = ['merge_group', 'pull_request', 'push', 'workflow_dispatch']; + + check(() => { + if (TIMING_GRADED_EVENT !== 'schedule' || timingGateMode('schedule') !== 'grade') { + throw new Error(`C2: the graded event is ${TIMING_GRADED_EVENT}, and schedule reads ${timingGateMode('schedule')}`); + } + }); + check(() => { + const graded = offSchedule.filter((e) => timingGateMode(e) !== 'report'); + if (graded.length) throw new Error(`C2: ${graded.join(', ')} exit-grade, but only schedule may`); + }); + // A step that lost its event is refused, never defaulted to either mode. + check(() => { + const accepted = [undefined, null, '', ' '].filter((e) => !threw(() => timingGateMode(e))); + if (accepted.length) throw new Error(`C2: a missing event was read as a mode: ${JSON.stringify(accepted)}`); + if (!threw(() => renderDriftVerdict(tierOf(151), label))) throw new Error('C2: a render with no event was graded'); + }); + check(() => { + const v = renderDriftVerdict(tierOf(151), label, 'schedule'); + if (v.verdict !== 'DRIFT' || v.exitCode !== 1 || v.out.length !== 0 || !v.err[0].includes('exit-graded')) { + throw new Error(`C2: a schedule DRIFT rendered ${v.verdict} exit ${v.exitCode}, ${v.out.length} stdout line(s)`); + } + }); + check(() => { + for (const event of offSchedule) { + const v = renderDriftVerdict(tierOf(151), label, event); + const commands = v.out.filter((line) => line.startsWith('::')); + if (v.verdict !== 'DRIFT' || v.exitCode !== 0 || commands.length !== 1) { + throw new Error(`C2: a ${event} DRIFT rendered ${v.verdict} exit ${v.exitCode} with ${commands.length} command(s)`); + } + if (!commands[0].startsWith('::warning ') || !commands[0].includes(label) || !commands[0].includes(event) || commands[0].includes('\n')) { + throw new Error(`C2: a ${event} DRIFT is not one ::warning line naming the shard and the event: ${commands[0]}`); + } + } + }); + // The SAME table on every event: only the mode line differs. + check(() => { + const body = (v) => v.err[0].split('\n').slice(0, -1).join('\n'); + const graded = renderDriftVerdict(tierOf(151), label, 'schedule'); + const reported = renderDriftVerdict(tierOf(151), label, 'merge_group'); + if (body(graded) !== body(reported) || !body(graded).includes('Heaviest overshoots:')) { + throw new Error(`C2: the report-only text is not the graded text:\n${graded.err[0]}\n---\n${reported.err[0]}`); + } + if (!reported.err[0].endsWith('so a red reading is a warning here and the step exits 0.')) { + throw new Error(`C2: the report-only text does not say so:\n${reported.err[0]}`); + } + }); + check(() => { + // The tiers under the red are unchanged off schedule: WARN annotates, OK and NOT MEASURED say nothing on stdout. + const warn = renderDriftVerdict(tierOf(131), label, 'pull_request'); + const ok = renderDriftVerdict(tierOf(118), label, 'push'); + const nm = renderDriftVerdict(driftReport(replayed.samples, driftTimings), label, 'merge_group'); + if (warn.verdict !== 'WARN' || warn.exitCode !== 0 || warn.out.length !== 1 || !warn.out[0].startsWith('::warning ')) { + throw new Error(`C2: a pull_request WARN rendered ${warn.verdict} exit ${warn.exitCode}`); + } + if (ok.verdict !== 'OK' || ok.exitCode !== 0 || ok.out.length !== 0 || !ok.err[0].includes('report only')) { + throw new Error(`C2: a push OK rendered ${ok.verdict} exit ${ok.exitCode}:\n${ok.err[0]}`); + } + if (nm.verdict !== 'NOT MEASURED' || nm.exitCode !== 0 || nm.out.length !== 0) { + throw new Error(`C2: a merge_group replay-only shard rendered ${nm.verdict}`); + } + }); + // The CLI: `--event` is read, and its absence is refused before any summary is. + check(() => { + const parsed = parseDriftArgs(['--check-drift', 'a.json', '--label', 'L', '--event', 'merge_group']); + if (parsed.event !== 'merge_group' || parsed.inputs.join() !== 'a.json' || parsed.label !== 'L') { + throw new Error(`C2: --check-drift parsed as ${JSON.stringify(parsed)}`); + } + if (!threw(() => parseDriftArgs(['--check-drift', 'a.json']))) throw new Error('C2: --check-drift with no --event was accepted'); + if (!threw(() => parseDriftArgs(['--check-drift', 'a.json', '--event', '']))) throw new Error('C2: an empty --event was accepted'); + }); + // -- FILE-LEVEL SLICE ITEMS (#16173) ------------------------------------ // // The grammar, its refusals, and the two joins that read it. The balancing @@ -2474,19 +2620,31 @@ function selfTest() { // test tasks were all cache replays has said nothing about the dataset, and // reporting that as OK is the #4690 shape -- a check that read nothing // reporting as a check that found nothing wrong. -function checkDrift(argv) { +// +// `--event` is required (C2): the run's event decides whether a DRIFT exits 1 +// (`schedule`) or is reported as a warning (every other event), and a missing +// one is refused by timingGateMode rather than defaulted either way. +export function parseDriftArgs(argv) { const inputs = []; let label = 'this shard'; + let event = null; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; if (arg === '--check-drift') continue; else if (arg === '--label') label = argv[++i]; + else if (arg === '--event') event = argv[++i]; else if (arg.startsWith('--')) throw new Error(`unrecognized argument: ${arg}`); else inputs.push(arg); } + timingGateMode(event); + return { inputs, label, event }; +} + +function checkDrift(argv) { + const { inputs, label, event } = parseDriftArgs(argv); if (inputs.length === 0) { console.error( - 'usage: partition-test-shards.mjs --check-drift ... [--label ]' + 'usage: partition-test-shards.mjs --check-drift ... --event [--label ]' ); process.exit(1); } @@ -2515,7 +2673,7 @@ function checkDrift(argv) { } const timings = loadTimings(); const report = driftReport(merged, timings, MAX_MEASURED_OVER_PREDICTED, observedSlices); - const rendered = renderDriftVerdict(report, label); + const rendered = renderDriftVerdict(report, label, event); for (const line of rendered.out) console.log(line); for (const line of rendered.err) console.error(line); if (rendered.exitCode !== 0) process.exit(rendered.exitCode); From 944ebdb98d6e53c7882c614705141d7ac776edf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 07:03:02 +0000 Subject: [PATCH 23/25] ci(ceilings): exit-grade the suite-duration ceilings on `schedule` alone, with a 20s floor The ceiling check takes the run's event (`--event`, required). On `schedule`, the profile the ceilings were measured on, a package over its ceiling reds the step as before; on every other event the same table prints, each package over its ceiling is a `::warning::` annotation, and the step exits 0. Refusals stay red on every event. A package whose effective ceiling is under CEILING_FLOOR_SECONDS (20s) is never red and prints "below floor"; the header records the floor's reading. ci.yml passes `--event "$GITHUB_EVENT_NAME"` to both timing steps, which keep no `if:` and no `continue-on-error`. Claude-Session: https://claude.ai/code/session_012yK1ddAdAqfzwigRHZq3r2 Co-authored-by: Claude --- .github/workflows/ci.yml | 48 +++- scripts/check-test-suite-ceilings.mjs | 320 ++++++++++++++++++++++---- 2 files changed, 314 insertions(+), 54 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 382ed879727..8bba0be0dc6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1066,6 +1066,15 @@ jobs: # second red on wall time against `timeout-minutes`. Both constants, their # measured basis and a self-test battery each live in the script. # + # THE RED IS A `schedule` RED ALONE (C2, both timing steps, ruling + # https://github.com/objectstack-ai/objectstack/issues/22949#issuecomment-6119186511): + # the dataset's weights are measured on the hourly `schedule` runs, so + # only those exit 1 on DRIFT. On `merge_group`, `pull_request`, `push` + # and `workflow_dispatch` the step prints the same verdict, raises the + # DRIFT as a `::warning::` annotation and exits 0. The event reaches the + # script as `--event "$GITHUB_EVENT_NAME"`, which it requires; the rule + # and its self-test live there (TIMING_GRADED_EVENT, timingGateMode). + # # EXECUTED WINDOWS ONLY. Measurements come from samplesFromSummary() in # measure-test-shard-timings.mjs, which drops cache HITs and failed # suites, and the prediction is summed over the same executed packages. @@ -1073,10 +1082,11 @@ jobs: # predicted and vouch for a rotted dataset, so a shard whose packages all # replayed is NOT MEASURED, never "fast". # - # NO `if:` and NO `continue-on-error` (the red is the point), and ABOVE - # the attestation pair for the #6082 reason documented on the upload - # above: a failed unguarded step skips that pair, so a drift red also - # withholds this shard's attestation, the fail-closed direction. A shard + # NO `if:` and NO `continue-on-error` (the red is the point, and the + # `schedule`-only red is the script's `--event`, so neither is needed), + # and ABOVE the attestation pair for the #6082 reason documented on the + # upload above: a failed unguarded step skips that pair, so a drift red + # also withholds this shard's attestation, the fail-closed direction. A shard # with no packages runs no turbo and writes no summary; that is NOT # MEASURED here rather than a usage error from the script. # @@ -1109,7 +1119,7 @@ jobs: exit 0 fi node scripts/partition-test-shards.mjs --check-drift "${SUMMARIES[@]}" \ - --label "Test Core (${{ matrix.shard }}/6)" + --label "Test Core (${{ matrix.shard }}/6)" --event "$GITHUB_EVENT_NAME" # Runs even when the suite failed — that is when it earns its keep. It # answers TWO questions about a red suite, and needs both to be able to @@ -1486,12 +1496,12 @@ jobs: # ── The per-package suite-duration ratchet (#16468) ────────────────── # # A GATE, unlike the two reporting steps above: NO `continue-on-error`, - # so a package whose suite ran past its ceiling reds `Test Core`, naming - # the package and its slowest test files. The ceiling is the package's - # slowest executed run in the refresh window x 1.25, generated into - # scripts/test-shard-timings.json (`ceilings`) by the refresh lane and - # held by every later refresh; it rises only by a ruled entry in the - # script's RULED_CEILING_RAISES. A `provisional` package prints "no + # so on a `schedule` run a package whose suite ran past its ceiling reds + # `Test Core`, naming the package and its slowest test files. The + # ceiling is the package's slowest executed run in the refresh window + # x 1.25, generated into scripts/test-shard-timings.json (`ceilings`) by + # the refresh lane and held by every later refresh; it rises only by a + # ruled entry in the script's RULED_CEILING_RAISES. A `provisional` package prints "no # ceiling: provisional" and is not red; a package absent from the dataset # prints "no ceiling: new package" and is not red until the first refresh # after it lands gives it a ceiling, while a package the dataset knows @@ -1505,6 +1515,20 @@ jobs: # samplesFromSummary, and every vitest file line); a capture that did not # arrive leaves its packages ungraded and says so, never red. # + # EXIT-GRADED ON `schedule` ALONE (C2, both timing steps, ruling + # https://github.com/objectstack-ai/objectstack/issues/22949#issuecomment-6119186511): + # the ceilings come from the hourly `schedule` runs, so only those red + # this job on a package over its ceiling. On `merge_group`, + # `pull_request`, `push` and `workflow_dispatch` the step prints the same + # table, raises each package over its ceiling as a `::warning::` + # annotation and exits 0; a real slowdown merged to `main` reds the next + # hourly run. A package whose ceiling is under the absolute floor (20s, + # CEILING_FLOOR_SECONDS) prints "below floor" and is never red. The + # event reaches the script as `--event "$GITHUB_EVENT_NAME"`, which it + # requires; a refusal (a malformed table or raise record) is red on every + # event. The rule, the floor's reading and their self-tests live in the + # script. + # # NOT MEASURED, exit 0, while the dataset carries no `ceilings`: they are # generated only where the run-summary artifacts are downloadable (the # refresh lane), so the first refresh after this lands writes them, and @@ -1515,7 +1539,7 @@ jobs: # captured, so a red shard's packages are graded too. - name: Check the per-package suite-duration ceilings if: always() - run: node scripts/check-test-suite-ceilings.mjs --captures "$RUNNER_TEMP/test-timings" --label 'Test Core' + run: node scripts/check-test-suite-ceilings.mjs --captures "$RUNNER_TEMP/test-timings" --label 'Test Core' --event "$GITHUB_EVENT_NAME" # ── Temporal conformance against live, non-UTC servers (ADR-0053 D-A3) ───── diff --git a/scripts/check-test-suite-ceilings.mjs b/scripts/check-test-suite-ceilings.mjs index 570a0ceaa04..cc855740c0d 100644 --- a/scripts/check-test-suite-ceilings.mjs +++ b/scripts/check-test-suite-ceilings.mjs @@ -2,11 +2,12 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * Per-package suite-duration ratchet (#16468): a run in which a package's test - * suite took longer than its measured ceiling is RED, naming the package and - * its slowest test files, and a ceiling rises only by a ruling. + * Per-package suite-duration ratchet (#16468): a `schedule` run in which a + * package's test suite took longer than its measured ceiling is RED, naming the + * package and its slowest test files; every other run reports the same reading + * as a warning; and a ceiling rises only by a ruling. * - * node scripts/check-test-suite-ceilings.mjs --captures [--label ] + * node scripts/check-test-suite-ceilings.mjs --captures --event [--label ] * node scripts/check-test-suite-ceilings.mjs --self-test * * ## Why it exists @@ -39,6 +40,52 @@ * - A ceiling rises only by a ruling: RULED_CEILING_RAISES, each entry naming * the ruling's comment URL. Nothing else in this file moves a ceiling. * + * ## Exit-graded on `schedule` alone; every other event reports (C2) + * + * Ruling (letter C2, for both timing steps, plus an absolute floor): + * https://github.com/objectstack-ai/objectstack/issues/22949#issuecomment-6119186511 + * "a timing gate exit-grades only on the run profile its baseline was measured + * on; everywhere else it reports." The ceilings come from the refresh window, + * which reads ci.yml's hourly `schedule` runs alone; those replay part of the + * workspace from the turbo cache, while a merge-queue or PR build executes far + * more of it on the same runners. Graded against that window, 9 of the 19 queue + * builds on the morning the table went live redded on packages no PR in them + * touched, and PR heads did too, while the hourly run read green. + * + * So `--event` is required, and the mode is TIMING_GRADED_EVENT's, shared with + * the drift step (timingGateMode in partition-test-shards.mjs): + * + * schedule exit-graded, as before: a package over its ceiling exits 1 + * with an `::error::` annotation naming it + * every other REPORT ONLY (merge_group, pull_request, push, + * event workflow_dispatch): the same table prints, each package over + * its ceiling is a `::warning::` annotation, and it exits 0 + * + * A real slowdown merged to `main` reds the next hourly run, which names the + * package and its slowest files. A refusal -- a malformed raise record or + * ceiling table, or a missing `--event` -- is not a timing reading, does not + * vary with runner load, and exits 1 on every event. + * + * ⚠ The hourly run grades only the packages it EXECUTES: a turbo cache replay + * carries no window, so it is never graded. A package the hourly run replays on + * every run (ci.yml's `timeout-minutes` note: "scheduled runs mostly replay the + * cache their commit's push run saved") is graded on any hourly run that does + * execute it and reported everywhere else; nothing here forces one to. + * + * ## The absolute floor: CEILING_FLOOR_SECONDS, 20 s + * + * Same ruling: "a package whose ceiling is below the floor is never red; it + * prints 'below floor'". The floor is read off the table's distribution by the + * `domain:devx` seat and is not below 20 s. The reading: on `1f312fdfa` the 72 + * ceilings run 3.40, 3.69, 4.24, 5.18, 6.39, 9.10 and 16.23 s, then 22.71 s + * and up, and 20 s sits in the widest gap of the low band (16.23 -> 22.71). The + * seven packages under it then were knowledge-ragflow, service-cache, + * observability, sdui-parser, knowledge-memory, embedder-openai and + * client-react. A 3.4 s ceiling crossed by a 4.2 s run is noise, not a + * regression. The floor is compared with the EFFECTIVE ceiling, so a ruled + * raise to the floor or above grades the package again; the formula, its + * x 1.25 and RULED_CEILING_RAISES are unchanged. + * * ## No table yet is NOT MEASURED, never red * * The ceilings can only be generated where the run-summary artifacts can be @@ -94,10 +141,11 @@ * ## Exit codes * * 0 OK, or NOT MEASURED (no table yet; nothing executed; no captures); a - * new package alone never makes it 1 - * 1 a package over its ceiling, or a refusal (a malformed ceiling table -- - * a package the dataset knows that lost its row among them -- or raise - * record) + * new package or one below the floor never makes it 1; and on any event + * but `schedule`, a package over its ceiling (reported as a warning) + * 1 a package over its ceiling on a `schedule` run, or a refusal on any + * event (a malformed ceiling table -- a package the dataset knows that + * lost its row among them -- or raise record, or no `--event`) */ import { readFileSync, readdirSync, statSync } from 'node:fs'; @@ -106,7 +154,12 @@ import { fileURLToPath } from 'node:url'; import process from 'node:process'; import { isEntrypoint } from './invoked-as.mjs'; -import { escapeWorkflowCommandMessage } from './partition-test-shards.mjs'; +import { + TIMING_GRADED_EVENT, + escapeWorkflowCommandMessage, + timingGateMode, + timingModeNote, +} from './partition-test-shards.mjs'; const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const DATASET_PATH = path.join(REPO_ROOT, 'scripts', 'test-shard-timings.json'); @@ -115,6 +168,13 @@ const DATASET_REL = 'scripts/test-shard-timings.json'; /** How many of a red package's slowest test files the verdict names. */ export const SLOWEST_FILES_NAMED = 5; +/** + * The absolute floor (C2 ruling): a package whose effective ceiling is below it + * is never red and prints "below floor". Its reading and ruling are in the + * header, "The absolute floor". + */ +export const CEILING_FLOOR_SECONDS = 20; + // ── The ruled raises ────────────────────────────────────────────────────── // // A ceiling rises ONLY here, and only on a ruling. One entry per package: @@ -241,8 +301,9 @@ export function readCeilingTable(dataset) { * Returns `{ table, rows, problems }`; each row is one executed package with a * `status`: * - * over graded, over its effective ceiling RED + * over graded, over its effective ceiling RED on `schedule`, a warning elsewhere * ok graded, within its effective ceiling + * below-floor effective ceiling under CEILING_FLOOR_SECONDS never red * new not in the dataset, and no ruled raise no ceiling, not red * provisional in the dataset's `provisional` list no ceiling, not red * uncapped `uncapped` with a reason (`carried`) no ceiling, not red @@ -297,6 +358,11 @@ export function gradeCeilings({ merged, dataset, raises = RULED_CEILING_RAISES } continue; } row.ceiling = Math.max(generated ?? 0, raise?.seconds ?? 0); + // The absolute floor: strict `<`, so a ceiling AT the floor is graded. + if (row.ceiling < CEILING_FLOOR_SECONDS) { + row.status = 'below-floor'; + continue; + } // Strict `>`: a run AT its ceiling is within it, like the drift bound. row.status = seconds > row.ceiling ? 'over' : 'ok'; } @@ -335,8 +401,13 @@ function ceilingText(row) { * The verdict a runner receives, rendered without printing it so the * self-test can read it: `out` is stdout (workflow-command annotations), * `err` the human log. + * + * `event` is the run's (`--event`, required). Off TIMING_GRADED_EVENT a + * package over its ceiling prints the same table, is a `::warning::` + * annotation, and exits 0 (C2); a refusal exits 1 on every event. */ -export function renderCeilingVerdict(report, label) { +export function renderCeilingVerdict(report, label, event) { + const mode = timingGateMode(event); const head = `suite-ceiling`; if (report.problems.length > 0) { return { @@ -378,6 +449,12 @@ export function renderCeilingVerdict(report, label) { return ` -- ${r.name}: ${r.seconds.toFixed(2)}s -- no ceiling: provisional`; case 'uncapped': return ` -- ${r.name}: ${r.seconds.toFixed(2)}s -- no ceiling: ${r.reason}`; + case 'below-floor': + return ( + ` -- ${r.name}: ${r.seconds.toFixed(2)}s against ${r.ceiling.toFixed(2)}s ` + + `(${(r.seconds / r.ceiling).toFixed(2)}x)${sliceNote(r)} -- below floor: a ceiling under ` + + `${CEILING_FLOOR_SECONDS}s is never red` + ); case 'partial': return ` -- ${r.name}: NOT MEASURED -- a part of its suite is missing from this run${sliceNote(r)}; a part is never graded as the whole`; case 'new': @@ -395,18 +472,22 @@ export function renderCeilingVerdict(report, label) { const basis = `ceilings from ${DATASET_REL}${report.table.measuredAt ? ` (refreshed ${report.table.measuredAt})` : ''}: ` + `each package's slowest executed run x ${report.table.headroom ?? '?'} when first set, held since`; + const belowFloor = report.rows.filter((r) => r.status === 'below-floor').length; + const floorNote = belowFloor ? `; ${belowFloor} below the ${CEILING_FLOOR_SECONDS}s floor, never red` : ''; + const note = `\n ${timingModeNote(event)}.`; if (red.length === 0) { const verdict = graded.length === 0 ? 'NOT MEASURED' : 'OK'; const summaryLine = graded.length === 0 - ? `${head}: NOT MEASURED -- ${label}: no executed package carried a ceiling to grade against (${report.rows.length} executed).` - : `${head}: OK -- ${label}: ${graded.length} package(s) graded, every one within its ceiling.`; + ? `${head}: NOT MEASURED -- ${label}: no executed package carried a ceiling at or above the ` + + `${CEILING_FLOOR_SECONDS}s floor to grade against (${report.rows.length} executed${floorNote}).` + : `${head}: OK -- ${label}: ${graded.length} package(s) graded, every one within its ceiling${floorNote}.`; return { verdict, exitCode: 0, out: [], - err: [`${summaryLine}\n ${basis}.${lines.length ? `\n${lines.join('\n')}` : ''}${stale}`], + err: [`${summaryLine}\n ${basis}.${lines.length ? `\n${lines.join('\n')}` : ''}${stale}${note}`], }; } @@ -417,25 +498,37 @@ export function renderCeilingVerdict(report, label) { `(${(r.seconds / r.ceiling).toFixed(2)}x; ${ceilingText(r)})${sliceNote(r)}. Its slowest test files:\n${filesText(r)}` ) .join('\n'); + const reading = (r) => + `${r.name}: ${r.seconds.toFixed(2)}s, over its ${r.ceiling.toFixed(2)}s ceiling. Slowest file: ` + + `${r.files[0] ? `${r.files[0].file} ${(r.files[0].ms / 1000).toFixed(2)}s` : '(none captured)'}.`; + const text = + `${head}: OVER -- ${label}: ${red.length} package(s) over their suite-duration ceiling.\n` + + `${detail}\n` + + ` ${basis}.\n` + + ' Make the suite faster, or take the raise to a ruling: a ceiling rises only by an entry in\n' + + ' RULED_CEILING_RAISES (scripts/check-test-suite-ceilings.mjs) naming the ruling comment. ⛔ Never\n' + + ` hand-edit \`ceilings\` in ${DATASET_REL} -- it is generated, and the next refresh holds what it finds.\n` + + ` Every executed package this run:\n${lines.join('\n')}${stale}${note}`; + if (mode === 'report') { + return { + verdict: 'OVER', + exitCode: 0, + out: red.map( + (r) => + `::warning title=Suite-duration ceiling::${escapeWorkflowCommandMessage( + `${reading(r)} Report only on a \`${event}\` run: this gate exit-grades on \`${TIMING_GRADED_EVENT}\` ` + + 'runs alone, the profile its ceilings were measured on, so a slowdown that is real reds the next ' + + 'hourly run on main.' + )}` + ), + err: [text], + }; + } return { verdict: 'OVER', exitCode: 1, - out: red.map( - (r) => - `::error title=Suite-duration ceiling::${escapeWorkflowCommandMessage( - `${r.name}: ${r.seconds.toFixed(2)}s, over its ${r.ceiling.toFixed(2)}s ceiling. Slowest file: ` + - `${r.files[0] ? `${r.files[0].file} ${(r.files[0].ms / 1000).toFixed(2)}s` : '(none captured)'}.` - )}` - ), - err: [ - `${head}: OVER -- ${label}: ${red.length} package(s) over their suite-duration ceiling.\n` + - `${detail}\n` + - ` ${basis}.\n` + - ' Make the suite faster, or take the raise to a ruling: a ceiling rises only by an entry in\n' + - ' RULED_CEILING_RAISES (scripts/check-test-suite-ceilings.mjs) naming the ruling comment. ⛔ Never\n' + - ` hand-edit \`ceilings\` in ${DATASET_REL} -- it is generated, and the next refresh holds what it finds.\n` + - ` Every executed package this run:\n${lines.join('\n')}${stale}`, - ], + out: red.map((r) => `::error title=Suite-duration ceiling::${escapeWorkflowCommandMessage(reading(r))}`), + err: [text], }; } @@ -541,11 +634,13 @@ const SELF_TEST_BATTERIES = Object.freeze({ 'file-sliced packages are summed across shards': 6, 'a ruled raise lifts a ceiling, and only a well-formed one does': 8, 'a malformed ceiling table is refused, never read as a pass': 4, + 'the absolute floor: a ceiling under CEILING_FLOOR_SECONDS is never red, and prints "below floor"': 8, + 'off `schedule` the gate reports, never reds (C2)': 8, }); // Deleting an entry silences that battery's floor as effectively as zeroing // it, so the roster's own size is pinned too. -const SELF_TEST_BATTERY_FLOOR = 7; +const SELF_TEST_BATTERY_FLOOR = 9; const UNATTRIBUTED_BATTERY = '(no battery open)'; @@ -594,9 +689,11 @@ function selfTest() { provenance: { measuredAt: '2026-10-12', ceilingHeadroom: 1.25 }, ...extra, }); - const run = (captures, ds = dataset(), raises = {}) => { + // `event` defaults to the graded one, so every battery above C2's reads the + // exit-graded verdict it was written against. + const run = (captures, ds = dataset(), raises = {}, event = TIMING_GRADED_EVENT) => { const report = gradeCeilings({ merged: foldCaptures(captures), dataset: ds, raises }); - return { report, ...renderCeilingVerdict(report, 'Test Core') }; + return { report, ...renderCeilingVerdict(report, 'Test Core', event) }; }; const status = (r, name) => r.report.rows.find((row) => row.name === name)?.status; const raiseUrl = 'https://github.com/objectstack-ai/objectstack/issues/1#issuecomment-2'; @@ -849,6 +946,132 @@ function selfTest() { if (!threw(() => readCeilingTable({ ceilings: {} }))) throw new Error('table: a dataset with no `packages` was accepted'); }); + battery('the absolute floor: a ceiling under CEILING_FLOOR_SECONDS is never red, and prints "below floor"'); + // The ruling's two readings: a 3.40 s ceiling (knowledge-ragflow's) crossed + // at 4.2 s, and the lowest ceiling above the floor, 22.71 s, crossed at 25 s. + const floorSet = dataset({ + packages: { ...dataset().packages, tiny: 2.72, small: 18.17, edge: 16 }, + ceilings: { ...dataset().ceilings, tiny: 3.4, small: 22.71, edge: CEILING_FLOOR_SECONDS }, + }); + check(() => { + if (!(CEILING_FLOOR_SECONDS >= 20)) throw new Error(`floor: ${CEILING_FLOOR_SECONDS}s is below the ruled minimum of 20s`); + }); + check(() => { + const r = run([capture('1/6', [['tiny', 4.2], ['a', 10]])], floorSet); + if (r.exitCode !== 0 || r.verdict !== 'OK' || status(r, 'tiny') !== 'below-floor' || r.out.length !== 0) { + throw new Error(`floor: a 3.40s ceiling run at 4.20s read ${status(r, 'tiny')} / ${r.verdict}, exit ${r.exitCode}`); + } + }); + check(() => { + const r = run([capture('1/6', [['tiny', 4.2], ['a', 10]])], floorSet); + if (!r.err[0].includes('tiny: 4.20s against 3.40s (1.24x) -- below floor') || !r.err[0].includes('1 below the 20s floor')) { + throw new Error(`floor: the line does not say "below floor":\n${r.err[0]}`); + } + }); + check(() => { + // Off schedule too: below the floor is never a warning either. + const r = run([capture('1/6', [['tiny', 4.2], ['a', 10]])], floorSet, {}, 'merge_group'); + if (r.exitCode !== 0 || status(r, 'tiny') !== 'below-floor' || r.out.length !== 0) { + throw new Error(`floor: a below-floor package annotated off schedule: ${JSON.stringify(r.out)}`); + } + }); + check(() => { + const r = run([capture('1/6', [['small', 25]])], floorSet); + if (r.exitCode !== 1 || r.verdict !== 'OVER' || !r.out.some((l) => l.startsWith('::error title=Suite-duration ceiling::small: 25.00s, over its 22.71s ceiling'))) { + throw new Error(`floor: a 22.71s ceiling run at 25s on schedule was not red: ${r.verdict} ${JSON.stringify(r.out)}`); + } + }); + check(() => { + const r = run([capture('1/6', [['small', 25]])], floorSet, {}, 'merge_group'); + const errors = r.out.filter((l) => l.startsWith('::error')); + if (r.exitCode !== 0 || errors.length || r.out.length !== 1 || !r.out[0].startsWith('::warning title=Suite-duration ceiling::small: 25.00s')) { + throw new Error(`floor: a 22.71s ceiling run at 25s off schedule was not a warning alone: exit ${r.exitCode} ${JSON.stringify(r.out)}`); + } + }); + check(() => { + // AT the floor is graded (strict `<`), and so is a below-floor ceiling a ruled raise lifts to it. + const at = run([capture('1/6', [['edge', CEILING_FLOOR_SECONDS + 1]])], floorSet); + const raised = run([capture('1/6', [['tiny', 26]])], floorSet, { tiny: { seconds: 25, ruling: raiseUrl } }); + if (status(at, 'edge') !== 'over' || at.exitCode !== 1 || status(raised, 'tiny') !== 'over' || raised.exitCode !== 1) { + throw new Error(`floor: AT the floor read ${status(at, 'edge')}, a raised tiny read ${status(raised, 'tiny')}`); + } + }); + check(() => { + // Alone, a below-floor package grades nothing: NOT MEASURED, naming the floor. + const r = run([capture('1/6', [['tiny', 400]])], floorSet); + if (r.exitCode !== 0 || r.verdict !== 'NOT MEASURED' || !r.err[0].includes('at or above the 20s floor')) { + throw new Error(`floor: a below-floor package alone read ${r.verdict}:\n${r.err[0]}`); + } + }); + + battery('off `schedule` the gate reports, never reds (C2)'); + const offSchedule = ['merge_group', 'pull_request', 'push', 'workflow_dispatch']; + const twoOver = [capture('1/6', [['a', 151], ['b', 90]], [file('a', 'slow.test.ts', 90_000)])]; + check(() => { + for (const event of offSchedule) { + const r = run(twoOver, dataset(), {}, event); + const warnings = r.out.filter((l) => l.startsWith('::warning title=Suite-duration ceiling::')); + if (r.exitCode !== 0 || r.verdict !== 'OVER' || warnings.length !== 2 || r.out.length !== 2) { + throw new Error(`C2: two packages over on ${event} read ${r.verdict}, exit ${r.exitCode}, ${JSON.stringify(r.out)}`); + } + } + }); + check(() => { + const r = run(twoOver, dataset(), {}, 'merge_group'); + if (r.out.some((l) => l.startsWith('::error')) || r.out.some((l) => l.includes('\n')) || !r.out.every((l) => l.includes('merge_group'))) { + throw new Error(`C2: an off-schedule annotation is an error, spans lines, or does not name the event: ${JSON.stringify(r.out)}`); + } + }); + check(() => { + const r = run(twoOver); + if (r.exitCode !== 1 || r.out.length !== 2 || !r.out.every((l) => l.startsWith('::error title=Suite-duration ceiling::'))) { + throw new Error(`C2: two packages over on schedule were not red: exit ${r.exitCode} ${JSON.stringify(r.out)}`); + } + }); + check(() => { + // The SAME table on every event: only the mode line differs. + const body = (r) => r.err[0].split('\n').slice(0, -1).join('\n'); + const graded = run(twoOver); + const reported = run(twoOver, dataset(), {}, 'pull_request'); + if (body(graded) !== body(reported) || !body(graded).includes('Every executed package this run')) { + throw new Error(`C2: the report-only table is not the graded one:\n${graded.err[0]}\n---\n${reported.err[0]}`); + } + if (!graded.err[0].endsWith('the profile the baseline was measured on.') || !reported.err[0].endsWith('the step exits 0.')) { + throw new Error(`C2: the mode line is missing:\n${graded.err[0]}\n---\n${reported.err[0]}`); + } + }); + check(() => { + // Within every ceiling, off schedule: OK, no annotation, the mode said. + const r = run([capture('1/6', [['a', 10]])], dataset(), {}, 'push'); + if (r.exitCode !== 0 || r.verdict !== 'OK' || r.out.length !== 0 || !r.err[0].includes('report only: a `push` run')) { + throw new Error(`C2: an OK push run read ${r.verdict}:\n${r.err[0]}`); + } + }); + check(() => { + // A refusal is not a timing reading: red on every event. + const r = run([capture('1/6', [['a', 180]])], dataset(), { a: { seconds: 200, ruling: 'no' } }, 'merge_group'); + if (r.exitCode !== 1 || r.verdict !== 'REFUSED') throw new Error(`C2: a malformed raise record off schedule read ${r.verdict}`); + }); + check(() => { + // No table yet is unchanged off schedule: NOT MEASURED with its warning. + const { ceilings: _x, uncapped: _y, ...noTable } = dataset(); + const r = run([capture('1/6', [['a', 9999]])], noTable, {}, 'merge_group'); + if (r.exitCode !== 0 || r.verdict !== 'NOT MEASURED' || !r.out.some((l) => l.startsWith('::warning title=Suite-duration ceilings::NOT MEASURED'))) { + throw new Error(`C2: no table off schedule read ${r.verdict}`); + } + }); + check(() => { + // A missing event is refused, in the renderer and at the command line, never defaulted. + const report = gradeCeilings({ merged: foldCaptures([capture('1/6', [['a', 151]])]), dataset: dataset(), raises: {} }); + if (!threw(() => renderCeilingVerdict(report, 'Test Core'))) throw new Error('C2: a render with no event was graded'); + if (!threw(() => parseCeilingArgs(['--captures', 'd']))) throw new Error('C2: --captures with no --event was accepted'); + if (!threw(() => parseCeilingArgs(['--captures', 'd', '--event', '']))) throw new Error('C2: an empty --event was accepted'); + const parsed = parseCeilingArgs(['--captures', 'd', '--label', 'L', '--event', 'merge_group']); + if (parsed.event !== 'merge_group' || parsed.capturesDir !== 'd' || parsed.label !== 'L') { + throw new Error(`C2: the arguments parsed as ${JSON.stringify(parsed)}`); + } + }); + // The floor: every declared battery ran, and ran its cases. const floorFailures = []; const declared = Object.keys(SELF_TEST_BATTERIES); @@ -883,6 +1106,25 @@ function selfTest() { // ── Entry point ─────────────────────────────────────────────────────────── +/** + * The grading run's arguments. `--event` is required (C2): it decides whether + * a package over its ceiling exits 1 (`schedule`) or is a warning (any other + * event), and a missing one is refused by timingGateMode, never defaulted. + */ +export function parseCeilingArgs(argv) { + let capturesDir = null; + let label = 'Test Core'; + let event = null; + for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--captures') capturesDir = argv[++i]; + else if (argv[i] === '--label') label = argv[++i]; + else if (argv[i] === '--event') event = argv[++i]; + else throw new Error(`unrecognized argument: ${argv[i]}`); + } + if (capturesDir) timingGateMode(event); + return { capturesDir, label, event }; +} + function main() { const argv = process.argv.slice(2); if (argv.includes('--self-test')) { @@ -898,15 +1140,9 @@ function main() { return; } - let capturesDir = null; - let label = 'Test Core'; - for (let i = 0; i < argv.length; i++) { - if (argv[i] === '--captures') capturesDir = argv[++i]; - else if (argv[i] === '--label') label = argv[++i]; - else throw new Error(`unrecognized argument: ${argv[i]}`); - } + const { capturesDir, label, event } = parseCeilingArgs(argv); if (!capturesDir) { - console.error('usage: check-test-suite-ceilings.mjs --captures [--label ] | --self-test'); + console.error('usage: check-test-suite-ceilings.mjs --captures --event [--label ] | --self-test'); process.exit(1); } @@ -914,7 +1150,7 @@ function main() { const merged = foldCaptures(captures); const dataset = JSON.parse(readFileSync(DATASET_PATH, 'utf8')); const report = gradeCeilings({ merged, dataset }); - const rendered = renderCeilingVerdict(report, label); + const rendered = renderCeilingVerdict(report, label, event); for (const line of rendered.out) console.log(line); for (const line of rendered.err) console.error(line); // Not a verdict of their own: a shard capture that could not be read leaves From 2bc34da074e65052e42536b1eff6c7962dd18921 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 12:01:35 +0000 Subject: [PATCH 24/25] fix(runtime): refuse the walled catalog discard-drafts door; judge duplicate by the rows it copies POST /packages/:id/discard-drafts now asks refuseWalledCatalogPackageWrite with the package's draft rows, like /revert: the two verbs discard the same drafts. The duplicate door now reads exactly duplicatePackage's selection (active, organization-less rows). Pins: discard-drafts is refused under isolated and group (the draft stays); the operator, single and tenant catalog-free controls run it. The tenant control's title and the file header now name the verbs it covers (all but adopt-orphans). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP --- ...wall-package-doors-refusal.dogfood.test.ts | 32 +++++++++++++++---- packages/runtime/src/domains/packages.ts | 11 +++++-- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/packages/qa/dogfood/test/wall-package-doors-refusal.dogfood.test.ts b/packages/qa/dogfood/test/wall-package-doors-refusal.dogfood.test.ts index 2520d4c6949..301f8fb2748 100644 --- a/packages/qa/dogfood/test/wall-package-doors-refusal.dogfood.test.ts +++ b/packages/qa/dogfood/test/wall-package-doors-refusal.dogfood.test.ts @@ -14,17 +14,21 @@ // undid the operator's published change: `200`, and B's member lost it; // - `POST /packages/:id/duplicate` copied the set, under the same name, into // A's own package, and `GET /meta` then served A's copy; -// - `POST /packages/:id/revert` discarded the operator's pending draft; +// - `POST /packages/:id/revert` discarded the operator's pending draft +// (`POST /packages/:id/discard-drafts` is the same discard, by a second door); // - `POST /packages/:id/adopt-orphans` moved the environment's catalog rows // into A's package. // // Pinned here, under each walled posture: every one of those answers // `403 PERMISSION_DENIED`, nothing moves, and B's resolution stays. The commit // revert is judged by the commit's own items, whatever package the URL names. -// CONTROLS: A runs every one of these doors on its own catalog-free package as -// before, and the platform operator runs them on the catalog-carrying one. -// And under `single` (CONTROL): the same `manage_metadata` holder runs them on -// the operator's package as before. +// CONTROLS: A runs publish-drafts, discard-drafts, revert, rollback, commit +// revert and duplicate on its own catalog-free package as before. Not +// adopt-orphans: that verb reads the environment's orphans, not A's package, +// and this environment carries the operator's catalog orphan. The platform +// operator runs every door on the catalog-carrying package, adopt-orphans +// included. And under `single` (CONTROL): the same `manage_metadata` holder +// runs every door on the operator's package as before. // // The boot is `multiTenant: 'posture-only'`: a real, non-degraded walled // posture. The harness declares the seeded admin as `OS_PLATFORM_OWNER_EMAIL`, @@ -199,6 +203,14 @@ for (const posture of ['isolated', 'group'] as const) { expect(await rowsOf(h, { package_id: OP_PKG, type: 'permission', name: OP_SET, state: 'draft' })).toHaveLength(1); }); + it('discard-drafts: A discarding the operator\'s pending catalog draft is refused, and the draft stays', async () => { + const drafted = await call(h.stack, h.operator, 'PUT', `/meta/permission/${OP_SET}?package=${OP_PKG}&mode=draft`, + set(OP_SET, ['manage_users', 'manage_metadata'])); + expect(drafted.status, await drafted.clone().text()).toBe(200); + await refused(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/discard-drafts`, {})); + expect(await rowsOf(h, { package_id: OP_PKG, type: 'permission', name: OP_SET, state: 'draft' })).toHaveLength(1); + }); + it('rollback: A rolling the operator\'s package back past a catalog commit is refused, and B keeps `manage_users`', async () => { const before = await bSnapshot(); await refused(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/rollback`, { commitId: h.c1 })); @@ -232,7 +244,7 @@ for (const posture of ['isolated', 'group'] as const) { expect(env.map((r) => r.package_id ?? null)).not.toContain('com.w3.a.adopt'); }); - it('CONTROL: A runs every one of these doors on its own catalog-free package as before', async () => { + it('CONTROL: A runs every door but adopt-orphans on its own catalog-free package as before', async () => { const pkg = 'com.w3.a.free'; const dash = 'w3_free_dash'; const ok = async (res: Response) => expect(res.status, await res.clone().text()).toBe(200); @@ -247,6 +259,9 @@ for (const posture of ['isolated', 'group'] as const) { await ok(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/rollback`, { commitId: first })); await ok(await call(h.stack, h.tokA, 'PUT', `/meta/dashboard/${dash}?package=${pkg}&mode=draft`, dashboard(dash, 'v3'))); await ok(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/revert`, {})); + await ok(await call(h.stack, h.tokA, 'PUT', `/meta/dashboard/${dash}?package=${pkg}&mode=draft`, dashboard(dash, 'v4'))); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/discard-drafts`, {})); + expect(await rowsOf(h, { package_id: pkg, state: 'draft' })).toHaveLength(0); await ok(await call(h.stack, h.tokA, 'POST', `/packages/${pkg}/duplicate`, { targetPackageId: 'com.w3.a.free.copy' })); }); @@ -254,6 +269,9 @@ for (const posture of ['isolated', 'group'] as const) { const ok = async (res: Response) => expect(res.status, await res.clone().text()).toBe(200); await ok(await call(h.stack, h.operator, 'POST', `/packages/${OP_PKG}/duplicate`, { targetPackageId: 'com.w3.op.copy' })); await ok(await call(h.stack, h.operator, 'POST', `/packages/${OP_PKG}/revert`, {})); + await ok(await call(h.stack, h.operator, 'PUT', `/meta/permission/${OP_SET}?package=${OP_PKG}&mode=draft`, set(OP_SET, ['manage_metadata']))); + await ok(await call(h.stack, h.operator, 'POST', `/packages/${OP_PKG}/discard-drafts`, {})); + expect(await rowsOf(h, { package_id: OP_PKG, type: 'permission', name: OP_SET, state: 'draft' })).toHaveLength(0); await ok(await call(h.stack, h.operator, 'POST', `/packages/${OP_PKG}/commits/${h.c2}/revert`, {})); expect((await bGrants()).systemPermissions).not.toContain('manage_users'); await ok(await call(h.stack, h.operator, 'PUT', `/meta/permission/${OP_SET}?package=${OP_PKG}&mode=draft`, set(OP_SET, ['manage_users']))); @@ -289,6 +307,8 @@ describe('[ADR-0131 D3 · #15204 W3] single (CONTROL): the refusal is inert', () await ok(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/rollback`, { commitId: h.c1 })); await ok(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/commits/${h.c1}/revert`, {})); await ok(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/revert`, {})); + await ok(await call(h.stack, h.operator, 'PUT', `/meta/permission/${OP_SET}?package=${OP_PKG}&mode=draft`, set(OP_SET, ['manage_metadata']))); + await ok(await call(h.stack, h.tokA, 'POST', `/packages/${OP_PKG}/discard-drafts`, {})); expect((await call(h.stack, h.tokA, 'POST', '/packages', { id: 'com.w3.a.adopt', name: 'adopt', version: '1.0.0', type: 'app' })).status).toBe(201); await ok(await call(h.stack, h.tokA, 'POST', '/packages/com.w3.a.adopt/adopt-orphans', {})); }); diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 9026c9601ba..aad47d51d54 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -1866,6 +1866,13 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin if (parts.length === 2 && parts[1] === 'discard-drafts' && m === 'POST') { const denied = requireManageMetadata(deps, _context); if (denied) return denied; const id = decodeURIComponent(parts[0]); + // [#15204 W3] ADR-0131 D3: discarding a catalog draft is the + // operator's under a wall. The drafts this verb discards. + const walled = await refuseWalledCatalogPackageWrite( + deps, _context, `Discarding the drafts of package "${id}"`, + storedRowTypes(qlService, { package_id: id, state: 'draft' }), + ); + if (walled) return walled; const protocol = await resolveProtocol(deps, _context); if (protocol && typeof protocol.discardPackageDrafts === 'function') { try { @@ -2101,10 +2108,10 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // [#15204 W3] ADR-0131 D3: a duplicate copies the source's items // into a new package, and a copied catalog item is a catalog // creation (its name is not re-namespaced), the operator's under - // a wall. The source's stored rows. + // a wall. The rows `duplicatePackage` copies: active, environment-wide. const walled = await refuseWalledCatalogPackageWrite( deps, _context, `Duplicating package "${id}"`, - storedRowTypes(qlService, { package_id: id }), + storedRowTypes(qlService, { package_id: id, state: 'active', organization_id: null }), ); if (walled) return walled; try { From 5ef2293942882496cbc3039fd1f7179945fdf757 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 12:01:37 +0000 Subject: [PATCH 25/25] docs: name discard-drafts and capability in the walled catalog package-door texts The changeset, the ADR-0087 entry, census row 55b and both docs pages add discard-drafts (eight /packages write doors with the delete), name capability beside position and permission set, and say duplicate is judged by the rows it copies. The entry's acceptanceCriteria says which verbs the tenant catalog-free control runs. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_015JgoXpZUGtNxnMDvRZsKzP --- .changeset/15204-walled-catalog-package-verbs.md | 11 ++++++----- content/docs/api/metadata-api.mdx | 8 ++++---- content/docs/permissions/permission-sets.mdx | 4 ++-- content/docs/permissions/system-context.mdx | 2 +- .../18.walled-catalog-package-verbs-retired.ts | 11 ++++++----- 5 files changed, 19 insertions(+), 17 deletions(-) diff --git a/.changeset/15204-walled-catalog-package-verbs.md b/.changeset/15204-walled-catalog-package-verbs.md index a0db4398c07..80165b29f5f 100644 --- a/.changeset/15204-walled-catalog-package-verbs.md +++ b/.changeset/15204-walled-catalog-package-verbs.md @@ -16,18 +16,19 @@ Clause-②: yes (narrowing) - rolled the package back, and the member lost it; - reverted the operator's commit, and the member lost it; - copied the set, under the same name, into a package of their own, and `GET /meta` then served their copy; -- discarded the operator's pending draft; +- discarded the operator's pending draft (through `revert`, and `discard-drafts` is the same discard by a second door); - adopted the environment's catalog rows into their own package. **FROM → TO**, on a `group` or `isolated` deployment, for a caller below the platform operator (the `PLATFORM_ADMIN` rung: the deployment's declared `OS_PLATFORM_OWNER_EMAIL` owner). Every door is on the runtime dispatcher, and each **FROM** is: admitted to any `manage_metadata` holder. Each **TO** is `403 PERMISSION_DENIED`, naming the posture, before the verb runs: -- `POST /api/v1/packages/:id/publish-drafts`, when the package has a pending position or permission-set draft. +- `POST /api/v1/packages/:id/publish-drafts`, when the package has a pending position, permission-set or capability draft. - `POST /api/v1/packages/:id/revert`, when the package has such a draft. -- `POST /api/v1/packages/:id/rollback`, when a commit it would undo touched a position or permission set. +- `POST /api/v1/packages/:id/discard-drafts`, when the package has such a draft. +- `POST /api/v1/packages/:id/rollback`, when a commit it would undo touched a position, permission set or capability. - `POST /api/v1/packages/:id/commits/:commitId/revert`, when that commit touched one. The commit's own items are judged: the verb takes the commit id alone, so the URL's package segment changes nothing. -- `POST /api/v1/packages/:id/duplicate`, when the source package holds one. +- `POST /api/v1/packages/:id/duplicate`, when the source package's published environment rows (the ones it copies) hold one. - `POST /api/v1/packages/:id/adopt-orphans`, when the environment's orphaned rows include one. **Unchanged.** A package whose drafts, commits or rows carry no catalog item is handled as before, for the same callers. The platform operator runs every verb as before. Under `single` (and with no `tenancy` service) nothing changes, and no extra read happens: the verdict is asked before the items are read. -**The remedy.** The platform operator publishes, rolls back, reverts, duplicates and adopts catalog-carrying packages. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-package-verbs-retired`. Every door asks the one predicate the catalog's other write doors ask, `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), through the same helper as the package delete. +**The remedy.** The platform operator publishes, discards, rolls back, reverts, duplicates and adopts catalog-carrying packages. `@objectstack/spec` registers the change as the ADR-0087 semantic entry `walled-catalog-package-verbs-retired`. Every door asks the one predicate the catalog's other write doors ask, `walledCatalogWriteVerdict` (`@objectstack/metadata-core`), through the same helper as the package delete. diff --git a/content/docs/api/metadata-api.mdx b/content/docs/api/metadata-api.mdx index b5d5c7fd46f..81906924101 100644 --- a/content/docs/api/metadata-api.mdx +++ b/content/docs/api/metadata-api.mdx @@ -129,15 +129,15 @@ other caller is refused `403 PERMISSION_DENIED` (ADR-0131 D3). The package's other write verbs follow the same rule under a wall, judged by the items each one acts on: -- `POST /packages/:id/publish-drafts` and `POST /packages/:id/revert`, by the - package's pending drafts; +- `POST /packages/:id/publish-drafts`, `POST /packages/:id/discard-drafts` and + `POST /packages/:id/revert`, by the package's pending drafts; - `POST /packages/:id/rollback`, by the commits it would undo; - `POST /packages/:id/commits/:commitId/revert`, by that commit's own items, whatever package the URL names; -- `POST /packages/:id/duplicate`, by the source's items; +- `POST /packages/:id/duplicate`, by the source's published items it copies; - `POST /packages/:id/adopt-orphans`, by the environment's orphaned rows. -When those items include a position or a permission set, only the platform +When those items include a position, a permission set or a capability, only the platform operator may run the verb. ### `PATCH /packages/:id/enable` diff --git a/content/docs/permissions/permission-sets.mdx b/content/docs/permissions/permission-sets.mdx index 220c73ca424..091db906aea 100644 --- a/content/docs/permissions/permission-sets.mdx +++ b/content/docs/permissions/permission-sets.mdx @@ -354,8 +354,8 @@ permission sets is the platform operator's (ADR-0131 D3): that delete, and an `install-local` install, upgrade or uninstall of such a package, refuse any other caller `403` before anything changes. So do the package verbs that publish, discard, roll back, revert, duplicate or adopt catalog items -(`publish-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, -`duplicate`, `adopt-orphans`). +(`publish-drafts`, `discard-drafts`, `revert`, `rollback`, +`commits/:commitId/revert`, `duplicate`, `adopt-orphans`). ## One authoritative store — the record is a projection (ADR-0094) diff --git a/content/docs/permissions/system-context.mdx b/content/docs/permissions/system-context.mdx index 0f6163c35b2..db116ec513d 100644 --- a/content/docs/permissions/system-context.mdx +++ b/content/docs/permissions/system-context.mdx @@ -183,7 +183,7 @@ One of the largest consumers of the flag; | 53 | Package REST route capability gate bypassed | rest | Get: a marketplace publish over REST (`POST /packages/publish`, the one route the REST registrar mounts since #14503) without `manage_metadata`; the package read cohort (`studio.access` / `setup.access`) is enforced by the dispatcher `/packages` domain's own read gate, where the reads are served | `packages/rest/src/package-routes.ts#refusePackageRequest` | | 54 | Package domain capability gates bypassed | runtime | Get: package management and package-inventory reads without the capability | `packages/runtime/src/domains/packages.ts#requireManageMetadata`, `#requireReadCapability` | | 55 | Activation write / authoring refusals do not fire | runtime | Get: activation artifacts writable and authorable without the activation-authoring capability | `packages/runtime/src/domains/activation-gate.ts#refuseUngrantedActivationWrite`, `#refuseUngrantedActivationAuthoring` | -| 55b | Walled catalog refusal does not fire | metadata-core, rest, plugin-security, runtime | Get: under `isolated` / `group`, a create, change or delete of a position, a permission set or a capability, at the `/meta` write doors (save, reset, publish, rollback), at the permission-set clone door, at the data door on `sys_position`, `sys_permission_set` and `sys_capability`, and at the `/packages` write doors (delete, `publish-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`) when the items they act on include a position or a permission set. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3), because one organization's write would change what every organization resolves by name. The seeders, the package door and migrations write the catalog this way. Under `single` the refusal is inert for every caller | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict`, `packages/rest/src/rest-server.ts#walledCatalogRefusal`, `packages/plugins/plugin-security/src/walled-catalog-data-door.ts#createWalledCatalogDataDoorRefusal`, `packages/runtime/src/domains/packages.ts#refuseWalledCatalogPackageWrite` | +| 55b | Walled catalog refusal does not fire | metadata-core, rest, plugin-security, runtime | Get: under `isolated` / `group`, a create, change or delete of a position, a permission set or a capability, at the `/meta` write doors (save, reset, publish, rollback), at the permission-set clone door, at the data door on `sys_position`, `sys_permission_set` and `sys_capability`, and at the `/packages` write doors (delete, `publish-drafts`, `discard-drafts`, `revert`, `rollback`, `commits/:commitId/revert`, `duplicate`, `adopt-orphans`) when the items they act on include a position, a permission set or a capability. Every other caller below the platform-operator rung is refused `403` there (ADR-0131 D3), because one organization's write would change what every organization resolves by name. The seeders, the package door and migrations write the catalog this way. Under `single` the refusal is inert for every caller | `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict`, `packages/rest/src/rest-server.ts#walledCatalogRefusal`, `packages/plugins/plugin-security/src/walled-catalog-data-door.ts#createWalledCatalogDataDoorRefusal`, `packages/runtime/src/domains/packages.ts#refuseWalledCatalogPackageWrite` | | 56 | Automation run-state read, flow-authoring write, the paused-run caller gate (screen read and resume), the two operator run-lifecycle writes and the elevated self-triggered start at every door that starts a flow by name all pass | runtime | Get: run state, flow writes, a paused run's screen and its resume with no grant and without being the run's starter — and cancelling or restoring a suspension without the platform-operator rung. The screen read and the resume ask one predicate, so this is one bypass for both. The lifecycle bypass is the in-process owner's door: plugin-approvals' revise-window recall (ADR-0044) cancels on behalf of a decision it already authorized and recorded. And starting a flow declared `runAs: 'system'` whose type is self-triggered (`autolaunched`, `record_change`, `schedule`) through any door that starts a flow by name — the trigger route, an action of `type: 'flow'` (REST `/actions` and the MCP `run_action` bridge) and a declared endpoint of `type: 'flow'` — which every other caller, the guest principal included, is refused `403 PERMISSION_DENIED` (the maintainer's rulings, letters B and A). The three doors ask one predicate, so this is one bypass for all of them, and it is the in-process caller's, such as a job: inbound HTTP never carries the flag | `packages/runtime/src/domains/automation.ts#mayReadRunState`, `#refuseUngrantedFlowWrite`, `#isRunStarterOrRunStateReader`, `#refuseUngrantedRunLifecycleWrite`, `packages/runtime/src/flow-start-admission.ts#refusesElevatedSelfTriggeredStart` | | 57 | Audience-binding suggestion authority gate passes | plugin-security | Get: a system caller lists, accepts and dismisses suggestions without `manage_metadata` and, under a wall, without the platform-operator rung | `packages/plugins/plugin-security/src/suggested-audience-bindings.ts#assertDeploymentAuthority` | | 58 | Email-template organization door passes; webhook provenance stamp skipped | plugin-email, plugin-webhooks | Get (`sys_email_template`): a create or update of a template row. The door refuses every other write that names a caller, `403 PERMISSION_DENIED` (ADR-0131 D6); the plugin itself no longer writes the table (templates resolve from the registry), so the v18 migration ceremony's promotion is the one remaining writer. Lose (`sys_webhook`): the row is not marked as an admin customization | `packages/plugins/plugin-email/src/email-template-door.ts#isOrganizationWrite`, `packages/plugins/plugin-webhooks/src/webhook-provenance.ts#bindWebhookProvenanceStamp` | diff --git a/packages/spec/src/migrations/entries/semantic/18.walled-catalog-package-verbs-retired.ts b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-package-verbs-retired.ts index 18ca0c689bc..27e3838036b 100644 --- a/packages/spec/src/migrations/entries/semantic/18.walled-catalog-package-verbs-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.walled-catalog-package-verbs-retired.ts @@ -13,10 +13,10 @@ export const entry: SemanticMigration = { // code span AND a table cell. surface: 'on a group or isolated deployment, by a caller below the platform operator, on the runtime ' - + 'dispatcher: POST /api/v1/packages/:id/publish-drafts and /revert when the package has a pending ' - + 'position or permission-set draft; /rollback when a commit it would undo touched one; ' + + 'dispatcher: POST /api/v1/packages/:id/publish-drafts, /discard-drafts and /revert when the package has a pending ' + + 'position, permission-set or capability draft; /rollback when a commit it would undo touched one; ' + '/commits/:commitId/revert when that commit touched one, whatever package the URL names; ' - + '/duplicate when the source holds one; and /adopt-orphans when the orphaned rows include one', + + '/duplicate when the published rows it copies hold one; and /adopt-orphans when the orphaned rows include one', replacement: 'the platform operator (the `PLATFORM_ADMIN` rung, the deployment\'s declared ' + '`OS_PLATFORM_OWNER_EMAIL` owner) runs those verbs on catalog-carrying packages; a package whose ' @@ -35,11 +35,12 @@ export const entry: SemanticMigration = { 'On an isolated or group deployment, the operator publishes a permission set in a writable ' + 'package twice (the second adding manage_users) and leaves a third draft pending; another ' + 'organization\'s member holds the set. An organization owner holding manage_metadata inside ' - + 'their own organization then calls publish-drafts, revert, rollback to the first commit, ' + + 'their own organization then calls publish-drafts, discard-drafts, revert, rollback to the first commit, ' + 'commits//revert (also under a URL naming their own package), duplicate and, from a ' + 'package of their own, adopt-orphans: each answers 403 PERMISSION_DENIED naming the posture, ' + 'the draft stays pending, nothing is copied or rebound, and the member resolves exactly what it ' - + 'resolved before. The same owner runs every verb on a package of their own whose items carry no ' + + 'resolved before. The same owner runs every verb but adopt-orphans (which reads the ' + + 'environment\'s orphans, not the package) on a package of their own whose items carry no ' + 'catalog item with 200; the platform operator runs every verb on the catalog-carrying package ' + 'with 200; and on a single deployment a manage_metadata holder runs them exactly as before.', };