From 420fb8ce8b02288ddbcb8ab86c2346c0565cae17 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:06:14 +0000 Subject: [PATCH 01/44] wip(plugin-webhooks): declared webhooks read from the registry; seeder retired; webhook activation door Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ Co-authored-by: Claude --- .../plugin-webhooks/src/auto-enqueuer.ts | 189 +++++++++- .../src/bootstrap-declared-webhooks.ts | 352 ------------------ .../plugin-webhooks/src/declared-webhooks.ts | 255 +++++++++++++ .../src/migrate-webhook-secrets.ts | 8 +- .../plugin-webhooks/src/redeliver-guard.ts | 54 +++ .../plugin-webhooks/src/sys-webhook.object.ts | 31 +- .../src/webhook-headers-gate.ts | 6 +- .../plugin-webhooks/src/webhook-headers.ts | 47 --- .../src/webhook-outbox-plugin.ts | 84 +++-- .../plugin-webhooks/src/webhook-provenance.ts | 14 +- .../plugin-webhooks/src/webhook-secret.ts | 5 - .../runtime/src/domains/activation-gate.ts | 13 + .../domains/catalog-activation-door.test.ts | 73 ++++ .../runtime/src/domains/catalog-activation.ts | 101 ++++- packages/runtime/src/domains/security.ts | 5 +- packages/runtime/src/route-ledger.ts | 3 +- 16 files changed, 751 insertions(+), 489 deletions(-) delete mode 100644 packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts create mode 100644 packages/plugins/plugin-webhooks/src/declared-webhooks.ts diff --git a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts index a7afb3b09e3..3edd7553e68 100644 --- a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts +++ b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts @@ -3,7 +3,7 @@ import type { IDataEngine, IRealtimeService, RealtimeEventPayload } from '@objectstack/spec/contracts'; import type { WebhookTriggerType } from '@objectstack/spec/automation'; import type { EnqueueHttpInput } from '@objectstack/service-messaging'; -import { omitInternalFieldsFromWriteResponse } from '@objectstack/core'; +import { METADATA_ACTIVATION_TABLE, omitInternalFieldsFromWriteResponse } from '@objectstack/core'; import { WEBHOOK_SECRET_FIELD, WEBHOOK_SECRET_REFUSAL_CODE, @@ -11,16 +11,24 @@ import { onCryptoProviderChange, readLegacySecret, resolveWebhookSecret, + splitWebhookSecret, } from './webhook-secret.js'; import { WEBHOOK_HEADERS_FIELD, readLegacyHeaders, resolveWebhookHeaders, + splitWebhookHeaders, } from './webhook-headers.js'; import { WebhookLegacyCleartextError, type LegacyDefinitionCredentialKey, } from './webhook-legacy-cleartext.js'; +import { + classifyDeclaredNameHolder, + declaredWebhookRefId, + type DeclaredWebhook, + type DeclaredWebhookReader, +} from './declared-webhooks.js'; /** * [#21913] The execution context the subscription cache refresh @@ -175,6 +183,15 @@ export interface AutoEnqueuerOptions { */ refreshIntervalMs?: number; + /** + * [ADR-0131 D2/D6] The declared webhooks (`defineStack({ webhooks })`), + * read from the registry with their activation-ledger switch resolved. + * Each one switched on is a subscription of its own, beside the + * `sys_webhook` rows — see `declared-webhooks.ts` for how a row holding a + * declared name is read. Absent: rows only. + */ + declaredWebhooks?: DeclaredWebhookReader; + logger?: OptionalLogger; } @@ -277,6 +294,16 @@ export class AutoEnqueuer { * is deleted and re-created reports again. */ private readonly organizationRefusalReported = new Set(); + /** [ADR-0131 D2/D6] The declared-webhook reader, when the host binds one. */ + private readonly declared?: DeclaredWebhookReader; + /** Self-heal subscription on the activation ledger — bound only with {@link declared}. */ + private subIdLedger: string | undefined; + /** + * Declared names already reported as withheld behind a `sys_webhook` row + * that holds the name (see {@link resolveDeclared}) — said once per name + * per process, the #8022 say-once rule. + */ + private readonly declarationWithheldReported = new Set(); constructor( private readonly engine: IDataEngine, @@ -287,6 +314,7 @@ export class AutoEnqueuer { this.subscriptionsObject = opts.subscriptionsObject ?? 'sys_webhook'; this.refreshIntervalMs = opts.refreshIntervalMs ?? 60_000; this.logger = opts.logger; + this.declared = opts.declaredWebhooks; } /** @@ -322,6 +350,18 @@ export class AutoEnqueuer { { object: this.subscriptionsObject }, ); + // [ADR-0131 D6 regime C] A declared webhook is switched by an + // activation-ledger row, not by a `sys_webhook` write — so a switch + // refreshes the cache the way a row edit does, in the request that + // wrote it rather than at the next periodic refresh. + if (this.declared) { + this.subIdLedger = await this.realtime.subscribe( + 'webhook-auto-enqueuer-activation', + (event) => this.handleLedgerEvent(event), + { object: METADATA_ACTIVATION_TABLE }, + ); + } + if (this.refreshIntervalMs > 0) { this.refreshTimer = setInterval(() => { this.refresh().catch((err) => @@ -338,10 +378,12 @@ export class AutoEnqueuer { this.running = false; if (this.subId) await this.realtime.unsubscribe(this.subId); if (this.subIdSelfHeal) await this.realtime.unsubscribe(this.subIdSelfHeal); + if (this.subIdLedger) await this.realtime.unsubscribe(this.subIdLedger); if (this.refreshTimer) clearInterval(this.refreshTimer); this.unbindCryptoListener?.(); this.subId = undefined; this.subIdSelfHeal = undefined; + this.subIdLedger = undefined; this.refreshTimer = undefined; this.unbindCryptoListener = undefined; } @@ -401,8 +443,22 @@ export class AutoEnqueuer { return; } + // [ADR-0131 D2/D6] The declared webhooks, and the 18.0 mirror rows they + // replace. A failed read keeps the previous cache, exactly as a failed + // row read above does: neither a double fire nor a silent loss. + const declared = await this.resolveDeclared(); + if (!declared) return; + const next = new Map(); + const add = (sub: CachedSubscription) => { + // Empty objectName == "any object" → indexed under '*'. + const key = sub.objectName ?? '*'; + const arr = next.get(key) ?? []; + arr.push(sub); + next.set(key, arr); + }; for (const row of rows) { + if (declared.mirrorIds.has(String(row?.id))) continue; const sub = this.parseRow(row); if (!sub) continue; // [#7799, #7986] Neither credential is in the row we just read — @@ -416,12 +472,9 @@ export class AutoEnqueuer { // as undeliverable instead of silently discarded (#8069). See // `attachCredentials`. await this.attachCredentials(sub, row); - // Empty objectName == "any object" → indexed under '*'. - const key = sub.objectName ?? '*'; - const arr = next.get(key) ?? []; - arr.push(sub); - next.set(key, arr); + add(sub); } + for (const sub of declared.subscriptions) add(sub); this.subscriptions.clear(); for (const [k, v] of next) this.subscriptions.set(k, v); @@ -432,6 +485,7 @@ export class AutoEnqueuer { // broken would have its first report suppressed as a repeat. if (this.droppedForSecret.size > 0 || this.organizationRefusalReported.size > 0) { const live = new Set(rows.map((r) => String(r?.id))); + for (const sub of declared.subscriptions) live.add(sub.id); for (const id of this.droppedForSecret) { if (!live.has(id)) this.droppedForSecret.delete(id); } @@ -444,7 +498,122 @@ export class AutoEnqueuer { this.logger?.debug?.('[webhook-auto-enqueuer] cache refreshed', { objects: this.subscriptions.size, rows: rows.length, + declared: declared.subscriptions.length, + }); + } + + /** + * [ADR-0131 D2/D6] The declared webhooks switched on, as subscriptions, + * and the ids of the 18.0 mirror rows they replace. `undefined` when a read + * failed, so the caller keeps the cache it has. + * + * A `sys_webhook` row holding a declared name — any row, active or not — is + * read the way the 18.0 seeder would have treated it on this boot + * (`classifyDeclaredNameHolder`): a pristine mirror is skipped and the + * declaration answers; a customized or administrator-authored row answers + * as itself and the declaration is withheld, said once per name. That is + * what keeps a declared webhook from firing twice on an upgraded database, + * and an administrator's `active: false` on a seeded row in force. + */ + private async resolveDeclared(): Promise< + { subscriptions: CachedSubscription[]; mirrorIds: Set } | undefined + > { + const empty = { subscriptions: [], mirrorIds: new Set() }; + if (!this.declared) return empty; + let declared: DeclaredWebhook[]; + try { + declared = await this.declared.list(); + } catch (err) { + this.logger?.warn?.('[webhook-auto-enqueuer] failed to read the declared webhooks', err); + return undefined; + } + if (declared.length === 0) return empty; + + let holders: any[]; + try { + holders = await this.engine.find( + this.subscriptionsObject, + { + where: { name: { $in: declared.map((d) => d.webhook.name) } }, + fields: ['id', 'name', 'managed_by', 'customized'], + }, + { context: SYSTEM_CTX }, + ); + } catch (err) { + this.logger?.warn?.( + `[webhook-auto-enqueuer] failed to read the ${this.subscriptionsObject} rows holding declared names`, + err, + ); + return undefined; + } + + const mirrorIds = new Set(); + const heldByRow = new Set(); + for (const row of Array.isArray(holders) ? holders : []) { + if (classifyDeclaredNameHolder(row) === 'mirror') mirrorIds.add(String(row.id)); + else heldByRow.add(String(row.name)); + } + + const subscriptions: CachedSubscription[] = []; + for (const d of declared) { + const name = d.webhook.name; + if (heldByRow.has(name)) { + this.reportDeclarationWithheld(name); + continue; + } + if (!d.active) continue; + const sub = this.projectDeclared(d); + if (sub) subscriptions.push(sub); + } + return { subscriptions, mirrorIds }; + } + + /** + * One declared webhook as a cached subscription. The shape goes through + * {@link parseRow} — the same trigger drift-guard and the same + * `webhook/without-triggers` refusal as a row — and both credentials come + * from the declaration itself: they are code, held in memory like the + * plaintext a row's refresh decrypts, and never written anywhere. + */ + private projectDeclared(d: DeclaredWebhook): CachedSubscription | null { + const wh = d.webhook; + const sub = this.parseRow({ + id: declaredWebhookRefId(wh.name), + name: wh.name, + object_name: wh.object ?? null, + triggers: wh.triggers ?? [], + url: wh.url, + method: wh.method, + definition_json: JSON.stringify({ timeoutMs: wh.timeoutMs }), }); + if (!sub) return null; + const { secret } = splitWebhookSecret(wh as Record); + const { headers } = splitWebhookHeaders(wh as Record); + if (secret) sub.secret = secret; + if (headers) sub.headers = headers; + return sub; + } + + /** + * A declared webhook withheld because a `sys_webhook` row holds its name: + * a seeded row an administrator edited before this release, or a row an + * administrator authored under the name. The row answers; the declaration + * does not also fire beside it. Said once per name per process at `warn` — + * the row is in force and dispatching (or switched off on purpose), so + * nothing claimed is being lost. + */ + private reportDeclarationWithheld(name: string): void { + if (this.declarationWithheldReported.has(name)) return; + this.declarationWithheldReported.add(name); + this.logger?.warn?.( + `[webhook-auto-enqueuer] declared webhook '${name}' is not dispatched: a ${this.subscriptionsObject} row ` + + 'of the same name answers for it (a seeded copy edited before declared webhooks were read from the ' + + 'registry, or a row authored under the name). The row stays in force as it is. To return to the ' + + 'declaration, delete the row; to change the declared webhook, author a copy under a new name and ' + + `switch the declared one off (POST /api/v1/security/_activation/webhook/${name} with ` + + '{ "enabled": false }).', + { webhook: name }, + ); } /** @@ -1282,6 +1451,14 @@ export class AutoEnqueuer { ); } + private handleLedgerEvent(event: RealtimeEventPayload): void { + if (event.object !== METADATA_ACTIVATION_TABLE) return; + if (!event.type?.startsWith('data.record.') && !event.type?.startsWith('data.records.')) return; + this.refresh().catch((err) => + this.logger?.warn?.('[webhook-auto-enqueuer] activation refresh failed', err), + ); + } + /** Test / admin accessor. */ snapshot(): ReadonlyMap> { return this.subscriptions; diff --git a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts b/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts deleted file mode 100644 index 2f7f2b817af..00000000000 --- a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts +++ /dev/null @@ -1,352 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * bootstrapDeclaredWebhooks — materialize STACK-declared `webhooks` - * (`defineStack({ webhooks })`) into `sys_webhook` rows so the dispatcher can - * actually see them (closes #3461 for that half — see the next section for the - * half it does not reach). - * - * ## What this path does NOT reach: a connector's nested `webhooks[]` - * This opening sentence used to say "stack/connector-declared", and the - * connector half of it was false. The function's ONE source is - * `readDeclared(engine, metadataService, 'webhook')` — `webhook` METADATA - * ITEMS — and every registrar of that type is keyed on the TOP-LEVEL - * `webhooks:` collection of a manifest / nested-plugin document: - * `registerMetadataCollections` enumerates `METADATA_ARRAY_KEYS` over that - * document (objectql `engine.ts`), and the artifact loader's collection map - * (`packages/metadata/src/plugin.ts`) reads the same spelling table - * (`PLURAL_TO_SINGULAR`, `@objectstack/spec/meta-spelling`). None of them walks - * INTO a `connectors:` entry. A connector's nested `webhooks[]` therefore stays - * inside the connector document, never becomes a `webhook` metadata item, is - * never in `declared`, and is never materialized here — no row, no warning, no - * skip count. The shapes were not even the same: a connector's entries were - * `WebhookConfigSchema` (`WebhookSchema` plus `events` / `signatureAlgorithm`), - * not the `WebhookSchema` this seeder parses. - * - * That gap is now CLOSED BY RETIREMENT, not an oversight to route around: the - * spec retired `connector.webhooks` and the `WebhookConfig` shape under ADR-0049 - * enforce-or-remove (`integration/connector.zod.ts`, "REMOVED: `health`, - * `status` and the nested `webhooks`"), so the authoring doors refuse a nested - * array with a prescription pointing at the top-level collection, and the D2 - * conversion `connector-resilience-keys-removed` strips it from stored rows. - * Widening this seeder to read connectors would resurrect a retired surface. - * The absence is still pinned as behaviour by - * `bootstrap-declared-webhooks.connector-nested.test.ts`, which drives the - * registry below the authoring parse, so this paragraph cannot quietly go - * stale the way the sentence above it did. - * - * ## The disconnect this closes - * The spec authoring surface (`WebhookSchema` — `defineStack({ webhooks })`, - * `@objectstack/spec/automation/webhook`) declares `object` / `isActive`, and - * is generically decomposed into the ObjectQL registry at boot as metadata - * type `webhook`. But the runtime dispatcher ({@link AutoEnqueuer}) reads - * `sys_webhook` DATA rows (`object_name` / `active`), which until now were only - * ever written by hand through the object's CRUD UI. Nothing bridged the two — - * so authoring `webhooks:` on a stack produced metadata artifacts that never - * became dispatchable rows (a silent no-op; ADR-0078). This seeder is that - * missing ingestion path. - * - * ## Shape translation (authoring → runtime row) - * The spec shape diverges from the runtime column names; we map only at this - * boundary and stash the validated envelope in `definition_json` (whence the - * enqueuer reads headers / timeout): - * - `object` → `object_name` - * - `isActive` → `active` - * - `triggers` / `url` / `method` / `label` / `description` → same-named columns - * - `secret` → `signing_secret` (ENCRYPTED — see below) - * - `headers` → `headers_secret` (ENCRYPTED — #7986, same channel) - * - the rest of the parsed {@link Webhook} → `definition_json` (JSON string) - * - * ## Neither credential goes in `definition_json` (#7799, #7986) - * It used to: `definition_json: JSON.stringify(wh)` serialized the whole - * envelope, key included, into an ordinary textarea on an admin-authorable - * object — so `GET /api/v1/data/sys_webhook` returned the receiver's only proof - * of origin to anyone who could read the object. The authored key now goes to - * `sys_webhook.signing_secret`, a `type: 'secret'` column the engine encrypts - * into `sys_secret` and masks on read; `definition_json` carries the same - * envelope MINUS `secret`. Nothing about the authoring surface changes — - * `webhook.zod.ts` still declares `secret` and authors still write it. - * - * Fail-closed: with no CryptoProvider registered the engine REFUSES the write - * (it will not store cleartext), so a secret-bearing webhook is skipped with an - * actionable log line instead of being seeded with an exposed key. - * - * Each item is validated through `WebhookSchema.parse()` first — this gives the - * spec schema a real consumer (defaults for `method`/`isActive`/`timeoutMs` get - * applied) and rejects malformed authoring with a warning instead of crashing - * boot. - * - * ## Seed-not-clobber (mirrors sys_sharing_rule, #2909) - * `sys_webhook` is admin-editable (`managedBy: 'config'`). Declared webhooks - * ship with the app/package, so they seed with `managed_by: 'package'` - * provenance and re-seed on every boot — but a row an admin has created - * (`managed_by: 'admin'`) or edited (`customized: true`, stamped by - * {@link bindWebhookProvenanceStamp}) is never overwritten. Most importantly, - * an admin's `active: false` on a noisy webhook survives redeploys. - * - * MUST run before {@link AutoEnqueuer.start} so the enqueuer's first cache - * refresh already sees the declared rows. - */ - -import type { IDataEngine } from '@objectstack/spec/contracts'; -import { WebhookSchema, type Webhook } from '@objectstack/spec/automation'; -import { - WEBHOOK_SECRET_FIELD, - WEBHOOK_SECRET_REFUSAL_CODE, - WEBHOOK_SECRET_REFUSAL_STATUS, - canResolveSecrets, - isSecretProtectionFailure, - splitWebhookSecret, -} from './webhook-secret.js'; -import { - WEBHOOK_HEADERS_FIELD, - headersPatch, - serializeHeaders, - splitWebhookHeaders, -} from './webhook-headers.js'; - -/** System write context — the boot seeder is not an admin authoring action. */ -const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as const; - -interface Logger { - info?: (msg: string, meta?: unknown) => void; - warn?: (msg: string, meta?: unknown) => void; -} - -/** Random id with a stable prefix — mirrors the sharing-rule seeder. */ -function uid(prefix: string): string { - const g: any = globalThis as any; - if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`; - return `${prefix}_${Math.random().toString(36).slice(2, 10)}`; -} - -/** - * Read declared `webhook` items from the ObjectQL registry (where the manifest - * decomposition parks `stack.webhooks`), falling back to the metadata service. - * - * [#8378] Both reads hand back the authoring document itself. The sentence that - * used to stand here — "Items may be wrapped as `{ content }` — unwrap to the - * raw authoring object" — described an envelope with **no producer**: - * `registerMetadataCollections` (objectql `engine.ts`) registers each - * `stack.webhooks` element as-is, `loadMetaFromDb` registers the parsed body - * rather than the `sys_metadata` row, and `MetadataFacade` shed its own copy of - * this unwrap in #7519. `WebhookSchema` declares no `content` key and rejects - * one as unrecognized, so wherever the key did appear the unwrap replaced the - * whole webhook with one of its values — and `''` (falsy, non-nullish) passed - * `??` and then died at `filter(Boolean)`, dropping the webhook silently. - */ -function readDeclared(engine: any, metadataService: any, type: string): any[] { - try { - const reg = engine?._registry; - if (reg?.listItems) { - const items = (reg.listItems(type) ?? []).filter(Boolean); - if (items.length > 0) return items; - } - } catch { - /* fall through to metadata service */ - } - try { - const listed = metadataService?.list?.(type); - const arr = typeof (listed as any)?.then === 'function' ? [] : (listed ?? []); - return Array.isArray(arr) ? arr.filter(Boolean) : []; - } catch { - return []; - } -} - -export interface BootstrapDeclaredWebhooksResult { - seeded: number; - skipped: number; -} - -/** - * Materialize declared webhooks into `sys_webhook`. Idempotent and safe to run - * on every boot. - */ -export async function bootstrapDeclaredWebhooks( - engine: IDataEngine, - metadataService: any, - logger?: Logger, - subscriptionsObject = 'sys_webhook', -): Promise { - const declared = readDeclared(engine, metadataService, 'webhook'); - if (declared.length === 0) return { seeded: 0, skipped: 0 }; - - const now = new Date().toISOString(); - let seeded = 0; - let skipped = 0; - - for (const raw of declared) { - // Validate + fill defaults through the canonical spec schema. A real - // consumer at last — a malformed webhook warns and is skipped, never - // crashing boot. - let wh: Webhook; - try { - wh = WebhookSchema.parse(raw); - } catch (err: any) { - logger?.warn?.('[webhook] declared webhook failed validation — skipped', { - name: (raw as any)?.name, - error: err?.message ?? String(err), - }); - skipped += 1; - continue; - } - - try { - const existing = await engine.find(subscriptionsObject, { - where: { name: wh.name }, - limit: 1, - context: SYSTEM_CTX, - } as any); - const row: any = Array.isArray(existing) ? existing[0] : undefined; - - if (row) { - // Admin owns a same-named row, or has edited this seeded one — never - // clobber. `active: false` on a noisy webhook must survive redeploys. - if (row.managed_by === 'admin') { - logger?.warn?.('[webhook] declared name collides with an admin-authored row — seed skipped', { - name: wh.name, - }); - skipped += 1; - continue; - } - if (row.customized === true) { - skipped += 1; - continue; - } - const patch = { - id: row.id, - ...mapWebhookToRow(wh), - ...(await secretPatch(engine, wh, row, subscriptionsObject)), - ...(await headersPatch( - engine, - splitWebhookHeaders(wh as Record).headers, - row, - subscriptionsObject, - )), - // Adopt pristine/legacy (pre-provenance) rows so future boots - // recognize them as package-managed. - managed_by: 'package', - updated_at: now, - }; - await engine.update(subscriptionsObject, patch, { context: SYSTEM_CTX } as any); - seeded += 1; - continue; - } - - const { secret } = splitWebhookSecret(wh as Record); - const { headers } = splitWebhookHeaders(wh as Record); - const newRow = { - id: uid('whk'), - ...mapWebhookToRow(wh), - // Cleartext goes in exactly once, into the `secret`-typed column; the - // engine's write path wraps it into `sys_secret` and leaves an opaque - // ref behind. Omitted entirely when unauthored, so a webhook with no - // secret costs no crypto and needs no CryptoProvider. - ...(secret ? { [WEBHOOK_SECRET_FIELD]: secret } : {}), - // [#7986] Same channel, same rule, for the header map — omitted when - // unauthored so a header-less webhook still seeds on a host with no - // CryptoProvider wired. - ...(headers ? { [WEBHOOK_HEADERS_FIELD]: serializeHeaders(headers) } : {}), - managed_by: 'package', - customized: false, - created_at: now, - updated_at: now, - }; - await engine.insert(subscriptionsObject, newRow, { context: SYSTEM_CTX } as any); - seeded += 1; - } catch (err: any) { - // [#7799] The engine refuses to persist a `secret` field with no - // CryptoProvider wired, rather than falling back to cleartext. Say so in - // those words — "seed failed" would read as a transient glitch, when what - // actually happened is that the deployment has nowhere safe to put a key. - const protection = isSecretProtectionFailure(err); - logger?.warn?.( - protection - ? '[webhook] declared webhook NOT seeded — its signing secret cannot be stored encrypted, and it is never stored in cleartext instead' - : '[webhook] declared webhook seed failed', - { - name: wh.name, - ...(protection - ? { code: WEBHOOK_SECRET_REFUSAL_CODE, status: WEBHOOK_SECRET_REFUSAL_STATUS } - : {}), - error: err?.message ?? String(err), - }, - ); - skipped += 1; - } - } - - logger?.info?.('[webhook] declared webhooks materialized into sys_webhook', { - seeded, - skipped, - total: declared.length, - }); - return { seeded, skipped }; -} - -/** - * Decide what a RE-SEED should do with an existing row's `signing_secret`. - * - * Re-seeding runs on every boot, and a `secret`-typed write always mints a - * fresh `sys_secret` ciphertext row — so blindly restating the declared key - * would leak one orphan cipher row per webhook per restart. Compare against the - * stored plaintext first (via the engine's privileged dereference) and write - * only on an actual change: - * - * - declared key differs from stored ⇒ write it (rotation in code propagates, - * exactly as it did when the whole envelope was rewritten every boot); - * - identical ⇒ omit the key entirely, leaving the existing ref untouched; - * - declared key removed, row still holds one ⇒ write `null` to CLEAR it - * (code remains the authority for package rows); - * - engine cannot dereference (older engine, or the compare threw) ⇒ fall back - * to writing the declared value. Correct signatures beat tidy storage. - */ -async function secretPatch( - engine: IDataEngine, - wh: Webhook, - row: any, - subscriptionsObject: string, -): Promise> { - const { secret } = splitWebhookSecret(wh as Record); - const hasStored = row?.[WEBHOOK_SECRET_FIELD] != null && row[WEBHOOK_SECRET_FIELD] !== ''; - - if (!secret) return hasStored ? { [WEBHOOK_SECRET_FIELD]: null } : {}; - if (!hasStored || !canResolveSecrets(engine)) return { [WEBHOOK_SECRET_FIELD]: secret }; - - try { - const current = await (engine as any).resolveSecretField( - subscriptionsObject, - String(row.id), - WEBHOOK_SECRET_FIELD, - ); - return current === secret ? {} : { [WEBHOOK_SECRET_FIELD]: secret }; - } catch { - return { [WEBHOOK_SECRET_FIELD]: secret }; - } -} - -/** - * Translate a validated {@link Webhook} into `sys_webhook` column values. - * `object → object_name`, `isActive → active`; the envelope MINUS its two - * credential passengers — `secret` (#7799) and `headers` (#7986) — is stashed - * in `definition_json` for the enqueuer's advanced-config read (`timeoutMs`). - * Both credentials are written separately, into their own encrypted columns. - */ -function mapWebhookToRow(wh: Webhook): Record { - const { envelope: withoutSecret } = splitWebhookSecret(wh as Record); - const { envelope } = splitWebhookHeaders(withoutSecret as Record); - return { - name: wh.name, - label: wh.label ?? wh.name, - object_name: wh.object ?? null, - triggers: wh.triggers ?? [], - url: wh.url, - // Store lowercase to match the object's Field.select option values - // (get/post/…); the enqueuer upper-cases before delivery either way. - method: String(wh.method ?? 'POST').toLowerCase(), - description: wh.description ?? null, - active: wh.isActive !== false, - definition_json: JSON.stringify(envelope), - }; -} diff --git a/packages/plugins/plugin-webhooks/src/declared-webhooks.ts b/packages/plugins/plugin-webhooks/src/declared-webhooks.ts new file mode 100644 index 00000000000..23e538264e1 --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/declared-webhooks.ts @@ -0,0 +1,255 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Declared webhooks, read from the registry — ADR-0131 D2 and D6, as the + * ruling on the declared-webhook seeder decided (Q2 A: the seeder retires and + * the dispatcher reads the declaration). + * + * A stack's `webhooks:` are code. They used to be COPIED into `sys_webhook` on + * every boot by `bootstrapDeclaredWebhooks`, one `managed_by: 'package'` row per + * declaration. That seeder is retired: a fresh boot writes no `sys_webhook` row + * for a declared webhook, and the dispatcher ({@link AutoEnqueuer}) reads the + * declaration itself, here, on every cache refresh. `sys_webhook` holds only the + * webhooks an organization authors — plus, on a database an 18.0 install + * created, the mirror rows that release's seeder left behind (below). + * + * ## Where the declaration is read + * + * The two in-process readers the seeder read, in the same order: + * + * 1. ObjectQL's `SchemaRegistry` (`engine._registry.listItems('webhook')`), + * which manifest decomposition fills from every stack's TOP-LEVEL + * `webhooks:` collection. A connector's nested `webhooks[]` is never one of + * these items: that key is retired under ADR-0049 (`integration/ + * connector.zod.ts`), and widening this reader to walk connectors would + * resurrect it. `declared-webhooks.connector-nested.test.ts` pins the + * absence. + * 2. The kernel `metadata` service (`list('webhook')`), only when the registry + * holds no webhook at all — the registry-less host. A promise-returning + * `list` is not awaited, as the seeder did not await it. + * + * Both readers hand back the authoring document itself, so each item is parsed + * through `WebhookSchema` — defaults for `method` / `isActive` / `timeoutMs` + * applied, a malformed item skipped with one warning per name per process, + * never a crashed refresh. + * + * ## The switch: regime C (ADR-0131 D6) + * + * A declared webhook is read-only. It is switched on or off for the deployment + * by one `sys_metadata_activation` row of type {@link WEBHOOK_ACTIVATION_TYPE}, + * written through the activation door (`POST /api/v1/security/_activation/ + * webhook/:name`), and changed by a clone under a new name — an organization's + * own `sys_webhook` row. A ledger row overrides the declared `isActive` in both + * directions, so a webhook shipped inactive is activated by a row with + * `active: true`. No row means the declared default. The read follows the + * sharing-rule reader's rules: a composition with no ledger object issues no + * read, and a read that fails switches nothing for this pass. + * + * ## An 18.0 database: the dual read (expand first) + * + * The 18.0 seeder's rows stay where they are; deleting them, and re-sourcing + * their `sys_http_delivery` tails, is the ownership ceremony's step + * (ADR-0131 D10 fate 2). Until then the reader must not fire a declared webhook + * twice, and must not undo what an administrator did to a seeded row. So a + * `sys_webhook` row holding a declared name is judged by what the 18.0 seeder + * would have done with it on this boot ({@link classifyDeclaredNameHolder}): + * + * - **A pristine mirror** (`managed_by: 'package'`, not `customized`): the + * seeder would have overwritten it from the declaration. The row is ignored + * and the declaration answers. + * - **Any other row** (`customized: true`, or `managed_by: 'admin'`): the + * seeder never touched it, and an administrator's `active: false` on a noisy + * webhook survived every redeploy. The row answers as itself and the + * declaration is withheld, reported once per name per process. + * + * A pristine mirror whose declaration is gone is an ordinary row: 18.0 left it + * in place and dispatching, and so does this release. + */ + +import { WebhookSchema, type WebhookParsed } from '@objectstack/spec/automation'; +import { METADATA_ACTIVATION_TABLE } from '@objectstack/core'; + +/** System read context — the dispatcher reads its own configuration. */ +const SYSTEM_CTX = { isSystem: true } as const; + +/** + * [ADR-0131 D6 regime C] The `sys_metadata_activation.metadata_type` a declared + * webhook's switch is recorded under — one row per webhook name, no tenant + * column. + */ +export const WEBHOOK_ACTIVATION_TYPE = 'webhook'; + +/** + * The prefix of a declared webhook's subscription id, which is also the + * `sys_http_delivery.ref_id` its deliveries carry. A declared name is a + * `SnakeCaseIdentifierSchema` value and cannot hold a colon, and no row id this + * plugin or the engine mints starts this way, so a delivery's `ref_id` says by + * itself whether a declaration or a row produced it. + */ +export const DECLARED_WEBHOOK_REF_PREFIX = 'declared:'; + +/** The subscription id (and delivery `ref_id`) of the declared webhook `name`. */ +export function declaredWebhookRefId(name: string): string { + return `${DECLARED_WEBHOOK_REF_PREFIX}${name}`; +} + +/** The declared webhook name a delivery `ref_id` addresses, or `undefined` for a row's. */ +export function declaredWebhookNameOfRefId(refId: string): string | undefined { + return refId.startsWith(DECLARED_WEBHOOK_REF_PREFIX) + ? refId.slice(DECLARED_WEBHOOK_REF_PREFIX.length) || undefined + : undefined; +} + +interface Logger { + warn?: (msg: string, meta?: unknown) => void; +} + +/** One declared webhook, parsed, with its switch resolved. */ +export interface DeclaredWebhook { + /** The parsed declaration, defaults applied. */ + webhook: WebhookParsed; + /** The package that ships it (the registry item's `_packageId`), when one does. */ + packageId?: string; + /** In force on this deployment: the ledger row's `active` when one exists, else the declared `isActive`. */ + active: boolean; +} + +/** + * The read side the dispatcher, the redelivery veto and the activation door + * share — one reader, so the three cannot disagree about which declared + * webhooks exist or which are switched on. + */ +export interface DeclaredWebhookReader { + /** Every valid declaration, each with its switch resolved. */ + list(): Promise; + /** The declaration named `name`, switched on or off, or `null` when nothing declares it. */ + declaredWebhookNamed(name: string): Promise; +} + +/** + * The raw declared `webhook` items, registry first, metadata service second. + * Never throws: a reader that cannot answer yields nothing from that reader. + */ +export function readDeclaredWebhookItems(engine: unknown, metadataService: unknown): unknown[] { + try { + const reg = (engine as { _registry?: { listItems?: (type: string) => unknown[] } } | undefined)?._registry; + if (typeof reg?.listItems === 'function') { + const items = (reg.listItems('webhook') ?? []).filter(Boolean); + if (items.length > 0) return items; + } + } catch { + /* fall through to the metadata service */ + } + try { + const listed = (metadataService as { list?: (type: string) => unknown } | undefined)?.list?.('webhook'); + const arr = typeof (listed as { then?: unknown } | undefined)?.then === 'function' ? [] : (listed ?? []); + return Array.isArray(arr) ? arr.filter(Boolean) : []; + } catch { + return []; + } +} + +/** + * The reader the plugin binds: the declarations from + * {@link readDeclaredWebhookItems}, the switch from the activation ledger. + */ +export class DeclaredWebhookSource implements DeclaredWebhookReader { + /** Names already reported as failing validation — said once per process. */ + private readonly invalidReported = new Set(); + + constructor( + private readonly engine: unknown, + private readonly metadataService: unknown, + private readonly logger?: Logger, + ) {} + + async list(): Promise { + const parsed = this.parseDeclared(); + if (parsed.length === 0) return []; + const switched = await readWebhookActivation(this.engine, this.logger); + return parsed.map(({ webhook, packageId }) => ({ + webhook, + ...(packageId !== undefined ? { packageId } : {}), + active: switched.get(webhook.name) ?? webhook.isActive !== false, + })); + } + + async declaredWebhookNamed(name: string): Promise { + if (!name) return null; + return (await this.list()).find((d) => d.webhook.name === name) ?? null; + } + + private parseDeclared(): Array<{ webhook: WebhookParsed; packageId?: string }> { + const out: Array<{ webhook: WebhookParsed; packageId?: string }> = []; + const seen = new Set(); + for (const raw of readDeclaredWebhookItems(this.engine, this.metadataService)) { + let webhook: WebhookParsed; + try { + webhook = WebhookSchema.parse(raw); + } catch (err: any) { + const name = String((raw as { name?: unknown })?.name ?? ''); + if (!this.invalidReported.has(name)) { + this.invalidReported.add(name); + this.logger?.warn?.('[webhook] declared webhook failed validation — not dispatched', { + name: name || undefined, + error: err?.message ?? String(err), + }); + } + continue; + } + // Two registrations of one name: the first wins, as the seeder's + // name-keyed upsert let the first one hold the row. + if (seen.has(webhook.name)) continue; + seen.add(webhook.name); + const pkg = (raw as { _packageId?: unknown })._packageId; + out.push({ webhook, ...(typeof pkg === 'string' && pkg ? { packageId: pkg } : {}) }); + } + return out; + } +} + +/** + * [ADR-0131 D6 regime C] The declared webhooks the activation ledger switches, + * by name: `true` on, `false` off; a name with no row is absent. Read as the + * sharing-rule reader reads the same ledger: a composition with no ledger object + * issues no read, and a read that fails switches nothing for this pass. + */ +export async function readWebhookActivation(engine: unknown, logger?: Logger): Promise> { + const out = new Map(); + const ql = engine as { + registry?: { getObject?: (name: string) => unknown }; + find?: (object: string, query: unknown) => Promise; + } | undefined; + if (typeof ql?.find !== 'function') return out; + if (typeof ql.registry?.getObject === 'function' && !ql.registry.getObject(METADATA_ACTIVATION_TABLE)) return out; + try { + const rows = await ql.find(METADATA_ACTIVATION_TABLE, { + filter: { metadata_type: WEBHOOK_ACTIVATION_TYPE }, + fields: ['metadata_type', 'name', 'active'], + limit: 100000, + context: SYSTEM_CTX, + }); + for (const row of (Array.isArray(rows) ? rows : []) as Array<{ metadata_type?: unknown; name?: unknown; active?: unknown }>) { + if (row.metadata_type !== undefined && row.metadata_type !== WEBHOOK_ACTIVATION_TYPE) continue; + if (typeof row.name !== 'string' || !row.name) continue; + out.set(row.name, !(row.active === false || row.active === 0)); + } + } catch (err: any) { + logger?.warn?.( + `[webhook] ${METADATA_ACTIVATION_TABLE} read failed — declared webhooks keep their declared isActive for this pass`, + { error: err?.message ?? String(err) }, + ); + } + return out; +} + +/** + * How a `sys_webhook` row that holds a declared webhook's name is read — what + * the 18.0 seeder would have done with it on this boot (see the module doc): + * `'mirror'` — a pristine seeded copy, ignored in favour of the declaration; + * `'row'` — customized or administrator-authored, answering as itself while the + * declaration is withheld. + */ +export function classifyDeclaredNameHolder(row: { managed_by?: unknown; customized?: unknown }): 'mirror' | 'row' { + return row.managed_by === 'package' && row.customized !== true ? 'mirror' : 'row'; +} diff --git a/packages/plugins/plugin-webhooks/src/migrate-webhook-secrets.ts b/packages/plugins/plugin-webhooks/src/migrate-webhook-secrets.ts index ff82d04f0ad..78976c4ab85 100644 --- a/packages/plugins/plugin-webhooks/src/migrate-webhook-secrets.ts +++ b/packages/plugins/plugin-webhooks/src/migrate-webhook-secrets.ts @@ -6,9 +6,11 @@ * `signing_secret` column. * * ## Why a sweep and not just the seeder - * `bootstrapDeclaredWebhooks` re-seeds package-declared rows on every boot, so - * those heal themselves the moment the new mapping lands. The rows that do NOT - * heal are exactly the ones most likely to hold a real production key: + * The 18.0 seeder `bootstrapDeclaredWebhooks` (retired since: declared webhooks + * are read from the registry, ADR-0131 D2) re-seeded package-declared rows on + * every boot, so those healed themselves the moment the new mapping landed. The + * rows that did NOT heal are exactly the ones most likely to hold a real + * production key: * * - `managed_by: 'admin'` — authored in Setup, never touched by the seeder; * - `customized: true` — a package row an admin edited, deliberately frozen diff --git a/packages/plugins/plugin-webhooks/src/redeliver-guard.ts b/packages/plugins/plugin-webhooks/src/redeliver-guard.ts index 4a8096099b2..cfbc9451c59 100644 --- a/packages/plugins/plugin-webhooks/src/redeliver-guard.ts +++ b/packages/plugins/plugin-webhooks/src/redeliver-guard.ts @@ -61,9 +61,26 @@ * own presence check because the refusal REASON it returns is written from the * subscription row, and keeps its behaviour byte for byte: an unresolvable key * is refused with the text below, and anything else still propagates. + * + * ## A declared webhook's deliveries (ADR-0131 D2/D6) + * A declared webhook has no `sys_webhook` row: its deliveries carry + * `ref_id = declared:` (`declaredWebhookRefId`), and the guard asks the + * same questions of the declaration, through the reader the dispatcher uses. + * Not declared any more is case 1, switched off (the activation ledger, or a + * declared `isActive: false` no ledger row overrides) is case 2. Case 3 cannot + * arise: the signing key is code, held with the declaration. A pristine 18.0 + * mirror row (`classifyDeclaredNameHolder`) whose name is still declared is + * judged by its declaration too, because the dispatcher reads the declaration + * in its place: replaying one of its old deliveries must answer to the switch + * that now governs the webhook, not to a row nothing reads. */ import type { IDataEngine } from '@objectstack/spec/contracts'; +import { + classifyDeclaredNameHolder, + declaredWebhookNameOfRefId, + type DeclaredWebhookReader, +} from './declared-webhooks.js'; import { WEBHOOK_OBJECT, WEBHOOK_SECRET_FIELD, @@ -99,10 +116,14 @@ export interface RedeliverGuardRow { export function createWebhookRedeliverGuard( engine: IDataEngine, subscriptionsObject: string = WEBHOOK_OBJECT, + declared?: DeclaredWebhookReader, ): (row: RedeliverGuardRow) => Promise { return async (row) => { if (row.source !== 'webhook') return undefined; + const declaredName = declaredWebhookNameOfRefId(row.refId); + if (declaredName !== undefined) return judgeDeclared(declared, declaredName); + const subscription = (await engine.findOne( subscriptionsObject, { where: { id: row.refId } }, @@ -119,6 +140,11 @@ export function createWebhookRedeliverGuard( ); } + if (declared && classifyDeclaredNameHolder(subscription) === 'mirror' && typeof subscription.name === 'string') { + const declaration = await declared.declaredWebhookNamed(subscription.name); + if (declaration) return judgeDeclared(declared, subscription.name, declaration); + } + // [#22804] Case 2: the off switch. `=== true` is the dispatcher's own // predicate (`AutoEnqueuer` loads `{ active: true }`), so a replay is // refused for exactly the rows automatic dispatch skips — `false`, and @@ -165,3 +191,31 @@ export function createWebhookRedeliverGuard( ); }; } + +/** + * The guard's answer for a delivery a declared webhook produced, or a pristine + * 18.0 mirror the declaration now answers for (see the module doc). A lookup + * that throws propagates: "we could not check" never reads as "allowed". + */ +async function judgeDeclared( + declared: DeclaredWebhookReader | undefined, + name: string, + known?: Awaited>, +): Promise { + const declaration = known ?? (declared ? await declared.declaredWebhookNamed(name) : null); + if (!declaration) { + return ( + `the declared webhook '${name}' this delivery belongs to is no longer declared by the application, so ` + + 'there is nothing left to say whether it may still be signed and sent, and it is refused. Declare the ' + + 'webhook again if the endpoint should keep receiving events.' + ); + } + if (!declaration.active) { + return ( + `declared webhook '${name}' is switched off, and a switched-off webhook is skipped by the dispatcher, ` + + 'so this delivery is refused rather than sent to an endpoint that was switched off. Fix: switch it on ' + + `(POST /api/v1/security/_activation/webhook/${name} with { "enabled": true }), then redeliver.` + ); + } + return undefined; +} diff --git a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts index 20a0e568e60..18789f7cccb 100644 --- a/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts +++ b/packages/plugins/plugin-webhooks/src/sys-webhook.object.ts @@ -10,17 +10,20 @@ import { ObjectSchema, Field } from '@objectstack/spec/data'; * Studio UI without code changes. The canonical Zod schema for the * `definition_json` envelope lives at `@objectstack/spec/automation/webhook`. * - * ## Two authoring doors, one row - * Rows land here two ways, distinguished by the `managed_by` provenance column: + * ## Rows are the webhooks an organization authors (ADR-0131 D2) + * `managed_by` tells two kinds of row apart: * - **admin** — created/edited directly through this object's CRUD UI. - * - **package** — declared in code (`defineStack({ webhooks })` / - * `defineWebhook()`) and materialized on boot by - * `bootstrapDeclaredWebhooks` (#3461). Re-seeded every boot, but an admin - * edit stamps `customized: true` and freezes the row (seed-not-clobber, - * mirrors `sys_sharing_rule` #2909). + * - **package** — a copy of a webhook declared in code (`defineStack({ + * webhooks })` / `defineWebhook()`), written by the 18.0 boot seeder + * `bootstrapDeclaredWebhooks`. That seeder is retired: a declared webhook + * is read from the registry (`declared-webhooks.ts`) and no boot writes + * such a row any more. The rows an 18.0 install left stay until the + * ownership ceremony deletes them; meanwhile a pristine one is ignored in + * favour of its declaration, and one an administrator edited (`customized: + * true`) answers as itself. * - * One row per `name`. This plugin's {@link AutoEnqueuer} loads active rows on - * boot + on `sys_webhook:changed` events, and turns matching `data.record.*` + * One row per `name`. This plugin's {@link AutoEnqueuer} loads active rows, + * and the declared webhooks, on boot + on `sys_webhook:changed` events, and turns matching `data.record.*` * events into deliveries on the shared `service-messaging` HTTP outbox * (ADR-0018 M3 — `sys_http_delivery`, drained by the messaging dispatcher). * @@ -262,10 +265,10 @@ export const SysWebhook = ObjectSchema.create({ // ── Provenance (#3461 — record-authoritative seed-not-clobber) ── // Mirrors sys_sharing_rule (#2909). Both columns are `readonly`: the // engine strips them from non-system payloads (forge/clear-proof), while - // bootstrapDeclaredWebhooks and the provenance stamp hook write with - // isSystem. Deliberately NOT a write gate: webhooks are a first-class admin - // authoring/tuning surface — admins may edit or deactivate a package row; - // the seeder simply stops overwriting it once `customized` is stamped. + // the provenance stamp hook writes with isSystem (as the retired 18.0 seeder + // did). Deliberately NOT a write gate: webhooks are a first-class admin + // authoring/tuning surface — admins may edit or deactivate a package row, + // which then answers as itself rather than for its declaration. managed_by: Field.select( ['platform', 'package', 'admin'], { @@ -335,7 +338,7 @@ export const SysWebhook = ObjectSchema.create({ * |:---|:---|:---| * | Setup/Studio console — `nav_webhooks` (`webhook-outbox-plugin.ts`), the four list views above, `userActions` create/edit/delete | REST `/api/v1/data/sys_webhook` — the gated data API | `get` `list` `create` `update` `delete` | * | Operator predicate write — "deactivate every webhook on an object" (#4639, for which `AutoEnqueuer.handleSelfHealEvent` carries a `data.records.*` branch built expressly for this gesture) | REST `updateMany` / `deleteMany`, both gated on the `bulk` primitive | `bulk` | - * | `AutoEnqueuer` cache refresh, `bootstrapDeclaredWebhooks`, `stampWebhookProvenance`, `redeliver-guard`, `migrateLegacyWebhookSecrets`, the `headers_secret` write gate | `engine.find/findOne/insert/update` and lifecycle hooks — ObjectQL directly, which never consults `enable.apiMethods` | ungated: unaffected by anything declared here | + * | `AutoEnqueuer` cache refresh, `stampWebhookProvenance`, `redeliver-guard`, `migrateLegacyWebhookSecrets`, the `headers_secret` write gate | `engine.find/findOne/insert/update` and lifecycle hooks — ObjectQL directly, which never consults `enable.apiMethods` | ungated: unaffected by anything declared here | * * ⇒ every primitive is required by a real, measured consumer, so the set is * all six. No consumer outside the admin/operator surface was found. diff --git a/packages/plugins/plugin-webhooks/src/webhook-headers-gate.ts b/packages/plugins/plugin-webhooks/src/webhook-headers-gate.ts index 14e6859fc42..a9bdfbd38af 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-headers-gate.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-headers-gate.ts @@ -32,7 +32,7 @@ * * ## Why a hook, and why THIS hook * Maintainer ruling 2026-08-13 (option 2). Validating at the plugin's own write - * paths (`bootstrapDeclaredWebhooks` / `headersPatch` / the migration sweep) + * paths (the since-retired declared-webhook seeder / the migration sweep) * was rejected as insufficient: a direct `PATCH /api/v1/data/sys_webhook` never * goes through any of them, and that is the measured trigger. An engine hook * covers every door at once — the generic data API, the Setup UI, scripts, the @@ -291,8 +291,8 @@ const GATE_PRIORITY = 50; * ADMIN edit; this is a validity verdict on a payload, and a malformed header * map is exactly as unusable when a seeder writes it. Ruling item 2 says the * plugin's own write paths inherit this validation through the hook, which is - * only true if system writes are covered. They pass by construction — - * `bootstrapDeclaredWebhooks` and the migration sweep both write + * only true if system writes are covered. They pass by construction — the + * migration sweep (and the since-retired declared-webhook seeder) writes * `serializeHeaders(...)` of an already `isHeaderMap`-filtered map — so * covering them costs nothing and closes the door for a future write path that * is less careful. diff --git a/packages/plugins/plugin-webhooks/src/webhook-headers.ts b/packages/plugins/plugin-webhooks/src/webhook-headers.ts index 0da824063dd..8bba33268eb 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-headers.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-headers.ts @@ -340,50 +340,3 @@ export async function resolveWebhookHeaders( } return requireHeaderMap(plain, row, `${object}.${WEBHOOK_HEADERS_FIELD}`); } - -/** - * Decide what a RE-SEED should do with an existing row's `headers_secret`. - * - * Same discipline, and the same reason, as `secretPatch` in - * `bootstrap-declared-webhooks.ts`: a `secret`-typed write always mints a fresh - * `sys_secret` ciphertext row and the engine never deletes the superseded one, - * so blindly restating the declared headers on every boot would leak one orphan - * cipher row per webhook per restart. - * - * - declared map differs from stored ⇒ write it (an edit in code propagates); - * - identical ⇒ omit the key entirely, leaving the existing ref untouched; - * - declared headers removed, row still holds some ⇒ write `null` to CLEAR - * (code remains the authority for package rows); - * - engine cannot dereference (older engine, or the compare threw) ⇒ fall back - * to writing the declared value. A correct request beats tidy storage. - */ -export async function headersPatch( - engine: IDataEngine, - declared: WebhookHeaders | undefined, - row: { id: string; [k: string]: unknown }, - object: string, -): Promise> { - const hasStored = row?.[WEBHOOK_HEADERS_FIELD] != null && row[WEBHOOK_HEADERS_FIELD] !== ''; - - if (!declared) return hasStored ? { [WEBHOOK_HEADERS_FIELD]: null } : {}; - - const serialized = serializeHeaders(declared); - const resolver = engine as SecretResolvingEngine; - if (!hasStored || typeof resolver.resolveSecretField !== 'function') { - return { [WEBHOOK_HEADERS_FIELD]: serialized }; - } - - try { - const current = await resolver.resolveSecretField(object, String(row.id), WEBHOOK_HEADERS_FIELD); - // Compared as the CANONICAL serialization on both sides, not as raw - // strings: the stored form was produced by this same function, so key order - // is stable, and a re-parse guards against a hand-edited value that differs - // only in whitespace re-encrypting on every boot. - const stored = parseStoredHeaders(current); - return stored && serializeHeaders(stored) === serialized - ? {} - : { [WEBHOOK_HEADERS_FIELD]: serialized }; - } catch { - return { [WEBHOOK_HEADERS_FIELD]: serialized }; - } -} diff --git a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts index 79d1c6bb9e9..de8b2c0bb51 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts @@ -19,7 +19,7 @@ import { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js'; /** What the auth service's in-process session read answers, as `AuthSessionApi` declares it. */ type SessionRead = Awaited>>; import { SysWebhook } from './sys-webhook.object.js'; -import { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js'; +import { DeclaredWebhookSource } from './declared-webhooks.js'; import { migrateLegacyWebhookSecrets } from './migrate-webhook-secrets.js'; import { createWebhookRedeliverGuard } from './redeliver-guard.js'; import { bindWebhookProvenanceStamp, unbindWebhookProvenanceStamp } from './webhook-provenance.js'; @@ -72,6 +72,15 @@ interface MessagingHttpSurface { */ const WEBHOOKS_SLOT = 'webhooks'; +/** + * [ADR-0131 D6 regime C] The service slot the declared-webhook reader is bound + * to (`DeclaredWebhookReader`), which the activation door + * (`POST /api/v1/security/_activation/webhook/:name`) asks whether a name is + * declared — the reader the dispatcher and the redelivery veto use, so the + * three cannot disagree. The sharing-rule door's `sharingRules` is the pattern. + */ +const DECLARED_WEBHOOKS_SLOT = 'declaredWebhooks'; + /** * [#22756] The slice of a Hono context the redeliver door reads and answers * through, and nothing more: the request's headers (the caller's credentials), @@ -166,6 +175,8 @@ export class WebhookOutboxPlugin implements Plugin { private autoEnqueuer: AutoEnqueuer | undefined; /** Engine the provenance hook was bound to, so `dispose()` can unbind it. */ private boundEngine: any; + /** [ADR-0131 D2/D6] The declared-webhook reader, bound once the data engine is known. */ + private declaredSource: DeclaredWebhookSource | undefined; constructor(private readonly options: WebhookOutboxPluginOptions = {}) {} @@ -221,12 +232,11 @@ export class WebhookOutboxPlugin implements Plugin { if (typeof (ctx as any).hook === 'function') { (ctx as any).hook('kernel:ready', async () => { - // Materialize declared webhooks FIRST — this only needs the data + // The table's write doors, the legacy-secret sweep and the + // declared-webhook reader FIRST — this only needs the data // engine, so it must not be gated behind the auto-enqueue - // dispatch prerequisites (realtime + messaging). Otherwise a - // deployment without realtime would silently fail to materialize - // declared webhooks — the very no-op this bridge closes (#3461). - await this.bootDeclaredWebhooks(ctx); + // dispatch prerequisites (realtime + messaging). + await this.bootWebhookTable(ctx); // [#22756] The redelivery veto, and beside it the `webhooks` // service — gated on the engine and messaging alone, like the // step above, and never on the auto-enqueue prerequisites. It @@ -284,27 +294,35 @@ export class WebhookOutboxPlugin implements Plugin { } /** - * [#3461] Bridge the declarative authoring surface to the dispatcher: - * materialize stack/connector-declared `webhook` metadata into `sys_webhook` - * rows so the auto-enqueuer (and the Studio UI) can see them. + * The `sys_webhook` table's write doors, its legacy-secret sweep, and the + * declared-webhook reader. + * + * [ADR-0131 D2, D6] Declared webhooks are no longer materialized: the boot + * seeder (`bootstrapDeclaredWebhooks`) is retired, a fresh boot writes no + * `sys_webhook` row for one, and the dispatcher, the redelivery veto and the + * activation door read the declaration through one + * {@link DeclaredWebhookSource}, bound here to the `declaredWebhooks` slot. + * The 18.0 seeder's rows stay until the ownership ceremony deletes them; + * `declared-webhooks.ts` says how they are read meanwhile. * * Gated on the DATA ENGINE alone — deliberately independent of the - * auto-enqueue dispatch prerequisites (realtime + messaging). Materializing - * rows is a pure write; a deployment that mounts the webhook plugin without - * realtime must still get its declared webhooks into the table (and the - * Setup UI), even if nothing dispatches them yet. Runs before - * {@link bootAutoEnqueue} so the enqueuer's first cache refresh sees the rows. + * auto-enqueue dispatch prerequisites (realtime + messaging): the write + * doors, the sweep and the activation door serve on a kernel that + * dispatches nothing. Runs before {@link bootAutoEnqueue}, which reads the + * reader bound here. */ - private async bootDeclaredWebhooks(ctx: PluginContext): Promise { + private async bootWebhookTable(ctx: PluginContext): Promise { const engine = this.tryGetService(ctx, ['objectql', 'data']); if (!engine) { - ctx.logger.warn?.('[webhook] declared-webhook bootstrap skipped — no data engine available'); + ctx.logger.warn?.('[webhook] sys_webhook write doors and the declared-webhook reader not bound — no data engine available'); return; } - // Bind the provenance stamp so an admin edit freezes a seeded row. + // Bind the provenance stamp so an admin edit freezes a seeded row: an + // 18.0 mirror an administrator edits stops being one, and answers as + // itself rather than for its declaration (`classifyDeclaredNameHolder`). this.boundEngine = engine; bindWebhookProvenanceStamp(engine as any, ctx.logger as any); - // [#8566] And the headers_secret shape gate, BEFORE the seeder below + // [#8566] And the headers_secret shape gate, BEFORE the sweep below // runs its first write — a validation door that arms after the first // write it is meant to judge is not a door. It covers every write path // at once (the generic data API included, which is the measured @@ -313,24 +331,16 @@ export class WebhookOutboxPlugin implements Plugin { bindWebhookHeadersShapeGate(engine as any, ctx.logger as any); // And the retired credential location: a write that puts `secret` or // `headers` back into `definition_json` is refused at the door, since - // the delivery path refuses such a row too. Bound before the seeder and - // the sweep for the same reason — both pass it by construction. + // the delivery path refuses such a row too. Bound before the sweep for + // the same reason — it passes it by construction. bindWebhookLegacyCleartextGate(engine as any, ctx.logger as any); let metadataService: IMetadataService | undefined; try { metadataService = ctx.getService('metadata'); } catch { /* optional */ } - try { - await bootstrapDeclaredWebhooks(engine, metadataService, ctx.logger as any); - } catch (err: any) { - ctx.logger.warn?.('[webhook] declared-webhook bootstrap failed (dispatcher still serves admin rows)', { - error: err?.message ?? String(err), - }); - } - // [#7799] Then heal the rows the seeder cannot touch. Package rows are - // rewritten above; `managed_by: 'admin'` and `customized: true` rows are - // deliberately frozen against re-seeding, and those are precisely the - // ones holding hand-authored production keys in cleartext. Runs AFTER - // the seeder so a row it just rewrote is already secret-free and the - // sweep is a no-op on it. + this.declaredSource = new DeclaredWebhookSource(engine, metadataService, ctx.logger as any); + ctx.registerService(DECLARED_WEBHOOKS_SLOT, this.declaredSource); + // [#7799] Then heal the rows that still hold a key in cleartext: the + // ones an administrator authored or edited, and any 18.0 seeded row the + // seeder had not yet rewritten. try { await migrateLegacyWebhookSecrets(engine, ctx.logger as any); } catch (err: any) { @@ -371,7 +381,11 @@ export class WebhookOutboxPlugin implements Plugin { engine, realtime, (input) => messaging.enqueueHttp(input), - { ...enqOpts, logger: ctx.logger }, + { + ...enqOpts, + ...(this.declaredSource ? { declaredWebhooks: this.declaredSource } : {}), + logger: ctx.logger, + }, ); await this.autoEnqueuer.start(); ctx.registerService('webhook.autoEnqueuer', this.autoEnqueuer); @@ -453,7 +467,7 @@ export class WebhookOutboxPlugin implements Plugin { } messaging.registerRedeliverGuard( 'webhook', - createWebhookRedeliverGuard(engine, subscriptionsObject), + createWebhookRedeliverGuard(engine, subscriptionsObject, this.declaredSource), ); ctx.logger.debug?.('[webhook-outbox] redeliver guard installed for source=webhook'); return true; diff --git a/packages/plugins/plugin-webhooks/src/webhook-provenance.ts b/packages/plugins/plugin-webhooks/src/webhook-provenance.ts index 06e6fe8de89..5e542a58497 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-provenance.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-provenance.ts @@ -3,12 +3,14 @@ /** * [#3461] Provenance stamp for `sys_webhook`. * - * `sys_webhook` is RECORD-AUTHORITATIVE: a code-declared webhook is a boot seed - * (`bootstrapDeclaredWebhooks`), and the row — including any admin tuning such - * as flipping a noisy webhook to `active: false` — is the authority. The seeder - * skips rows marked `customized`, so this hook is the half that DETECTS the - * admin edit: any non-system update touching a `package`/`platform`-seeded row - * stamps `customized: true` onto the payload. + * `sys_webhook` is RECORD-AUTHORITATIVE: a row — including any admin tuning such + * as flipping a noisy webhook to `active: false` — is the authority. A + * `package` row is a copy of a declared webhook the 18.0 seeder + * (`bootstrapDeclaredWebhooks`, retired: ADR-0131 D2) wrote; the dispatcher + * ignores a pristine one in favour of its declaration and lets one marked + * `customized` answer as itself (`classifyDeclaredNameHolder`), so this hook is + * the half that DETECTS the admin edit: any non-system update touching a + * `package`/`platform`-seeded row stamps `customized: true` onto the payload. * * Why a data hook (and not a write gate or the REST layer), verbatim to the * sys_sharing_rule rationale (#2909 T1): diff --git a/packages/plugins/plugin-webhooks/src/webhook-secret.ts b/packages/plugins/plugin-webhooks/src/webhook-secret.ts index 1af645c91d8..f7e016e5882 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-secret.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-secret.ts @@ -202,11 +202,6 @@ type SecretResolvingEngine = IDataEngine & { onCryptoProviderChange?(listener: () => void): () => void; }; -/** True when this engine can dereference an encrypted field. */ -export function canResolveSecrets(engine: IDataEngine | undefined): boolean { - return typeof (engine as SecretResolvingEngine | undefined)?.resolveSecretField === 'function'; -} - /** * [#8022] Subscribe to the engine's crypto-provider registration. Returns an * unsubscribe function, or `undefined` when the engine has no such channel. diff --git a/packages/runtime/src/domains/activation-gate.ts b/packages/runtime/src/domains/activation-gate.ts index 1c1caf71740..0831d4b0807 100644 --- a/packages/runtime/src/domains/activation-gate.ts +++ b/packages/runtime/src/domains/activation-gate.ts @@ -192,6 +192,19 @@ export const SHARING_RULE_ACTIVATION_SUBJECT: ActivationSubject = { 'organization authors under names of its own stay yours to create and switch.', }; +/** + * [ADR-0131 D6 regime C] The declared-webhook door's wording. A declared webhook + * is customised by a copy under another name plus the declared one switched + * off; an organization still authors its own webhooks, so the remedy names that + * half and the operator for the switch. + */ +export const WEBHOOK_ACTIVATION_SUBJECT: ActivationSubject = { + subject: 'a declared webhook', + remedy: + 'To switch a declared webhook on or off for this installation, ask your platform operator; webhooks your ' + + 'organization authors under names of its own stay yours to create and switch.', +}; + /** * The activation body every `_activation` door reads: `{ enabled?: boolean }`, * unknown keys refused, `enabled` defaulting to `true`. diff --git a/packages/runtime/src/domains/catalog-activation-door.test.ts b/packages/runtime/src/domains/catalog-activation-door.test.ts index f09f3d4191f..f04444a9eac 100644 --- a/packages/runtime/src/domains/catalog-activation-door.test.ts +++ b/packages/runtime/src/domains/catalog-activation-door.test.ts @@ -653,3 +653,76 @@ describe('the catalog activation door — a declared sharing rule', () => { expect(h.tables[LEDGER][0].package_id ?? null).toBeNull(); }); }); + +// [ADR-0131 D2/D6 regime C] A DECLARED webhook is switched through the same door +// and the same ledger: `:type` `webhook`, the name resolved by plugin-webhooks' +// declared-webhook reader (`declaredWebhooks`, `declaredWebhookNamed`), which the +// dispatcher reads its switch through. The effect on dispatch is pinned beside +// that reader (`declared-webhooks.test.ts`). +describe('the catalog activation door — a declared webhook', () => { + const HOOK = 'crm_contact_changed'; + const declaredWebhooks = (declared: Record = { [HOOK]: { packageId: 'crm' } }) => ({ + declaredWebhooks: { + async declaredWebhookNamed(name: string) { + return declared[name] + ? { webhook: { name }, active: true, ...declared[name] } + : null; + }, + }, + }); + + it('`single`: a tenant admin holding `manage_metadata` switches it — one ledger row of type webhook, its package recorded', async () => { + const h = boot('single', {}, declaredWebhooks()); + const res = await flip(h, TENANT_ADMIN(), `/_activation/webhook/${HOOK}`, { enabled: true }); + expect(statusOf(res)).toBe(200); + expect(res.response?.body?.data).toEqual({ type: 'webhook', name: HOOK, enabled: true }); + expect(h.tables[LEDGER]).toEqual([ + expect.objectContaining({ metadata_type: 'webhook', name: HOOK, package_id: 'crm', active: true }), + ]); + }); + + for (const posture of ['group', 'isolated'] as const) { + it(`\`${posture}\`: the switch is the platform operator's — a tenant admin is refused 403 and nothing is written`, async () => { + const h = boot(posture, {}, declaredWebhooks()); + const refused = await flip(h, TENANT_ADMIN(), `/_activation/webhook/${HOOK}`); + expect(statusOf(refused)).toBe(403); + expect(codeOf(refused)).toBe('PERMISSION_DENIED'); + expect(messageOf(refused)).toContain('a declared webhook'); + expect(h.writes).toEqual([]); + + expect(statusOf(await flip(h, PLATFORM_OPERATOR(), `/_activation/webhook/${HOOK}`))).toBe(200); + expect(h.writes).toHaveLength(1); + }); + } + + it('a name no declaration holds is 404 and writes nothing — an organization\'s own webhook has its own switch', async () => { + const h = boot('single', {}, declaredWebhooks()); + const res = await flip(h, TENANT_ADMIN(), '/_activation/webhook/our_own_hook'); + expect(statusOf(res)).toBe(404); + expect(h.writes).toEqual([]); + }); + + it('no declared-webhook reader is 503 SERVICE_UNAVAILABLE, never a write for an unconfirmed name', async () => { + const h = boot('single'); + const res = await flip(h, TENANT_ADMIN(), `/_activation/webhook/${HOOK}`); + expect(statusOf(res)).toBe(503); + expect(codeOf(res)).toBe('SERVICE_UNAVAILABLE'); + expect(h.writes).toEqual([]); + }); + + it('a declared-webhook read that fails is 503, never a write', async () => { + const h = boot('single', {}, { + declaredWebhooks: { async declaredWebhookNamed() { throw new Error('registry down'); } }, + }); + const res = await flip(h, TENANT_ADMIN(), `/_activation/webhook/${HOOK}`); + expect(statusOf(res)).toBe(503); + expect(codeOf(res)).toBe('SERVICE_UNAVAILABLE'); + expect(h.writes).toEqual([]); + }); + + it('a declaration no package names is switched too, its row carrying no package', async () => { + const h = boot('single', {}, declaredWebhooks({ [HOOK]: {} })); + expect(statusOf(await flip(h, TENANT_ADMIN(), `/_activation/webhook/${HOOK}`))).toBe(200); + expect(h.tables[LEDGER][0].package_id ?? null).toBeNull(); + }); +}); diff --git a/packages/runtime/src/domains/catalog-activation.ts b/packages/runtime/src/domains/catalog-activation.ts index ffa07d29c56..6a816ce10cd 100644 --- a/packages/runtime/src/domains/catalog-activation.ts +++ b/packages/runtime/src/domains/catalog-activation.ts @@ -6,7 +6,10 @@ * `{ enabled?: boolean }`, `:type` one of `position` / `permission`, and — since * ADR-0131 D6 charts declared sharing rules under regime C — `sharing_rule`, a * rule a package declares, read from the sharing-rule service (`sharingRules`) - * rather than the security catalog. + * rather than the security catalog; and `webhook`, a webhook a stack declares + * (`defineStack({ webhooks })`), read from the declared-webhook reader + * (`declaredWebhooks`, plugin-webhooks) under the same regime (ADR-0131 D2/D6, + * the ruling that retired the declared-webhook seeder). * * ## Why it exists * @@ -63,6 +66,7 @@ import { POSITION_ACTIVATION_SUBJECT, PERMISSION_SET_ACTIVATION_SUBJECT, SHARING_RULE_ACTIVATION_SUBJECT, + WEBHOOK_ACTIVATION_SUBJECT, type ActivationSubject, } from './activation-gate.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; @@ -76,12 +80,15 @@ export const CATALOG_ACTIVATION_SEGMENT = '_activation'; * types the resolver reads from the ledger; `capability` is code-only on the * regime and has no switch. */ -const CATALOG_ACTIVATION_SUBJECTS: Readonly> = { +const CATALOG_ACTIVATION_SUBJECTS: Readonly> = { position: POSITION_ACTIVATION_SUBJECT, permission: PERMISSION_SET_ACTIVATION_SUBJECT, // [ADR-0131 D6 regime C] A declared sharing rule — read from the sharing-rule // service, which serves no declaration this ledger switches off. sharing_rule: SHARING_RULE_ACTIVATION_SUBJECT, + // [ADR-0131 D2/D6 regime C] A declared webhook — read from the declared-webhook + // reader, which the dispatcher reads its switch through. + webhook: WEBHOOK_ACTIVATION_SUBJECT, }; type CatalogActivationType = keyof typeof CATALOG_ACTIVATION_SUBJECTS; @@ -95,6 +102,7 @@ const NOUN: Readonly> = { position: 'Position', permission: 'Permission set', sharing_rule: 'Declared sharing rule', + webhook: 'Declared webhook', }; /** @@ -113,6 +121,60 @@ function declaredSharingRuleReaderOf(service: unknown): DeclaredSharingRuleReade return typeof candidate?.declaredRuleNamed === 'function' ? (candidate as DeclaredSharingRuleReader) : undefined; } +/** + * [ADR-0131 D2/D6 regime C] The declaration a `webhook` row addresses, as the + * declared-webhook reader (`declaredWebhooks`, plugin-webhooks) reads it: + * declared in the code, whether or not the ledger has it switched off. A + * webhook an organization authored is a `sys_webhook` row with a switch of its + * own (`active`) and is never addressed here. + */ +interface DeclaredWebhookReader { + declaredWebhookNamed(name: string): Promise<{ webhook: { name: string }; packageId?: string } | null>; +} + +function declaredWebhookReaderOf(service: unknown): DeclaredWebhookReader | undefined { + const candidate = service as Partial | null | undefined; + return typeof candidate?.declaredWebhookNamed === 'function' ? (candidate as DeclaredWebhookReader) : undefined; +} + +/** + * The service-backed types — declarations a plugin owns, not catalog entries — + * each with how its declaration is looked up and how it is named in a refusal. + */ +const DECLARATION_LOOKUPS: Readonly | undefined; +}>> = { + sharing_rule: { + service: 'sharingRules', + noun: 'sharing rule', + serviceNoun: 'sharing-rule service', + authoredRemedy: 'A rule your organization authored is switched by its own `active` field.', + lookup: (service, name) => declaredSharingRuleReaderOf(service)?.declaredRuleNamed(name), + }, + webhook: { + service: 'declaredWebhooks', + noun: 'webhook', + serviceNoun: 'declared-webhook reader', + authoredRemedy: 'A webhook your organization authored is switched by its own `active` field.', + lookup: (service, name) => { + const reader = declaredWebhookReaderOf(service); + return reader + ? reader.declaredWebhookNamed(name).then((d) => (d + ? { name: d.webhook.name, ...(d.packageId !== undefined ? { packageId: d.packageId } : {}) } + : null)) + : undefined; + }, + }, +}; + +function isDeclarationType(type: CatalogActivationType): type is keyof typeof DECLARATION_LOOKUPS { + return Object.prototype.hasOwnProperty.call(DECLARATION_LOOKUPS, type); +} + /** * Does this request belong to the door? Every `POST` under `_activation`, with * no upper bound on depth, so the gate is exactly as wide as the arm: a @@ -223,46 +285,55 @@ export async function handleCatalogActivationWrite( return { handled: true, response: deps.error('Data engine not available', 503) }; } let entry: { name: string; packageId?: string } | undefined; - if (type === 'sharing_rule') { - const rules = declaredSharingRuleReaderOf(await deps.resolveService(context, 'sharingRules')); - if (!rules) { + if (isDeclarationType(type)) { + const declaration = DECLARATION_LOOKUPS[type]; + const service = await deps.resolveService(context, declaration.service); + let pending: Promise<{ name: string; packageId?: string } | null> | undefined; + try { + pending = declaration.lookup(service, name); + } catch (err) { + pending = Promise.reject(err); // a reader that throws before its promise is still a failed read + } + if (!pending) { return { handled: true, response: deps.error( - `Cannot verify the declaration of sharing rule '${name}' — no sharing-rule service is bound to this ` + - `deployment. Refusing rather than writing an activation row for a name nobody can confirm exists.`, + `Cannot verify the declaration of ${declaration.noun} '${name}' — no ${declaration.serviceNoun} is ` + + `bound to this deployment. Refusing rather than writing an activation row for a name nobody can ` + + `confirm exists.`, 503, { code: 'SERVICE_UNAVAILABLE' }, ), }; } try { - entry = (await rules.declaredRuleNamed(name)) ?? undefined; + entry = (await pending) ?? undefined; } catch (err: any) { return { handled: true, response: deps.error( - `Cannot verify the declaration of sharing rule '${name}' — the declared rules could not be read ` + - `(${err?.message ?? String(err)}). Refusing rather than writing an activation row for a name nobody ` + - `can confirm exists.`, + `Cannot verify the declaration of ${declaration.noun} '${name}' — the declared ${declaration.noun}s ` + + `could not be read (${err?.message ?? String(err)}). Refusing rather than writing an activation row ` + + `for a name nobody can confirm exists.`, 503, { code: 'SERVICE_UNAVAILABLE' }, ), }; } if (!entry) { + const noun = declaration.noun.charAt(0).toUpperCase() + declaration.noun.slice(1); return { handled: true, response: deps.error( - `Sharing rule '${name}' is not declared by the application — there is nothing to switch ` + - `${enabled ? 'on' : 'off'}. A rule your organization authored is switched by its own \`active\` field.`, + `${noun} '${name}' is not declared by the application — there is nothing to switch ` + + `${enabled ? 'on' : 'off'}. ${declaration.authoredRemedy}`, 404, ), }; } } - const reader = type === 'sharing_rule' ? undefined : securityCatalogReaderOf(ql); - if (type !== 'sharing_rule' && !reader) { + const reader = isDeclarationType(type) ? undefined : securityCatalogReaderOf(ql); + if (!isDeclarationType(type) && !reader) { return { handled: true, response: deps.error( diff --git a/packages/runtime/src/domains/security.ts b/packages/runtime/src/domains/security.ts index 0a2661b6fbc..b5624d7368a 100644 --- a/packages/runtime/src/domains/security.ts +++ b/packages/runtime/src/domains/security.ts @@ -19,8 +19,9 @@ * POST /security/suggested-bindings/:id/confirm → accept it for the deployment * POST /security/suggested-bindings/:id/dismiss → decline it, or revoke an acceptance * POST /security/_activation/:type/:name → switch a position (`position`), a - * permission set (`permission`) or a declared - * sharing rule (`sharing_rule`) on/off + * permission set (`permission`), a declared + * sharing rule (`sharing_rule`) or a declared + * webhook (`webhook`) on/off * for this deployment (`./catalog-activation.ts`) * POST /security/permission-sets/:name/clone → copy a permission set's whole definition * under a new name (`./permission-set-clone.ts`) diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index b5f3b699d87..14135558b9f 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -378,7 +378,8 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ { route: 'POST /security/_activation/:type/:name', domain: '/security', disposition: 'server-only', note: 'ADR-0126 §3 regime C, as ADR-0131 D6 amends it — switch ONE position (`:type` = `position`), permission set ' + '(`:type` = `permission`) or declared sharing rule (`:type` = `sharing_rule`, resolved by the `sharingRules` service; ' - + 'its grants reconcile on the ledger write) on/off for this deployment. Writes one `sys_metadata_activation` row through the engine and ' + + 'its grants reconcile on the ledger write) or declared webhook (`:type` = `webhook`, resolved by the `declaredWebhooks` ' + + 'reader; the dispatcher's subscription cache refreshes on the ledger write) on/off for this deployment. Writes one `sys_metadata_activation` row through the engine and ' + 'nothing else (⛔ no definition write, ⛔ no catalog row `active`: ADR-0131 D3 moved the switch into the ledger, and ' + '`resolveUserAuthzGrants` reads only the ledger for these two types). Body `{ enabled?: boolean }`, the same reader as ' + 'the action door. The same two authority tiers as `POST /automation/:name/toggle` and `POST /actions/_activation/:object/:action`: ' From e44c642de5b786792bf820c1973b6fb9583db58c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:11:25 +0000 Subject: [PATCH 02/44] test(plugin-webhooks): pin the registry read, the ledger switch and the 18.0 dual read; door pins for webhook Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ Co-authored-by: Claude --- .../src/bootstrap-declared-webhooks.test.ts | 310 ---------- ...eclared-webhooks.connector-nested.test.ts} | 59 +- .../src/declared-webhooks.test.ts | 560 ++++++++++++++++++ ...lugin-shutdown-stops-auto-enqueuer.test.ts | 9 +- .../src/webhook-headers-gate.test.ts | 30 - .../src/webhook-secret-at-rest.test.ts | 208 ++++--- scripts/engine-double-contract.baseline.json | 4 +- scripts/engine-double-contract.pinned.json | 2 +- scripts/objectql-double-limit.baseline.json | 2 +- scripts/query-options-erasure-baseline.json | 1 - 10 files changed, 706 insertions(+), 479 deletions(-) delete mode 100644 packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts rename packages/plugins/plugin-webhooks/src/{bootstrap-declared-webhooks.connector-nested.test.ts => declared-webhooks.connector-nested.test.ts} (71%) create mode 100644 packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts diff --git a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts b/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts deleted file mode 100644 index 3061a59a917..00000000000 --- a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts +++ /dev/null @@ -1,310 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * bootstrapDeclaredWebhooks — the ingestion bridge that closes #3461. - * - * Verifies that STACK-declared `webhook` metadata (spec shape: `object` / - * `isActive`) is materialized into `sys_webhook` data rows (`object_name` / - * `active` / `definition_json`), idempotently and without clobbering admin - * edits — and that the dispatcher then sees those rows. - * - * This header used to say "stack/connector-declared", mirroring the same false - * clause the implementation's docblock carried: a connector's nested - * `webhooks[]` never becomes a `webhook` metadata item and so never reaches - * this bridge at all. Nothing in THIS file ever exercised a connector — every - * fixture below seeds `declared.webhook` directly — so the word was a claim the - * suite did not make. The claim it does not make is measured against a real - * engine next door, in `bootstrap-declared-webhooks.connector-nested.test.ts`. - */ - -import { describe, expect, it, vi } from 'vitest'; -import { AutoEnqueuer, type HttpEnqueueFn } from './auto-enqueuer.js'; -import { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js'; -import { bindWebhookProvenanceStamp } from './webhook-provenance.js'; -import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; - -// --------------------------------------------------------------------------- -// Fakes -// --------------------------------------------------------------------------- - -interface HookEntry { - event: string; - handler: (ctx: any) => any; - object?: string; - packageId?: string; -} - -/** - * A small engine fake that mirrors the real ObjectQL surface the bridge and - * provenance hook touch: `find({ filter | where })`, `insert`, update-by-id (the - * patch carries `id`, no `where`), a `_registry.listItems(type)` for declared - * metadata, and `beforeUpdate` hooks that run inside `update()`. - */ -class FakeEngine { - rows: Record = {}; - private hooks: HookEntry[] = []; - private declared: Record = {}; - - constructor(seed?: { rows?: Record; declared?: Record }) { - if (seed?.rows) this.rows = JSON.parse(JSON.stringify(seed.rows)); - if (seed?.declared) this.declared = JSON.parse(JSON.stringify(seed.declared)); - } - - // Declared-metadata registry (where manifest decomposition parks stack.webhooks). - // - // [#8378] Items are registered EXACTLY as the real engine registers them — - // the document itself. Boxing each as `{ content: }` made this fake - // the only producer of that envelope in the tree, which is what kept the - // production `i?.content ?? i` looking load-bearing. - get _registry() { - return { - listItems: (type: string) => [...(this.declared[type] ?? [])], - }; - } - - private matches(row: any, cond?: Record): boolean { - if (!cond) return true; - return Object.entries(cond).every(([k, v]) => row[k] === v); - } - - async find(name: string, q?: any): Promise { - const all = this.rows[name] ?? []; - const cond = q?.filter ?? q?.where; - const out = all.filter((r) => this.matches(r, cond)); - return typeof q?.limit === 'number' ? out.slice(0, q.limit) : out; - } - async findOne(name: string, q?: any): Promise { - assertEngineFindOnePredicate(name, q); - return (await this.find(name, q))[0] ?? null; - } - async insert(name: string, data: any): Promise { - const arr = (this.rows[name] = this.rows[name] ?? []); - arr.push({ ...data }); - return data; - } - async update(name: string, data: any, opts?: any): Promise { - // Run beforeUpdate hooks (the provenance stamp lives here). - const id = data?.id ?? opts?.where?.id; - // [#15302] `previous` is bound BEFORE the beforeUpdate dispatch, as the - // real engine binds it (#5574 / #5846 - by-id reads the prior row ahead of - // the dispatch; each per-row context of a predicate write carries its own). - // Withholding it here is what let this fake model a pre-#5574 engine, and - // a hook reading `ctx.previous` would have gone silently unstamped against - // a fake no production caller resembles. - const cond0 = opts?.where ?? (id ? { id } : undefined); - const previous = (this.rows[name] ?? []).filter((r) => this.matches(r, cond0)).map((r) => ({ ...r }))[0]; - const ctx = { input: { id, data }, previous, session: opts?.context }; - for (const h of this.hooks) { - if (h.event === 'beforeUpdate' && (!h.object || h.object === name)) { - await h.handler(ctx); - } - } - const arr = this.rows[name] ?? []; - const cond = opts?.where ?? (id ? { id } : undefined); - for (const r of arr) { - if (this.matches(r, cond)) Object.assign(r, data); - } - return { affected: 0 }; - } - async delete(): Promise { - return { affected: 0 }; - } - async count(name: string): Promise { - return (this.rows[name] ?? []).length; - } - async aggregate(): Promise { - return []; - } - - registerHook(event: string, handler: (ctx: any) => any, options?: Record): void { - this.hooks.push({ event, handler, object: options?.object, packageId: options?.packageId }); - } - unregisterHooksByPackage(packageId: string): number { - const before = this.hooks.length; - this.hooks = this.hooks.filter((h) => h.packageId !== packageId); - return before - this.hooks.length; - } -} - -class FakeRealtime { - private subs = new Map(); - private n = 0; - async publish(event: any): Promise { - for (const sub of this.subs.values()) { - const o = sub.opts ?? {}; - if (o.object && event.object !== o.object) continue; - await sub.handler(event); - } - } - async subscribe(_channel: string, handler: any, opts?: any): Promise { - const id = `s-${++this.n}`; - this.subs.set(id, { handler, opts }); - return id; - } - async unsubscribe(id: string): Promise { - this.subs.delete(id); - } -} - -const ADMIN_CTX = { isSystem: false, positions: [], permissions: [] }; - -function declaredWebhook(over: Record = {}): any { - return { - name: 'task_changed', - label: 'Task Changed', - object: 'showcase_task', - triggers: ['create', 'update'], - url: 'https://hooks.example/task', - method: 'POST', - isActive: true, - ...over, - }; -} - -async function flush() { - await new Promise((r) => setTimeout(r, 0)); -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('bootstrapDeclaredWebhooks', () => { - it('materializes a declared webhook into a sys_webhook row (object→object_name, isActive→active)', async () => { - const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } }); - const res = await bootstrapDeclaredWebhooks(engine as any, null); - - expect(res).toEqual({ seeded: 1, skipped: 0 }); - const rows = engine.rows['sys_webhook']; - expect(rows).toHaveLength(1); - const row = rows[0]; - expect(row.name).toBe('task_changed'); - expect(row.object_name).toBe('showcase_task'); // object → object_name - expect(row.active).toBe(true); // isActive → active - expect(row.method).toBe('post'); // lowercased to match the select options - expect(row.managed_by).toBe('package'); - expect(row.customized).toBe(false); - // Full validated envelope stashed for the enqueuer's advanced-config read. - const defn = JSON.parse(row.definition_json); - expect(defn.object).toBe('showcase_task'); - expect(defn.timeoutMs).toBe(30000); // default filled by WebhookSchema.parse - }); - - it('maps isActive:false → active:false so a placeholder webhook ships inactive', async () => { - const engine = new FakeEngine({ declared: { webhook: [declaredWebhook({ isActive: false })] } }); - await bootstrapDeclaredWebhooks(engine as any, null); - expect(engine.rows['sys_webhook'][0].active).toBe(false); - }); - - it('is idempotent — a second boot updates in place, never duplicates', async () => { - const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } }); - await bootstrapDeclaredWebhooks(engine as any, null); - await bootstrapDeclaredWebhooks(engine as any, null); - expect(engine.rows['sys_webhook']).toHaveLength(1); - }); - - it('propagates a declared change to a pristine (non-customized) row', async () => { - const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } }); - await bootstrapDeclaredWebhooks(engine as any, null); - - engine['declared'].webhook = [declaredWebhook({ url: 'https://hooks.example/task-v2' })]; - await bootstrapDeclaredWebhooks(engine as any, null); - - expect(engine.rows['sys_webhook']).toHaveLength(1); - expect(engine.rows['sys_webhook'][0].url).toBe('https://hooks.example/task-v2'); - }); - - it('seed-not-clobber: an admin edit (customized) survives the next boot', async () => { - const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } }); - bindWebhookProvenanceStamp(engine as any); - await bootstrapDeclaredWebhooks(engine as any, null); - - // Admin deactivates the noisy webhook through the CRUD door (non-system). - const id = engine.rows['sys_webhook'][0].id; - await engine.update('sys_webhook', { id, active: false }, { context: ADMIN_CTX }); - expect(engine.rows['sys_webhook'][0].customized).toBe(true); // hook stamped it - - // Redeploy re-runs the seeder — the declared row is still active:true, but - // the admin's active:false must win. - await bootstrapDeclaredWebhooks(engine as any, null); - expect(engine.rows['sys_webhook'][0].active).toBe(false); - }); - - it('never overwrites an admin-authored row that collides by name', async () => { - const engine = new FakeEngine({ - rows: { - sys_webhook: [ - { id: 'admin-1', name: 'task_changed', url: 'https://admin.example', active: true, managed_by: 'admin', customized: false }, - ], - }, - declared: { webhook: [declaredWebhook()] }, - }); - const res = await bootstrapDeclaredWebhooks(engine as any, null); - expect(res).toEqual({ seeded: 0, skipped: 1 }); - expect(engine.rows['sys_webhook']).toHaveLength(1); - expect(engine.rows['sys_webhook'][0].url).toBe('https://admin.example'); // untouched - }); - - it('skips an invalid declared webhook (bad URL) with a warning, without crashing boot', async () => { - const warn = vi.fn(); - const engine = new FakeEngine({ - declared: { webhook: [declaredWebhook({ name: 'good' }), declaredWebhook({ name: 'bad', url: 'not-a-url' })] }, - }); - const res = await bootstrapDeclaredWebhooks(engine as any, null, { warn }); - - expect(res.seeded).toBe(1); // the good one still lands - expect(res.skipped).toBe(1); - expect(engine.rows['sys_webhook'].map((r) => r.name)).toEqual(['good']); - expect(warn).toHaveBeenCalledWith(expect.stringContaining('failed validation'), expect.objectContaining({ name: 'bad' })); - }); - - it('is a no-op when nothing is declared', async () => { - const engine = new FakeEngine(); - const res = await bootstrapDeclaredWebhooks(engine as any, null); - expect(res).toEqual({ seeded: 0, skipped: 0 }); - expect(engine.rows['sys_webhook']).toBeUndefined(); - }); - - it('end-to-end: a declared webhook, once materialized, dispatches on a matching data event', async () => { - const engine = new FakeEngine({ - declared: { - webhook: [ - declaredWebhook({ - triggers: ['create'], - secret: 'shh', - headers: { 'X-Env': 'prod' }, - }), - ], - }, - }); - await bootstrapDeclaredWebhooks(engine as any, null); - - const realtime = new FakeRealtime(); - const calls: any[] = []; - const enqueue: HttpEnqueueFn = async (input) => { - calls.push(input); - return 'id'; - }; - const ae = new AutoEnqueuer(engine as any, realtime as any, enqueue, { refreshIntervalMs: 0 }); - await ae.start(); - - await realtime.publish({ - type: 'data.record.created', - object: 'showcase_task', - payload: { recordId: 't-1' }, - timestamp: '2026-05-24T00:00:00.000Z', - }); - await flush(); - - expect(calls).toHaveLength(1); - expect(calls[0].url).toBe('https://hooks.example/task'); - // Headers come from the definition_json envelope the bridge wrote. The - // signing secret does NOT (#7799) — it goes to the `signing_secret` column, - // which this fake engine (no encrypted-field channel) stores verbatim, so - // the enqueuer reads it back from there. - expect(calls[0].signingSecret).toBe('shh'); - expect(engine.rows['sys_webhook'][0].definition_json).not.toContain('shh'); - expect(calls[0].headers).toEqual({ 'X-Env': 'prod' }); - await ae.stop(); - }); -}); diff --git a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.connector-nested.test.ts b/packages/plugins/plugin-webhooks/src/declared-webhooks.connector-nested.test.ts similarity index 71% rename from packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.connector-nested.test.ts rename to packages/plugins/plugin-webhooks/src/declared-webhooks.connector-nested.test.ts index 7696c1b82e5..250a07a70db 100644 --- a/packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.connector-nested.test.ts +++ b/packages/plugins/plugin-webhooks/src/declared-webhooks.connector-nested.test.ts @@ -1,12 +1,13 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * A connector's nested `webhooks[]` does NOT reach `sys_webhook` — pinned as + * A connector's nested `webhooks[]` does NOT reach the dispatcher — pinned as * behaviour, against a REAL registration path. * * ## Why this file exists * - * `bootstrap-declared-webhooks.ts` used to open with "materialize + * The retired declared-webhook seeder (`bootstrap-declared-webhooks.ts`, which + * `declared-webhooks.ts` replaced: ADR-0131 D2) used to open with "materialize * stack/**connector**-declared `webhooks` into `sys_webhook` rows so the * dispatcher can actually see them". The connector half was false, and it was * false in the one place a reader looks when deciding whether a connector's @@ -26,13 +27,13 @@ * * ⚠️ ANTI-VACUITY CONTROL, on the same subject and in the same corpus: the same * manifest also declares a TOP-LEVEL `webhooks:` entry, and the same assertions - * demand that one IS registered and IS seeded. Without it, every expectation + * demand that one IS registered and IS read as a declared webhook. Without it, every expectation * below would also pass on an engine that registered nothing at all, on a * misspelled metadata type, or on a manifest this engine rejected outright. * * ## The direction this pins is the DECLARED one * - * ⛔ A red here is not a licence to widen the seeder. `packages/spec` RETIRED + * ⛔ A red here is not a licence to widen the declared-webhook reader. `packages/spec` RETIRED * the connector surface under ADR-0049 enforce-or-remove: `connector.webhooks` * is a `retiredKey()` tombstone and the `WebhookConfig` shape left whole * (`integration/connector.zod.ts`, "REMOVED: `health`, `status` and the nested @@ -53,7 +54,7 @@ import { describe, expect, it } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; -import { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js'; +import { DeclaredWebhookSource } from './declared-webhooks.js'; const PKG = 'com.acme.billing'; @@ -102,30 +103,16 @@ const namesOf = (registry: any, type: string): string[] => (registry.listItems(type) ?? []).filter(Boolean).map((i: any) => i?.name); /** - * The minimum `IDataEngine` surface the seeder's INSERT path touches, wired to - * a real registry so `readDeclared` sees exactly what boot registered. No - * secret is authored, so no CryptoProvider is needed. - * - * `update` is deliberately ABSENT rather than stubbed. `find` always answers - * empty here, so the seeder always takes the INSERT branch and never dispatches - * an update; a stub for it would be an engine double declaring a verb this file - * does not exercise, which `check:engine-double-contract` correctly treats as - * new surface to pin. A fake that stops where the path stops has nothing to be - * looser than, and a future change that DID reach the update branch would fail - * loudly here instead of passing against a permissive stub. + * The surface the declared-webhook reader touches, wired to a real registry so + * `readDeclaredWebhookItems` sees exactly what boot registered: the registry, + * and a `find` for the activation ledger that holds no row. */ -function seedingEngine(registry: any) { - const rows: any[] = []; +function readingEngine(registry: any) { return { - rows, _registry: registry, async find() { return []; }, - async insert(_object: string, row: any) { - rows.push(row); - return row; - }, }; } @@ -148,19 +135,23 @@ describe("a connector's nested `webhooks[]` never becomes a `webhook` metadata i expect(connector.webhooks?.map((w: any) => w?.name)).toEqual([CONNECTOR_NESTED.name]); }); - it('seeds `sys_webhook` from the top-level collection only — the connector half is not even skipped', async () => { - const engine = seedingEngine(bootedRegistry()); + it('reads the top-level collection only as declared webhooks — the connector half is not even skipped', async () => { + const warn: unknown[] = []; + const reader = new DeclaredWebhookSource(readingEngine(bootedRegistry()), undefined, { + warn: (msg: string) => { warn.push(msg); }, + }); - const result = await bootstrapDeclaredWebhooks(engine as any, undefined); + const declared = await reader.list(); - // CONTROL: the bridge really did run and really did materialize. - expect(result.seeded).toBe(1); - expect(engine.rows.map((r) => r.name)).toEqual([STACK_DECLARED.name]); + // CONTROL: the reader really did run and really did read the declaration. + expect(declared.map((d) => d.webhook.name)).toEqual([STACK_DECLARED.name]); - // SUBJECT: the connector-nested webhook produces NO row — and no `skipped` - // either, because the seeder never sees it at all. A future bridge that - // merely warned about it would move this number, not just the row list. - expect(engine.rows.map((r) => r.name)).not.toContain(CONNECTOR_NESTED.name); - expect(result.skipped).toBe(0); + // SUBJECT: the connector-nested webhook is no declared webhook — and no + // validation warning either, because the reader never sees it at all. A + // future reader that merely warned about it would move this, not just the + // name list. + expect(declared.map((d) => d.webhook.name)).not.toContain(CONNECTOR_NESTED.name); + expect(await reader.declaredWebhookNamed(CONNECTOR_NESTED.name)).toBeNull(); + expect(warn).toEqual([]); }); }); diff --git a/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts b/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts new file mode 100644 index 00000000000..09f20e6de69 --- /dev/null +++ b/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts @@ -0,0 +1,560 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Declared webhooks, read from the registry (ADR-0131 D2/D6) — the dispatcher + * side of the retired `bootstrapDeclaredWebhooks` seeder. + * + * Three things are pinned here: + * + * 1. **The new shape, on a fresh install.** A declared webhook dispatches from + * its declaration — URL, method, triggers, `timeoutMs`, signing secret and + * headers — and nothing writes a `sys_webhook` row for it. + * 2. **The switch (regime C).** A `sys_metadata_activation` row of type + * `webhook` turns a declared webhook off, or on when it shipped inactive, + * and a ledger write refreshes the cache in the request that made it. + * 3. **The dual read, on an 18.0-shaped database.** The rows the 18.0 seeder + * wrote stay. A pristine one is ignored in favour of its declaration — one + * delivery, never two; an edited one (`customized: true`) or an admin row + * of the same name answers as itself and the declaration is withheld, so + * an administrator's `active: false` survives the upgrade. The fixture + * {@link seeded18_0Row} is byte-for-byte the row shape 18.0's seeder wrote + * (its `mapWebhookToRow` plus provenance), which is the shape an upgraded + * database holds. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { AutoEnqueuer, type HttpEnqueueFn } from './auto-enqueuer.js'; +import { + DeclaredWebhookSource, + WEBHOOK_ACTIVATION_TYPE, + declaredWebhookRefId, +} from './declared-webhooks.js'; +import { createWebhookRedeliverGuard } from './redeliver-guard.js'; +import { bindWebhookProvenanceStamp } from './webhook-provenance.js'; +import { WebhookOutboxPlugin } from './webhook-outbox-plugin.js'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; + +// --------------------------------------------------------------------------- +// Fakes +// --------------------------------------------------------------------------- + +const LEDGER = 'sys_metadata_activation'; + +interface HookEntry { + event: string; + handler: (ctx: any) => any; + object?: string; + packageId?: string; +} + +/** + * The ObjectQL surface the dispatcher, the reader and the provenance hook + * touch: `find({ where | filter })` with equality and `$in`, `insert`, + * update-by-id with `beforeUpdate` hooks, and a `_registry.listItems(type)` for + * declared metadata. Every write is recorded, so "nothing writes a row" is a + * reading, not an assumption. + */ +class FakeEngine { + rows: Record = {}; + writes: Array<{ op: string; object: string; data: any }> = []; + declared: Record = {}; + failLedgerRead = false; + private hooks: HookEntry[] = []; + + constructor(seed?: { rows?: Record; declared?: Record }) { + if (seed?.rows) this.rows = JSON.parse(JSON.stringify(seed.rows)); + if (seed?.declared) this.declared = JSON.parse(JSON.stringify(seed.declared)); + } + + get _registry() { + return { listItems: (type: string) => [...(this.declared[type] ?? [])] }; + } + + private matches(row: any, cond?: Record): boolean { + if (!cond) return true; + return Object.entries(cond).every(([k, v]) => { + if (v && typeof v === 'object' && Array.isArray((v as any).$in)) return (v as any).$in.includes(row[k]); + return row[k] === v; + }); + } + + async find(name: string, q?: any): Promise { + if (name === LEDGER && this.failLedgerRead) throw new Error('ledger down'); + const all = this.rows[name] ?? []; + const out = all.filter((r) => this.matches(r, q?.filter ?? q?.where)).map((r) => ({ ...r })); + return typeof q?.limit === 'number' ? out.slice(0, q.limit) : out; + } + async findOne(name: string, q?: any): Promise { + assertEngineFindOnePredicate(name, q); + return (await this.find(name, q))[0] ?? null; + } + async insert(name: string, data: any): Promise { + this.writes.push({ op: 'insert', object: name, data }); + (this.rows[name] = this.rows[name] ?? []).push({ ...data }); + return data; + } + async update(name: string, data: any, opts?: any): Promise { + this.writes.push({ op: 'update', object: name, data }); + const id = data?.id ?? opts?.where?.id; + const cond = opts?.where ?? (id ? { id } : undefined); + const previous = (this.rows[name] ?? []).find((r) => this.matches(r, cond)); + const ctx = { input: { id, data }, previous: previous ? { ...previous } : undefined, session: opts?.context }; + for (const h of this.hooks) { + if (h.event === 'beforeUpdate' && (!h.object || h.object === name)) await h.handler(ctx); + } + for (const r of this.rows[name] ?? []) if (this.matches(r, cond)) Object.assign(r, data); + return { affected: 1 }; + } + async delete(): Promise { + return { affected: 0 }; + } + async count(name: string): Promise { + return (this.rows[name] ?? []).length; + } + registerHook(event: string, handler: (ctx: any) => any, options?: Record): void { + this.hooks.push({ event, handler, object: options?.object, packageId: options?.packageId }); + } + unregisterHooksByPackage(packageId: string): number { + const before = this.hooks.length; + this.hooks = this.hooks.filter((h) => h.packageId !== packageId); + return before - this.hooks.length; + } +} + +class FakeRealtime { + private subs = new Map(); + private n = 0; + async publish(event: any): Promise { + for (const sub of this.subs.values()) { + const o = sub.opts ?? {}; + if (o.object && event.object !== o.object) continue; + await sub.handler(event); + } + } + async subscribe(_channel: string, handler: any, opts?: any): Promise { + const id = `s-${++this.n}`; + this.subs.set(id, { handler, opts }); + return id; + } + async unsubscribe(id: string): Promise { + this.subs.delete(id); + } +} + +const ADMIN_CTX = { isSystem: false, positions: [], permissions: [] }; + +function declaredWebhook(over: Record = {}): any { + return { + name: 'task_changed', + label: 'Task Changed', + object: 'showcase_task', + triggers: ['create', 'update'], + url: 'https://hooks.example/task', + method: 'POST', + isActive: true, + ...over, + }; +} + +/** + * The row 18.0's `bootstrapDeclaredWebhooks` wrote for `wh` — its + * `mapWebhookToRow` (lower-cased method, `object`→`object_name`, + * `isActive`→`active`, the envelope minus both credentials in + * `definition_json`) plus `managed_by: 'package'` / `customized: false`. The + * credentials went to the encrypted columns; this fake engine has no encrypted + * channel, so the column holds the plaintext the real one would dereference + * to, which is what `resolveWebhookSecret` reads back here. + */ +function seeded18_0Row(id: string, wh: any, over: Record = {}): any { + const { secret, headers, ...envelope } = { method: 'POST', timeoutMs: 30000, isActive: true, ...wh }; + return { + id, + name: wh.name, + label: wh.label ?? wh.name, + object_name: wh.object ?? null, + triggers: wh.triggers ?? [], + url: wh.url, + method: String(envelope.method).toLowerCase(), + description: wh.description ?? null, + active: envelope.isActive !== false, + definition_json: JSON.stringify(envelope), + ...(secret ? { signing_secret: secret } : {}), + ...(headers ? { headers_secret: JSON.stringify(headers) } : {}), + managed_by: 'package', + customized: false, + created_at: '2026-10-01T00:00:00.000Z', + updated_at: '2026-10-01T00:00:00.000Z', + ...over, + }; +} + +function ledgerRow(name: string, active: boolean): any { + return { id: `act_${name}`, metadata_type: WEBHOOK_ACTIVATION_TYPE, name, active }; +} + +function taskCreated(recordId = 't-1'): any { + return { + type: 'data.record.created', + object: 'showcase_task', + payload: { recordId }, + timestamp: '2026-10-19T00:00:00.000Z', + }; +} + +async function flush() { + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); +} + +async function startEnqueuer(engine: FakeEngine, logger?: any) { + const realtime = new FakeRealtime(); + const calls: any[] = []; + const enqueue: HttpEnqueueFn = async (input) => { + calls.push(input); + return 'id'; + }; + const declaredWebhooks = new DeclaredWebhookSource(engine, undefined, logger); + const enqueuer = new AutoEnqueuer(engine as any, realtime as any, enqueue, { + refreshIntervalMs: 0, + declaredWebhooks, + ...(logger ? { logger } : {}), + }); + await enqueuer.start(); + return { enqueuer, realtime, calls, declaredWebhooks }; +} + +// --------------------------------------------------------------------------- +// 1. The new shape, on a fresh install +// --------------------------------------------------------------------------- + +describe('declared webhooks — a fresh install dispatches from the registry', () => { + it('a declared webhook dispatches on a matching event, with its secret, headers, method and timeout from the declaration', async () => { + const engine = new FakeEngine({ + declared: { + webhook: [declaredWebhook({ + triggers: ['create'], secret: 'shh', headers: { 'X-Env': 'prod' }, method: 'PUT', timeoutMs: 5000, + })], + }, + }); + const { enqueuer, realtime, calls } = await startEnqueuer(engine); + + await realtime.publish(taskCreated()); + await flush(); + + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + source: 'webhook', + refId: declaredWebhookRefId('task_changed'), + url: 'https://hooks.example/task', + method: 'PUT', + headers: { 'X-Env': 'prod' }, + signingSecret: 'shh', + timeoutMs: 5000, + }); + expect(calls[0].organizationId).toBeUndefined(); + await enqueuer.stop(); + }); + + it('nothing writes a sys_webhook row: the plugin boot binds the reader and seeds zero rows', async () => { + const engine = new FakeEngine({ declared: { webhook: [declaredWebhook({ secret: 'shh' })] } }); + const services: Record = { objectql: engine, manifest: { register() {} } }; + const hooks: Record Promise>> = {}; + const ctx: any = { + logger: { info() {}, warn() {}, error() {}, debug() {} }, + getService: (n: string) => { + if (!(n in services)) throw new Error(`no ${n}`); + return services[n]; + }, + registerService: (n: string, s: unknown) => { services[n] = s; }, + hook: (event: string, fn: () => Promise) => { (hooks[event] ??= []).push(fn); }, + }; + const plugin = new WebhookOutboxPlugin({ autoEnqueue: false }); + await plugin.init(ctx); + for (const fn of hooks['kernel:ready'] ?? []) await fn(); + + expect(engine.writes.filter((w) => w.object === 'sys_webhook')).toEqual([]); + expect(engine.rows['sys_webhook']).toBeUndefined(); + const reader = services['declaredWebhooks'] as DeclaredWebhookSource; + expect(reader).toBeInstanceOf(DeclaredWebhookSource); + expect((await reader.declaredWebhookNamed('task_changed'))?.webhook.object).toBe('showcase_task'); + await plugin.destroy(); + }); + + it('a declared webhook shipped inactive is not dispatched', async () => { + const engine = new FakeEngine({ declared: { webhook: [declaredWebhook({ isActive: false })] } }); + const { enqueuer, realtime, calls } = await startEnqueuer(engine); + await realtime.publish(taskCreated()); + await flush(); + expect(calls).toEqual([]); + await enqueuer.stop(); + }); + + it('an invalid declaration is skipped with one warning per name, and a valid one beside it still dispatches', async () => { + const warn = vi.fn(); + const engine = new FakeEngine({ + declared: { webhook: [declaredWebhook({ name: 'good' }), declaredWebhook({ name: 'bad', url: 'not-a-url' })] }, + }); + const { enqueuer, realtime, calls } = await startEnqueuer(engine, { warn, debug() {} }); + await enqueuer.refresh(); + await realtime.publish(taskCreated()); + await flush(); + + expect(calls.map((c) => c.refId)).toEqual([declaredWebhookRefId('good')]); + const reports = warn.mock.calls.filter(([m]) => String(m).includes('failed validation')); + expect(reports).toHaveLength(1); + expect(reports[0][1]).toMatchObject({ name: 'bad' }); + await enqueuer.stop(); + }); +}); + +// --------------------------------------------------------------------------- +// 2. The switch (regime C) +// --------------------------------------------------------------------------- + +describe('declared webhooks — the activation ledger switches them (ADR-0131 D6 regime C)', () => { + it('a ledger row with active:false switches a declared webhook off', async () => { + const engine = new FakeEngine({ + declared: { webhook: [declaredWebhook()] }, + rows: { [LEDGER]: [ledgerRow('task_changed', false)] }, + }); + const { enqueuer, realtime, calls } = await startEnqueuer(engine); + await realtime.publish(taskCreated()); + await flush(); + expect(calls).toEqual([]); + await enqueuer.stop(); + }); + + it('a ledger row with active:true switches on a webhook shipped inactive', async () => { + const engine = new FakeEngine({ + declared: { webhook: [declaredWebhook({ isActive: false })] }, + rows: { [LEDGER]: [ledgerRow('task_changed', true)] }, + }); + const { enqueuer, realtime, calls } = await startEnqueuer(engine); + await realtime.publish(taskCreated()); + await flush(); + expect(calls).toHaveLength(1); + await enqueuer.stop(); + }); + + it('another type\'s ledger row of the same name switches nothing', async () => { + const engine = new FakeEngine({ + declared: { webhook: [declaredWebhook()] }, + rows: { [LEDGER]: [{ id: 'a1', metadata_type: 'flow', name: 'task_changed', active: false }] }, + }); + const { enqueuer, realtime, calls } = await startEnqueuer(engine); + await realtime.publish(taskCreated()); + await flush(); + expect(calls).toHaveLength(1); + await enqueuer.stop(); + }); + + it('a ledger write refreshes the cache in the same request — no wait for the periodic refresh', async () => { + const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } }); + const { enqueuer, realtime, calls } = await startEnqueuer(engine); + + engine.rows[LEDGER] = [ledgerRow('task_changed', false)]; + await realtime.publish({ type: 'data.record.created', object: LEDGER, payload: { recordId: 'act_task_changed' } }); + await flush(); + await realtime.publish(taskCreated()); + await flush(); + + expect(calls).toEqual([]); + await enqueuer.stop(); + }); + + it('a ledger read that fails keeps each declared webhook at its declared isActive', async () => { + const warn = vi.fn(); + const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } }); + engine.failLedgerRead = true; + const { enqueuer, realtime, calls } = await startEnqueuer(engine, { warn, debug() {} }); + await realtime.publish(taskCreated()); + await flush(); + expect(calls).toHaveLength(1); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('sys_metadata_activation read failed'), expect.anything()); + await enqueuer.stop(); + }); +}); + +// --------------------------------------------------------------------------- +// 3. The dual read, on an 18.0-shaped database +// --------------------------------------------------------------------------- + +describe('declared webhooks — an 18.0 database keeps its seeded rows, and nothing fires twice', () => { + it('a pristine seeded row is ignored in favour of its declaration: one delivery, from the declaration', async () => { + const wh = declaredWebhook({ secret: 'shh' }); + const engine = new FakeEngine({ + declared: { webhook: [wh] }, + rows: { sys_webhook: [seeded18_0Row('whk_seeded', wh)] }, + }); + const { enqueuer, realtime, calls } = await startEnqueuer(engine); + await realtime.publish(taskCreated()); + await flush(); + + expect(calls).toHaveLength(1); + expect(calls[0].refId).toBe(declaredWebhookRefId('task_changed')); + expect(calls[0].signingSecret).toBe('shh'); + // Expand first: the row is left exactly where 18.0 put it. + expect(engine.rows['sys_webhook']).toHaveLength(1); + expect(engine.writes).toEqual([]); + await enqueuer.stop(); + }); + + it('a pristine seeded row follows its declaration\'s switch: switched off, neither the row nor the declaration fires', async () => { + const wh = declaredWebhook(); + const engine = new FakeEngine({ + declared: { webhook: [wh] }, + rows: { sys_webhook: [seeded18_0Row('whk_seeded', wh)], [LEDGER]: [ledgerRow('task_changed', false)] }, + }); + const { enqueuer, realtime, calls } = await startEnqueuer(engine); + await realtime.publish(taskCreated()); + await flush(); + expect(calls).toEqual([]); + await enqueuer.stop(); + }); + + it('a pristine seeded row follows a changed declaration — the new URL, as the 18.0 seeder would have rewritten it', async () => { + const engine = new FakeEngine({ + declared: { webhook: [declaredWebhook({ url: 'https://hooks.example/task-v2' })] }, + rows: { sys_webhook: [seeded18_0Row('whk_seeded', declaredWebhook())] }, + }); + const { enqueuer, realtime, calls } = await startEnqueuer(engine); + await realtime.publish(taskCreated()); + await flush(); + expect(calls.map((c) => c.url)).toEqual(['https://hooks.example/task-v2']); + await enqueuer.stop(); + }); + + it('a seeded row an administrator switched off (customized) stays off: the declaration is withheld, said once', async () => { + const warn = vi.fn(); + const wh = declaredWebhook(); + const engine = new FakeEngine({ + declared: { webhook: [wh] }, + rows: { sys_webhook: [seeded18_0Row('whk_seeded', wh, { active: false, customized: true })] }, + }); + const { enqueuer, realtime, calls } = await startEnqueuer(engine, { warn, debug() {} }); + await enqueuer.refresh(); + await realtime.publish(taskCreated()); + await flush(); + + expect(calls).toEqual([]); + const withheld = warn.mock.calls.filter(([m]) => String(m).includes("declared webhook 'task_changed' is not dispatched")); + expect(withheld).toHaveLength(1); + await enqueuer.stop(); + }); + + it('a seeded row an administrator pointed elsewhere (customized) answers as itself — one delivery, to the row\'s URL', async () => { + const wh = declaredWebhook({ isActive: false }); + const engine = new FakeEngine({ + declared: { webhook: [wh] }, + rows: { + sys_webhook: [seeded18_0Row('whk_seeded', wh, { + active: true, customized: true, url: 'https://real.example/task', + })], + }, + }); + const { enqueuer, realtime, calls } = await startEnqueuer(engine); + await realtime.publish(taskCreated()); + await flush(); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ refId: 'whk_seeded', url: 'https://real.example/task' }); + await enqueuer.stop(); + }); + + it('an admin edit through the provenance stamp turns a pristine seeded row into one that answers as itself', async () => { + const wh = declaredWebhook(); + const engine = new FakeEngine({ + declared: { webhook: [wh] }, + rows: { sys_webhook: [seeded18_0Row('whk_seeded', wh)] }, + }); + bindWebhookProvenanceStamp(engine as any); + const { enqueuer, realtime, calls } = await startEnqueuer(engine); + + await engine.update('sys_webhook', { id: 'whk_seeded', active: false }, { context: ADMIN_CTX }); + expect(engine.rows['sys_webhook'][0].customized).toBe(true); + await realtime.publish({ type: 'data.record.updated', object: 'sys_webhook', payload: { recordId: 'whk_seeded' } }); + await flush(); + await realtime.publish(taskCreated()); + await flush(); + + expect(calls).toEqual([]); + await enqueuer.stop(); + }); + + it('an administrator-authored row under a declared name answers as itself, and the declaration is withheld', async () => { + const engine = new FakeEngine({ + declared: { webhook: [declaredWebhook()] }, + rows: { + sys_webhook: [{ + id: 'admin-1', name: 'task_changed', object_name: 'showcase_task', triggers: ['create'], + url: 'https://admin.example', method: 'post', active: true, managed_by: 'admin', customized: false, + }], + }, + }); + const { enqueuer, realtime, calls } = await startEnqueuer(engine); + await realtime.publish(taskCreated()); + await flush(); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ refId: 'admin-1', url: 'https://admin.example' }); + await enqueuer.stop(); + }); + + it('a pristine seeded row whose declaration is gone keeps dispatching, as it did on 18.0', async () => { + const engine = new FakeEngine({ + rows: { sys_webhook: [seeded18_0Row('whk_orphan', declaredWebhook({ name: 'retired_hook' }))] }, + }); + const { enqueuer, realtime, calls } = await startEnqueuer(engine); + await realtime.publish(taskCreated()); + await flush(); + expect(calls.map((c) => c.refId)).toEqual(['whk_orphan']); + await enqueuer.stop(); + }); +}); + +// --------------------------------------------------------------------------- +// The redelivery veto reads the same declaration +// --------------------------------------------------------------------------- + +describe('declared webhooks — the redelivery veto', () => { + const guardFor = (engine: FakeEngine) => + createWebhookRedeliverGuard(engine as any, 'sys_webhook', new DeclaredWebhookSource(engine, undefined)); + + it('allows a declared webhook\'s delivery while it is declared and switched on', async () => { + const engine = new FakeEngine({ declared: { webhook: [declaredWebhook({ secret: 'shh' })] } }); + expect(await guardFor(engine)({ source: 'webhook', refId: declaredWebhookRefId('task_changed') })).toBeUndefined(); + }); + + it('refuses one whose declaration is gone, and one switched off through the ledger', async () => { + const gone = new FakeEngine(); + expect(await guardFor(gone)({ source: 'webhook', refId: declaredWebhookRefId('task_changed') })) + .toMatch(/no longer declared/); + + const off = new FakeEngine({ + declared: { webhook: [declaredWebhook()] }, + rows: { [LEDGER]: [ledgerRow('task_changed', false)] }, + }); + expect(await guardFor(off)({ source: 'webhook', refId: declaredWebhookRefId('task_changed') })) + .toMatch(/switched off/); + }); + + it('judges an 18.0 pristine seeded row\'s old delivery by the declaration that now answers for it', async () => { + const wh = declaredWebhook(); + const engine = new FakeEngine({ + declared: { webhook: [wh] }, + rows: { sys_webhook: [seeded18_0Row('whk_seeded', wh)], [LEDGER]: [ledgerRow('task_changed', false)] }, + }); + // The row itself still reads active:true; the declaration's switch governs. + expect(await guardFor(engine)({ source: 'webhook', refId: 'whk_seeded' })).toMatch(/switched off/); + }); + + it('judges a customized seeded row by the row, as before', async () => { + const wh = declaredWebhook(); + const engine = new FakeEngine({ + declared: { webhook: [wh] }, + rows: { + sys_webhook: [seeded18_0Row('whk_seeded', wh, { customized: true })], + [LEDGER]: [ledgerRow('task_changed', false)], + }, + }); + expect(await guardFor(engine)({ source: 'webhook', refId: 'whk_seeded' })).toBeUndefined(); + }); +}); diff --git a/packages/plugins/plugin-webhooks/src/plugin-shutdown-stops-auto-enqueuer.test.ts b/packages/plugins/plugin-webhooks/src/plugin-shutdown-stops-auto-enqueuer.test.ts index a07af1773c3..554610f60b7 100644 --- a/packages/plugins/plugin-webhooks/src/plugin-shutdown-stops-auto-enqueuer.test.ts +++ b/packages/plugins/plugin-webhooks/src/plugin-shutdown-stops-auto-enqueuer.test.ts @@ -6,8 +6,9 @@ * * THE DEFECT, AND HOW FAR IT WENT. At `kernel:ready` the plugin binds two * hooks onto the data engine and starts an {@link AutoEnqueuer}, which holds - * TWO realtime subscriptions, a crypto-provider listener and a `setInterval` - * refresh timer. The teardown that released all of it was spelled `dispose()`. + * TWO realtime subscriptions (three since declared webhooks are read from the + * registry: the activation ledger's is the third), a crypto-provider listener + * and a `setInterval` refresh timer. The teardown that released all of it was spelled `dispose()`. * `Plugin` (`@objectstack/core`'s `types.ts`) declares `init()`, `start?(ctx)` * and `destroy?()` — and NO `dispose()` — and `ObjectKernel.performShutdown()` * / `LiteKernel.destroy()` walk the plugins in reverse calling @@ -121,7 +122,7 @@ describe('#10772 WebhookOutboxPlugin stops its auto-enqueuer on kernel shutdown' // POSITIVE CONTROL — the enqueuer really did subscribe, so the // assertion below measures a release and not an absence. - expect(fixture.realtime.live.size).toBe(2); + expect(fixture.realtime.live.size).toBe(3); await kernel.shutdown(); @@ -162,7 +163,7 @@ describe('#10772 WebhookOutboxPlugin stops its auto-enqueuer on kernel shutdown' it('the retained dispose() alias still tears down for an embedder that calls it directly', async () => { const { plugin, fixture } = await boot(); - expect(fixture.realtime.live.size).toBe(2); + expect(fixture.realtime.live.size).toBe(3); await plugin.dispose(); diff --git a/packages/plugins/plugin-webhooks/src/webhook-headers-gate.test.ts b/packages/plugins/plugin-webhooks/src/webhook-headers-gate.test.ts index 6ea136c2f20..b088bd95b75 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-headers-gate.test.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-headers-gate.test.ts @@ -39,7 +39,6 @@ import type { } from '@objectstack/spec/contracts'; import { SysWebhook } from './sys-webhook.object.js'; import { WEBHOOK_HEADERS_FIELD, resolveWebhookHeaders } from './webhook-headers.js'; -import { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js'; import { WebhookHeadersShapeError, WEBHOOK_HEADERS_SHAPE_REFUSAL_CODE, @@ -386,35 +385,6 @@ describe('sys_webhook.headers_secret shape gate — the write door (#8566)', () ).rejects.toMatchObject({ code: 'VALIDATION_ERROR', status: 400 }); expect(cipherRows(stores)).toHaveLength(0); }); - - it('the declared-webhook seeder still materializes headers through the bound gate', async () => { - // Ruling item 2: the plugin's own write paths inherit this validation - // rather than carrying a second check. That is only safe if they pass - // — `bootstrapDeclaredWebhooks` writes `serializeHeaders()` of an - // already-filtered map, so it does, and this is the pin that keeps it - // true if either side moves. - const { engine, stores } = await buildEngine(); - await bootstrapDeclaredWebhooks(engine, { - list: (type: string) => (type === 'webhook' - ? [{ - name: 'crm_hook', object: 'contact', triggers: ['create'], - url: 'https://receiver.example/hook', method: 'POST', - headers: { 'X-Team': 'crm' }, - }] - : []), - } as any); - - const [row] = await engine.find('sys_webhook', { - where: { name: 'crm_hook' }, context: SYSTEM_CTX, - }); - expect(row).toBeDefined(); - // The seeder's write really went through the encrypted channel — it - // was not skipped, and the gate did not stand in its way. - expect(cipherRows(stores)).toHaveLength(1); - await expect(resolveWebhookHeaders(engine, row as any, 'sys_webhook')).resolves.toEqual({ - 'X-Team': 'crm', - }); - }); }); describe('the four values the gate deliberately lets through', () => { diff --git a/packages/plugins/plugin-webhooks/src/webhook-secret-at-rest.test.ts b/packages/plugins/plugin-webhooks/src/webhook-secret-at-rest.test.ts index 2075850d4cb..ccac6ce50ea 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-secret-at-rest.test.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-secret-at-rest.test.ts @@ -38,15 +38,21 @@ import { type FetchImpl, } from '@objectstack/service-messaging'; import { AutoEnqueuer } from './auto-enqueuer.js'; -import { bootstrapDeclaredWebhooks } from './bootstrap-declared-webhooks.js'; +import { DeclaredWebhookSource, declaredWebhookRefId } from './declared-webhooks.js'; import { migrateLegacyWebhookSecrets } from './migrate-webhook-secrets.js'; import { SysWebhook } from './sys-webhook.object.js'; import { WEBHOOK_SECRET_FIELD, __objectqlSecretWireForms, resolveWebhookSecret, + splitWebhookSecret, } from './webhook-secret.js'; -import { WEBHOOK_HEADERS_FIELD, resolveWebhookHeaders } from './webhook-headers.js'; +import { + WEBHOOK_HEADERS_FIELD, + resolveWebhookHeaders, + serializeHeaders, + splitWebhookHeaders, +} from './webhook-headers.js'; import { bindWebhookLegacyCleartextGate } from './webhook-legacy-cleartext.js'; /** @@ -243,11 +249,49 @@ function declaredWebhook(overrides: Record = {}) { }; } -/** Metadata service that hands the seeder its declared items. */ +/** Metadata service that hands the declared-webhook reader its declared items. */ function metadataWith(items: unknown[]) { return { list: (type: string) => (type === 'webhook' ? items : []) }; } +/** + * Write the `sys_webhook` row an 18.0 install's boot seeder wrote for each + * declared webhook — the rows an upgraded database still holds (ADR-0131 D2: + * the seeder is retired, its rows stay until the ownership ceremony). The same + * mapping that seeder made, through the REAL engine, so both credentials land + * in their encrypted columns exactly as they did: `object`→`object_name`, + * `isActive`→`active`, a lower-cased method, the envelope minus `secret` and + * `headers` in `definition_json`, `managed_by: 'package'`. This file pins what + * the dispatcher, the sweep and the write doors do with credential-bearing + * rows, and these are the credential-bearing rows that exist without an + * administrator typing one in. + */ +async function seed18_0Rows(engine: any, metadata: { list(type: string): unknown[] }) { + for (const raw of metadata.list('webhook') as Array>) { + const wh: Record = { method: 'POST', timeoutMs: 30000, isActive: true, ...raw }; + const { envelope: withoutSecret, secret } = splitWebhookSecret(wh); + const { envelope, headers } = splitWebhookHeaders(withoutSecret as Record); + await engine.insert('sys_webhook', { + id: `whk_${wh.name}`, + name: wh.name, + label: wh.label ?? wh.name, + object_name: wh.object ?? null, + triggers: wh.triggers ?? [], + url: wh.url, + method: String(wh.method).toLowerCase(), + description: wh.description ?? null, + active: wh.isActive !== false, + definition_json: JSON.stringify(envelope), + ...(secret ? { [WEBHOOK_SECRET_FIELD]: secret } : {}), + ...(headers ? { [WEBHOOK_HEADERS_FIELD]: serializeHeaders(headers) } : {}), + managed_by: 'package', + customized: false, + created_at: '2026-10-01T00:00:00.000Z', + updated_at: '2026-10-01T00:00:00.000Z', + }, { context: SYSTEM_CTX } as any); + } +} + function recordEvent(object: string, record: any): RealtimeEventPayload { const payload = DataEventSchema.parse({ id: randomUUID(), @@ -342,7 +386,7 @@ describe('webhook signing secret at rest (#7799)', () => { it('the secret\'s bytes appear nowhere in the persisted sys_webhook row', async () => { const { engine, stores } = await buildEngine(); - await bootstrapDeclaredWebhooks(engine, metadataWith([declaredWebhook()])); + await seed18_0Rows(engine, metadataWith([declaredWebhook()])); // ── The read path an ordinary GET /api/v1/data/sys_webhook takes ── // Substring, not a field check: the defect was the key NESTED inside a @@ -383,9 +427,36 @@ describe('webhook signing secret at rest (#7799)', () => { }); }); + it('a declared webhook, read from the registry, signs the same bytes with no row and no cipher stored', async () => { + const { engine, stores } = await buildEngine(); + const realtime = new FakeRealtime(); + const outbox = new MemoryHttpOutbox(); + const enqueuer = new AutoEnqueuer(engine, realtime, (input) => outbox.enqueue(input), { + refreshIntervalMs: 0, + declaredWebhooks: new DeclaredWebhookSource(engine, metadataWith([declaredWebhook()])), + }); + await enqueuer.start(); + await realtime.publish(recordEvent('contact', { id: 'c1', name: 'Ada' })); + await new Promise((r) => setTimeout(r, 0)); + const { impl, calls } = makeFetch(); + await new HttpDispatcher({ nodeId: 'n1', outbox, fetchImpl: impl, partitionCount: 1 }).tick(); + await enqueuer.stop(); + + expect(calls).toHaveLength(1); + const expected = createHmac('sha256', SECRET).update(calls[0].body).digest('hex'); + expect(calls[0].headers['X-Objectstack-Signature']).toBe(`sha256=${expected}`); + expect(calls[0].headers['X-Team']).toBe('crm'); + const [row] = (await outbox.list()) as any[]; + expect(row.refId ?? row.ref_id).toBe(declaredWebhookRefId('crm_hook')); + // The key and the header map are code: nothing at rest holds either. + expect(stores.get('sys_webhook')?.size ?? 0).toBe(0); + expect(stores.get('sys_secret')?.size ?? 0).toBe(0); + expect(JSON.stringify(await outbox.list())).not.toContain(SECRET); + }); + it('signs with the byte-identical signature existing receivers already verify', async () => { const { engine } = await buildEngine(); - await bootstrapDeclaredWebhooks(engine, metadataWith([declaredWebhook()])); + await seed18_0Rows(engine, metadataWith([declaredWebhook()])); const { calls, outbox } = await deliverOnce(engine); @@ -402,33 +473,6 @@ describe('webhook signing secret at rest (#7799)', () => { expect(JSON.stringify(await outbox.list())).not.toContain(SECRET); }); - it('re-seeding an unchanged webhook does not mint a second sys_secret row', async () => { - const { engine, stores } = await buildEngine(); - const declared = metadataWith([declaredWebhook()]); - - await bootstrapDeclaredWebhooks(engine, declared); - await bootstrapDeclaredWebhooks(engine, declared); - await bootstrapDeclaredWebhooks(engine, declared); - - // Every boot re-seeds package rows; a blind restatement of the key would - // leave one orphan ciphertext row per restart. Two rows, not one, since - // #7986 — one per credential — and the property under test is that the - // count does not GROW across the three boots. - expect(stores.get('sys_secret')!.size).toBe(2); - }); - - it('rotating the declared secret in code re-encrypts and signs with the new key', async () => { - const { engine } = await buildEngine(); - await bootstrapDeclaredWebhooks(engine, metadataWith([declaredWebhook()])); - - const ROTATED = 'whsec_7799_rotated'; - await bootstrapDeclaredWebhooks(engine, metadataWith([declaredWebhook({ secret: ROTATED })])); - - const { calls } = await deliverOnce(engine); - const expected = createHmac('sha256', ROTATED).update(calls[0].body).digest('hex'); - expect(calls[0].headers['X-Objectstack-Signature']).toBe(`sha256=${expected}`); - }); - it('a webhook authored without a secret still materializes and delivers unsigned', async () => { const { engine, stores } = await buildEngine(); // `headers` dropped alongside `secret` so this pin keeps asserting what @@ -436,7 +480,7 @@ describe('webhook signing secret at rest (#7799)', () => { // at all and therefore needs no CryptoProvider. (Since #7986 a header // map is a credential too; the headers-only case is pinned separately in // the #7986 block below.) - await bootstrapDeclaredWebhooks( + await seed18_0Rows( engine, metadataWith([declaredWebhook({ secret: undefined, headers: undefined })]), ); @@ -527,23 +571,28 @@ describe('legacy cleartext migration (#7799)', () => { }); describe('fail-closed: no CryptoProvider (#7799)', () => { - it('refuses to seed a secret-bearing webhook, reporting an ADR-0112 code + status', async () => { + it('a declared webhook needs no CryptoProvider at all: its secret is code, and nothing is stored', async () => { const { engine, stores } = await buildEngine({ withCrypto: false }); - const warns: Array<{ msg: string; meta: any }> = []; - - const result = await bootstrapDeclaredWebhooks(engine, metadataWith([declaredWebhook()]), { - warn: (msg: string, meta?: unknown) => { warns.push({ msg, meta }); }, + const realtime = new FakeRealtime(); + const outbox = new MemoryHttpOutbox(); + const enqueuer = new AutoEnqueuer(engine, realtime, (input) => outbox.enqueue(input), { + refreshIntervalMs: 0, + declaredWebhooks: new DeclaredWebhookSource(engine, metadataWith([declaredWebhook()])), }); + await enqueuer.start(); + await realtime.publish(recordEvent('contact', { id: 'c1', name: 'Ada' })); + await new Promise((r) => setTimeout(r, 0)); + const { impl, calls } = makeFetch(); + await new HttpDispatcher({ nodeId: 'n1', outbox, fetchImpl: impl, partitionCount: 1 }).tick(); + await enqueuer.stop(); - expect(result).toEqual({ seeded: 0, skipped: 1 }); - // Nothing was written — not the row, not a cleartext fallback column. + // Where the 18.0 seeder refused (no safe place for the key), the + // registry read has nothing to store: no row, no cipher, signed anyway. expect(stores.get('sys_webhook')?.size ?? 0).toBe(0); expect(stores.get('sys_secret')?.size ?? 0).toBe(0); - - // ADR-0112: a consumer branches on the pair, not on the message text. - const refusal = warns.find((w) => w.meta?.code); - expect(refusal?.meta).toMatchObject({ code: 'INTERNAL_ERROR', status: 500 }); - expect(refusal?.msg).toMatch(/cannot be stored encrypted/); + expect(calls).toHaveLength(1); + const expected = createHmac('sha256', SECRET).update(calls[0].body).digest('hex'); + expect(calls[0].headers['X-Objectstack-Signature']).toBe(`sha256=${expected}`); }); it('leaves a legacy row exactly as it was, and reports it as still cleartext', async () => { @@ -592,7 +641,7 @@ describe('boot ordering: the cache is built before the CryptoProvider (#8022)', /** Reboot onto the same rows in the host's real order: kernel first, crypto after. */ async function bootWithoutCrypto() { const first = await buildEngine(); - await bootstrapDeclaredWebhooks(first.engine, metadataWith([declaredWebhook()])); + await seed18_0Rows(first.engine, metadataWith([declaredWebhook()])); // Precondition: the credentials are at rest as ciphertext only — the // exact population #7799 created, and the only one this defect can // reach. Two rows since #7986 (signing secret + header map). @@ -751,7 +800,7 @@ describe('webhook custom headers at rest (#7986)', () => { it('the credential header appears nowhere in the persisted sys_webhook row', async () => { const { engine, stores } = await buildEngine(); - await bootstrapDeclaredWebhooks(engine, metadataWith([headerBearingWebhook()])); + await seed18_0Rows(engine, metadataWith([headerBearingWebhook()])); // ── The read path an ordinary GET /api/v1/data/sys_webhook takes ── // `sys_webhook` declares no `enable` block at all, so this is the FULL @@ -784,7 +833,7 @@ describe('webhook custom headers at rest (#7986)', () => { it('still delivers every authored header on the wire, credential included', async () => { const { engine } = await buildEngine(); - await bootstrapDeclaredWebhooks(engine, metadataWith([headerBearingWebhook()])); + await seed18_0Rows(engine, metadataWith([headerBearingWebhook()])); const { calls, outbox } = await deliverOnce(engine); @@ -801,42 +850,9 @@ describe('webhook custom headers at rest (#7986)', () => { expect(JSON.stringify(await outbox.list())).not.toContain(SECRET); }); - it('re-seeding an unchanged webhook mints no extra cipher rows for the headers', async () => { - const { engine, stores } = await buildEngine(); - const declared = metadataWith([headerBearingWebhook()]); - - await bootstrapDeclaredWebhooks(engine, declared); - await bootstrapDeclaredWebhooks(engine, declared); - await bootstrapDeclaredWebhooks(engine, declared); - - // A `secret`-typed write ALWAYS mints a fresh `sys_secret` row and the - // engine never deletes the old one, so a blind restatement leaks one - // orphan cipher row per webhook per restart — the reason - // `secretPatch` compares before writing. The headers channel owes the - // same discipline. Exactly two: one signing secret, one header map. - expect(stores.get('sys_secret')!.size).toBe(2); - }); - - it('rotating the declared headers in code re-encrypts and delivers the new value', async () => { - const { engine, stores } = await buildEngine(); - await bootstrapDeclaredWebhooks(engine, metadataWith([headerBearingWebhook()])); - await bootstrapDeclaredWebhooks( - engine, - metadataWith([headerBearingWebhook({ headers: { Authorization: 'Bearer rotated_7986' } })]), - ); - - const { calls } = await deliverOnce(engine); - expect(calls[0].headers.Authorization).toBe('Bearer rotated_7986'); - expect(calls[0].headers['X-Team']).toBeUndefined(); - // Code remains the authority for package rows: the superseded map is - // not readable from the row any more either. - const stored = Array.from(stores.get('sys_webhook')!.values())[0] as any; - expect(JSON.stringify(stored)).not.toContain(BEARER); - }); - it('a webhook authored without headers still materializes and delivers', async () => { const { engine, stores } = await buildEngine(); - await bootstrapDeclaredWebhooks(engine, metadataWith([declaredWebhook({ headers: undefined })])); + await seed18_0Rows(engine, metadataWith([declaredWebhook({ headers: undefined })])); const { calls } = await deliverOnce(engine); expect(calls).toHaveLength(1); @@ -939,7 +955,7 @@ describe('legacy cleartext headers migration (#7986)', () => { describe('fail-closed and re-arm, extended to headers (#7986 × #7799/#8022)', () => { async function bootWithoutCrypto() { const first = await buildEngine(); - await bootstrapDeclaredWebhooks(first.engine, metadataWith([headerBearingWebhook()])); + await seed18_0Rows(first.engine, metadataWith([headerBearingWebhook()])); // Precondition: BOTH credentials are at rest as ciphertext only. expect(first.stores.get('sys_secret')!.size).toBe(2); return buildEngine({ @@ -1112,7 +1128,7 @@ describe('a stored signing secret that resolves to nothing (#8542)', () => { it('refuses to arm when the stored secret decrypts to nothing (historical row — the data-API road is closed by #8559)', async () => { const { engine, stores, driver } = await buildEngine(); - await bootstrapDeclaredWebhooks(engine, metadataWith([declaredWebhook()])); + await seed18_0Rows(engine, metadataWith([declaredWebhook()])); const [row] = await engine.find('sys_webhook', { where: { name: 'crm_hook' } }); // This state USED to be reachable from the ordinary data API with no @@ -1147,7 +1163,7 @@ describe('a stored signing secret that resolves to nothing (#8542)', () => { it('refuses to arm when the column no longer holds a resolvable ref', async () => { const { engine, stores, driver } = await buildEngine(); - await bootstrapDeclaredWebhooks(engine, metadataWith([declaredWebhook()])); + await seed18_0Rows(engine, metadataWith([declaredWebhook()])); const [row] = await engine.find('sys_webhook', { where: { name: 'crm_hook' } }); // Written BELOW the engine deliberately, because that is the only route @@ -1165,7 +1181,7 @@ describe('a stored signing secret that resolves to nothing (#8542)', () => { it('refuses when the row is deleted between the cache read and the dereference', async () => { const { engine } = await buildEngine(); - await bootstrapDeclaredWebhooks(engine, metadataWith([declaredWebhook()])); + await seed18_0Rows(engine, metadataWith([declaredWebhook()])); // The snapshot the enqueuer's refresh loop holds while it dereferences // each row's credentials, one at a time. @@ -1185,7 +1201,7 @@ describe('a stored signing secret that resolves to nothing (#8542)', () => { it('a webhook authored unsigned still arms and delivers — the refusal is not a blanket', async () => { const { engine } = await buildEngine(); - await bootstrapDeclaredWebhooks( + await seed18_0Rows( engine, metadataWith([declaredWebhook({ secret: undefined })]), ); @@ -1317,7 +1333,7 @@ describe('a stored header map that resolves to nothing (#8558)', () => { it('refuses to arm when the stored map decrypts to nothing (historical row — the data-API road is closed by #8559)', async () => { const { engine, stores, driver } = await buildEngine(); - await bootstrapDeclaredWebhooks(engine, metadataWith([headerBearingWebhook()])); + await seed18_0Rows(engine, metadataWith([headerBearingWebhook()])); const [row] = await engine.find('sys_webhook', { where: { name: 'crm_hook' } }); // The '' member of the unusable-spellings family below is special: it @@ -1363,7 +1379,7 @@ describe('a stored header map that resolves to nothing (#8558)', () => { 'refuses to arm when the stored value decrypts fine but is unusable: %s', async (_label, badValue) => { const { engine, stores } = await buildEngine(); - await bootstrapDeclaredWebhooks(engine, metadataWith([headerBearingWebhook()])); + await seed18_0Rows(engine, metadataWith([headerBearingWebhook()])); const [row] = await engine.find('sys_webhook', { where: { name: 'crm_hook' } }); // Written through the ORDINARY data API — no driver access, no SQL. @@ -1385,7 +1401,7 @@ describe('a stored header map that resolves to nothing (#8558)', () => { it('refuses to arm when the column no longer holds a resolvable ref', async () => { const { engine, stores, driver } = await buildEngine(); - await bootstrapDeclaredWebhooks(engine, metadataWith([headerBearingWebhook()])); + await seed18_0Rows(engine, metadataWith([headerBearingWebhook()])); const [row] = await engine.find('sys_webhook', { where: { name: 'crm_hook' } }); // Written BELOW the engine deliberately: that is the only route measured @@ -1407,7 +1423,7 @@ describe('a stored header map that resolves to nothing (#8558)', () => { it('refuses when the row is deleted between the cache read and the dereference', async () => { const { engine } = await buildEngine(); - await bootstrapDeclaredWebhooks(engine, metadataWith([headerBearingWebhook()])); + await seed18_0Rows(engine, metadataWith([headerBearingWebhook()])); // The snapshot the enqueuer's refresh loop holds while it dereferences // each row's credentials, one at a time. @@ -1455,7 +1471,7 @@ describe('a stored header map that resolves to nothing (#8558)', () => { it('a webhook authored with NO headers still arms and delivers — the refusal is not a blanket', async () => { const { engine } = await buildEngine(); - await bootstrapDeclaredWebhooks( + await seed18_0Rows( engine, metadataWith([declaredWebhook({ headers: undefined })]), ); @@ -1485,7 +1501,7 @@ describe('a stored header map that resolves to nothing (#8558)', () => { // written as "refuse whenever headers are stored": the healthy path has // to keep working end to end, credential entry included. const { engine, stores } = await buildEngine(); - await bootstrapDeclaredWebhooks(engine, metadataWith([headerBearingWebhook()])); + await seed18_0Rows(engine, metadataWith([headerBearingWebhook()])); await expectHeadersGenuinelyStored(engine, stores); const { calls, rows, errors } = await driveOnce(engine); @@ -1634,10 +1650,10 @@ describe('the retired definition_json credential location — refused at the wri expect(calls[0].headers.Authorization).toBe(BEARER); }); - it('the declared-webhook seeder still materializes secret + headers through the bound gate', async () => { + it('an 18.0-seeded row\'s secret + headers still pass the bound gate into their encrypted columns', async () => { const { engine, stores } = await gatedEngine(); - const result = await bootstrapDeclaredWebhooks(engine, metadataWith([headerBearingWebhook()])); - expect(result).toMatchObject({ seeded: 1 }); + await seed18_0Rows(engine, metadataWith([headerBearingWebhook()])); + expect(stores.get('sys_webhook')!.size).toBe(1); expect(JSON.stringify(rowsAtRest(stores))).not.toContain(BEARER); }); }); diff --git a/scripts/engine-double-contract.baseline.json b/scripts/engine-double-contract.baseline.json index db86142a923..82b61e0662d 100644 --- a/scripts/engine-double-contract.baseline.json +++ b/scripts/engine-double-contract.baseline.json @@ -481,7 +481,7 @@ "closes": "add @objectstack/objectql to this package's devDependencies (verified acyclic — see `why`), then open the fake's update with assertEngineUpdateDispatch(data, options)" }, { - "file": "packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts", + "file": "packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts", "verb": "delete", "unguarded": 1, "kind": "DEBT", @@ -489,7 +489,7 @@ "closes": "add @objectstack/objectql to this package's devDependencies (verified acyclic on this branch — see `why`), then open the fake's delete with assertEngineDeleteDispatch(options)" }, { - "file": "packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts", + "file": "packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts", "verb": "update", "unguarded": 1, "kind": "DEBT", diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index aebfa7dea7e..c6f82bed31b 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -3732,7 +3732,7 @@ "pinned": 1 }, { - "file": "packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts", + "file": "packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts", "verb": "findOne", "pinned": 1 }, diff --git a/scripts/objectql-double-limit.baseline.json b/scripts/objectql-double-limit.baseline.json index f527efbccde..5f158b0c341 100644 --- a/scripts/objectql-double-limit.baseline.json +++ b/scripts/objectql-double-limit.baseline.json @@ -618,7 +618,7 @@ "packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts": { "unjudged": 1 }, - "packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.test.ts": { + "packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts": { "unjudged": 1 }, "packages/plugins/plugin-webhooks/src/webhook-headers-gate.test.ts": { diff --git a/scripts/query-options-erasure-baseline.json b/scripts/query-options-erasure-baseline.json index a455d5de3d9..c2b55fc8e18 100644 --- a/scripts/query-options-erasure-baseline.json +++ b/scripts/query-options-erasure-baseline.json @@ -43,7 +43,6 @@ "packages/plugins/plugin-auth/src/auth-manager.ts": 14, "packages/plugins/plugin-sharing/src/share-link-routes.ts": 3, "packages/plugins/plugin-sharing/src/share-link-service.ts": 5, - "packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts": 1, "packages/runtime/src/action-execution.ts": 1, "packages/runtime/src/domains/share-links.ts": 3, "packages/runtime/src/http-dispatcher.ts": 1, From 8189a843f676e4a2add67a1c57d4d8d81029cf85 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:14:19 +0000 Subject: [PATCH 03/44] wip: liveness ledger re-anchored to the registry read; dogfood proof asserts no row and a dispatch Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ Co-authored-by: Claude --- .../webhook-materialization-fixture.ts | 29 +++--- .../webhook-materialization.dogfood.test.ts | 99 ++++++++++++++----- packages/runtime/src/route-ledger.ts | 2 +- packages/spec/liveness/webhook.json | 70 +++++++------ 4 files changed, 122 insertions(+), 78 deletions(-) diff --git a/packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts b/packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts index 93aa278522b..d61a8761110 100644 --- a/packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts +++ b/packages/qa/dogfood/test/fixtures/webhook-materialization-fixture.ts @@ -3,17 +3,18 @@ // Webhook materialization fixture — the deterministic ADR-0054 proof for the // `webhook-materialization` high-risk class. // -// A webhook prop is `live` in the ledger because a stack-authored `webhooks:` -// entry is materialized into a dispatchable `sys_webhook` row on boot -// (#3461/#3489) — but "materialized" is not "reads the schema". The authored -// value crosses manifest-decomposition → the ObjectQL registry (type `webhook`) -// → `bootstrapDeclaredWebhooks` → `engine.insert('sys_webhook')`, and the break -// can live in any seam: the bridge was first gated BEHIND the realtime/messaging -// dispatch guard, so a boot without realtime silently materialized nothing (the -// exact silent no-op #3461 set out to kill). This fixture authors ONE webhook -// against ONE object, with ZERO dependence on an example app, so the proof can -// assert the runtime outcome: the sys_webhook row exists with the spec→runtime -// remap applied (`object`→`object_name`, `isActive`→`active`). +// A webhook prop is `live` in the ledger because the dispatcher reads it — but +// "read" is not "reads the schema". Since ADR-0131 D2 retired the boot seeder, +// the authored value crosses manifest-decomposition → the ObjectQL registry +// (type `webhook`) → plugin-webhooks' declared-webhook reader (bound as the +// `declaredWebhooks` service on the data engine alone) → the dispatcher's +// subscription, with NO `sys_webhook` row in between, and the break can live in +// any seam: the old bridge was first gated BEHIND the realtime/messaging +// dispatch guard, so a boot without realtime silently did nothing. This fixture +// authors ONE webhook against ONE object, with ZERO dependence on an example +// app, so the proof can assert the runtime outcome: no row, and a delivery with +// the spec→runtime remap applied (`object`→`object_name`, `isActive`→the +// switch). import { defineStack } from '@objectstack/spec'; import { ObjectSchema, Field } from '@objectstack/spec/data'; @@ -33,8 +34,8 @@ export const WmTask = ObjectSchema.create({ }); /** - * The stack-authored webhook. `object` (spec) must land as `object_name` - * (runtime col) and `isActive` as `active` — the remap the proof asserts. + * The stack-authored webhook. `object` (spec) must land as the dispatcher's + * `object_name` and `isActive` as its switch — the remap the proof asserts. */ export const wmTaskChanged = defineWebhook({ name: 'wm_task_changed', @@ -52,7 +53,7 @@ export const webhookFixtureStack = defineStack({ version: '0.0.0', type: 'app', name: 'Webhook Materialization Fixture', - description: 'Single-object app that authors one webhook to prove stack `webhooks:` entries materialize into dispatchable sys_webhook rows (ADR-0054).', + description: 'Single-object app that authors one webhook to prove stack `webhooks:` entries reach the dispatcher from the registry, with no sys_webhook row (ADR-0054, ADR-0131 D2).', }, objects: [WmTask], webhooks: [wmTaskChanged], diff --git a/packages/qa/dogfood/test/webhook-materialization.dogfood.test.ts b/packages/qa/dogfood/test/webhook-materialization.dogfood.test.ts index cf513b26a3b..c90ace2509e 100644 --- a/packages/qa/dogfood/test/webhook-materialization.dogfood.test.ts +++ b/packages/qa/dogfood/test/webhook-materialization.dogfood.test.ts @@ -9,32 +9,56 @@ // (packages/spec/liveness/webhook.json); the spec liveness gate fails if this // tag is removed. See proof-registry.mts. // -// A webhook prop being `live` means the materializer + dispatcher READ it — -// necessary but not sufficient. This boots the real stack with the webhook + -// messaging plugins, authors a webhook via the app config's `webhooks:` array, -// and asserts the observable runtime outcome: a dispatchable `sys_webhook` row -// materialized on boot with the spec→runtime remap applied (`object`→ -// `object_name`, `isActive`→`active`). It also pins the #3461 integration bug: -// the fixture boots WITHOUT realtime, so a materializer re-gated behind the -// dispatch guard would materialize nothing and this proof would fail. +// A webhook prop being `live` means the dispatcher READS it — necessary but not +// sufficient. This boots the real stack with the webhook + messaging plugins, +// authors a webhook via the app config's `webhooks:` array, and asserts the +// observable runtime outcome of ADR-0131 D2 (the declared-webhook seeder is +// retired): NO `sys_webhook` row is written for the declaration, the plugin +// binds the declared-webhook reader (`declaredWebhooks`) over the registry the +// manifest decomposition filled, and a dispatcher reading through that service +// delivers a matching record event with the spec→runtime remap applied +// (`object`→`object_name`, `isActive`→the switch). The fixture still boots +// WITHOUT realtime: the reader is bound on the data engine alone, so a binding +// re-gated behind the dispatch prerequisites would bind nothing and this proof +// would fail. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { bootStack, type VerifyStack } from '@objectstack/verify'; import { MessagingServicePlugin } from '@objectstack/service-messaging'; -import { WebhookOutboxPlugin } from '@objectstack/plugin-webhooks'; +import { AutoEnqueuer, WebhookOutboxPlugin } from '@objectstack/plugin-webhooks'; import { webhookFixtureStack } from './fixtures/webhook-materialization-fixture.js'; const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] }; -describe('objectstack verify WEBHOOK: authored webhook materializes into sys_webhook (#webhook-materialization)', () => { +/** The realtime surface the dispatcher subscribes through, driven by hand. */ +function handRealtime() { + const subs = new Map unknown; opts?: { object?: string } }>(); + let n = 0; + return { + async publish(event: any) { + for (const s of subs.values()) { + if (s.opts?.object && s.opts.object !== event.object) continue; + await s.handler(event); + } + }, + async subscribe(_channel: string, handler: (e: any) => unknown, opts?: { object?: string }) { + const id = `s-${++n}`; + subs.set(id, { handler, opts }); + return id; + }, + async unsubscribe(id: string) { + subs.delete(id); + }, + }; +} + +describe('objectstack verify WEBHOOK: a declared webhook is dispatched from the registry, never materialized (#webhook-materialization)', () => { let stack: VerifyStack; beforeAll(async () => { // `extraPlugins` mirrors the capability plugins `objectstack serve` mounts // for `requires: ['webhooks']` — messaging first (WebhookOutboxPlugin - // depends on it), then the webhook plugin. Deliberately NO realtime service: - // materialization must run on the data engine alone (bootDeclaredWebhooks), - // independent of the auto-enqueue dispatch prerequisites. + // depends on it), then the webhook plugin. Deliberately NO realtime service. stack = await bootStack(webhookFixtureStack, { extraPlugins: [new MessagingServicePlugin(), new WebhookOutboxPlugin()], }); @@ -44,27 +68,48 @@ describe('objectstack verify WEBHOOK: authored webhook materializes into sys_web await stack?.stop(); }); - it('materializes the stack-authored webhook into a sys_webhook row (object→object_name, isActive→active)', async () => { + it('writes no sys_webhook row for the declared webhook, and dispatches it with object→object_name applied', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const engine = (await stack.kernel.getServiceAsync('objectql')) as any; const rows = await engine.find('sys_webhook', { filter: { name: 'wm_task_changed' }, context: SYSTEM_CTX, }); + expect(rows, 'the retired seeder wrote a sys_webhook row for a declared webhook').toEqual([]); - expect(rows, 'authored webhook was NOT materialized into a sys_webhook row').toHaveLength(1); - const row = rows[0]; - - // The remaps that are the whole point of the bridge. - expect(row.object_name, 'spec `object` did not remap to runtime `object_name`').toBe('wm_task'); - expect(row.active, 'spec `isActive:true` did not remap to runtime `active`').toBeTruthy(); - - // Boot-seeded provenance (seed-not-clobber): a package row, not admin. - expect(row.managed_by).toBe('package'); + // The reader the plugin bound on the data engine alone — no realtime here. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const declaredWebhooks = (await stack.kernel.getServiceAsync('declaredWebhooks')) as any; + expect(declaredWebhooks, 'the declared-webhook reader was not bound').toBeTruthy(); + const declared = await declaredWebhooks.declaredWebhookNamed('wm_task_changed'); + expect(declared?.webhook.object, 'the registry does not hold the authored webhook').toBe('wm_task'); + expect(declared?.active, 'spec `isActive:true` did not become the switch').toBe(true); - // The full envelope is stashed for the dispatcher's advanced-config read. - const defn = JSON.parse(row.definition_json); - expect(defn.object).toBe('wm_task'); - expect(defn.triggers).toEqual(['create', 'update']); + // The dispatcher, reading through that same service, delivers a matching + // record event: the remap reached the subscription it keys on. + const realtime = handRealtime(); + const enqueued: any[] = []; + const enqueuer = new AutoEnqueuer(engine, realtime as any, async (input) => { + enqueued.push(input); + return 'd1'; + }, { refreshIntervalMs: 0, declaredWebhooks }); + await enqueuer.start(); + try { + await realtime.publish({ + type: 'data.record.created', + object: 'wm_task', + payload: { recordId: 't1' }, + timestamp: '2026-10-19T00:00:00.000Z', + }); + await new Promise((r) => setTimeout(r, 0)); + } finally { + await enqueuer.stop(); + } + expect(enqueued, 'spec `object` did not remap to the runtime subscription key').toHaveLength(1); + expect(enqueued[0]).toMatchObject({ + source: 'webhook', + refId: 'declared:wm_task_changed', + url: 'https://hooks.example/wm-task', + }); }); }); diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index 14135558b9f..46d8d419a16 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -379,7 +379,7 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ note: 'ADR-0126 §3 regime C, as ADR-0131 D6 amends it — switch ONE position (`:type` = `position`), permission set ' + '(`:type` = `permission`) or declared sharing rule (`:type` = `sharing_rule`, resolved by the `sharingRules` service; ' + 'its grants reconcile on the ledger write) or declared webhook (`:type` = `webhook`, resolved by the `declaredWebhooks` ' - + 'reader; the dispatcher's subscription cache refreshes on the ledger write) on/off for this deployment. Writes one `sys_metadata_activation` row through the engine and ' + + 'reader; the webhook dispatcher refreshes its subscriptions on the ledger write) on/off for this deployment. Writes one `sys_metadata_activation` row through the engine and ' + 'nothing else (⛔ no definition write, ⛔ no catalog row `active`: ADR-0131 D3 moved the switch into the ledger, and ' + '`resolveUserAuthzGrants` reads only the ledger for these two types). Body `{ enabled?: boolean }`, the same reader as ' + 'the action door. The same two authority tiers as `POST /automation/:name/toggle` and `POST /actions/_activation/:object/:action`: ' diff --git a/packages/spec/liveness/webhook.json b/packages/spec/liveness/webhook.json index e867d8cf543..3a0e3b50f1d 100644 --- a/packages/spec/liveness/webhook.json +++ b/packages/spec/liveness/webhook.json @@ -1,73 +1,71 @@ { "type": "webhook", - "_note": "WebhookSchema (outbound webhook — packages/spec/src/automation/webhook.zod.ts). Governed via a spec-only schema override in the gate (SPEC_ONLY_SCHEMAS): webhook is still NOT a registered metadata type (absent from kernel/metadata-type-schemas.ts) — registering it would turn on Studio webhook CRUD + saveMetaItem overlay + create-seeds; that reassessment is tracked in #3490 and deliberately deferred. TWO THINGS CLOSED THE OLD 'entire surface is dead' classification: (1) #3494 PRUNED the aspirational dead props outright — body / payloadFields / includeSession / retryPolicy / tags / authentication are gone from the schema; (2) the #3461 materializer bridge (PR #3489) makes every REMAINING prop live. `bootstrapDeclaredWebhooks` (plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts) materializes each stack/connector-authored webhook into a `sys_webhook` DATA row on boot — WebhookSchema.parse (:114) → mapWebhookToRow (:191): `object`→`object_name` (:195), `isActive`→`active` (:202), same-named `name`/`label`/`triggers`/`url`/`method`/`description`, `secret`→the encrypted `signing_secret` column (#7799), `headers`→the encrypted `headers_secret` column (#7986), and the REST of the envelope → `definition_json`. The dispatcher (AutoEnqueuer) reads those rows (auto-enqueuer.ts:175 `where:{active:true}`) and fans out on data.record.* events (plus the aggregate data.records.* a predicate write publishes, dispatched under the opt-in bulk_update/bulk_delete triggers — #4639), reading `object_name`/`name`/`url`/`method`/`triggers` off the row, `timeoutMs` back out of `definition_json`, and BOTH credentials through `engine.resolveSecretField()` against their encrypted columns — the signing key from `signing_secret` (#7799) and the custom headers from `headers_secret` (#7986). So all 11 remaining props are LIVE; there is no dead/experimental prop left, so nothing carries an authorWarn (the old per-webhook `url` heads-up is gone — authoring is no longer a no-op). Seed-not-clobber: an admin-edited row (`customized`) is never re-seeded (bootstrap-declared-webhooks.ts:143). The `object` prop carries the ADR-0054 runtime proof for the whole materialization pipeline (bound high-risk class `webhook-materialization`). Field-level line refs: materializer bootstrap-declared-webhooks.ts, runtime object plugins/plugin-webhooks/src/sys-webhook.object.ts, dispatcher auto-enqueuer.ts. 2026-08-28 — WHOLE FILE RE-ANCHORED (commit 9ee2dcfbd, adoption of the #12516 symbol-anchor grammar), and this file was the batch's worst starting position by a distance. Every citation in it was written as a BARE FILENAME with a line (`bootstrap-declared-webhooks.ts:193`, `auto-enqueuer.ts:266`, `sys-webhook.object.ts:160`), which `PATH_RE` cannot parse: a citation must be repo-rooted to be a citation at all. So all eleven `live` entries scanned as ZERO local paths and ZERO line citations — no existence check, no line bound, no key-mention anchoring, nothing. The gate was not passing this file; it was never asked about it, and the ledger's own counters could not say so. Combined with the second fact — no entry carried `verifiedAt`, which the review that accepted the re-anchoring sorts as OLDEST — these rows were unfalsifiable and unclocked at once. Re-closed by hand against c459da6bc: every consumer read on the current tree, no line carried forward. What the re-closure found: the claims were all TRUE and the pointers were all merely unresolvable — the opposite of `object.json` in batch 1, where the pointers resolved and the claims were wrong. Both are the same defect at different ends. Two prose repairs fell out of it: the parenthetical line numbers are gone in favour of the symbols that own each read, and the dispatcher's credential legs are named as `attachSecret` / `attachHeaders`, which REFUSE a subscription whose credential cannot be recovered rather than delivering without it — a fail-closed behaviour the old prose did not record.", + "_note": "WebhookSchema (outbound webhook — packages/spec/src/automation/webhook.zod.ts). Governed via a spec-only schema override in the gate (SPEC_ONLY_SCHEMAS): webhook is still NOT a registered metadata type (absent from kernel/metadata-type-schemas.ts) — registering it would turn on Studio webhook CRUD + saveMetaItem overlay + create-seeds; that reassessment is tracked in #3490 and deliberately deferred. TWO THINGS CLOSED THE OLD 'entire surface is dead' classification: (1) #3494 PRUNED the aspirational dead props outright — body / payloadFields / includeSession / retryPolicy / tags / authentication are gone from the schema; (2) the #3461 materializer bridge (PR #3489) makes every REMAINING prop live. `bootstrapDeclaredWebhooks` (plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts) materializes each stack/connector-authored webhook into a `sys_webhook` DATA row on boot — WebhookSchema.parse (:114) → mapWebhookToRow (:191): `object`→`object_name` (:195), `isActive`→`active` (:202), same-named `name`/`label`/`triggers`/`url`/`method`/`description`, `secret`→the encrypted `signing_secret` column (#7799), `headers`→the encrypted `headers_secret` column (#7986), and the REST of the envelope → `definition_json`. The dispatcher (AutoEnqueuer) reads those rows (auto-enqueuer.ts:175 `where:{active:true}`) and fans out on data.record.* events (plus the aggregate data.records.* a predicate write publishes, dispatched under the opt-in bulk_update/bulk_delete triggers — #4639), reading `object_name`/`name`/`url`/`method`/`triggers` off the row, `timeoutMs` back out of `definition_json`, and BOTH credentials through `engine.resolveSecretField()` against their encrypted columns — the signing key from `signing_secret` (#7799) and the custom headers from `headers_secret` (#7986). So all 11 remaining props are LIVE; there is no dead/experimental prop left, so nothing carries an authorWarn (the old per-webhook `url` heads-up is gone — authoring is no longer a no-op). Seed-not-clobber: an admin-edited row (`customized`) is never re-seeded (bootstrap-declared-webhooks.ts:143). The `object` prop carries the ADR-0054 runtime proof for the whole materialization pipeline (bound high-risk class `webhook-materialization`). Field-level line refs: materializer bootstrap-declared-webhooks.ts, runtime object plugins/plugin-webhooks/src/sys-webhook.object.ts, dispatcher auto-enqueuer.ts. 2026-08-28 — WHOLE FILE RE-ANCHORED (commit 9ee2dcfbd, adoption of the #12516 symbol-anchor grammar), and this file was the batch's worst starting position by a distance. Every citation in it was written as a BARE FILENAME with a line (`bootstrap-declared-webhooks.ts:193`, `auto-enqueuer.ts:266`, `sys-webhook.object.ts:160`), which `PATH_RE` cannot parse: a citation must be repo-rooted to be a citation at all. So all eleven `live` entries scanned as ZERO local paths and ZERO line citations — no existence check, no line bound, no key-mention anchoring, nothing. The gate was not passing this file; it was never asked about it, and the ledger's own counters could not say so. Combined with the second fact — no entry carried `verifiedAt`, which the review that accepted the re-anchoring sorts as OLDEST — these rows were unfalsifiable and unclocked at once. Re-closed by hand against c459da6bc: every consumer read on the current tree, no line carried forward. What the re-closure found: the claims were all TRUE and the pointers were all merely unresolvable — the opposite of `object.json` in batch 1, where the pointers resolved and the claims were wrong. Both are the same defect at different ends. Two prose repairs fell out of it: the parenthetical line numbers are gone in favour of the symbols that own each read, and the dispatcher's credential legs are named as `attachSecret` / `attachHeaders`, which REFUSE a subscription whose credential cannot be recovered rather than delivering without it — a fail-closed behaviour the old prose did not record. 2026-10-19 — THE MATERIALIZER RETIRED (ADR-0131 D2, ruling Q2 A on the declared-webhook seeder): `bootstrapDeclaredWebhooks` no longer exists, so no declared webhook becomes a `sys_webhook` DATA row. The dispatcher reads the declaration from the registry instead (`packages/plugins/plugin-webhooks/src/declared-webhooks.ts`, projected per cache refresh by `auto-enqueuer.ts#projectDeclared`), and switches it through the activation ledger (regime C). Every delivery-bearing prop stays LIVE on the new reader and is re-anchored there; `label` and `description` lost their only reader (the Setup row) and are DEAD, owed enforce-or-remove. The `object` proof now boots the stack and asserts the declared webhook reaches the dispatcher with `object`→`object_name` applied and NO row written.", "props": { "name": { "status": "live", - "evidence": "packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts#mapWebhookToRow (`name: wh.name` — materialized to sys_webhook.name, #3489); packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts#bootstrapDeclaredWebhooks (the same value is the idempotency key: `where: { name: wh.name }` decides seed vs patch vs skip); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#parseRow (`name: (row.name as string) ?? row.id` — the dispatcher's dedup/log key)", - "note": "Was dead pre-bridge (admin-CRUD-only). Now fed by authoring. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time.", - "verifiedAt": "2026-08-28" + "evidence": "packages/plugins/plugin-webhooks/src/declared-webhooks.ts#DeclaredWebhookSource (`WebhookSchema.parse` of each registry item; the first registration of a name holds it); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#projectDeclared (`name: wh.name`, and `declaredWebhookRefId(wh.name)` as the subscription id and every delivery's `ref_id`); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#resolveDeclared (the name finds the `sys_webhook` row holding it, which an 18.0 database may still carry, and `classifyDeclaredNameHolder` decides which answers); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#parseRow (`name: (row.name as string) ?? row.id` — the dispatcher's dedup/log key)", + "note": "Was dead pre-bridge (admin-CRUD-only). Now fed by authoring. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time. 2026-10-19: RE-ANCHORED to the registry read — ADR-0131 D2 retired the boot seeder `bootstrapDeclaredWebhooks`, so a declared webhook is no longer copied into a `sys_webhook` row; the dispatcher reads the declaration itself (packages/plugins/plugin-webhooks/src/declared-webhooks.ts#DeclaredWebhookSource, projected by packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#projectDeclared). Re-closed by hand against that change.", + "verifiedAt": "2026-10-19" }, "label": { - "status": "live", - "evidence": "packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts#mapWebhookToRow (`label: wh.label ?? wh.name` — materialized to sys_webhook.label, #3489); packages/plugins/plugin-webhooks/src/sys-webhook.object.ts#SysWebhook (every one of the object's four listViews leads with the `label` column)", - "note": "Display-only — surfaced in Studio, never sent on the wire. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time.", - "verifiedAt": "2026-08-28" + "status": "dead", + "note": "Display-only — surfaced in Studio, never sent on the wire. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time. 2026-10-19: DEAD since ADR-0131 D2 retired the boot seeder `bootstrapDeclaredWebhooks`, which was its only reader — it copied this value into the declared webhook's `sys_webhook` row, where the Setup list showed it. A declared webhook has no row any more, and nothing displays a declaration: the dispatcher reads only what it delivers on (`auto-enqueuer.ts#projectDeclared`), the activation door only the name. Display-only, so the loss is a label nobody sees, never a delivery that changes. Enforce-or-remove (ADR-0049) is owed: a Setup surface that lists declared webhooks (the declared-sharing-rule Disable and Clone surface is the pattern) makes it live again, otherwise it retires.", + "verifiedAt": "2026-10-19" }, "object": { "status": "live", - "evidence": "packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts#mapWebhookToRow (`object_name: wh.object ?? null` — the spec→runtime remap this entry's proof exists for); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#parseRow (`objectName: row.object_name ? String(row.object_name) : undefined` — what the per-object subscription keys on)", + "evidence": "packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#projectDeclared (`object_name: wh.object ?? null` — the spec→runtime remap this entry's proof exists for, now made in memory rather than into a row); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#parseRow (`objectName: row.object_name ? String(row.object_name) : undefined` — what the per-object subscription keys on)", "proof": "packages/qa/dogfood/test/webhook-materialization.dogfood.test.ts#webhook-materialization", - "note": "ADR-0054 high-risk class (webhook-materialization): the spec `object`→runtime `object_name` remap is the representative check for the whole authoring→dispatcher pipeline that crosses manifest-decomposition → materializer → sys_webhook row → dispatcher subscription — the exact multi-layer seam that broke in integration (the materializer was first gated behind the realtime guard) despite green unit tests. The proof authors a stack `webhooks:` entry against the real in-process stack and asserts the sys_webhook row materialized with object_name set. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time.", - "verifiedAt": "2026-08-28" + "note": "ADR-0054 high-risk class (webhook-materialization): the spec `object`→runtime `object_name` remap is the representative check for the whole authoring→dispatcher pipeline that crosses manifest-decomposition → materializer → sys_webhook row → dispatcher subscription — the exact multi-layer seam that broke in integration (the materializer was first gated behind the realtime guard) despite green unit tests. The proof authors a stack `webhooks:` entry against the real in-process stack and asserts the sys_webhook row materialized with object_name set. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time. 2026-10-19: RE-ANCHORED to the registry read — ADR-0131 D2 retired the boot seeder `bootstrapDeclaredWebhooks`, so a declared webhook is no longer copied into a `sys_webhook` row; the dispatcher reads the declaration itself (packages/plugins/plugin-webhooks/src/declared-webhooks.ts#DeclaredWebhookSource, projected by packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#projectDeclared). Re-closed by hand against that change.", + "verifiedAt": "2026-10-19" }, "triggers": { "status": "live", - "evidence": "packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts#mapWebhookToRow (`triggers: wh.triggers ?? []`); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#parseRow (accepts array / comma-string / JSON-string shapes, maps them onto the dispatchable set, warns by name on values the engine never emits (#3196), and refuses a subscription whose set is empty under rule `webhook/without-triggers`)", - "note": "Was dead pre-bridge. Salvaged 1:1. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time.", - "verifiedAt": "2026-08-28" + "evidence": "packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#projectDeclared (`triggers: wh.triggers ?? []`); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#parseRow (accepts array / comma-string / JSON-string shapes, maps them onto the dispatchable set, warns by name on values the engine never emits (#3196), and refuses a subscription whose set is empty under rule `webhook/without-triggers`)", + "note": "Was dead pre-bridge. Salvaged 1:1. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time. 2026-10-19: RE-ANCHORED to the registry read — ADR-0131 D2 retired the boot seeder `bootstrapDeclaredWebhooks`, so a declared webhook is no longer copied into a `sys_webhook` row; the dispatcher reads the declaration itself (packages/plugins/plugin-webhooks/src/declared-webhooks.ts#DeclaredWebhookSource, projected by packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#projectDeclared). Re-closed by hand against that change.", + "verifiedAt": "2026-10-19" }, "url": { "status": "live", - "evidence": "packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts#mapWebhookToRow (`url: wh.url`); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#parseRow (`if (!row?.id || !row?.url) return null` — a row without one is not a subscription at all — then `url: String(row.url)` for delivery)", - "note": "Was dead pre-bridge and carried the per-webhook authorWarn; the bridge (#3489) makes authoring live, so the warning is removed. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time.", - "verifiedAt": "2026-08-28" + "evidence": "packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#projectDeclared (`url: wh.url`); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#parseRow (`if (!row?.id || !row?.url) return null` — a subscription without one is not a subscription at all — then `url: String(row.url)` for delivery)", + "note": "Was dead pre-bridge and carried the per-webhook authorWarn; the bridge (#3489) makes authoring live, so the warning is removed. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time. 2026-10-19: RE-ANCHORED to the registry read — ADR-0131 D2 retired the boot seeder `bootstrapDeclaredWebhooks`, so a declared webhook is no longer copied into a `sys_webhook` row; the dispatcher reads the declaration itself (packages/plugins/plugin-webhooks/src/declared-webhooks.ts#DeclaredWebhookSource, projected by packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#projectDeclared). Re-closed by hand against that change.", + "verifiedAt": "2026-10-19" }, "method": { "status": "live", - "evidence": "packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts#mapWebhookToRow (`method: String(wh.method ?? 'POST').toLowerCase()` — stored lowercase to match the object's select option values); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#parseRow (`String(row.method ?? defn.method ?? 'POST').toUpperCase()` — upper-cased back for the wire, so both spellings survive the select change)", - "note": "Was dead pre-bridge. Salvaged 1:1. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time.", - "verifiedAt": "2026-08-28" + "evidence": "packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#projectDeclared (`method: wh.method`, the parsed default `POST` included); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#parseRow (`String(row.method ?? defn.method ?? 'POST').toUpperCase()` — the canonical wire method)", + "note": "Was dead pre-bridge. Salvaged 1:1. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time. 2026-10-19: RE-ANCHORED to the registry read — ADR-0131 D2 retired the boot seeder `bootstrapDeclaredWebhooks`, so a declared webhook is no longer copied into a `sys_webhook` row; the dispatcher reads the declaration itself (packages/plugins/plugin-webhooks/src/declared-webhooks.ts#DeclaredWebhookSource, projected by packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#projectDeclared). Re-closed by hand against that change.", + "verifiedAt": "2026-10-19" }, "headers": { "status": "live", - "evidence": "packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts#mapWebhookToRow (the envelope it returns is the authored one MINUS its two credential passengers — `splitWebhookHeaders` lifts this map out before `definition_json` is serialized, #7986); packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts#bootstrapDeclaredWebhooks (`headersPatch` / the `WEBHOOK_HEADERS_FIELD` insert write the map into the encrypted `headers_secret` column); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#attachHeaders (recovers and re-parses it per cache refresh, and REFUSES the subscription when recovery fails rather than delivering header-less)", - "note": "Was dead pre-bridge (definition_json only reachable via hand-authored rows), then materialized from authoring into definition_json as CLEARTEXT — recoverable over the ordinary data API, which is #7986: the same column and the same absent `enable` block as #7799's signing secret, one key of the blob later. It no longer rides in that blob; the authored map itself is unchanged. The WHOLE map moves because only some entries are credentials and the platform cannot tell which. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time.", - "verifiedAt": "2026-08-28" + "evidence": "packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#projectDeclared (`splitWebhookHeaders` lifts the authored map off the declaration onto the cached subscription — held in memory with the code it came from, never written to `sys_webhook` or `sys_secret`); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#attachHeaders (the same map for an organization's own row: recovered from the encrypted `headers_secret` column per cache refresh, the subscription REFUSED when recovery fails rather than delivering header-less, #7986)", + "note": "Was dead pre-bridge (definition_json only reachable via hand-authored rows), then materialized from authoring into definition_json as CLEARTEXT — recoverable over the ordinary data API, which is #7986: the same column and the same absent `enable` block as #7799's signing secret, one key of the blob later. It no longer rides in that blob; the authored map itself is unchanged. The WHOLE map moves because only some entries are credentials and the platform cannot tell which. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time. 2026-10-19: RE-ANCHORED to the registry read — ADR-0131 D2 retired the boot seeder `bootstrapDeclaredWebhooks`, so a declared webhook is no longer copied into a `sys_webhook` row; the dispatcher reads the declaration itself (packages/plugins/plugin-webhooks/src/declared-webhooks.ts#DeclaredWebhookSource, projected by packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#projectDeclared). Re-closed by hand against that change.", + "verifiedAt": "2026-10-19" }, "timeoutMs": { "status": "live", - "evidence": "packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts#mapWebhookToRow (`definition_json: JSON.stringify(envelope)` — this key rides the envelope rather than a column of its own); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#parseRow (`timeoutMs: defn.timeoutMs`, read back out of that JSON onto the cached subscription and passed to the outbox on both enqueue paths); packages/services/service-messaging/src/http-outbox.ts#assertEnqueueDeliverable (the outbox side of the same field)", - "note": "Was dead pre-bridge (definition_json only reachable via hand-authored rows). Now materialized from authoring. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time.", - "verifiedAt": "2026-08-28" + "evidence": "packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#projectDeclared (`definition_json: JSON.stringify({ timeoutMs: wh.timeoutMs })`, the parsed default 30000 included); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#parseRow (`timeoutMs: defn.timeoutMs`, read back onto the cached subscription and passed to the outbox on both enqueue paths); packages/services/service-messaging/src/http-outbox.ts#assertEnqueueDeliverable (the outbox side of the same field)", + "note": "Was dead pre-bridge (definition_json only reachable via hand-authored rows). Now materialized from authoring. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time. 2026-10-19: RE-ANCHORED to the registry read — ADR-0131 D2 retired the boot seeder `bootstrapDeclaredWebhooks`, so a declared webhook is no longer copied into a `sys_webhook` row; the dispatcher reads the declaration itself (packages/plugins/plugin-webhooks/src/declared-webhooks.ts#DeclaredWebhookSource, projected by packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#projectDeclared). Re-closed by hand against that change.", + "verifiedAt": "2026-10-19" }, "secret": { "status": "live", - "evidence": "packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts#mapWebhookToRow (`splitWebhookSecret` lifts the key out of the envelope before serialization, #7799); packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts#bootstrapDeclaredWebhooks (`secretPatch` / the `WEBHOOK_SECRET_FIELD` insert write cleartext exactly once into the `secret`-typed `signing_secret` column, which the engine wraps into sys_secret); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#attachSecret (recovers the plaintext per cache refresh and hands it to the outbox as `signingSecret`, consumed for one HMAC and never persisted, #7722)", - "note": "Was dead pre-bridge (definition_json only reachable via hand-authored rows), then materialized from authoring into definition_json as CLEARTEXT — recoverable over the ordinary data API, which is #7799. It no longer rides in that blob; the authored key itself is unchanged. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time.", - "verifiedAt": "2026-08-28" + "evidence": "packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#projectDeclared (`splitWebhookSecret` lifts the key off the declaration onto the cached subscription, handed to the outbox as `signingSecret`, consumed for one HMAC and never persisted, #7722 — a declared key is code, so no row or cipher holds it); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#attachSecret (the same key for an organization's own row: recovered from the encrypted `signing_secret` column per cache refresh, #7799)", + "note": "Was dead pre-bridge (definition_json only reachable via hand-authored rows), then materialized from authoring into definition_json as CLEARTEXT — recoverable over the ordinary data API, which is #7799. It no longer rides in that blob; the authored key itself is unchanged. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time. 2026-10-19: RE-ANCHORED to the registry read — ADR-0131 D2 retired the boot seeder `bootstrapDeclaredWebhooks`, so a declared webhook is no longer copied into a `sys_webhook` row; the dispatcher reads the declaration itself (packages/plugins/plugin-webhooks/src/declared-webhooks.ts#DeclaredWebhookSource, projected by packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#projectDeclared). Re-closed by hand against that change.", + "verifiedAt": "2026-10-19" }, "isActive": { "status": "live", - "evidence": "packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts#mapWebhookToRow (`active: wh.isActive !== false` — the NAME MISMATCH remap, spec `isActive` → runtime `active`); packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts#bootstrapDeclaredWebhooks (seed-not-clobber: an admin-authored (`managed_by === 'admin'`) or admin-edited (`customized === true`) row is skipped, so an operator's `active: false` survives redeploys)", - "note": "Was dead pre-bridge (NAME MISMATCH spec `isActive` vs runtime `active`, now remapped). Seed-not-clobber preserves an admin's `active:false` across redeploys. The dispatcher gates delivery on the REMAPPED column — `AutoEnqueuer.doRefresh` loads `where: { active: true }`, so only armed rows are ever cached — and that call site is kept here in PROSE rather than as a citation, on the `object.tenancy.organizationField` precedent: a consumer that never names the key belongs in the note, not in `evidence`. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — and this row is the one that PAID for the migration inside the same run. Rewriting this file's bare-filename citations as repo-rooted anchors made them askable for the first time, and the key-mention check immediately answered: `webhook/isActive -> auto-enqueuer.ts` UNANCHORED, because that file names `active` and never `isActive` in any case fold. The check was right — the remap happens at the materializer and nothing downstream of it sees the authored spelling — so the citation moved to prose rather than being exempted. Recording the direction because it is the useful one: an unparseable citation does not fail, it is simply never asked, and eleven of those had been sitting here reading as evidence. Re-closed by hand against c459da6bc, DATED for the first time.", - "verifiedAt": "2026-08-28" + "evidence": "packages/plugins/plugin-webhooks/src/declared-webhooks.ts#DeclaredWebhookSource (`active: switched.get(webhook.name) ?? webhook.isActive !== false` — the declared default, overridden in either direction by a `sys_metadata_activation` row of type `webhook`, ADR-0131 D6 regime C); packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#resolveDeclared (`if (!d.active) continue` — a declaration switched off is no subscription)", + "note": "Was dead pre-bridge (NAME MISMATCH spec `isActive` vs runtime `active`, now remapped). Seed-not-clobber preserves an admin's `active:false` across redeploys. The dispatcher gates delivery on the REMAPPED column — `AutoEnqueuer.doRefresh` loads `where: { active: true }`, so only armed rows are ever cached — and that call site is kept here in PROSE rather than as a citation, on the `object.tenancy.organizationField` precedent: a consumer that never names the key belongs in the note, not in `evidence`. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — and this row is the one that PAID for the migration inside the same run. Rewriting this file's bare-filename citations as repo-rooted anchors made them askable for the first time, and the key-mention check immediately answered: `webhook/isActive -> auto-enqueuer.ts` UNANCHORED, because that file names `active` and never `isActive` in any case fold. The check was right — the remap happens at the materializer and nothing downstream of it sees the authored spelling — so the citation moved to prose rather than being exempted. Recording the direction because it is the useful one: an unparseable citation does not fail, it is simply never asked, and eleven of those had been sitting here reading as evidence. Re-closed by hand against c459da6bc, DATED for the first time. 2026-10-19: RE-ANCHORED to the registry read — ADR-0131 D2 retired the boot seeder `bootstrapDeclaredWebhooks`, so a declared webhook is no longer copied into a `sys_webhook` row; the dispatcher reads the declaration itself (packages/plugins/plugin-webhooks/src/declared-webhooks.ts#DeclaredWebhookSource, projected by packages/plugins/plugin-webhooks/src/auto-enqueuer.ts#projectDeclared). Re-closed by hand against that change.", + "verifiedAt": "2026-10-19" }, "description": { - "status": "live", - "evidence": "packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts#mapWebhookToRow (`description: wh.description ?? null` — materialized to sys_webhook.description, #3489); packages/plugins/plugin-webhooks/src/sys-webhook.object.ts#SysWebhook (the column the Studio editor renders and edits)", - "note": "Display-only — surfaced/editable in Studio, inert in dispatch. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time.", - "verifiedAt": "2026-08-28" + "status": "dead", + "note": "Display-only — surfaced/editable in Studio, inert in dispatch. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time. 2026-10-19: DEAD since ADR-0131 D2 retired the boot seeder `bootstrapDeclaredWebhooks`, which was its only reader — it copied this value into the declared webhook's `sys_webhook` row, where the Setup list showed it. A declared webhook has no row any more, and nothing displays a declaration: the dispatcher reads only what it delivers on (`auto-enqueuer.ts#projectDeclared`), the activation door only the name. Display-only, so the loss is a label nobody sees, never a delivery that changes. Enforce-or-remove (ADR-0049) is owed: a Setup surface that lists declared webhooks (the declared-sharing-rule Disable and Clone surface is the pattern) makes it live again, otherwise it retires.", + "verifiedAt": "2026-10-19" } } } From e412cb80974bd5d4fe4becdf27e233b9249a48c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:24:25 +0000 Subject: [PATCH 04/44] docs+ledger: the registry read named across spec comments, liveness, the checklist, the webhooks guide and the showcase; changeset Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ Co-authored-by: Claude --- .../24529-declared-webhooks-from-registry.md | 30 +++++++++++++ content/docs/automation/webhooks.mdx | 42 ++++++++++++++++--- .../areas/integration-system.json | 34 ++++++++------- .../src/automation/webhooks/index.ts | 14 ++++--- packages/spec/liveness/connector.json | 2 +- packages/spec/src/automation/webhook.test.ts | 4 +- packages/spec/src/automation/webhook.zod.ts | 35 ++++++++-------- .../18.integration__Connector__webhooks.ts | 3 +- 8 files changed, 117 insertions(+), 47 deletions(-) create mode 100644 .changeset/24529-declared-webhooks-from-registry.md diff --git a/.changeset/24529-declared-webhooks-from-registry.md b/.changeset/24529-declared-webhooks-from-registry.md new file mode 100644 index 00000000000..4fad5d6836d --- /dev/null +++ b/.changeset/24529-declared-webhooks-from-registry.md @@ -0,0 +1,30 @@ +--- +'@objectstack/plugin-webhooks': minor +'@objectstack/runtime': minor +'@objectstack/spec': patch +--- + +feat(plugin-webhooks,runtime)!: declared webhooks are read from the registry — the boot seeder retires, no `sys_webhook` row is written for one, and it is switched through the activation ledger + +Clause-②: yes (narrowing) + + + +**BREAKING** (a narrowing of where a declared webhook can be edited), shipped as `minor` under the launch-window convention for breaking changes. + +ADR-0131 D2 says no seeder writes declared items into a table, and D6 charts behavioural types under regime C. A webhook declared with `defineStack({ webhooks })` now follows both, as declared sharing rules already do: the dispatcher reads the declaration from the metadata registry on every subscription-cache refresh, and `bootstrapDeclaredWebhooks` no longer runs. + +What changes: + +- **No `sys_webhook` row for a declared webhook.** Before: every boot wrote or refreshed one `managed_by: 'package'` row per declared webhook, shown and editable in Setup → Integrations → Webhooks. Now: a fresh install writes none, and Setup lists only the webhooks an organization authors. The declared webhook still delivers, with the same URL, method, triggers, `timeoutMs`, signing secret and headers; its secret and headers are code, held in memory, and never stored. Its deliveries carry `ref_id = declared:`. +- **A declared webhook is read-only, and switched through the activation ledger.** Before: an administrator edited the seeded row in Setup (`customized: true`). Now: switch it on or off for the deployment with `POST /api/v1/security/_activation/webhook/` and `{ "enabled": true | false }` — one `sys_metadata_activation` row of type `webhook`, which overrides the declared `isActive` in either direction, so a webhook shipped inactive is switched on the same way. The door is the declared-sharing-rule door with `webhook` admitted: `manage_metadata`, then the platform operator under a wall; `404` for a name nothing declares; `503` when no declared-webhook reader is bound or it cannot be read. The dispatcher refreshes in the request that wrote the row. Remedy for a change beyond on/off: author a copy under a new name as a `sys_webhook` row, and switch the declared one off. +- **`label` and `description` on a declared webhook are no longer shown anywhere.** The seeded row was their only reader. They are display-only, so no delivery changes; the liveness ledger marks them dead, owed enforce-or-remove. + +On a database an 18.0 install created (expand first — nothing is moved or deleted): + +- The rows the 18.0 seeder wrote stay where they are. A pristine one (`managed_by: 'package'`, not `customized`) is ignored in favour of its declaration, so a declared webhook is never delivered twice; it follows the declaration's URL and switch, as the seeder's rewrite on every boot made it do. +- A seeded row an administrator edited (`customized: true`), or a row an administrator authored under a declared name, answers as itself, and the declaration is not dispatched beside it — an administrator's `active: false` on a noisy webhook keeps holding. This is logged at `warn` once per name per process, with the remedy. +- A seeded row whose declaration was removed keeps delivering, as it did on 18.0. +- Redelivering an old delivery of a pristine seeded row is judged by the declaration that now answers for it, so a webhook switched off through the ledger is not re-sent. + +Deleting the seeded rows, and re-pointing their `sys_http_delivery` history at the declaration, is a step of the ownership migration in a later 18.x release. diff --git a/content/docs/automation/webhooks.mdx b/content/docs/automation/webhooks.mdx index b5397669cb9..2b0499a3c0d 100644 --- a/content/docs/automation/webhooks.mdx +++ b/content/docs/automation/webhooks.mdx @@ -80,9 +80,38 @@ Two persisted objects underpin the runtime, owned by different packages. Both are defined as `ObjectSchema.create()` schemas so they participate in CRUD, permissions, audit, and Studio UI without bespoke code. +### 3.0 Declared webhooks (code) and `sys_webhook` rows + +A webhook reaches the dispatcher one of two ways: + +- **Declared in code** — `defineStack({ webhooks })` / `defineWebhook()`. The + declaration is read from the metadata registry on every subscription-cache + refresh, and **no `sys_webhook` row is written for it** (ADR-0131 D2). Its + signing secret and headers are code too, held in memory with it and never + stored. A declared webhook is read-only: switch it on or off for the + deployment with + `POST /api/v1/security/_activation/webhook/:name` and body + `{ "enabled": true | false }`, which writes one `sys_metadata_activation` row + of type `webhook` and overrides the declared `isActive` in either direction + (ADR-0131 D6, regime C — the door, its authority tiers and its refusals are + the ones the declared-sharing-rule switch uses). To change one, author a copy + under a new name as a `sys_webhook` row and switch the declared one off. + Deliveries of a declared webhook carry `ref_id = declared:`. +- **Authored by an organization** — a `sys_webhook` row (§3.1), switched by its + own `active` field. + +On a database an 18.0 install created, the rows that release's boot seeder +wrote for declared webhooks (`managed_by: 'package'`) are still there. Until the +ownership migration removes them, a pristine one is ignored in favour of its +declaration, so the webhook is never delivered twice; one an administrator +edited (`customized: true`) answers as itself, and the declaration of the same +name is not dispatched beside it — an administrator's `active: false` keeps +holding. + ### 3.1 `sys_webhook` -The subscription record. One row per "I want webhook X to fire for object Y". +The subscription record an organization authors. One row per "I want webhook +X to fire for object Y". The authored envelope — the per-attempt `timeoutMs` and the rest of the `Webhook` config — is carried in `definition_json`, a serialised `Webhook` JSON (canonical schema: `WebhookSchema`, exported from @@ -149,8 +178,9 @@ readable through an ordinary `GET /api/v1/data/sys_webhook`. They now go to `signing_secret` and `headers_secret`; existing rows are migrated on boot by one idempotent sweep that moves both passengers and strips them from the blob in a single update. Authoring does not change — `defineWebhook({ secret, headers })` -is written exactly as before, and the boot materializer routes each value to its -own column. +is written exactly as before; a declared webhook's values are now read from the +declaration itself and stored nowhere (§3.0), and an authored row's go to their +own columns. A row the sweep has not converted — for example a pre-v17 row on a runtime with no `CryptoProvider` wired, where the encrypted write is refused and the row is @@ -366,9 +396,9 @@ UNIQUE constraint. For each incoming event the subscriber: -1. Loads `sys_webhook` rows where `active = true` AND `object_name` - matches the event's object AND the event's action is in the row's - `triggers`. +1. Loads `sys_webhook` rows where `active = true`, and the declared + webhooks switched on (§3.0), whose `object_name` matches the event's + object AND whose `triggers` include the event's action. 2. For each match, builds the payload (§5). 3. Enqueues a `sys_http_delivery` row (`source = 'webhook'`). diff --git a/docs/qa/platform-checklist/areas/integration-system.json b/docs/qa/platform-checklist/areas/integration-system.json index a05b3c99f7e..8eb155a0371 100644 --- a/docs/qa/platform-checklist/areas/integration-system.json +++ b/docs/qa/platform-checklist/areas/integration-system.json @@ -519,17 +519,17 @@ }, { "id": "integration-system.webhook-lifecycle", - "title": "Outbound webhooks materialize (spec object→object_name, isActive→active), fire per trigger variant through the sys_http_delivery outbox with HMAC + timeout honored, redeliver terminal rows tenant-scoped via POST /api/v1/webhooks/redeliver, reject retired trigger kinds, and never clobber admin edits", + "title": "Outbound webhooks are read from the registry (spec object→object_name, no sys_webhook row) and switched through the activation ledger, fire per trigger variant through the sys_http_delivery outbox with HMAC + timeout honored, redeliver terminal rows tenant-scoped via POST /api/v1/webhooks/redeliver, and reject retired trigger kinds", "since": "v15", "status": "active", - "revision": 7, + "revision": 8, "priority": "P1", "surface": "mixed", "fixtures": { "app": "showcase", "requires": [ - "a reachable webhook receiver (local echo server on the run's own port range) and a SCRATCH webhook authored INSIDE the showcase organization that points at it — Setup → Integrations → Webhooks → New as the signed-in admin, or POST /api/v1/data/sys_webhook under that session, so the row carries the organization. ⚠ The shipped showcase_task_changed cannot carry the per-record or bulk delivery legs: it materializes ORG-LESS (a declared webhook belongs to no organization), and the auto-enqueuer hands an organization-walled event only to a subscription of that organization (packages/plugins/plugin-webhooks/src/auto-enqueuer.ts, AutoEnqueuer.admitsOrganization). On the org-walled showcase it therefore never fans out, while Setup reads it active:true (QA run #23166)", - "the shipped fixture: showcase_task_changed (object showcase_task, triggers create/update/delete, isActive:false ON PURPOSE — flipping it active in Setup is part of the test, examples/app-showcase/src/automation/webhooks/index.ts)", + "a reachable webhook receiver (local echo server on the run's own port range) and a SCRATCH webhook authored INSIDE the showcase organization that points at it — Setup → Integrations → Webhooks → New as the signed-in admin, or POST /api/v1/data/sys_webhook under that session, so the row carries the organization. ⚠ The shipped showcase_task_changed cannot carry the per-record or bulk delivery legs: it is read ORG-LESS from the registry (a declared webhook belongs to no organization), and the auto-enqueuer hands an organization-walled event only to a subscription of that organization (packages/plugins/plugin-webhooks/src/auto-enqueuer.ts, AutoEnqueuer.admitsOrganization). On the org-walled showcase it therefore never fans out, while Setup reads it active:true (QA run #23166)", + "the shipped fixture: showcase_task_changed (object showcase_task, triggers create/update/delete, isActive:false ON PURPOSE — switching it on through the activation door is part of the test, examples/app-showcase/src/automation/webhooks/index.ts)", "a predicate multi-write path for the bulk variants (update/delete with multi:true on showcase_task) — NOT reachable over REST, see the knownGap below: author a flow with an `update_record` / `delete_record` node carrying multi:true and fire it through the api trigger" ], "knownGaps": [ @@ -547,21 +547,21 @@ "retired: api (manual fire) — no fire path exists (#3196); parse-rejected" ], "steps": [ - "boot the showcase; verify the materializer bridge: read sys_webhook over /api/v1/data and locate showcase_task_changed with object_name:'showcase_task', active:false, managed_by:'package', and the envelope in definition_json", - "in Setup → Integrations → Webhooks flip the shipped showcase_task_changed row active and point its url at the local receiver (an admin edit — it must stamp customized). This edit is the subject of the seed-not-clobber clause (acceptance[6]) ONLY: the row stays org-less, so it delivers nothing on the org-walled showcase (fixtures.requires[0]). Author the in-organization scratch webhook beside it for the delivery legs", + "boot the showcase on a fresh database; verify the declared webhook is NOT materialized (ADR-0131 D2 retired the boot seeder): read sys_webhook over /api/v1/data and find NO showcase_task_changed row — the dispatcher reads the declaration from the registry", + "switch the shipped declared showcase_task_changed on as the admin: POST /api/v1/security/_activation/webhook/showcase_task_changed with { enabled: true } — 200 { type: 'webhook', name, enabled: true }, and one sys_metadata_activation row of metadata_type 'webhook'; an undeclared name answers 404. A declared webhook is read-only: pointing it at the receiver means a copy under a new name (the scratch webhook below). This switch is the subject of acceptance[6] ONLY: the declaration stays org-less, so it delivers nothing on the org-walled showcase (fixtures.requires[0]). Author the in-organization scratch webhook beside it for the delivery legs", "with the in-organization scratch webhook (fixtures.requires[0]) active and pointed at the receiver, create, update, then delete a showcase_task; capture the three deliveries at the receiver (headers incl. the HMAC signature when a secret is set, body incl. recordId). The create needs showcase_task's required `project` lookup — pass an existing showcase_project id, or the create answers 400 VALIDATION_FAILED 'Project is required' and no create event fires", "run a predicate multi-update and multi-delete (multi:true) matching several rows — through a FLOW node, not REST: author update_record / delete_record nodes with multi:true and fire them via the api trigger (the REST batch routes strip options.multi, #3897 — see the knownGap); capture the bulk deliveries and their { object, matched } shape", "read sys_http_delivery over /api/v1/data: one row per delivery with status/attempts/lastStatusCode", "kill the receiver and mutate again; re-read the delivery row through its retry/failure states", "restore the receiver, let the failing row reach a terminal status (failed/dead), then POST /api/v1/webhooks/redeliver with { deliveryId: } as an authenticated user: capture the 200 { id, status } and the receiver's re-hit — compare the replayed X-Objectstack-Signature byte-for-byte against the original delivery's; re-read the row; then probe the refusals: anonymous POST (401), non-JSON body (400 INVALID_REQUEST), body without deliveryId (400 MISSING_REQUIRED_FIELD), a garbage/unknown deliveryId (404), a row still pending/in_flight (409 DELIVERY_NOT_ELIGIBLE)", - "redeploy/reboot and confirm the admin-edited row survived re-seed (customized rows are never clobbered)" + "redeploy/reboot and confirm the activation switch survived: the sys_metadata_activation row is still there and the declaration still reads switched on (the switch is deployment state; the declaration is code)" ], "acceptance": [ { - "clause": "authoring is LIVE, not a no-op: the declared webhook materializes into the sys_webhook row the dispatcher reads, with the two documented remaps (object→object_name, isActive→active) and the envelope in definition_json (#3461/#3489)", + "clause": "authoring is LIVE, not a no-op: the declared webhook is read from the registry by the dispatcher (ADR-0131 D2) — NO sys_webhook row is written for it — and the activation door finds it by name", "oracle": "api", - "verify": "the sys_webhook row fields against bootstrap-declared-webhooks mapWebhookToRow", - "evidence": "the row read" + "verify": "zero sys_webhook rows named showcase_task_changed on a fresh boot; the activation door answers 200 for it and 404 for an undeclared name (declared-webhooks.ts DeclaredWebhookSource, the projection in auto-enqueuer.ts projectDeclared)", + "evidence": "the row read + the two door responses" }, { "clause": "each per-record trigger variant delivers exactly its event: create/update/delete each produce one receiver hit whose payload names the event and carries the recordId — verified per-variant, none inferred", @@ -594,9 +594,9 @@ "evidence": "the two build errors" }, { - "clause": "seed-not-clobber: the admin-edited row (customized:true) survives redeploys — the deactivation/receiver-URL edit is still there after reboot", + "clause": "the activation switch survives redeploys: the sys_metadata_activation row (metadata_type webhook) the door wrote is still in force after reboot — a declared webhook's on/off lives in the ledger, never in a row (ADR-0131 D6 regime C)", "oracle": "api", - "verify": "post-reboot sys_webhook row keeps the admin's values (bootstrap-declared-webhooks.ts)", + "verify": "post-reboot sys_metadata_activation row, and the declaration still reads switched on (declared-webhooks.ts readWebhookActivation)", "evidence": "before/after row reads" } ], @@ -615,8 +615,8 @@ }, "source": [ "packages/spec/src/automation/webhook.zod.ts#WebhookTriggerType (WebhookTriggerType enum + why undelete/api are absent; materialization contract; strict shape #4001)", - "packages/spec/liveness/webhook.json (all 11 props live via the #3489 bridge; per-prop line refs)", - "packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts + auto-enqueuer.ts (remaps; trigger→event mapping incl. the #4639 bulk pair; #3196 unknown-trigger warn; seed-not-clobber)", + "packages/spec/liveness/webhook.json (9 props live on the registry read; label/description dead since the boot seeder retired)", + "packages/plugins/plugin-webhooks/src/declared-webhooks.ts + auto-enqueuer.ts (the registry read and its remaps; trigger→event mapping incl. the #4639 bulk pair; #3196 unknown-trigger warn; the activation-ledger switch; an 18.0 database's seeded rows read beside the declaration)", "packages/services/service-messaging/src/http-outbox.ts#HttpRedeliverError (delivery statuses, attempts; HttpRedeliverError + assertHttpRedeliverable/assertRedeliverAllowed — terminal-only, the attempts===0 parked-row refusal, the fail-closed guard) + packages/services/service-messaging/src/http-signature-at-rest.integration.test.ts (redelivery replays the stored signature byte-for-byte)", "packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts#session (POST /api/v1/webhooks/redeliver — session auth with NO capability gate by design ('every authenticated user counts'), #10740 activeOrganizationId threading, the 401/400/404/409/500 arms) + (the #8069 redeliver-guard install; its ABSENCE is an error-level log, not a silent pass)", "packages/rest/src/rest-server.ts#deleteMany (#3897 — the batch routes parse against the spec contract and Zod STRIPS unknown keys, so options.multi/options.where cannot ride in; deleteMany deletes per id. This is why the bulk clause must be driven from a flow node, not REST)", @@ -665,6 +665,12 @@ "date": "2026-10-16", "change": "steps[2] names the create body the delivery leg needs (checklist-accuracy finding of QA run #23922, wave #23878): a showcase_task create over REST requires its `project` lookup and answers 400 VALIDATION_FAILED 'Project is required' without one, so the create variant never fires. The clauses are unchanged", "ref": "#24036" + }, + { + "revision": 8, + "date": "2026-10-19", + "change": "ADR-0131 D2/D6: the declared-webhook boot seeder retired, so a declared webhook is read from the registry and writes no sys_webhook row, and is switched through POST /api/v1/security/_activation/webhook/:name (regime C). steps[0], steps[1] and the reboot step, acceptance[0] and acceptance[6], fixtures.requires[0]/[1] and the source list now say so; the delivery, bulk, redeliver and retired-trigger clauses are unchanged", + "ref": "#24529" } ], "enumSource": { diff --git a/examples/app-showcase/src/automation/webhooks/index.ts b/examples/app-showcase/src/automation/webhooks/index.ts index 222686327a6..0a26cd51ad3 100644 --- a/examples/app-showcase/src/automation/webhooks/index.ts +++ b/examples/app-showcase/src/automation/webhooks/index.ts @@ -4,15 +4,17 @@ import { defineWebhook } from '@objectstack/spec/automation'; /** * Outbound webhook — fans out task changes to an external endpoint. - * Authored via `defineStack({ webhooks })`; on boot the webhooks - * plugin materializes this into a `sys_webhook` row (#3461) that the - * auto-enqueuer dispatches off. + * Authored via `defineStack({ webhooks })`; the webhooks plugin's dispatcher + * reads this declaration from the registry (ADR-0131 D2) — no `sys_webhook` + * row is written for it. * * Shipped INACTIVE on purpose: `hooks.example` is a placeholder endpoint, so an * active subscription would emit a failed HTTP delivery on every task change. - * The demo flow is to open Setup → Integrations → Webhooks and flip this row to - * Active (pointing it at a real endpoint) — that admin edit is remembered - * (`customized: true`) and survives redeploys. + * A declared webhook is read-only (ADR-0131 D6, regime C). The demo flow is to + * author a copy under a new name in Setup → Integrations → Webhooks, pointed at + * a real endpoint; this declaration itself is switched on or off with + * `POST /api/v1/security/_activation/webhook/showcase_task_changed` and + * `{ "enabled": true }`, which survives redeploys. */ export const TaskChangedWebhook = defineWebhook({ name: 'showcase_task_changed', diff --git a/packages/spec/liveness/connector.json b/packages/spec/liveness/connector.json index 78b969da998..6d549f301d4 100644 --- a/packages/spec/liveness/connector.json +++ b/packages/spec/liveness/connector.json @@ -135,7 +135,7 @@ "webhooks": { "status": "dead", "verifiedAt": "2026-09-27", - "note": "RETIRED 2026-09-27 (ADR-0049 enforce-or-remove; the connector resilience family) — the `retire` arm of the decision this row asked for. Tombstoned with `retiredKey` (non-strict schema, ADR-0104), carried by `DeclarativeConnectorEntrySchema` too; the nested shape left whole (`WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm`). Stripped — never moved — from sources and stored rows by the protocol-18 conversion `connector-resilience-keys-removed`: moving a nested webhook into the top-level collection would START deliveries it never made, which is the author's call (the D3 entry `connector-resilience-keys-retired`). The row stays because `retiredKey` keeps the key in the walked shape (the `rls.priority` precedent); it is a LEAF now, so its `connector/webhooks` row in scripts/liveness/undrilled-containers.baseline.json was deleted (the gate reported it stale once the container stopped being one). Why, unchanged from the prior verdict: a connector's NESTED webhook array is not the collection the dispatcher reads — `bootstrapDeclaredWebhooks` (packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts) materializes `sys_webhook` rows only from metadata ITEMS of type `webhook`, which the decomposition registers from the TOP-LEVEL `webhooks:` stack collection, and `connectors: 'connector'` registers a connector entry WHOLE; `bootstrap-declared-webhooks.connector-nested.test.ts` pins that the nested array is not hoisted. Declare a webhook in the top-level `webhooks:` collection. The tombstone is packages/spec/src/integration/connector.zod.ts#ConnectorSchema." + "note": "RETIRED 2026-09-27 (ADR-0049 enforce-or-remove; the connector resilience family) — the `retire` arm of the decision this row asked for. Tombstoned with `retiredKey` (non-strict schema, ADR-0104), carried by `DeclarativeConnectorEntrySchema` too; the nested shape left whole (`WebhookConfig`, `WebhookEvent`, `WebhookSignatureAlgorithm`). Stripped — never moved — from sources and stored rows by the protocol-18 conversion `connector-resilience-keys-removed`: moving a nested webhook into the top-level collection would START deliveries it never made, which is the author's call (the D3 entry `connector-resilience-keys-retired`). The row stays because `retiredKey` keeps the key in the walked shape (the `rls.priority` precedent); it is a LEAF now, so its `connector/webhooks` row in scripts/liveness/undrilled-containers.baseline.json was deleted (the gate reported it stale once the container stopped being one). Why, unchanged from the prior verdict: a connector's NESTED webhook array is not the collection the dispatcher reads — the declared-webhook reader (packages/plugins/plugin-webhooks/src/declared-webhooks.ts, which replaced the retired boot seeder `bootstrapDeclaredWebhooks`) reads only metadata ITEMS of type `webhook`, which the decomposition registers from the TOP-LEVEL `webhooks:` stack collection, and `connectors: 'connector'` registers a connector entry WHOLE; `bootstrap-declared-webhooks.connector-nested.test.ts` pins that the nested array is not hoisted. Declare a webhook in the top-level `webhooks:` collection. The tombstone is packages/spec/src/integration/connector.zod.ts#ConnectorSchema." }, "rateLimitConfig": { "status": "dead", diff --git a/packages/spec/src/automation/webhook.test.ts b/packages/spec/src/automation/webhook.test.ts index 7d8233e22e0..d85d3a29665 100644 --- a/packages/spec/src/automation/webhook.test.ts +++ b/packages/spec/src/automation/webhook.test.ts @@ -224,8 +224,8 @@ describe('WebhookSchema', () => { // #4001 batch 11. The ledger carried this row as `authorable (p)` — "spec-only // (#3461)". Resolving the `(p)` found the opposite: three parse doors, one of -// them a BOOT path. `plugin-webhooks`' `bootstrapDeclaredWebhooks` re-parses -// every declared webhook before materializing it into `sys_webhook`, and a +// them a dispatch path. `plugin-webhooks`' declared-webhook reader re-parses +// every declared webhook before the dispatcher subscribes it, and a // failure there warns and SKIPS the subscription — so a rejection here is the // difference between a webhook that exists and one that does not. describe('unknown keys are rejected, not stripped', () => { diff --git a/packages/spec/src/automation/webhook.zod.ts b/packages/spec/src/automation/webhook.zod.ts index 7adb9e9fbd6..f7b4cd3730d 100644 --- a/packages/spec/src/automation/webhook.zod.ts +++ b/packages/spec/src/automation/webhook.zod.ts @@ -58,16 +58,17 @@ export type WebhookTriggerType = z.input; * Webhook Protocol - Outbound HTTP Integration * Push data to external URLs when events occur in the system. * - * **RUNTIME MATERIALIZATION (#3461):** - * A webhook authored on a stack (`defineStack({ webhooks })`) is not dispatched - * directly from this envelope. On boot, `@objectstack/plugin-webhooks` - * materializes each declared webhook into a `sys_webhook` data row (mapping - * `object → object_name`, `isActive → active`, and stashing the full envelope - * in `definition_json`); the auto-enqueuer dispatches off those rows. Declared - * webhooks re-seed every boot as `managed_by: 'package'`, but a row an admin has - * edited in Setup (`customized: true`) is never clobbered — a deactivated noisy - * webhook survives redeploys. Authoring `webhooks:` is therefore live, not a - * no-op. (A connector's NESTED `webhooks` array never was — it was never + * **RUNTIME (ADR-0131 D2, D6):** + * A webhook authored on a stack (`defineStack({ webhooks })`) is code, and the + * dispatcher reads it from the metadata registry: `@objectstack/plugin-webhooks` + * subscribes each declared webhook on every cache refresh (mapping + * `object → object_name`, with `secret` and `headers` held in memory), and no + * `sys_webhook` row is written for it. `sys_webhook` holds the webhooks an + * organization authors. A declared webhook is read-only: it is switched on or + * off for the deployment through the activation ledger + * (`POST /api/v1/security/_activation/webhook/:name`), which overrides + * `isActive` in either direction, and changed by a copy under a new name. + * Authoring `webhooks:` is therefore live, not a no-op. (A connector's NESTED `webhooks` array never was — it was never * registered as a `webhook` item — and was retired under ADR-0049; declare * every webhook here.) * @@ -113,10 +114,10 @@ export type WebhookTriggerType = z.input; * * 1. {@link defineWebhook} — the authoring factory. * 2. `defineStack({ webhooks })` — `StackSchema` holds `z.array(WebhookSchema)`. - * 3. **Boot** — `plugin-webhooks`' `bootstrapDeclaredWebhooks` re-parses every - * declared webhook before materializing it into a `sys_webhook` row - * (`bootstrap-declared-webhooks.ts`), and a parse failure there warns and - * SKIPS the webhook. So an undeclared key here is not merely stripped — from + * 3. **Dispatch** — `plugin-webhooks`' declared-webhook reader re-parses every + * declared webhook on each subscription-cache refresh + * (`declared-webhooks.ts`, which replaced the boot seeder ADR-0131 D2 + * retired), and a parse failure there warns and SKIPS the webhook. So an undeclared key here is not merely stripped — from * this change on it is the difference between a subscription that exists and * one that does not, which is why the rejection has to carry its fix. * @@ -136,8 +137,8 @@ export const WebhookSchema = lazySchema(() => strictObject({ surface: 'this webhook', aliases: { // Real, in-repo spellings of the same field on the OTHER side of the - // materialization bridge: `sys_webhook`'s columns are `object_name` / - // `active` (`mapWebhookToRow` in plugin-webhooks does the remap). An admin + // dispatcher: `sys_webhook`'s columns are `object_name` / `active` (the + // declared-webhook projection in plugin-webhooks makes the same remap). An admin // or agent reading a row back and re-authoring it from those column names // is the concrete path here, and neither word is within edit distance. // The column spelling alone covers `objectName` too — `aliasProbe` folds @@ -225,7 +226,7 @@ export const WebhookSchema = lazySchema(() => strictObject({ // Declared in #4001 batch 11 for the reason the module header states: both // metadata load paths call `applyProtection` on EVERY type, so a // package-loaded webhook has always carried these keys by the time - // `bootstrapDeclaredWebhooks` re-parses it. `.strip` discarded them; the + // the declared-webhook reader re-parses it. `.strip` discarded them; the // `.strict()` gate above would have rejected them, and the observable failure // would not have looked like a validation problem at all — just a webhook // that stopped existing after a redeploy, with one `warn` to say so. diff --git a/packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__webhooks.ts b/packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__webhooks.ts index bcd7307b190..10b50936bd7 100644 --- a/packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__webhooks.ts +++ b/packages/spec/src/migrations/entries/retired-keys/18.integration__Connector__webhooks.ts @@ -10,7 +10,8 @@ // reads of a connector's own `webhooks` outside `packages/spec`, while // `stack.webhooks` — the lit control, same scan — is read five times; the one // test that authors a nested array -// (`bootstrap-declared-webhooks.connector-nested.test.ts`) exists to pin that it +// (`declared-webhooks.connector-nested.test.ts`, renamed from the retired +// seeder's `bootstrap-declared-webhooks.connector-nested.test.ts`) exists to pin that it // is NOT hoisted. And no code path emits a connector lifecycle event // (`sync.completed`, `auth.expired`, …) for its `events` to subscribe to. // From bdad70b7406e95ec445e5bb92a5a0517f97a34e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:31:15 +0000 Subject: [PATCH 05/44] spec(liveness): state counts regenerated; isActive named at the dispatcher's switch read Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ Co-authored-by: Claude --- packages/plugins/plugin-webhooks/src/auto-enqueuer.ts | 1 + packages/spec/liveness/state-counts/webhook.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts index 3edd7553e68..d67d2fda2bc 100644 --- a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts +++ b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts @@ -561,6 +561,7 @@ export class AutoEnqueuer { this.reportDeclarationWithheld(name); continue; } + // `active` is the declared `isActive`, or the activation ledger's switch over it. if (!d.active) continue; const sub = this.projectDeclared(d); if (sub) subscriptions.push(sub); diff --git a/packages/spec/liveness/state-counts/webhook.md b/packages/spec/liveness/state-counts/webhook.md index d052b4768eb..1cf9e8785ee 100644 --- a/packages/spec/liveness/state-counts/webhook.md +++ b/packages/spec/liveness/state-counts/webhook.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `webhook` | 19 | 0 | 0 | 0 | 0 | 19 | +| `webhook` | 17 | 0 | 0 | 2 | 0 | 19 | From 723af97227dd08c0c22611f0434934ed12fdf81e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:52:30 +0000 Subject: [PATCH 06/44] fix(plugin-webhooks): a declared webhook is deployment-wide when the posture builds no wall, as its 18.0 seeded copy stamped with the Default Organization was Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ Co-authored-by: Claude --- .../plugin-webhooks/src/auto-enqueuer.ts | 44 ++++++++++- .../src/declared-webhooks.test.ts | 75 ++++++++++++++++++- .../src/webhook-outbox-plugin.ts | 25 ++++++- 3 files changed, 138 insertions(+), 6 deletions(-) diff --git a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts index d67d2fda2bc..71d36cb3957 100644 --- a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts +++ b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts @@ -154,6 +154,20 @@ interface CachedSubscription { * its own organization's. */ organizationId?: string; + /** + * [ADR-0131 D2/D9] Set on a DECLARED webhook when the deployment's posture + * builds no organization wall (`single`). A declared webhook is the + * application's, not one organization's, so it has no `organizationId` of + * its own — and under `single` every record event is stamped with the + * Default Organization (D9), so the org-less refusal below would make it + * receive nothing at all. Deployment-wide, it receives every organization's + * events and each delivery row is stamped with the EVENT's organization: + * exactly what the 18.0 seeded copy did, whose row the boot stamped with + * that same Default Organization. Never set under a wall, where the + * ruling's org-less refusal ({@link AutoEnqueuer.admitsOrganization}) + * stands. + */ + deploymentWide?: boolean; /** * [#8069] Set when a credential this subscription needs could not be * recovered. The subscription stays CACHED — that is the change — but every @@ -192,6 +206,14 @@ export interface AutoEnqueuerOptions { */ declaredWebhooks?: DeclaredWebhookReader; + /** + * Whether the deployment's tenancy posture builds no organization wall, + * read on every refresh. True makes each declared webhook deployment-wide + * (see `CachedSubscription.deploymentWide`); absent or false keeps it + * org-less, refused for an organization-walled event. + */ + declaredWebhooksDeploymentWide?: () => boolean; + logger?: OptionalLogger; } @@ -296,6 +318,7 @@ export class AutoEnqueuer { private readonly organizationRefusalReported = new Set(); /** [ADR-0131 D2/D6] The declared-webhook reader, when the host binds one. */ private readonly declared?: DeclaredWebhookReader; + private readonly declaredDeploymentWide?: () => boolean; /** Self-heal subscription on the activation ledger — bound only with {@link declared}. */ private subIdLedger: string | undefined; /** @@ -315,6 +338,7 @@ export class AutoEnqueuer { this.refreshIntervalMs = opts.refreshIntervalMs ?? 60_000; this.logger = opts.logger; this.declared = opts.declaredWebhooks; + this.declaredDeploymentWide = opts.declaredWebhooksDeploymentWide; } /** @@ -554,6 +578,12 @@ export class AutoEnqueuer { else heldByRow.add(String(row.name)); } + let deploymentWide = false; + try { + deploymentWide = this.declaredDeploymentWide?.() === true; + } catch { + /* an unreadable posture is not an open one: org-less, as under a wall */ + } const subscriptions: CachedSubscription[] = []; for (const d of declared) { const name = d.webhook.name; @@ -564,7 +594,9 @@ export class AutoEnqueuer { // `active` is the declared `isActive`, or the activation ledger's switch over it. if (!d.active) continue; const sub = this.projectDeclared(d); - if (sub) subscriptions.push(sub); + if (!sub) continue; + if (deploymentWide) sub.deploymentWide = true; + subscriptions.push(sub); } return { subscriptions, mirrorIds }; } @@ -1167,8 +1199,9 @@ export class AutoEnqueuer { // organization — the one honest tenant in scope on this // fire-and-forget path (no request context exists here). // Absent for an org-less subscription; the row then lands - // NULL, the global-row shape. - organizationId: sub.organizationId, + // NULL, the global-row shape. A deployment-wide declared + // webhook takes the event's organization instead. + organizationId: sub.deploymentWide ? organization.organizationId : sub.organizationId, // [#3946] Envelope keys are written LAST so the event payload // cannot rewrite them. Behaviour-neutral for the engine's own // publishers — since #4626 a `data.record.*` payload is a @@ -1315,7 +1348,7 @@ export class AutoEnqueuer { timeoutMs: sub.timeoutMs, // [#13546] See the per-record path — the subscription's own // organization, absent for an org-less subscription. - organizationId: sub.organizationId, + organizationId: sub.deploymentWide ? organization.organizationId : sub.organizationId, // [#3946] Envelope keys last so the payload cannot rewrite them. payload: { ...payload, @@ -1394,6 +1427,9 @@ export class AutoEnqueuer { eventOrganizationId: string | undefined, event: RealtimeEventPayload, ): boolean { + // [ADR-0131 D2/D9] A declared webhook on a deployment with no wall + // belongs to the deployment, so every organization's event is its own. + if (sub.deploymentWide) return true; // org A × org A, and none × absent. if (sub.organizationId === eventOrganizationId) return true; // org A × org B — another organization's subscription is simply not a diff --git a/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts b/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts index 09f20e6de69..21c1e3452ca 100644 --- a/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts +++ b/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts @@ -206,7 +206,7 @@ async function flush() { await new Promise((r) => setTimeout(r, 0)); } -async function startEnqueuer(engine: FakeEngine, logger?: any) { +async function startEnqueuer(engine: FakeEngine, logger?: any, deploymentWide?: boolean) { const realtime = new FakeRealtime(); const calls: any[] = []; const enqueue: HttpEnqueueFn = async (input) => { @@ -217,6 +217,7 @@ async function startEnqueuer(engine: FakeEngine, logger?: any) { const enqueuer = new AutoEnqueuer(engine as any, realtime as any, enqueue, { refreshIntervalMs: 0, declaredWebhooks, + ...(deploymentWide !== undefined ? { declaredWebhooksDeploymentWide: () => deploymentWide } : {}), ...(logger ? { logger } : {}), }); await enqueuer.start(); @@ -307,6 +308,78 @@ describe('declared webhooks — a fresh install dispatches from the registry', ( }); }); +describe('declared webhooks — the organization dimension follows the posture (ADR-0131 D2/D9)', () => { + const orgEvent = (organizationId: string) => ({ + ...taskCreated(), + payload: { recordId: 't-1', organizationId }, + }); + + it('with no wall (`single`) a declared webhook receives an organization-stamped event, its delivery stamped with that organization', async () => { + // Under `single` every record carries the Default Organization (D9), and + // the 18.0 seeded copy carried it too, so it delivered. The declaration + // has no organization of its own; deployment-wide, it delivers as well. + const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } }); + const { enqueuer, realtime, calls } = await startEnqueuer(engine, undefined, true); + await realtime.publish(orgEvent('org_default')); + await flush(); + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ refId: declaredWebhookRefId('task_changed'), organizationId: 'org_default' }); + await enqueuer.stop(); + }); + + it('under a wall a declared webhook stays org-less: an organization-walled event is refused, said once', async () => { + const warn = vi.fn(); + const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } }); + const { enqueuer, realtime, calls } = await startEnqueuer(engine, { warn, debug() {} }, false); + await realtime.publish(orgEvent('org_a')); + await realtime.publish(orgEvent('org_a')); + await flush(); + expect(calls).toEqual([]); + expect(warn.mock.calls.filter(([m]) => String(m).includes('belongs to NO organization'))).toHaveLength(1); + await enqueuer.stop(); + }); + + it('the plugin reads the posture from the tenancy service: `single` is deployment-wide, an absent service is walled', async () => { + const boot = async (tenancy?: unknown) => { + const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } }); + const realtime = new FakeRealtime(); + const calls: any[] = []; + const services: Record = { + objectql: engine, + manifest: { register() {} }, + realtime, + messaging: { + isHttpDeliveryReady: () => true, + enqueueHttp: async (input: any) => { calls.push(input); return 'd'; }, + redeliverHttp: async () => ({ id: 'd', status: 'pending' }), + registerRedeliverGuard() {}, + }, + ...(tenancy ? { tenancy } : {}), + }; + const hooks: Array<() => Promise> = []; + const ctx: any = { + logger: { info() {}, warn() {}, error() {}, debug() {} }, + getService: (n: string) => { + if (!(n in services)) throw new Error(`no ${n}`); + return services[n]; + }, + registerService: (n: string, svc: unknown) => { services[n] = svc; }, + hook: (event: string, fn: () => Promise) => { if (event === 'kernel:ready') hooks.push(fn); }, + }; + const plugin = new WebhookOutboxPlugin({ autoEnqueue: { refreshIntervalMs: 0 } }); + await plugin.init(ctx); + for (const fn of hooks) await fn(); + await realtime.publish(orgEvent('org_default')); + await flush(); + await plugin.destroy(); + return calls; + }; + expect(await boot({ posture: 'single' })).toHaveLength(1); + expect(await boot({ posture: 'isolated' })).toEqual([]); + expect(await boot()).toEqual([]); + }); +}); + // --------------------------------------------------------------------------- // 2. The switch (regime C) // --------------------------------------------------------------------------- diff --git a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts index de8b2c0bb51..c581513b29c 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-outbox-plugin.ts @@ -20,6 +20,7 @@ import { AutoEnqueuer, type AutoEnqueuerOptions } from './auto-enqueuer.js'; type SessionRead = Awaited>>; import { SysWebhook } from './sys-webhook.object.js'; import { DeclaredWebhookSource } from './declared-webhooks.js'; +import { normalizeTenancyPosture, postureEnforcesWall } from '@objectstack/spec/security'; import { migrateLegacyWebhookSecrets } from './migrate-webhook-secrets.js'; import { createWebhookRedeliverGuard } from './redeliver-guard.js'; import { bindWebhookProvenanceStamp, unbindWebhookProvenanceStamp } from './webhook-provenance.js'; @@ -383,7 +384,12 @@ export class WebhookOutboxPlugin implements Plugin { (input) => messaging.enqueueHttp(input), { ...enqOpts, - ...(this.declaredSource ? { declaredWebhooks: this.declaredSource } : {}), + ...(this.declaredSource + ? { + declaredWebhooks: this.declaredSource, + declaredWebhooksDeploymentWide: () => this.postureBuildsNoWall(ctx), + } + : {}), logger: ctx.logger, }, ); @@ -473,6 +479,23 @@ export class WebhookOutboxPlugin implements Plugin { return true; } + /** + * [ADR-0131 D9] Whether the deployment's tenancy posture builds no + * organization wall — the `tenancy` service's posture, read as the sharing + * plugin reads it for its declared rules: an absent or unreadable service + * is read as walled, never as open. + */ + private postureBuildsNoWall(ctx: PluginContext): boolean { + try { + const probe = ctx.getService<{ posture?: unknown; isolationActive?: boolean }>('tenancy'); + const posture = normalizeTenancyPosture(probe?.posture); + if (posture) return !postureEnforcesWall(posture); + return probe?.isolationActive === false; + } catch { + return false; + } + } + private tryGetService(ctx: PluginContext, names: string[]): T | undefined { for (const n of names) { try { From e0a6dc9901000c36865790fe590615c01ff7adc9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:58:44 +0000 Subject: [PATCH 07/44] fix(gates): tenant-audit census regenerated for the seeder's two retired writes; the declared-webhook double is drivable by the limit gate Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ Co-authored-by: Claude --- .../docs/permissions/tenant-audit-census.mdx | 42 ++-- ...08-tenant-audit-write-call-sites.counts.md | 18 +- .../src/declared-webhooks.test.ts | 194 +++++++++--------- scripts/objectql-double-limit.baseline.json | 3 - 4 files changed, 130 insertions(+), 127 deletions(-) diff --git a/content/docs/permissions/tenant-audit-census.mdx b/content/docs/permissions/tenant-audit-census.mdx index 3f71d3bdc5b..5af980d2653 100644 --- a/content/docs/permissions/tenant-audit-census.mdx +++ b/content/docs/permissions/tenant-audit-census.mdx @@ -122,7 +122,7 @@ are reported as `undecidable` rather than assumed either way. The same holds twice over for the context. An options argument spelled as a literal can be read; one spelled `options`, `{ ...opts }`, or handed through a -forwarding shim cannot, and **52 of the 231 sites are spelled that way**. A +forwarding shim cannot, and **50 of the 229 sites are spelled that way**. A context resolved from an inline literal or a local `const` can be tested for `isSystem`; one arriving from a helper call cannot. @@ -150,8 +150,8 @@ now **0**: nothing on this surface threads a context that provably lacks the fla **"No tenant context" counted sites it had not read.** An options argument the walker could not parse was folded into the same bucket as one it had read and -found empty. That published **60 sites "carrying no tenant context at all"** -when 8 said so and 52 were simply unread — an over-claim in the *alarming* +found empty. That published **58 sites "carrying no tenant context at all"** +when 8 said so and 50 were simply unread — an over-claim in the *alarming* direction, on the very figure this page tells other cards to cite. `carries` is now three-valued, and an unreadable argument can never contribute to the provable count. @@ -187,10 +187,10 @@ reproduce them. Where it disagrees, it disagrees on the page: | carried figure | where it survives | this census | | :--- | :--- | ---: | -| 175 write call sites | quoted in the merged changeset | **231** | -| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **31** more whose options argument is unreadable | -| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **160 of 231** decidable, **71** undecidable | -| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 128 decidably elevated, 0 decidably not, 95 undecidable | +| 175 write call sites | quoted in the merged changeset | **229** | +| 24 carrying no tenant context | quoted in the merged changeset | **2** provable and tenancy-enabled; **29** more whose options argument is unreadable | +| 127 of 175 statically decidable, 48 runtime-parameter-name sites | restated on the `isSystem`-scoping card | **158 of 229** decidable, **71** undecidable | +| 135 (77%) silenced by the `isSystem` guard before the posture gate | the lost issue body — **no surviving corroboration** | **not reproduced**: 128 decidably elevated, 0 decidably not, 93 undecidable | | 141 and 132, two independent re-derivations | the card that filed this work | — | **The differences are not reconciled, and deliberately so.** The old census's @@ -201,20 +201,20 @@ at any commit. Two structural facts do plausibly widen this reading against any hand or regex one, and both are counted in the generated tables below: the 40 sites reached -through an erased (`any`) receiver, and the 52 that name their object through a +through an erased (`any`) receiver, and the 50 that name their object through a `const` rather than inline. An instrument that read either the way a person does would report a smaller number and would not say so. The fourth row is the one worth flagging to anyone citing it. **The 135 / 77% figure has no surviving corroboration anywhere in the tree.** This census reads -128 of 231 (55%) as decidably elevated, with 95 more whose elevation is a +128 of 229 (56%) as decidably elevated, with 93 more whose elevation is a run-time fact — so the claim is neither confirmed nor refuted, and the honest answer is that a static reading cannot settle it. -⇒ **Cite `2 / 231`, and say what it is**: the sites whose options argument was +⇒ **Cite `2 / 229`, and say what it is**: the sites whose options argument was READ and holds no tenant context, against a decidably tenancy-enabled object. That is the control's provable yield surface. ⛔ Do not cite it as "the sites -without tenant context" — **31 further sites** have an options argument this +without tenant context" — **29 further sites** have an options argument this cannot read, and they are neither in nor out. {/* BEGIN GENERATED: tenant-audit-census (scripts/tenant-audit-census.mjs) — DO NOT EDIT */} @@ -223,29 +223,29 @@ cannot read, and they are neither in nor out. | what | count | | :--- | ---: | -| write call sites on the application surface | **231** | -| …whose object name is statically decidable | 160 | +| write call sites on the application surface | **229** | +| …whose object name is statically decidable | 158 | | …whose object name is chosen at run time | 71 | -| …against an object with tenancy ENABLED | 159 | +| …against an object with tenancy ENABLED | 157 | | …against an object that declares tenancy off | 1 | | threading a tenant context | 171 | | PROVABLY carrying none (options read, no context key) | **8** | | …of those, against a decidably tenancy-enabled object | **2** | -| options argument UNREADABLE — may or may not carry one | 52 | -| …of those, against a decidably tenancy-enabled object | 31 | +| options argument UNREADABLE — may or may not carry one | 50 | +| …of those, against a decidably tenancy-enabled object | 29 | | threading a decidably ELEVATED (`isSystem`) context | 128 | | threading a context that is decidably NOT elevated | 0 | -| threading a context whose elevation is a run-time fact | 95 | +| threading a context whose elevation is a run-time fact | 93 | | how the instrument reached the site | count | | :--- | ---: | -| receiver carried a readable engine type | 191 | +| receiver carried a readable engine type | 189 | | receiver erased, placed by the object NAME | 30 | | receiver erased, placed by an `object: string` PARAMETER | 5 | | receiver erased, placed by an `UNTYPED_RECEIVERS` row | 5 | | object name spelled inline | 108 | -| object name spelled through a `const` | 52 | +| object name spelled through a `const` | 50 | | object name is an `object: string` parameter | 7 | | object name is some other run-time expression | 64 | @@ -297,11 +297,11 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-19 at `2623d6d6c`. +Measured on 2026-10-19 at `723af9722`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 670 | +| tracked non-test sources scanned | 671 | | engine-shaped types recognised | 79 | | declared objects in the registry | 116 | | same-named calls subtracted as non-engine | 166 | diff --git a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md index 7626b4b1f53..d08c3528db9 100644 --- a/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md +++ b/docs/audits/2026-08-tenant-audit-write-call-sites.counts.md @@ -33,19 +33,19 @@ silent, and `node scripts/tenant-audit-census.mjs --write` is the resolution. | Measure | Value | |---|---:| -| Write call sites | 231 | -| Object name statically decidable | 160 | +| Write call sites | 229 | +| Object name statically decidable | 158 | | Object name chosen at run time | 71 | -| Against a tenancy-enabled object | 159 | +| Against a tenancy-enabled object | 157 | | Against an object declaring tenancy off | 1 | | Threading a tenant context | 171 | | Provably carrying none | 8 | | …and decidably tenancy-enabled | 2 | -| Options argument unreadable | 52 | -| …and decidably tenancy-enabled | 31 | +| Options argument unreadable | 50 | +| …and decidably tenancy-enabled | 29 | | Threading a decidably elevated context | 128 | | Threading a decidably non-elevated context | 0 | -| Threading a context of undecidable elevation | 95 | +| Threading a context of undecidable elevation | 93 | ## Subtractions the census could NOT defend — enforced @@ -90,11 +90,11 @@ holds still. They are required to be HERE and to say WHEN they were true; their values are not compared. The reasoning, and the measurement behind it, are in `scripts/check-tenant-audit-census.mjs`. -Measured on 2026-10-19 at `2623d6d6c`. +Measured on 2026-10-19 at `723af9722`. | corpus scale (not enforced) | count | | :--- | ---: | -| tracked non-test sources scanned | 670 | +| tracked non-test sources scanned | 671 | | engine-shaped types recognised | 79 | | declared objects in the registry | 116 | | same-named calls subtracted as non-engine | 166 | @@ -183,8 +183,6 @@ Measured on 2026-10-19 at `2623d6d6c`. | `packages/plugins/plugin-sharing/src/sharing-service.ts` | `delete` | `sys_record_share` | enabled | elevated | 2 | | `packages/plugins/plugin-sharing/src/sharing-service.ts` | `insert` | `sys_record_share` | enabled | elevated | 1 | | `packages/plugins/plugin-sharing/src/sharing-service.ts` | `update` | `sys_record_share` | enabled | elevated | 1 | -| `packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts` | `insert` | `sys_webhook` | enabled | options unreadable | 1 | -| `packages/plugins/plugin-webhooks/src/bootstrap-declared-webhooks.ts` | `update` | `sys_webhook` | enabled | options unreadable | 1 | | `packages/plugins/plugin-webhooks/src/migrate-webhook-secrets.ts` | `update` | `subscriptionsObject` | undecidable | options unreadable | 1 | | `packages/services/service-automation/src/builtin/crud-nodes.ts` | `delete` | `objectName` | undecidable | context, elevation undecidable | 1 | | `packages/services/service-automation/src/builtin/crud-nodes.ts` | `insert` | `objectName` | undecidable | context, elevation undecidable | 1 | diff --git a/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts b/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts index 21c1e3452ca..e261e519df5 100644 --- a/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts +++ b/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts @@ -47,78 +47,82 @@ interface HookEntry { packageId?: string; } +/** Equality and `$in` — the two predicate shapes the reader and the dispatcher send. */ +function rowMatches(row: any, cond?: Record): boolean { + if (!cond) return true; + return Object.entries(cond).every(([k, v]) => { + if (v && typeof v === 'object' && Array.isArray((v as any).$in)) return (v as any).$in.includes(row[k]); + return row[k] === v; + }); +} + /** * The ObjectQL surface the dispatcher, the reader and the provenance hook - * touch: `find({ where | filter })` with equality and `$in`, `insert`, - * update-by-id with `beforeUpdate` hooks, and a `_registry.listItems(type)` for - * declared metadata. Every write is recorded, so "nothing writes a row" is a - * reading, not an assumption. + * touch: `find({ where | filter })` with equality and `$in` and the caller's + * `limit`, `insert`, update-by-id with `beforeUpdate` hooks, and a + * `_registry.listItems(type)` for declared metadata. Every write is recorded, so + * "nothing writes a row" is a reading, not an assumption. */ -class FakeEngine { - rows: Record = {}; - writes: Array<{ op: string; object: string; data: any }> = []; - declared: Record = {}; - failLedgerRead = false; - private hooks: HookEntry[] = []; - - constructor(seed?: { rows?: Record; declared?: Record }) { - if (seed?.rows) this.rows = JSON.parse(JSON.stringify(seed.rows)); - if (seed?.declared) this.declared = JSON.parse(JSON.stringify(seed.declared)); - } - - get _registry() { - return { listItems: (type: string) => [...(this.declared[type] ?? [])] }; - } +function makeFakeEngine(rows: Record, declared: Record) { + const writes: Array<{ op: string; object: string; data: any }> = []; + let hooks: HookEntry[] = []; + return { + rows, + writes, + declared, + get _registry() { + return { listItems: (type: string) => [...(declared[type] ?? [])] }; + }, + async find(name: string, q?: any): Promise { + const matched = (rows[name] ?? []).filter((r) => rowMatches(r, q?.filter ?? q?.where)); + const bounded = q?.limit !== undefined ? matched.slice(0, q.limit) : matched; + return bounded.map((r) => ({ ...r })); + }, + async findOne(name: string, q?: any): Promise { + assertEngineFindOnePredicate(name, q); + return (await this.find(name, q))[0] ?? null; + }, + async insert(name: string, data: any): Promise { + writes.push({ op: 'insert', object: name, data }); + (rows[name] = rows[name] ?? []).push({ ...data }); + return data; + }, + async update(name: string, data: any, opts?: any): Promise { + writes.push({ op: 'update', object: name, data }); + const id = data?.id ?? opts?.where?.id; + const cond = opts?.where ?? (id ? { id } : undefined); + const previous = (rows[name] ?? []).find((r) => rowMatches(r, cond)); + const ctx = { input: { id, data }, previous: previous ? { ...previous } : undefined, session: opts?.context }; + for (const h of hooks) { + if (h.event === 'beforeUpdate' && (!h.object || h.object === name)) await h.handler(ctx); + } + for (const r of rows[name] ?? []) if (rowMatches(r, cond)) Object.assign(r, data); + return { affected: 1 }; + }, + async delete(): Promise { + return { affected: 0 }; + }, + async count(name: string): Promise { + return (rows[name] ?? []).length; + }, + registerHook(event: string, handler: (ctx: any) => any, options?: Record): void { + hooks.push({ event, handler, object: options?.object, packageId: options?.packageId }); + }, + unregisterHooksByPackage(packageId: string): number { + const before = hooks.length; + hooks = hooks.filter((h) => h.packageId !== packageId); + return before - hooks.length; + }, + }; +} - private matches(row: any, cond?: Record): boolean { - if (!cond) return true; - return Object.entries(cond).every(([k, v]) => { - if (v && typeof v === 'object' && Array.isArray((v as any).$in)) return (v as any).$in.includes(row[k]); - return row[k] === v; - }); - } +type FakeEngine = ReturnType; - async find(name: string, q?: any): Promise { - if (name === LEDGER && this.failLedgerRead) throw new Error('ledger down'); - const all = this.rows[name] ?? []; - const out = all.filter((r) => this.matches(r, q?.filter ?? q?.where)).map((r) => ({ ...r })); - return typeof q?.limit === 'number' ? out.slice(0, q.limit) : out; - } - async findOne(name: string, q?: any): Promise { - assertEngineFindOnePredicate(name, q); - return (await this.find(name, q))[0] ?? null; - } - async insert(name: string, data: any): Promise { - this.writes.push({ op: 'insert', object: name, data }); - (this.rows[name] = this.rows[name] ?? []).push({ ...data }); - return data; - } - async update(name: string, data: any, opts?: any): Promise { - this.writes.push({ op: 'update', object: name, data }); - const id = data?.id ?? opts?.where?.id; - const cond = opts?.where ?? (id ? { id } : undefined); - const previous = (this.rows[name] ?? []).find((r) => this.matches(r, cond)); - const ctx = { input: { id, data }, previous: previous ? { ...previous } : undefined, session: opts?.context }; - for (const h of this.hooks) { - if (h.event === 'beforeUpdate' && (!h.object || h.object === name)) await h.handler(ctx); - } - for (const r of this.rows[name] ?? []) if (this.matches(r, cond)) Object.assign(r, data); - return { affected: 1 }; - } - async delete(): Promise { - return { affected: 0 }; - } - async count(name: string): Promise { - return (this.rows[name] ?? []).length; - } - registerHook(event: string, handler: (ctx: any) => any, options?: Record): void { - this.hooks.push({ event, handler, object: options?.object, packageId: options?.packageId }); - } - unregisterHooksByPackage(packageId: string): number { - const before = this.hooks.length; - this.hooks = this.hooks.filter((h) => h.packageId !== packageId); - return before - this.hooks.length; - } +function newEngine(seed?: { rows?: Record; declared?: Record }): FakeEngine { + return makeFakeEngine( + seed?.rows ? JSON.parse(JSON.stringify(seed.rows)) : {}, + seed?.declared ? JSON.parse(JSON.stringify(seed.declared)) : {}, + ); } class FakeRealtime { @@ -230,7 +234,7 @@ async function startEnqueuer(engine: FakeEngine, logger?: any, deploymentWide?: describe('declared webhooks — a fresh install dispatches from the registry', () => { it('a declared webhook dispatches on a matching event, with its secret, headers, method and timeout from the declaration', async () => { - const engine = new FakeEngine({ + const engine = newEngine({ declared: { webhook: [declaredWebhook({ triggers: ['create'], secret: 'shh', headers: { 'X-Env': 'prod' }, method: 'PUT', timeoutMs: 5000, @@ -257,7 +261,7 @@ describe('declared webhooks — a fresh install dispatches from the registry', ( }); it('nothing writes a sys_webhook row: the plugin boot binds the reader and seeds zero rows', async () => { - const engine = new FakeEngine({ declared: { webhook: [declaredWebhook({ secret: 'shh' })] } }); + const engine = newEngine({ declared: { webhook: [declaredWebhook({ secret: 'shh' })] } }); const services: Record = { objectql: engine, manifest: { register() {} } }; const hooks: Record Promise>> = {}; const ctx: any = { @@ -282,7 +286,7 @@ describe('declared webhooks — a fresh install dispatches from the registry', ( }); it('a declared webhook shipped inactive is not dispatched', async () => { - const engine = new FakeEngine({ declared: { webhook: [declaredWebhook({ isActive: false })] } }); + const engine = newEngine({ declared: { webhook: [declaredWebhook({ isActive: false })] } }); const { enqueuer, realtime, calls } = await startEnqueuer(engine); await realtime.publish(taskCreated()); await flush(); @@ -292,7 +296,7 @@ describe('declared webhooks — a fresh install dispatches from the registry', ( it('an invalid declaration is skipped with one warning per name, and a valid one beside it still dispatches', async () => { const warn = vi.fn(); - const engine = new FakeEngine({ + const engine = newEngine({ declared: { webhook: [declaredWebhook({ name: 'good' }), declaredWebhook({ name: 'bad', url: 'not-a-url' })] }, }); const { enqueuer, realtime, calls } = await startEnqueuer(engine, { warn, debug() {} }); @@ -318,7 +322,7 @@ describe('declared webhooks — the organization dimension follows the posture ( // Under `single` every record carries the Default Organization (D9), and // the 18.0 seeded copy carried it too, so it delivered. The declaration // has no organization of its own; deployment-wide, it delivers as well. - const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } }); + const engine = newEngine({ declared: { webhook: [declaredWebhook()] } }); const { enqueuer, realtime, calls } = await startEnqueuer(engine, undefined, true); await realtime.publish(orgEvent('org_default')); await flush(); @@ -329,7 +333,7 @@ describe('declared webhooks — the organization dimension follows the posture ( it('under a wall a declared webhook stays org-less: an organization-walled event is refused, said once', async () => { const warn = vi.fn(); - const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } }); + const engine = newEngine({ declared: { webhook: [declaredWebhook()] } }); const { enqueuer, realtime, calls } = await startEnqueuer(engine, { warn, debug() {} }, false); await realtime.publish(orgEvent('org_a')); await realtime.publish(orgEvent('org_a')); @@ -341,7 +345,7 @@ describe('declared webhooks — the organization dimension follows the posture ( it('the plugin reads the posture from the tenancy service: `single` is deployment-wide, an absent service is walled', async () => { const boot = async (tenancy?: unknown) => { - const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } }); + const engine = newEngine({ declared: { webhook: [declaredWebhook()] } }); const realtime = new FakeRealtime(); const calls: any[] = []; const services: Record = { @@ -386,7 +390,7 @@ describe('declared webhooks — the organization dimension follows the posture ( describe('declared webhooks — the activation ledger switches them (ADR-0131 D6 regime C)', () => { it('a ledger row with active:false switches a declared webhook off', async () => { - const engine = new FakeEngine({ + const engine = newEngine({ declared: { webhook: [declaredWebhook()] }, rows: { [LEDGER]: [ledgerRow('task_changed', false)] }, }); @@ -398,7 +402,7 @@ describe('declared webhooks — the activation ledger switches them (ADR-0131 D6 }); it('a ledger row with active:true switches on a webhook shipped inactive', async () => { - const engine = new FakeEngine({ + const engine = newEngine({ declared: { webhook: [declaredWebhook({ isActive: false })] }, rows: { [LEDGER]: [ledgerRow('task_changed', true)] }, }); @@ -410,7 +414,7 @@ describe('declared webhooks — the activation ledger switches them (ADR-0131 D6 }); it('another type\'s ledger row of the same name switches nothing', async () => { - const engine = new FakeEngine({ + const engine = newEngine({ declared: { webhook: [declaredWebhook()] }, rows: { [LEDGER]: [{ id: 'a1', metadata_type: 'flow', name: 'task_changed', active: false }] }, }); @@ -422,7 +426,7 @@ describe('declared webhooks — the activation ledger switches them (ADR-0131 D6 }); it('a ledger write refreshes the cache in the same request — no wait for the periodic refresh', async () => { - const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } }); + const engine = newEngine({ declared: { webhook: [declaredWebhook()] } }); const { enqueuer, realtime, calls } = await startEnqueuer(engine); engine.rows[LEDGER] = [ledgerRow('task_changed', false)]; @@ -437,8 +441,12 @@ describe('declared webhooks — the activation ledger switches them (ADR-0131 D6 it('a ledger read that fails keeps each declared webhook at its declared isActive', async () => { const warn = vi.fn(); - const engine = new FakeEngine({ declared: { webhook: [declaredWebhook()] } }); - engine.failLedgerRead = true; + const engine = newEngine({ declared: { webhook: [declaredWebhook()] } }); + const read = engine.find; + engine.find = async (name: string, q?: any) => { + if (name === LEDGER) throw new Error('ledger down'); + return read.call(engine, name, q); + }; const { enqueuer, realtime, calls } = await startEnqueuer(engine, { warn, debug() {} }); await realtime.publish(taskCreated()); await flush(); @@ -455,7 +463,7 @@ describe('declared webhooks — the activation ledger switches them (ADR-0131 D6 describe('declared webhooks — an 18.0 database keeps its seeded rows, and nothing fires twice', () => { it('a pristine seeded row is ignored in favour of its declaration: one delivery, from the declaration', async () => { const wh = declaredWebhook({ secret: 'shh' }); - const engine = new FakeEngine({ + const engine = newEngine({ declared: { webhook: [wh] }, rows: { sys_webhook: [seeded18_0Row('whk_seeded', wh)] }, }); @@ -474,7 +482,7 @@ describe('declared webhooks — an 18.0 database keeps its seeded rows, and noth it('a pristine seeded row follows its declaration\'s switch: switched off, neither the row nor the declaration fires', async () => { const wh = declaredWebhook(); - const engine = new FakeEngine({ + const engine = newEngine({ declared: { webhook: [wh] }, rows: { sys_webhook: [seeded18_0Row('whk_seeded', wh)], [LEDGER]: [ledgerRow('task_changed', false)] }, }); @@ -486,7 +494,7 @@ describe('declared webhooks — an 18.0 database keeps its seeded rows, and noth }); it('a pristine seeded row follows a changed declaration — the new URL, as the 18.0 seeder would have rewritten it', async () => { - const engine = new FakeEngine({ + const engine = newEngine({ declared: { webhook: [declaredWebhook({ url: 'https://hooks.example/task-v2' })] }, rows: { sys_webhook: [seeded18_0Row('whk_seeded', declaredWebhook())] }, }); @@ -500,7 +508,7 @@ describe('declared webhooks — an 18.0 database keeps its seeded rows, and noth it('a seeded row an administrator switched off (customized) stays off: the declaration is withheld, said once', async () => { const warn = vi.fn(); const wh = declaredWebhook(); - const engine = new FakeEngine({ + const engine = newEngine({ declared: { webhook: [wh] }, rows: { sys_webhook: [seeded18_0Row('whk_seeded', wh, { active: false, customized: true })] }, }); @@ -517,7 +525,7 @@ describe('declared webhooks — an 18.0 database keeps its seeded rows, and noth it('a seeded row an administrator pointed elsewhere (customized) answers as itself — one delivery, to the row\'s URL', async () => { const wh = declaredWebhook({ isActive: false }); - const engine = new FakeEngine({ + const engine = newEngine({ declared: { webhook: [wh] }, rows: { sys_webhook: [seeded18_0Row('whk_seeded', wh, { @@ -535,7 +543,7 @@ describe('declared webhooks — an 18.0 database keeps its seeded rows, and noth it('an admin edit through the provenance stamp turns a pristine seeded row into one that answers as itself', async () => { const wh = declaredWebhook(); - const engine = new FakeEngine({ + const engine = newEngine({ declared: { webhook: [wh] }, rows: { sys_webhook: [seeded18_0Row('whk_seeded', wh)] }, }); @@ -554,7 +562,7 @@ describe('declared webhooks — an 18.0 database keeps its seeded rows, and noth }); it('an administrator-authored row under a declared name answers as itself, and the declaration is withheld', async () => { - const engine = new FakeEngine({ + const engine = newEngine({ declared: { webhook: [declaredWebhook()] }, rows: { sys_webhook: [{ @@ -572,7 +580,7 @@ describe('declared webhooks — an 18.0 database keeps its seeded rows, and noth }); it('a pristine seeded row whose declaration is gone keeps dispatching, as it did on 18.0', async () => { - const engine = new FakeEngine({ + const engine = newEngine({ rows: { sys_webhook: [seeded18_0Row('whk_orphan', declaredWebhook({ name: 'retired_hook' }))] }, }); const { enqueuer, realtime, calls } = await startEnqueuer(engine); @@ -592,16 +600,16 @@ describe('declared webhooks — the redelivery veto', () => { createWebhookRedeliverGuard(engine as any, 'sys_webhook', new DeclaredWebhookSource(engine, undefined)); it('allows a declared webhook\'s delivery while it is declared and switched on', async () => { - const engine = new FakeEngine({ declared: { webhook: [declaredWebhook({ secret: 'shh' })] } }); + const engine = newEngine({ declared: { webhook: [declaredWebhook({ secret: 'shh' })] } }); expect(await guardFor(engine)({ source: 'webhook', refId: declaredWebhookRefId('task_changed') })).toBeUndefined(); }); it('refuses one whose declaration is gone, and one switched off through the ledger', async () => { - const gone = new FakeEngine(); + const gone = newEngine(); expect(await guardFor(gone)({ source: 'webhook', refId: declaredWebhookRefId('task_changed') })) .toMatch(/no longer declared/); - const off = new FakeEngine({ + const off = newEngine({ declared: { webhook: [declaredWebhook()] }, rows: { [LEDGER]: [ledgerRow('task_changed', false)] }, }); @@ -611,7 +619,7 @@ describe('declared webhooks — the redelivery veto', () => { it('judges an 18.0 pristine seeded row\'s old delivery by the declaration that now answers for it', async () => { const wh = declaredWebhook(); - const engine = new FakeEngine({ + const engine = newEngine({ declared: { webhook: [wh] }, rows: { sys_webhook: [seeded18_0Row('whk_seeded', wh)], [LEDGER]: [ledgerRow('task_changed', false)] }, }); @@ -621,7 +629,7 @@ describe('declared webhooks — the redelivery veto', () => { it('judges a customized seeded row by the row, as before', async () => { const wh = declaredWebhook(); - const engine = new FakeEngine({ + const engine = newEngine({ declared: { webhook: [wh] }, rows: { sys_webhook: [seeded18_0Row('whk_seeded', wh, { customized: true })], diff --git a/scripts/objectql-double-limit.baseline.json b/scripts/objectql-double-limit.baseline.json index 5f158b0c341..abd1860f443 100644 --- a/scripts/objectql-double-limit.baseline.json +++ b/scripts/objectql-double-limit.baseline.json @@ -618,9 +618,6 @@ "packages/plugins/plugin-webhooks/src/auto-enqueuer.test.ts": { "unjudged": 1 }, - "packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts": { - "unjudged": 1 - }, "packages/plugins/plugin-webhooks/src/webhook-headers-gate.test.ts": { "blind": 1 }, From 6b3042a841be557baf221e9443c9682046cb534d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 14:09:11 +0000 Subject: [PATCH 08/44] test(plugin-webhooks): the fake engine refuses a WHERE combinator it does not implement Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ Co-authored-by: Claude --- packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts b/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts index e261e519df5..134245506bf 100644 --- a/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts +++ b/packages/plugins/plugin-webhooks/src/declared-webhooks.test.ts @@ -51,6 +51,7 @@ interface HookEntry { function rowMatches(row: any, cond?: Record): boolean { if (!cond) return true; return Object.entries(cond).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake engine: the ${k} combinator is not implemented`); if (v && typeof v === 'object' && Array.isArray((v as any).$in)) return (v as any).$in.includes(row[k]); return row[k] === v; }); From 820121cf3be5b9078fc103148d8bf470df70ad7a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 15:58:30 +0000 Subject: [PATCH 09/44] fix(lint,showcase): the dead label/description verdict is carried to authors; checklist and admitsOrganization docblock say a declared webhook is deployment-wide under single Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ Co-authored-by: Claude --- .../areas/integration-system.json | 4 +- .../src/automation/webhooks/index.ts | 6 +-- .../lint/src/lint-liveness-properties.test.ts | 43 +++++++++++++++---- .../plugin-webhooks/src/auto-enqueuer.ts | 16 ++++--- packages/spec/liveness/webhook.json | 6 ++- 5 files changed, 54 insertions(+), 21 deletions(-) diff --git a/docs/qa/platform-checklist/areas/integration-system.json b/docs/qa/platform-checklist/areas/integration-system.json index 8eb155a0371..62b6f4b4803 100644 --- a/docs/qa/platform-checklist/areas/integration-system.json +++ b/docs/qa/platform-checklist/areas/integration-system.json @@ -528,7 +528,7 @@ "fixtures": { "app": "showcase", "requires": [ - "a reachable webhook receiver (local echo server on the run's own port range) and a SCRATCH webhook authored INSIDE the showcase organization that points at it — Setup → Integrations → Webhooks → New as the signed-in admin, or POST /api/v1/data/sys_webhook under that session, so the row carries the organization. ⚠ The shipped showcase_task_changed cannot carry the per-record or bulk delivery legs: it is read ORG-LESS from the registry (a declared webhook belongs to no organization), and the auto-enqueuer hands an organization-walled event only to a subscription of that organization (packages/plugins/plugin-webhooks/src/auto-enqueuer.ts, AutoEnqueuer.admitsOrganization). On the org-walled showcase it therefore never fans out, while Setup reads it active:true (QA run #23166)", + "a reachable webhook receiver (local echo server on the run's own port range) and a SCRATCH webhook authored INSIDE the showcase organization that points at it — Setup → Integrations → Webhooks → New as the signed-in admin, or POST /api/v1/data/sys_webhook under that session, so the row carries the organization. ⚠ The shipped showcase_task_changed cannot carry the per-record or bulk delivery legs: it is a declared webhook, read-only, so its url stays the placeholder hooks.example and cannot be pointed at the receiver. Once switched on it DOES deliver under `single` — a declared webhook is deployment-wide there and each delivery is stamped with the event's organization (packages/plugins/plugin-webhooks/src/auto-enqueuer.ts, AutoEnqueuer.admitsOrganization, CachedSubscription.deploymentWide) — and under a walled posture it would be refused as belonging to no organization", "the shipped fixture: showcase_task_changed (object showcase_task, triggers create/update/delete, isActive:false ON PURPOSE — switching it on through the activation door is part of the test, examples/app-showcase/src/automation/webhooks/index.ts)", "a predicate multi-write path for the bulk variants (update/delete with multi:true on showcase_task) — NOT reachable over REST, see the knownGap below: author a flow with an `update_record` / `delete_record` node carrying multi:true and fire it through the api trigger" ], @@ -548,7 +548,7 @@ ], "steps": [ "boot the showcase on a fresh database; verify the declared webhook is NOT materialized (ADR-0131 D2 retired the boot seeder): read sys_webhook over /api/v1/data and find NO showcase_task_changed row — the dispatcher reads the declaration from the registry", - "switch the shipped declared showcase_task_changed on as the admin: POST /api/v1/security/_activation/webhook/showcase_task_changed with { enabled: true } — 200 { type: 'webhook', name, enabled: true }, and one sys_metadata_activation row of metadata_type 'webhook'; an undeclared name answers 404. A declared webhook is read-only: pointing it at the receiver means a copy under a new name (the scratch webhook below). This switch is the subject of acceptance[6] ONLY: the declaration stays org-less, so it delivers nothing on the org-walled showcase (fixtures.requires[0]). Author the in-organization scratch webhook beside it for the delivery legs", + "switch the shipped declared showcase_task_changed on as the admin: POST /api/v1/security/_activation/webhook/showcase_task_changed with { enabled: true } — 200 { type: 'webhook', name, enabled: true }, and one sys_metadata_activation row of metadata_type 'webhook'; an undeclared name answers 404. A declared webhook is read-only: pointing it at the receiver means a copy under a new name (the scratch webhook below). This switch is the subject of acceptance[6] ONLY: switched on, the declaration delivers under `single` (one sys_http_delivery row with ref_id declared:showcase_task_changed, stamped with the Default Organization, per task change), but only to its placeholder url (fixtures.requires[0]). Author the in-organization scratch webhook beside it for the delivery legs", "with the in-organization scratch webhook (fixtures.requires[0]) active and pointed at the receiver, create, update, then delete a showcase_task; capture the three deliveries at the receiver (headers incl. the HMAC signature when a secret is set, body incl. recordId). The create needs showcase_task's required `project` lookup — pass an existing showcase_project id, or the create answers 400 VALIDATION_FAILED 'Project is required' and no create event fires", "run a predicate multi-update and multi-delete (multi:true) matching several rows — through a FLOW node, not REST: author update_record / delete_record nodes with multi:true and fire them via the api trigger (the REST batch routes strip options.multi, #3897 — see the knownGap); capture the bulk deliveries and their { object, matched } shape", "read sys_http_delivery over /api/v1/data: one row per delivery with status/attempts/lastStatusCode", diff --git a/examples/app-showcase/src/automation/webhooks/index.ts b/examples/app-showcase/src/automation/webhooks/index.ts index 0a26cd51ad3..e04a91ea3c1 100644 --- a/examples/app-showcase/src/automation/webhooks/index.ts +++ b/examples/app-showcase/src/automation/webhooks/index.ts @@ -3,7 +3,9 @@ import { defineWebhook } from '@objectstack/spec/automation'; /** - * Outbound webhook — fans out task changes to an external endpoint. + * Outbound webhook — fans out task lifecycle events (create/update/delete) + * to an external endpoint. No `label` / `description`: a declared webhook has + * no Setup row to show them on, and the liveness ledger marks both dead. * Authored via `defineStack({ webhooks })`; the webhooks plugin's dispatcher * reads this declaration from the registry (ADR-0131 D2) — no `sys_webhook` * row is written for it. @@ -18,13 +20,11 @@ import { defineWebhook } from '@objectstack/spec/automation'; */ export const TaskChangedWebhook = defineWebhook({ name: 'showcase_task_changed', - label: 'Task Changed → External', object: 'showcase_task', triggers: ['create', 'update', 'delete'], url: 'https://hooks.example/showcase/task', method: 'POST', isActive: false, - description: 'Sends task lifecycle events to an external system. Activate in Setup and point at a real endpoint.', }); export const allWebhooks = [TaskChangedWebhook]; diff --git a/packages/lint/src/lint-liveness-properties.test.ts b/packages/lint/src/lint-liveness-properties.test.ts index 7b42f7e8594..1c22df12954 100644 --- a/packages/lint/src/lint-liveness-properties.test.ts +++ b/packages/lint/src/lint-liveness-properties.test.ts @@ -247,26 +247,51 @@ describe('lintLivenessProperties', () => { expect(findings).toEqual([]); }); - // ── webhook (#3461 bridge landed → #3490; #3494 prune) ──────────────────── - // Two things closed the old "entire surface is dead" state: #3494 PRUNED the - // aspirational dead props (body/payloadFields/includeSession/retryPolicy/tags/ - // authentication) from the schema, and the #3489 materializer bridge makes - // every REMAINING prop live (object/isActive/url/triggers/method/name/headers/ - // secret/timeoutMs/label/description). So a webhook has no misleading dead - // surface left — authoring one is silent. Runs against the REAL webhook.json. - - it('does not warn on an authored webhook — all remaining props are live (#3490)', () => { + // ── webhook (#3461 bridge landed → #3490; #3494 prune; ADR-0131 D2) ───── + // #3494 PRUNED the aspirational dead props from the schema and the #3489 + // materializer bridge made every remaining prop live. ADR-0131 D2 then + // retired that bridge: a declared webhook is read from the registry and has + // no Setup row, so the two display-only props, `label` and `description`, + // lost their only reader and the ledger marks them dead. Every delivery- + // bearing prop stays silent. Runs against the REAL webhook.json, so a ledger + // flip that is not carried to authors fails here. + + it('warns on a declared webhook\'s label and description only — every delivery-bearing prop is live', () => { const findings = lintLivenessProperties({ webhooks: [{ name: 'showcase_task_changed', + label: 'Task Changed', object: 'showcase_task', triggers: ['create', 'update', 'delete'], url: 'https://hooks.example/showcase/task', method: 'POST', + headers: { 'X-Team': 'crm' }, + secret: 'whsec_x', + timeoutMs: 5000, isActive: true, description: 'Sends task lifecycle events to an external system.', }], }); + expect(findings.map((f) => [f.rule, f.message.match(/`(\w+)`/)?.[1]])).toEqual([ + ['liveness-dead-property', 'label'], + ['liveness-dead-property', 'description'], + ]); + for (const f of findings) { + expect(f.where).toBe("webhook 'showcase_task_changed'"); + expect(f.hint).toContain('no Setup row'); + } + }); + + it('stays silent on a declared webhook that authors only what is delivered on', () => { + const findings = lintLivenessProperties({ + webhooks: [{ + name: 'showcase_task_changed', + object: 'showcase_task', + triggers: ['create'], + url: 'https://hooks.example/showcase/task', + isActive: false, + }], + }); expect(findings).toEqual([]); }); diff --git a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts index 71d36cb3957..bb4bbf1025d 100644 --- a/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts +++ b/packages/plugins/plugin-webhooks/src/auto-enqueuer.ts @@ -1409,11 +1409,17 @@ export class AutoEnqueuer { * webhook — accepted, and said once so the subscription is not dead while * looking armed. * - * **`none` × `absent` — deliver.** A `single`-posture deployment stamps - * nothing on either side (`postureStampsOrganization` is false there), so - * this cell is every event on every non-walled install; on a walled one - * it is an environment-wide subscription taking an environment-wide - * event. No organization is named anywhere, so there is no wall to cross. + * **`none` × `absent` — deliver.** An environment-wide subscription taking + * an environment-wide event: no organization is named anywhere, so there is + * no wall to cross. ⚠ It is NOT every event on a `single` install: since + * ADR-0131 D9 every record there is stamped with the Default Organization, + * so a `single` event names that organization like a walled one does. + * + * **A deployment-wide declared webhook — deliver, whatever the event names.** + * Checked first ({@link CachedSubscription.deploymentWide}): a declared + * webhook on a posture with no wall belongs to the deployment, so every + * organization's event is its own. Without it the `none` × `org A` row + * above would refuse every event on a `single` install. * * Both refusals are said ONCE per subscription (the #8022 say-once rule, * ledger {@link organizationRefusalReported}): the first refused event is diff --git a/packages/spec/liveness/webhook.json b/packages/spec/liveness/webhook.json index 3a0e3b50f1d..44531971175 100644 --- a/packages/spec/liveness/webhook.json +++ b/packages/spec/liveness/webhook.json @@ -11,7 +11,8 @@ "label": { "status": "dead", "note": "Display-only — surfaced in Studio, never sent on the wire. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time. 2026-10-19: DEAD since ADR-0131 D2 retired the boot seeder `bootstrapDeclaredWebhooks`, which was its only reader — it copied this value into the declared webhook's `sys_webhook` row, where the Setup list showed it. A declared webhook has no row any more, and nothing displays a declaration: the dispatcher reads only what it delivers on (`auto-enqueuer.ts#projectDeclared`), the activation door only the name. Display-only, so the loss is a label nobody sees, never a delivery that changes. Enforce-or-remove (ADR-0049) is owed: a Setup surface that lists declared webhooks (the declared-sharing-rule Disable and Clone surface is the pattern) makes it live again, otherwise it retires.", - "verifiedAt": "2026-10-19" + "verifiedAt": "2026-10-19", + "authorHint": "A declared webhook's `label` is shown nowhere: declared webhooks are read from the registry and have no Setup row. Remove it — it changes no delivery. A webhook your organization authors in Setup keeps its label." }, "object": { "status": "live", @@ -65,7 +66,8 @@ "description": { "status": "dead", "note": "Display-only — surfaced/editable in Studio, inert in dispatch. 2026-08-28: RE-ANCHORED (commit 9ee2dcfbd) — see the file `_note` for what the re-closure found across this ledger; re-closed by hand against c459da6bc, DATED for the first time. 2026-10-19: DEAD since ADR-0131 D2 retired the boot seeder `bootstrapDeclaredWebhooks`, which was its only reader — it copied this value into the declared webhook's `sys_webhook` row, where the Setup list showed it. A declared webhook has no row any more, and nothing displays a declaration: the dispatcher reads only what it delivers on (`auto-enqueuer.ts#projectDeclared`), the activation door only the name. Display-only, so the loss is a label nobody sees, never a delivery that changes. Enforce-or-remove (ADR-0049) is owed: a Setup surface that lists declared webhooks (the declared-sharing-rule Disable and Clone surface is the pattern) makes it live again, otherwise it retires.", - "verifiedAt": "2026-10-19" + "verifiedAt": "2026-10-19", + "authorHint": "A declared webhook's `description` is shown nowhere: declared webhooks are read from the registry and have no Setup row. Remove it — it changes no delivery; keep the explanation as a code comment instead." } } } From c3b14527d61604a268d8a4bcf3d37f419977254e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:07:00 +0000 Subject: [PATCH 10/44] test(dogfood): the R2 harness pins one delivery per task change from the fixture's customized declared-webhook mirror Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ Co-authored-by: Claude --- .../test/upgrade-18x-harness.dogfood.test.ts | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts b/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts index 2c9f272f60f..49b5266f99c 100644 --- a/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts +++ b/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts @@ -98,6 +98,42 @@ describe(`[#24522] an 18.0 database (fixture from ${manifest.ref} @ ${manifest.s expect(titles).toContain(`${FIXTURE_NAMES.task} — edited`); }); + // [#24529, step 1] Declared webhooks are read from the registry, and the 18.0 + // seeder's rows stay until the ceremony. The fixture's mirror of + // `showcase_task_changed` was activated in 18.0, which stamped it + // `customized`, so it answers as itself and the declaration of the same name + // is withheld: one delivery per task change, from the row, never a second + // one from the declaration. + it('2b. the fixture\'s customized declared webhook dispatches exactly once on a task change', async () => { + expect(stack).toBeDefined(); + const adminTok = await stack!.signIn(FIXTURE_PEOPLE.admin.email, FIXTURE_PEOPLE.admin.password); + const hooks = rowsOf(await (await stack!.apiAs(adminTok, 'GET', `/data/sys_webhook?name=${FIXTURE_NAMES.webhook}`)).json()) + .filter((h) => h?.name === FIXTURE_NAMES.webhook); + expect(hooks, 'the 18.0 mirror row is still there').toHaveLength(1); + expect(hooks[0].customized, 'the fixture activated it in 18.0, which marks it customized').toBe(true); + const deliveries = async () => rowsOf(await (await stack!.apiAs(adminTok, 'GET', '/data/sys_http_delivery?limit=500')).json()) + .filter((d) => d?.source === 'webhook'); + const before = new Set((await deliveries()).map((d) => String(d.id))); + + const projects = rowsOf(await (await stack!.apiAs(adminTok, 'GET', '/data/showcase_project')).json()); + const created = await stack!.apiAs(adminTok, 'POST', '/data/showcase_task', { + title: 'Upgrade harness task (HEAD)', + project: projects[0]?.id, + }); + expect(created.status, await created.clone().text()).toBeLessThan(300); + + // The enqueue runs off the write path: wait for the first new row, then a + // settle window in which a second (double) delivery would land too. + let fresh: any[] = []; + for (let i = 0; i < 50 && fresh.length === 0; i++) { + await new Promise((r) => setTimeout(r, 100)); + fresh = (await deliveries()).filter((d) => !before.has(String(d.id))); + } + await new Promise((r) => setTimeout(r, 1000)); + fresh = (await deliveries()).filter((d) => !before.has(String(d.id))); + expect(fresh.map((d) => d.ref_id), 'one delivery, from the customized row').toEqual([hooks[0].id]); + }); + it('3. every reshaped table keeps every 18.0 row, with its key columns, once HEAD has stopped', async () => { await stack?.stop(); stack = undefined; From 9bce0aafaf0f3606d4c954df2c8b3fc994e0278b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 16:08:28 +0000 Subject: [PATCH 11/44] test(dogfood): the harness case switches the declaration on, so it can fail on a double delivery Claude-Session: https://claude.ai/code/session_01PDSxBZE561TFe1N2iEjKXZ Co-authored-by: Claude --- .../qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts b/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts index 49b5266f99c..313b9fe89b2 100644 --- a/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts +++ b/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts @@ -102,8 +102,8 @@ describe(`[#24522] an 18.0 database (fixture from ${manifest.ref} @ ${manifest.s // seeder's rows stay until the ceremony. The fixture's mirror of // `showcase_task_changed` was activated in 18.0, which stamped it // `customized`, so it answers as itself and the declaration of the same name - // is withheld: one delivery per task change, from the row, never a second - // one from the declaration. + // is withheld even while the declaration is switched on: one delivery per + // task change, from the row, never a second one from the declaration. it('2b. the fixture\'s customized declared webhook dispatches exactly once on a task change', async () => { expect(stack).toBeDefined(); const adminTok = await stack!.signIn(FIXTURE_PEOPLE.admin.email, FIXTURE_PEOPLE.admin.password); @@ -111,6 +111,11 @@ describe(`[#24522] an 18.0 database (fixture from ${manifest.ref} @ ${manifest.s .filter((h) => h?.name === FIXTURE_NAMES.webhook); expect(hooks, 'the 18.0 mirror row is still there').toHaveLength(1); expect(hooks[0].customized, 'the fixture activated it in 18.0, which marks it customized').toBe(true); + // Switch the declaration ON as well (the showcase ships it inactive), so a + // reader that served both the row and the declaration WOULD deliver twice: + // without this the case could not fail on the rule it pins. + const on = await stack!.apiAs(adminTok, 'POST', `/security/_activation/webhook/${FIXTURE_NAMES.webhook}`, { enabled: true }); + expect(on.status, await on.clone().text()).toBe(200); const deliveries = async () => rowsOf(await (await stack!.apiAs(adminTok, 'GET', '/data/sys_http_delivery?limit=500')).json()) .filter((d) => d?.source === 'webhook'); const before = new Set((await deliveries()).map((d) => String(d.id))); From f89c0f205a876b457903560caed8b942b48b1890 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:50:08 +0000 Subject: [PATCH 12/44] wip: birth marker, shapeGeneration, boot guard rule --- .../src/organization-ownership-boot-guard.ts | 320 +++++++++++++----- packages/spec/src/system/migration.zod.ts | 76 ++++- 2 files changed, 302 insertions(+), 94 deletions(-) diff --git a/packages/runtime/src/organization-ownership-boot-guard.ts b/packages/runtime/src/organization-ownership-boot-guard.ts index 957e0de7c06..f16aebe51ba 100644 --- a/packages/runtime/src/organization-ownership-boot-guard.ts +++ b/packages/runtime/src/organization-ownership-boot-guard.ts @@ -3,10 +3,14 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { isDataMigrationFlagVerified, + organizationOwnershipShapeGenerationOf, DATA_MIGRATION_FLAG_OBJECT, ORGANIZATION_OWNERSHIP_MIGRATION_ID, + ORGANIZATION_OWNERSHIP_SHAPE_GENERATION, type DataMigrationFlag, + type OrganizationOwnershipBirthMarker, } from '@objectstack/spec/system'; +import { resolveRuntimeVersion } from './runtime-version.js'; /** * OrganizationOwnershipBootGuardPlugin — the v18 boot refusal (ADR-0131 D10 @@ -22,33 +26,42 @@ import { * * ## What is refused * - * Two questions, in order: + * The guard reads the ceremony's `sys_migration` row + * ({@link ORGANIZATION_OWNERSHIP_MIGRATION_ID}) and decides on ONE field of + * it: the ownership shape generation it attests + * (`organizationOwnershipShapeGenerationOf`), against the one this release + * requires ({@link ORGANIZATION_OWNERSHIP_SHAPE_GENERATION}; ruling record + * 6269955919 on the v18 epic, ① A and item 2). It never compares versions. * - * 1. Does a table whose object declares NO organization column - * (`systemFields.tenant: false` and no declared `organization_id`) still - * carry a PHYSICAL `organization_id`? Schema sync only adds, so on a - * database a v17 runtime created the answer is yes until the ceremony's - * column-drops step has run. If it is, the guard reads the ceremony's - * `sys_migration` marker ({@link ORGANIZATION_OWNERSHIP_MIGRATION_ID}); - * unless that marker is verified, it throws - * {@link OrganizationOwnershipBootRefusal}. - * 2. With no such table left: does the marker exist and say the ceremony has - * not finished? A ceremony whose every step ran (the columns are gone) but - * whose post-check found discrepancies records `applied_at` and leaves - * `verified_at` NULL — rows of an organization-owned object still without - * their organization. D10 item 5 refuses a database the ceremony has not - * COMPLETED, and `verified_at` is the ceremony's completion marker, so this - * database is refused too, naming the re-run. Without a marker there is - * nothing to judge: a fresh database is never refused. + * 1. A VERIFIED row at the current generation — the ceremony's completion + * marker, or the birth marker below — boots, whatever the columns hold. + * 2. No row, on a datastore this boot observed being CREATED FROM EMPTY: the + * guard writes the BIRTH MARKER (verified, `details.birth` = + * {@link OrganizationOwnershipBirthMarker}) exactly once and boots. The + * observation is the store that holds the ledger — its driver's own schema + * sync stats — never the sum over every driver: a `telemetry` sibling left + * over from an earlier boot does not make a fresh primary look old, and a + * fresh sibling does not make an old primary look new. + * 3. No row on any other store: a database an earlier release created (every + * 18.0 database and every v17 one) — refused until the ceremony has run on + * it. The tables still carrying a retired `organization_id` (an object that + * declares no organization column — `systemFields.tenant: false`, + * `systemFields: false` or `tenancy: { enabled: false }` — whose physical + * table still has one) are named when there are any. + * 4. A verified row BELOW the current generation — a database a shorter + * ceremony certified, or a marker written before generations existed — is + * refused, naming the ceremony again: its new steps have not run there. + * 5. A row the ceremony wrote and did not verify (its post-check found rows it + * has not carried) is refused, naming the re-run; so is a row that could + * not be read. * - * - A FRESH database is never refused: no table it creates carries a retired - * column, and no ceremony wrote a marker on it. - * - A database whose ceremony verified boots, whatever its columns. * - A read that fails is refused, never passed: "I could not check" is not * "there is nothing to find" (the `readDataMigrationFlag` asymmetry). + * - A birth marker that cannot be written refuses the boot: a store left + * without one would be taken, at its next boot, for a database an earlier + * release created. * - ⛔ No escape hatch: no `OS_ALLOW_*` / `OS_SKIP_*` variable and no flag - * skips the check (D10 verbatim). A 17.x runtime is the way to keep 17.x - * semantics on an unmigrated database. + * skips the check (D10 verbatim). * * ## When it judges: in `start()`, before any plugin writes a row * @@ -79,9 +92,10 @@ import { * every CLI data and schema boot shares that builder, the ceremony's own boot * among them, and a guard there would refuse the one command that clears it. * - * A driver with no physical columns to read (memory, MongoDB) has no v17 - * shape to find: the guard is inert there, as the recovery plugin is on a - * kernel with no journal. + * A store with no physical columns to read (memory, MongoDB), or a + * composition with no ledger or no store serving it, has nothing to judge: + * the guard is inert there, as the recovery plugin is on a kernel with no + * journal. */ /** The machine code a host discriminates this refusal by, across module instances. */ @@ -181,32 +195,57 @@ function columnNameOf(row: Record): string | null { return typeof v === 'string' ? v : null; } +interface GuardDriver { + dialectName?: unknown; + config?: { client?: unknown }; + execute?: (sql: string, params?: unknown[]) => Promise; + getSchemaSyncStats?: () => { created: number; existing: number } | undefined; +} + interface GuardEngine { registry?: { getAllObjects?: () => unknown[] }; getDriverForObject?: (object: string) => unknown; findOne?: (object: string, query: Record) => Promise; + insert?: (object: string, data: Record, options?: Record) => Promise; getObject?: (name: string) => unknown; + wasDatastoreCreatedFromEmpty?: () => boolean; + invalidateDataMigrationFlags?: () => void; +} + +/** + * Whether an object declares that it carries no organization column: every + * spelling the injected-column resolver honours for an object of its own — + * `systemFields: false`, `systemFields.tenant: false`, and + * `tenancy: { enabled: false }` (`injected-system-columns.ts`). + */ +function declaresNoOrganizationColumn(object: { + systemFields?: unknown; + tenancy?: { enabled?: unknown }; +}): boolean { + if (object.systemFields === false) return true; + if ((object.systemFields as { tenant?: unknown } | undefined)?.tenant === false) return true; + return object.tenancy?.enabled === false; } /** * The tables in the v17 shape: an object that declares no organization column - * whose physical table still carries one. Only SQL drivers are read; any other - * driver is skipped. Throws when a column read fails. + * ({@link declaresNoOrganizationColumn}) whose physical table still carries + * one. Only SQL drivers are read; any other driver is skipped. Throws when a + * column read fails. */ export async function findV17ShapeTables(engine: GuardEngine): Promise { const objects = (engine.registry?.getAllObjects?.() ?? []) as Array<{ name?: string; - systemFields?: { tenant?: unknown }; + systemFields?: unknown; + tenancy?: { enabled?: unknown }; fields?: Record; }>; const found: string[] = []; for (const object of objects) { const name = object?.name; - if (typeof name !== 'string' || object.systemFields?.tenant !== false) continue; + if (typeof name !== 'string' || !declaresNoOrganizationColumn(object)) continue; if (object.fields && Object.prototype.hasOwnProperty.call(object.fields, ORG)) continue; - const driver = engine.getDriverForObject?.(name) as - | { dialectName?: unknown; config?: { client?: unknown }; execute?: (sql: string, params?: unknown[]) => Promise } - | undefined; + const driver = engine.getDriverForObject?.(name) as GuardDriver | undefined; const dialect = dialectOfDriver(driver); if (!dialect || typeof driver?.execute !== 'function') continue; const columns = rowsOf(await driver.execute(columnListSql(dialect), [name])) @@ -217,45 +256,56 @@ export async function findV17ShapeTables(engine: GuardEngine): Promise return found.sort(); } +/** The guard's reading of the ceremony's `sys_migration` row (see {@link readMarker}). */ +export type OrganizationOwnershipMarkerState = 'absent' | 'unverified' | 'unreadable' | 'outdated'; + /** - * The refusal text: what was found, that the server refuses to start, the - * command, and the 17.x way out. On a v17-shaped database it says first that - * 18.0 does not upgrade it, because in 18.0 the command it names refuses too, - * so the operator does not reach that answer in two hops. With no table left in the v17 shape (`tables` - * empty), the ceremony ran every step and its marker is not verified: the - * text says so and names the re-run instead. + * The refusal text: what was found, that the server refuses to start, and the + * command. It names no release line to stay on: a database an 18.0 install + * created is refused here exactly as a v17 one is, and both take the same + * ceremony. */ export function organizationOwnershipRefusalMessage( tables: readonly string[], - marker: 'absent' | 'unverified' | 'unreadable', - opts: OrganizationOwnershipBootGuardOptions = {}, + marker: OrganizationOwnershipMarkerState, + opts: OrganizationOwnershipBootGuardOptions & { generation?: number } = {}, ): string { const ceremony = ceremonyCommand(opts.dev); - if (tables.length === 0) { - const state = marker === 'unreadable' - ? `its marker (sys_migration '${ORGANIZATION_OWNERSHIP_MIGRATION_ID}') could not be read` - : `its marker (sys_migration '${ORGANIZATION_OWNERSHIP_MIGRATION_ID}') is not verified: the post-check ` + - 'still found rows the ceremony has not carried'; + const run = + `Run '${ceremony} --out PLAN' to write and review its plan, take a backup, then run ` + + `'${ceremony} --apply --plan-file PLAN --backup-taken', and start again.`; + const columns = tables.length > 0 + ? ` ${tables.join(', ')} still carr${tables.length === 1 ? 'ies' : 'y'} a physical organization_id column its ` + + 'object no longer declares.' + : ''; + if (marker === 'outdated') { + return ( + `This database was certified by an organization-ownership ceremony of shape generation ${opts.generation ?? 0}, ` + + `and this release requires generation ${ORGANIZATION_OWNERSHIP_SHAPE_GENERATION}: the ceremony steps added since ` + + `have not run on it.${columns} The server refuses to start rather than serve it in a shape it was never ` + + `migrated to. ${run}` + ); + } + if (marker === 'absent') { return ( - `The organization-ownership ceremony has not finished on this database: ${state}. The server refuses to start ` + - 'rather than serve rows of an organization-owned object with no organization. Resolve what the apply report ' + - `names, then run '${ceremony} --out PLAN' again, review it, run '${ceremony} --apply --plan-file PLAN ` + - "--backup-taken', and start again." + 'This database carries no organization-ownership marker: an earlier release created it (a v17 install, or an ' + + '18.0 one), and this release serves only a database it created itself or one its ownership ceremony upgraded.' + + `${columns} The server refuses to start rather than serve it in a shape it was never migrated to. ${run}` + ); + } + if (marker === 'unreadable') { + return ( + `The organization-ownership marker (sys_migration '${ORGANIZATION_OWNERSHIP_MIGRATION_ID}') of this database ` + + `could not be read, and an unread answer is never taken for a migrated database.${columns} The server refuses ` + + `to start. Fix the read, or run the ceremony: ${run}` ); } - const state = marker === 'absent' - ? 'the ceremony has not run on it' - : marker === 'unverified' - ? 'the ceremony has not finished on it (its marker is not verified)' - : 'its marker could not be read'; return ( - `This database is still in the v17 organization-ownership shape: ${tables.join(', ')} still carr` + - `${tables.length === 1 ? 'ies' : 'y'} a physical organization_id column, and ${state}. The server refuses to ` + - 'start rather than serve v17 data with v18 semantics. 18.0 does not upgrade a v17 database: keep this one on a ' + - '17.x release until the 18.x release that carries the upgrade path, and run these steps there. ' + - `Run '${ceremony} --out PLAN' to write and review its ` + - `plan, take a backup, then run '${ceremony} --apply --plan-file PLAN --backup-taken', and start again. ` + - 'To keep 17.x semantics, run a 17.x runtime.' + `The organization-ownership ceremony has not finished on this database: its marker (sys_migration ` + + `'${ORGANIZATION_OWNERSHIP_MIGRATION_ID}') is not verified, so the post-check still found rows the ceremony has ` + + `not carried.${columns} The server refuses to start rather than serve rows of an organization-owned object with ` + + `no organization. Resolve what the apply report names, then run '${ceremony} --out PLAN' again, review it, run ` + + `'${ceremony} --apply --plan-file PLAN --backup-taken', and start again.` ); } @@ -293,43 +343,109 @@ export class OrganizationOwnershipBootGuardPlugin implements Plugin { } } +/** What the guard read of the ceremony's `sys_migration` row. */ +type MarkerReading = + /** No ledger on this composition, or no SQL store serving it: nothing to judge. */ + | { state: 'inert' } + | { state: 'absent' } + | { state: 'unreadable' } + | { state: 'unverified'; generation: number } + | { state: 'verified'; generation: number }; + /** - * The ceremony's marker: `absent` (no ledger on this composition, no store - * serving it, or no row), `verified`, `unverified`, or `unreadable` (a store - * serves the ledger and its read threw). + * The ceremony's marker. `inert` when this composition registers no ledger + * object, no store serves it (`os serve` with no OS_DATABASE_URL and no driver + * plugin), or the store that does has no physical columns to read (memory, + * MongoDB) — nothing was ever migrated there. `unreadable` only when a store + * serves the ledger and its read threw. */ -async function readMarker(engine: GuardEngine): Promise<'absent' | 'verified' | 'unverified' | 'unreadable'> { - if (typeof engine.findOne !== 'function') return 'absent'; - // A composition that registers no ledger object has no marker to hold. - if (typeof engine.getObject === 'function' && !engine.getObject(DATA_MIGRATION_FLAG_OBJECT)) return 'absent'; - // A boot with no storage (no driver serves the ledger — `os serve` with no - // OS_DATABASE_URL and no driver plugin) has no database to judge, exactly as - // the column probe skips an object no driver serves: nothing was ever - // migrated there. Only a store that serves the ledger and fails its read is - // `unreadable`. - if (typeof engine.getDriverForObject === 'function' && !engine.getDriverForObject(DATA_MIGRATION_FLAG_OBJECT)) return 'absent'; +async function readMarker(engine: GuardEngine): Promise { + if (typeof engine.findOne !== 'function') return { state: 'inert' }; + if (typeof engine.getObject === 'function' && !engine.getObject(DATA_MIGRATION_FLAG_OBJECT)) return { state: 'inert' }; + const store = ledgerStore(engine); + if (!store || !dialectOfDriver(store)) return { state: 'inert' }; try { const row = (await engine.findOne(DATA_MIGRATION_FLAG_OBJECT, { where: { id: ORGANIZATION_OWNERSHIP_MIGRATION_ID }, context: { isSystem: true }, })) as DataMigrationFlag | null; - if (!row) return 'absent'; + if (!row) return { state: 'absent' }; const flag = { ...row, blocking: Number((row as { blocking?: unknown }).blocking) } as DataMigrationFlag; - return isDataMigrationFlagVerified(flag) ? 'verified' : 'unverified'; + const generation = organizationOwnershipShapeGenerationOf(flag.details); + return isDataMigrationFlagVerified(flag) ? { state: 'verified', generation } : { state: 'unverified', generation }; + } catch { + return { state: 'unreadable' }; + } +} + +/** The driver that serves the ledger, or `undefined`. */ +function ledgerStore(engine: GuardEngine): GuardDriver | undefined { + if (typeof engine.getDriverForObject !== 'function') return undefined; + try { + return (engine.getDriverForObject(DATA_MIGRATION_FLAG_OBJECT) as GuardDriver | undefined) ?? undefined; } catch { - return 'unreadable'; + return undefined; } } +/** + * Did THIS boot observe the store that holds the ledger being created from + * empty? Its own driver's schema-sync stats (`getSchemaSyncStats`): every + * table it synced it created, none it found. A driver that cannot report + * answers through the engine's all-driver reading, and "cannot say" is no. + */ +function ledgerStoreCreatedFromEmpty(engine: GuardEngine): boolean { + const stats = ledgerStore(engine)?.getSchemaSyncStats?.(); + if (stats) return (Number(stats.existing) || 0) === 0 && (Number(stats.created) || 0) > 0; + try { + return engine.wasDatastoreCreatedFromEmpty?.() === true; + } catch { + return false; + } +} + +/** + * Write the birth marker: the ceremony's row, verified, with `details.birth` + * naming the generation the store was created in. Only where no row exists. + */ +async function writeBirthMarker(engine: GuardEngine, now: Date): Promise { + if (typeof engine.insert !== 'function') throw new Error('the engine offers no insert'); + const at = now.toISOString(); + // `commit` stays absent: no build of this runtime records its source commit. + const birth: OrganizationOwnershipBirthMarker = { + shapeGeneration: ORGANIZATION_OWNERSHIP_SHAPE_GENERATION, + platformVersion: resolveRuntimeVersion(), + createdAt: at, + }; + await engine.insert( + DATA_MIGRATION_FLAG_OBJECT, + { + id: ORGANIZATION_OWNERSHIP_MIGRATION_ID, + last_run_at: at, + verified_at: at, + applied_at: null, + blocking: 0, + advisory: 0, + details: JSON.stringify({ birth }), + created_at: at, + updated_at: at, + }, + { context: { isSystem: true } }, + ); + engine.invalidateDataMigrationFlags?.(); + return birth; +} + /** * The check itself, for the plugin and for a host that composes its own boot: - * resolves when the database may be served, throws - * {@link OrganizationOwnershipBootRefusal} when it may not. + * resolves when the database may be served (writing the birth marker on a + * store created from empty), throws {@link OrganizationOwnershipBootRefusal} + * when it may not. */ export async function assertOrganizationOwnershipMigrated( engine: GuardEngine, logger?: { info?: (msg: string) => void }, - opts: OrganizationOwnershipBootGuardOptions = {}, + opts: OrganizationOwnershipBootGuardOptions & { now?: () => Date } = {}, ): Promise { const ceremony = ceremonyCommand(opts.dev); let tables: string[]; @@ -337,26 +453,50 @@ export async function assertOrganizationOwnershipMigrated( tables = await findV17ShapeTables(engine); } catch (error) { throw new OrganizationOwnershipBootRefusal( - `The server refuses to start: it could not read whether this database is still in the v17 organization-ownership ` + - `shape (${error instanceof Error ? error.message : String(error)}), and an unread answer is never taken for a ` + - `migrated database. Fix the read, or run the ceremony ('${ceremony} --out PLAN', then ` + + `The server refuses to start: it could not read whether this database is still in an earlier organization-` + + `ownership shape (${error instanceof Error ? error.message : String(error)}), and an unread answer is never ` + + `taken for a migrated database. Fix the read, or run the ceremony ('${ceremony} --out PLAN', then ` + `'${ceremony} --apply --plan-file PLAN --backup-taken'), then start again.`, [], ); } const marker = await readMarker(engine); - if (marker === 'verified') { + if (marker.state === 'inert') return; + if (marker.state === 'verified' && marker.generation >= ORGANIZATION_OWNERSHIP_SHAPE_GENERATION) { if (tables.length > 0) { logger?.info?.( `[organization-ownership] ${tables.join(', ')} still carry a physical organization_id column, and the ` + - `ceremony's marker is verified: the server starts.`, + `marker is verified at shape generation ${marker.generation}: the server starts.`, ); } return; } - // No table left in the v17 shape: only a marker the ceremony wrote and did - // not verify refuses (its post-check found rows it has not carried). No - // marker is a database no ceremony ran on — a fresh one — and it boots. - if (tables.length === 0 && marker === 'absent') return; - throw new OrganizationOwnershipBootRefusal(organizationOwnershipRefusalMessage(tables, marker, opts), tables); + if (marker.state === 'absent' && tables.length === 0 && ledgerStoreCreatedFromEmpty(engine)) { + let birth: OrganizationOwnershipBirthMarker; + try { + birth = await writeBirthMarker(engine, (opts.now ?? (() => new Date()))()); + } catch (error) { + throw new OrganizationOwnershipBootRefusal( + `The server refuses to start: this datastore was created from empty, and its organization-ownership birth ` + + `marker (sys_migration '${ORGANIZATION_OWNERSHIP_MIGRATION_ID}') could not be written ` + + `(${error instanceof Error ? error.message : String(error)}). Without it the next boot would take this ` + + `store for a database an earlier release created. Fix the write and start again, or run the ceremony ` + + `('${ceremony} --out PLAN', then '${ceremony} --apply --plan-file PLAN --backup-taken').`, + [], + ); + } + logger?.info?.( + `[organization-ownership] a datastore created from empty: birth marker written (shape generation ` + + `${birth.shapeGeneration}, platform ${birth.platformVersion}).`, + ); + return; + } + const state: OrganizationOwnershipMarkerState = marker.state === 'verified' ? 'outdated' : marker.state; + throw new OrganizationOwnershipBootRefusal( + organizationOwnershipRefusalMessage(tables, state, { + ...opts, + ...('generation' in marker ? { generation: marker.generation } : {}), + }), + tables, + ); } diff --git a/packages/spec/src/system/migration.zod.ts b/packages/spec/src/system/migration.zod.ts index 5f91853fd18..7a16973168f 100644 --- a/packages/spec/src/system/migration.zod.ts +++ b/packages/spec/src/system/migration.zod.ts @@ -178,13 +178,81 @@ export const VALUE_SHAPES_MIGRATION_ID = 'adr-0104-value-shapes'; * to start against a database still in the v17 shape while this marker is not * verified (D10 item 5). * - * ⛔ Not a member of {@link CREATION_ATTESTED_MIGRATION_IDS}: a fresh store is - * not in the v17 shape (no table it creates carries a retired organization - * column), so the boot needs no attestation to start it, and a row claiming a - * ceremony that never ran would read as one that did. + * ⛔ Not a member of {@link CREATION_ATTESTED_MIGRATION_IDS}: those are + * attested by `attestFreshDatastore` as a claim that a backfill's fact holds, + * and a row claiming a ceremony that never ran would read as one that did. + * + * The one exception, and the only one (ruling record 6269955919 on the v18 + * epic, ① A): the organization-ownership boot guard itself writes this row + * ONCE, on a datastore it observed being created from empty, as the store's + * BIRTH MARKER ({@link OrganizationOwnershipBirthMarker}) — verified, with + * `details.birth` naming the {@link ORGANIZATION_OWNERSHIP_SHAPE_GENERATION} + * the store was born in. It claims no ceremony ran: it records that none was + * owed. A database with no row — every database an earlier release created — + * takes the ceremony before an 18.x server serves it. */ export const ORGANIZATION_OWNERSHIP_MIGRATION_ID = 'adr-0131-organization-ownership'; +/** + * The organization-ownership shape generation this release requires (ruling + * record 6269955919 on the v18 epic, item 2). Every ceremony step that changes + * the required shape bumps it, in the change that adds the step. + * + * Both markers carry it — the ceremony's verified checkpoint + * (`details.checkpoint.shapeGeneration`) and the birth marker + * (`details.birth.shapeGeneration`) — and the boot guard decides on it ALONE, + * by one rule: a marker whose generation is below this one refuses the boot + * and names the ceremony. It never compares version strings. A marker written + * before generations existed reads as generation 0, so a database a shorter + * ceremony certified is never taken for one this release's ceremony did. + * + * Generations: + * - `1` — the ceremony that upgrades v17 and 18.0 databases: the declared + * webhook settlement, the two sibling moves, the declared sharing-rule + * copies, the `sys_secret` / `sys_flow_credential` column drops and the + * `organization_id` NOT NULL step. + */ +export const ORGANIZATION_OWNERSHIP_SHAPE_GENERATION = 1; + +/** + * The birth marker the organization-ownership boot guard writes into + * `details.birth` of the {@link ORGANIZATION_OWNERSHIP_MIGRATION_ID} row on a + * datastore created from empty (ruling record 6269955919, ① A). Only + * `shapeGeneration` is ever decided on; the rest is provenance. + */ +export const OrganizationOwnershipBirthMarkerSchema = lazySchema(() => z.object({ + shapeGeneration: z.number().int().min(0) + .describe('The organization-ownership shape generation the store was created in; the only field the boot guard decides on'), + platformVersion: z.string() + .describe('The exact platform version that created the store (provenance only, never compared)'), + commit: z.string().optional() + .describe('The source commit of that build, when known (provenance only)'), + createdAt: z.string().datetime() + .describe('When the guard observed the store created from empty'), +}).describe('Birth marker of a datastore created from empty: the ownership shape generation it was born in')); +export type OrganizationOwnershipBirthMarker = z.infer; + +/** + * The shape generation an {@link ORGANIZATION_OWNERSHIP_MIGRATION_ID} row's + * `details` attests — its birth marker's, else its ceremony checkpoint's — or + * `0` when it names none (a marker written before generations existed, or + * details that do not parse). Pure derivation; the guard compares the answer + * with {@link ORGANIZATION_OWNERSHIP_SHAPE_GENERATION}. + */ +export function organizationOwnershipShapeGenerationOf(details: unknown): number { + let parsed: unknown = details; + if (typeof details === 'string') { + try { + parsed = JSON.parse(details); + } catch { + return 0; + } + } + const d = parsed as { birth?: { shapeGeneration?: unknown }; checkpoint?: { shapeGeneration?: unknown } } | null; + const g = d?.birth?.shapeGeneration ?? d?.checkpoint?.shapeGeneration; + return typeof g === 'number' && Number.isInteger(g) && g >= 0 ? g : 0; +} + /** * ⚰️ RETIRED migration id: ADR-0030 notification convergence. * From 6e14afa365288af231ed45ea0dd5bbdbd551851a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:56:35 +0000 Subject: [PATCH 13/44] wip: ceremony steps 5a-5d, inventory, flips, differ ownership --- .../src/utils/organization-ownership-apply.ts | 853 +++++++++++++++++- .../utils/organization-ownership-inventory.ts | 72 +- .../drivers/driver-sql/src/schema-drift.ts | 14 +- packages/drivers/driver-sql/src/sql-driver.ts | 3 + .../src/system/sys-secret.object.ts | 5 + .../plugin-webhooks/src/declared-webhooks.ts | 15 +- .../plugin-webhooks/src/webhook-provenance.ts | 4 +- .../src/sys-flow-credential.object.ts | 5 + .../service-settings/src/settings-service.ts | 8 +- .../src/settings-service.types.ts | 7 - packages/spec/authorable-surface/system.json | 4 + .../spec/json-schema.manifest/system.json | 1 + 12 files changed, 962 insertions(+), 29 deletions(-) diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index 2fdb0c395bd..53149c8e586 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -1,6 +1,7 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system'; +import { randomUUID } from 'node:crypto'; +import { ORGANIZATION_OWNERSHIP_MIGRATION_ID, ORGANIZATION_OWNERSHIP_SHAPE_GENERATION } from '@objectstack/spec/system'; import { convertPositionBindingRows, permissionSetDefinitionFromRow, @@ -27,6 +28,7 @@ import { columnNameOf, predicateSql, PRIMARY_DATABASE, + requireColumn, Sql, underivableDepartureOf, type AttributionSink, @@ -103,6 +105,19 @@ import type { DeclaredSharingRuleSkip } from '@objectstack/plugin-sharing'; * copied, by the database itself, into `sys_platform_setting`, read back, * and only then removed from `sys_setting`. * A key the new table already holds with a different value is a blocker. + * 2d. `sibling-moves` (18.x step 5b) — the deployment-level rows an 18.0 + * database keeps in a tenant table move, id for id, into the sibling this + * release writes them to: the settings audit trail's global rung and + * identity mail, selected by shape, the mail's queued jobs and the rows' + * activity timeline pointed at the sibling first ({@link runSiblingMoves}). + * 2e. `declared-webhooks` (18.x step 5a) — each `sys_webhook` copy a seeder + * wrote under a declared name is settled: a pristine one deleted once its + * deliveries name the declaration, a switch-only edit kept as the + * declaration's activation row, any other edit kept as a clone under a new + * name with the declaration switched off ({@link runDeclaredWebhooks}). + * 2f. `declared-rule-copies` (18.x step 5c, C3 stage 9c) — each stored + * sharing rule under a declared rule's name becomes a clone under a new + * name, the declaration switched off ({@link runDeclaredRuleCopies}). * 3. `rewrite` — the verified id→name rewrite (D4): * `sys_user_permission_set.permission_set` is named from the set row its * `permission_set_id` points at, then EVERY grant is verified to name a set @@ -137,6 +152,10 @@ import type { DeclaredSharingRuleSkip } from '@objectstack/plugin-sharing'; * (C7b), once the rewrite has read it and no two grants hold one set for * one user in one organization (the unique index that replaces the old * one would refuse them; they are reported, never deleted). + * 6. `not-null` (18.x step 5d, 8e) — every table the plan now clears + * receives NOT NULL on `organization_id`, through the schema door + * ({@link NotNullPort}); the ceremony owns that nullability and the + * differ reads it as no drift (ruling record 6269955919, ② A). * * Then the post-check re-runs the plan and reports, per table, what remains. * A departure no step of a release can perform — the declared sharing-rule @@ -189,7 +208,9 @@ import type { DeclaredSharingRuleSkip } from '@objectstack/plugin-sharing'; * * ## What it never does * - * ⛔ Delete a row outside fate 2 and a declared `underivableDeparture`. ⛔ Give an unattributable row an owner: + * ⛔ Delete a row outside fate 2, a declared `underivableDeparture`, a moved + * row once its copy reads back, a declared webhook's copy, and the grants of a + * declaration it switched off. ⛔ Give an unattributable row an owner: * owners come only from the plan's anchors and, under `single`, the Default * Organization. ⛔ Create any organization but the Default Organization, and * that one only under `single` on a database that holds none. ⛔ Run at boot: @@ -205,10 +226,14 @@ export const CEREMONY_STEPS = [ 'metadata-promotion', 'template-promotion', 'settings-move', + 'sibling-moves', + 'declared-webhooks', + 'declared-rule-copies', 'rewrite', 'mirror-deletion', 'history-renumber', 'column-drops', + 'not-null', ] as const; export type CeremonyStep = (typeof CEREMONY_STEPS)[number]; @@ -269,7 +294,8 @@ export interface MetadataLockPort { * reason. */ export interface DeclaredSharingRulesPort { - rules(): Promise>; + /** `packageId`: the declaring package, for the switch row a settled copy writes (`declared-rule-copies`). */ + rules(): Promise>; } /** @@ -278,14 +304,17 @@ export interface DeclaredSharingRulesPort { * {@link DeclaredSharingRulesPort}: a nameless item is no rule to rename onto * and is left out. */ -export async function judgeDeclaredSharingRules(items: readonly unknown[]): Promise> { +export async function judgeDeclaredSharingRules( + items: readonly unknown[], +): Promise> { const { translateDeclaredSharingRule } = await import('@objectstack/plugin-sharing'); - const out: Array<{ name: string; refused: string | null }> = []; + const out: Array<{ name: string; refused: string | null; packageId: string | null }> = []; for (const item of items) { const name = (item as { name?: unknown } | null)?.name; if (typeof name !== 'string' || name === '') continue; const { skip } = translateDeclaredSharingRule(item); - out.push({ name, refused: skip ? describeDeclaredRuleSkip(skip) : null }); + const packageId = (item as { _packageId?: unknown })._packageId; + out.push({ name, refused: skip ? describeDeclaredRuleSkip(skip) : null, packageId: typeof packageId === 'string' && packageId ? packageId : null }); } return out; } @@ -345,6 +374,13 @@ export interface ColumnDropPort { export interface CeremonyCheckpoint { ceremony: 'ADR-0131 D10'; inventoryDigest: string; + /** + * The organization-ownership shape generation this ceremony's step set + * reaches ({@link ORGANIZATION_OWNERSHIP_SHAPE_GENERATION}): what the boot + * guard decides on once the marker is verified. A checkpoint an older + * ceremony wrote carries none and reads as generation 0. + */ + shapeGeneration?: number; completed: Partial }>>; /** * The `--metadata-choice` rows whose conflict this ceremony resolved, recorded @@ -596,6 +632,13 @@ export interface ApplyOptions { * that finds a declared-rule mirror standing blocks instead of deleting it. */ declaredSharingRules?: DeclaredSharingRulesPort; + /** + * [18.x step 5a] The declared webhooks, by name. Absent, a run that finds a + * seeded `sys_webhook` row blocks instead of settling it. + */ + declaredWebhooks?: DeclaredWebhooksPort; + /** [18.x step 5d] The schema door the NOT NULL step applies through. Absent, it applies nothing and says so. */ + notNull?: NotNullPort; /** The served views, for carrying form withdrawals. */ views: ServedViewsPort; /** The door's lock verdict, for the one write the ceremony makes in place. */ @@ -2925,6 +2968,795 @@ async function runColumnDrops(run: Run): Promise { }; } +// ── 18.x, shape generation 1: what an 18.0 database owes too ──────────────── +// +// The four steps below select their rows BY SHAPE, never by `organization_id +// IS NULL`: an 18.0 single-posture database stamped every one of them with the +// Default Organization (line fact F3 of the 18.x plan), so a NULL selector +// would find nothing there and certify a database that still owes the step. + +const WEBHOOKS = 'sys_webhook'; +const DELIVERIES = 'sys_http_delivery'; +const SECRETS = 'sys_secret'; +const ACTIVATIONS = 'sys_metadata_activation'; +const JOB_QUEUE = 'sys_job_queue'; +const ACTIVITY = 'sys_activity'; +/** The provenance a seeded copy of a declaration carries (`managed_by`). */ +const SEEDED_PROVENANCE = ['package', 'platform'] as const; +/** + * How a delivery names a declared webhook: plugin-webhooks' + * `declaredWebhookRefId` (`declared:NAME`). Spelled here because the CLI does + * not load the plugin to re-source rows; `organization-ownership-apply.declared-webhooks.test.ts` + * holds the two equal. + */ +export const DECLARED_WEBHOOK_REF_PREFIX = 'declared:'; +/** The `name` column's length on `sys_webhook` and `sys_sharing_rule`. */ +const NAME_MAX = 100; +/** The stored form of an encrypted field's value: `secret:` (objectql's `SECRET_REF_PREFIX`). */ +const SECRET_REF_PREFIX = 'secret:'; + +const truthy = (value: unknown): boolean => value === true || value === 1 || value === '1' || value === 'true' || value === 't'; + +/** The physical tables of `object` the plan this run started from found present. */ +function presentTables(run: Run, object: string): string[] { + const table = run.plan.tables.find((t) => t.object === object); + return table?.physical === 'present' ? table.tables : []; +} + +const blockedStep = (step: CeremonyStep, summary: Record, blockers: string[], notes: string[] = []): StepReport => + ({ step, status: 'blocked', summary, blockers, notes }); + +/** + * Set the deployment's switch for a declared item — the `sys_metadata_activation` + * row (ADR-0131 D14) keyed `(metadata_type, name)` — to `active`, read back. + * `false` when the row already said so (nothing written). + */ +async function setActivation(run: Run, type: string, name: string, packageId: string | null, active: boolean): Promise { + const [table] = presentTables(run, ACTIVATIONS); + if (!table) { + throw new Error( + `${ACTIVATIONS} does not exist on this database, so the switch of the declared ${type} ${name} cannot be written. ` + + 'Boot this release against it once (its schema sync creates the table), then re-run --apply.', + ); + } + const cols = (await columnsOf(run, table))!; + const { sql } = databaseFor(run, table); + const q = (c: string): string => sql.q(c, table); + const read = (): Promise[]> => + sql.read( + table, + `SELECT ${q('id')} AS ${q('id')}, ${q('active')} AS ${q('active')} FROM ${q(table)} WHERE ${q('metadata_type')} = ? AND ${q('name')} = ?`, + [type, name], + ); + const [row] = await read(); + if (row && truthy(row.active) === active) return false; + const at = (run.opts.now ?? (() => new Date()))().toISOString(); + if (row) { + await execute( + run, + table, + `UPDATE ${q(table)} SET ${q('active')} = ?${cols.has('updated_at') ? `, ${q('updated_at')} = ?` : ''} WHERE ${q('id')} = ?`, + [sql.bind(active), ...(cols.has('updated_at') ? [at] : []), row.id], + ); + } else { + const values: Record = { + id: `act_${randomUUID()}`, + metadata_type: type, + name, + package_id: packageId, + active: sql.bind(active), + created_at: at, + updated_at: at, + }; + const keys = Object.keys(values).filter((c) => cols.has(c)); + await execute( + run, + table, + `INSERT INTO ${q(table)} (${keys.map(q).join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`, + keys.map((k) => values[k]), + ); + } + const [after] = await read(); + if (!after || truthy(after.active) !== active) { + throw new Error(`The switch of the declared ${type} ${name} did not read back ${active ? 'on' : 'off'} in ${ACTIVATIONS}.`); + } + return true; +} + +/** + * A name for a clone: `_custom`, then `_custom_2`, … — one no row of + * `table` holds and no package declares (`taken`), within {@link NAME_MAX}. + */ +async function freeName(run: Run, table: string, base: string, taken: Set): Promise { + const { sql } = databaseFor(run, table); + for (let n = 1; ; n++) { + const suffix = n === 1 ? '_custom' : `_custom_${n}`; + const name = `${base.slice(0, NAME_MAX - suffix.length)}${suffix}`; + if (taken.has(name)) continue; + const held = await sql.count(table, `SELECT COUNT(*) AS n FROM ${sql.q(table, table)} WHERE ${sql.q('name', table)} = ?`, [name]); + if (held === 0) { + taken.add(name); + return name; + } + } +} + +// ── Step 2e: declared webhooks (18.x step 5a; ADR-0131 D2, D10 fate 2) ────── + +/** + * [ADR-0131 D2] The webhooks the deployment's packages DECLARE, by name, with + * the package that declares each — what plugin-webhooks serves from the + * registry (the command: the booted engine's registry, else the metadata + * service). + */ +export interface DeclaredWebhooksPort { + list(): Promise>; +} + +/** A `sys_webhook` row's `definition_json`, parsed, or `null`. */ +function webhookDefinition(row: Record): Record | null { + const raw = row.definition_json; + if (typeof raw !== 'string' || raw === '') return null; + try { + const parsed = JSON.parse(raw) as unknown; + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : null; + } catch { + return null; + } +} + +const triggerList = (value: unknown): string => { + let list: unknown = value; + if (typeof value === 'string') { + try { + list = JSON.parse(value); + } catch { + list = [value]; + } + } + return JSON.stringify(Array.isArray(list) ? list.map(String).sort() : []); +}; + +/** + * Whether a customized seeded webhook differs from the copy the 18.0 seeder + * last wrote ONLY in its switch. `definition_json` is that copy: the seeder + * rewrote it on every boot while the row was pristine, and the first edit + * stopped it. Every column the seeder mapped from the envelope must still + * read as the envelope says. A row holding a credential is never read as + * switch-only: a stored secret cannot be compared without decrypting it, so it + * is settled as a clone, which keeps it — the conservative reading. + */ +function isSwitchOnlyEdit(row: Record): boolean { + const def = webhookDefinition(row); + if (!def) return false; + if (text(row.headers_secret) !== null || text(row.signing_secret) !== null) return false; + const same = (a: unknown, b: unknown): boolean => text(a) === text(b); + return ( + same(row.label, def.label ?? def.name) && + same(row.object_name, def.object ?? null) && + triggerList(row.triggers) === triggerList(def.triggers) && + same(row.url, def.url) && + same(String(row.method ?? 'post').toLowerCase(), String(def.method ?? 'POST').toLowerCase()) && + same(row.description, def.description ?? null) + ); +} + +const secretIdsOf = (row: Record): string[] => + [row.headers_secret, row.signing_secret] + .map((v) => (typeof v === 'string' && v.startsWith(SECRET_REF_PREFIX) ? v.slice(SECRET_REF_PREFIX.length) : '')) + .filter((id) => id !== ''); + +/** + * [18.x step 5a; ADR-0131 D2, D10 fate 2] Settle every `sys_webhook` row a + * package's seeder wrote (`managed_by` `package` or `platform`) under a name a + * package still declares — plugin-webhooks serves that declaration from the + * registry, so the row is a copy: + * + * - a PRISTINE copy (not `customized`) is deleted, after every delivery that + * names it (`sys_http_delivery.ref_id` = its row id) is re-sourced to the + * declaration (`declared:NAME`), so history and redelivery follow the + * declaration; + * - a copy customized only in its switch ({@link isSwitchOnlyEdit}) becomes + * the declaration's activation row with that switch (regime C, ruling Q2 A + * on the declared-webhook card: an activation row for an `active` change), + * its deliveries are re-sourced, and it is deleted; + * - a copy customized in anything else becomes a CLONE under a new name — + * renamed in place, `managed_by 'admin'`, so its id, its credentials and + * every delivery naming it stay its own (the deliveries' tail already names + * the clone's row id) — and the declaration is switched off. + * + * The `sys_secret` rows a deleted copy's credential columns referenced are + * deleted with it, once no other webhook row references them. A seeded row + * whose name no package declares any more is an ordinary webhook — it + * dispatches as itself, as it did in 18.0 — and stays, reported. + * + * Under a wall the declaration's switch is deployment-wide (there is no + * per-organization switch-off), so a customized copy is not settled on the + * operator's behalf: it blocks, named, with the remedy. Idempotent: a second + * run finds no seeded row under a declared name. + */ +async function runDeclaredWebhooks(run: Run): Promise { + const step: CeremonyStep = 'declared-webhooks'; + const summary: Record = { mirrorsDeleted: 0, activationRows: 0, clones: 0, deliveriesResourced: 0, secretsDeleted: 0, undeclaredKept: 0 }; + const notes: string[] = []; + const [table] = presentTables(run, WEBHOOKS); + const cols = table ? await columnsOf(run, table) : null; + if (!table || !cols?.has('managed_by') || !cols.has('name')) return { step, status: 'done', summary, blockers: [], notes }; + const { sql } = databaseFor(run, table); + const q = (c: string): string => sql.q(c, table); + const seeded = predicateSql(sql, table, cols, [{ column: 'managed_by', in: SEEDED_PROVENANCE }]); + const select = ['name', 'managed_by', 'customized', 'active', 'label', 'object_name', 'triggers', 'url', 'method', 'description', 'definition_json', 'headers_secret', 'signing_secret']; + const rows = await readAll(run, table, select.filter((c) => cols.has(c)), seeded); + if (rows.length === 0) return { step, status: 'done', summary, blockers: [], notes }; + + if (!run.opts.declaredWebhooks) { + return blockedStep(step, summary, [ + `${table} holds ${rows.length} row(s) a package's seeder wrote, settled only against the webhooks the ` + + 'deployment declares, and this run has no registry to read them from. Run the command against the ' + + "deployment's own composition, then re-run.", + ]); + } + const declared = new Map((await run.opts.declaredWebhooks.list()).map((d) => [d.name, d.packageId] as const)); + const held = rows.filter((r) => declared.has(String(r.name))); + summary.undeclaredKept = rows.length - held.length; + if (summary.undeclaredKept > 0) { + notes.push( + `${table}: ${summary.undeclaredKept} seeded row(s) name a webhook no installed package declares any more ` + + `(${rows.filter((r) => !declared.has(String(r.name))).map((r) => String(r.id)).sort().join(', ')}). Each is an ` + + 'ordinary webhook — it dispatches as itself, as it did in 18.0 — and stays.', + ); + } + const customized = held.filter((r) => truthy(r.customized)); + if (customized.length > 0 && run.opts.posture !== 'single') { + return blockedStep(step, summary, [ + `${table} holds ${customized.length} customized copy(ies) of a declared webhook ` + + `(${customized.map((r) => `${String(r.id)} ${String(r.name)}`).sort().join(', ')}). Under the ${run.opts.posture} ` + + 'posture the declaration\'s switch is deployment-wide — there is no per-organization switch-off — so which ' + + 'organizations keep the declared webhook is not decided on the operator\'s behalf, and nothing was written. ' + + 'Rename each copy to a name no package declares (it becomes that organization\'s own webhook), or delete it; ' + + 'switch the declared webhook on or off through the activation door as the deployment wants; then re-run.', + ], notes); + } + + const deliveryTables: string[] = []; + for (const physical of presentTables(run, DELIVERIES)) { + const dcols = await columnsOf(run, physical); + if (dcols?.has('source') && dcols.has('ref_id')) deliveryTables.push(physical); + } + const resource = async (from: string, to: string): Promise => { + let n = 0; + for (const physical of deliveryTables) { + const { sql: dsql } = databaseFor(run, physical); + const where = `${dsql.q('source', physical)} = ? AND ${dsql.q('ref_id', physical)} = ?`; + const count = (): Promise => dsql.count(physical, `SELECT COUNT(*) AS n FROM ${dsql.q(physical, physical)} WHERE ${where}`, ['webhook', from]); + const before = await count(); + if (before === 0) continue; + await execute(run, physical, `UPDATE ${dsql.q(physical, physical)} SET ${dsql.q('ref_id', physical)} = ? WHERE ${where}`, [to, 'webhook', from]); + const left = await count(); + if (left !== 0) throw new Error(`${left} delivery(ies) on ${physical} still name the webhook row ${from} after the re-source; nothing was deleted.`); + n += before; + } + return n; + }; + const deleteCopy = async (row: Record): Promise => { + const where = `${q('id')} = ? AND (${seeded.text})`; + await execute(run, table, `DELETE FROM ${q(table)} WHERE ${where}`, [row.id, ...seeded.params]); + if ((await sql.count(table, `SELECT COUNT(*) AS n FROM ${q(table)} WHERE ${q('id')} = ?`, [row.id])) !== 0) { + throw new Error(`The declared-webhook copy ${String(row.id)} on ${table} was not deleted; nothing past it was.`); + } + }; + + const orphaned: string[] = []; + const taken = new Set(declared.keys()); + for (const row of held) { + const name = String(row.name); + const id = String(row.id); + const def = webhookDefinition(row); + const packageId = (typeof def?._packageId === 'string' && def._packageId ? def._packageId : null) ?? declared.get(name) ?? null; + if (!truthy(row.customized) || isSwitchOnlyEdit(row)) { + if (truthy(row.customized) && (await setActivation(run, 'webhook', name, packageId, truthy(row.active)))) summary.activationRows++; + summary.deliveriesResourced += await resource(id, `${DECLARED_WEBHOOK_REF_PREFIX}${name}`); + await deleteCopy(row); + orphaned.push(...secretIdsOf(row)); + summary.mirrorsDeleted++; + continue; + } + const clone = await freeName(run, table, name, taken); + const { _provenance: _p, _packageId: _k, _packageVersion: _v, ...own } = def ?? {}; + const sets: Array<[string, unknown]> = [ + ['name', clone], + ['managed_by', 'admin'], + ...(cols.has('customized') ? [['customized', sql.bind(false)] as [string, unknown]] : []), + ...(def && cols.has('definition_json') ? [['definition_json', JSON.stringify({ ...own, name: clone })] as [string, unknown]] : []), + ]; + await execute( + run, + table, + `UPDATE ${q(table)} SET ${sets.map(([c]) => `${q(c)} = ?`).join(', ')} WHERE ${q('id')} = ? AND (${seeded.text})`, + [...sets.map(([, v]) => v), row.id, ...seeded.params], + ); + const [landed] = await sql.read(table, `SELECT ${q('name')} AS ${q('name')} FROM ${q(table)} WHERE ${q('id')} = ?`, [row.id]); + if (text(landed?.name) !== clone) throw new Error(`The clone of the declared webhook ${name} (${id}) did not read back as ${clone}.`); + if (await setActivation(run, 'webhook', name, packageId, false)) summary.activationRows++; + summary.clones++; + notes.push( + `${table} ${id}: customized beyond its switch, so it is now the administrator's own webhook ${clone} (its ` + + `credentials and deliveries with it), and the declared ${name} is switched off.`, + ); + } + + // The ciphers the deleted copies referenced, once nothing else does. + const [secrets] = presentTables(run, SECRETS); + if (secrets && orphaned.length > 0) { + const { sql: ssql } = databaseFor(run, secrets); + for (const secretId of [...new Set(orphaned)].sort()) { + const ref = `${SECRET_REF_PREFIX}${secretId}`; + const credentialCols = ['headers_secret', 'signing_secret'].filter((c) => cols.has(c)); + const still = credentialCols.length === 0 + ? 0 + : await sql.count(table, `SELECT COUNT(*) AS n FROM ${q(table)} WHERE ${credentialCols.map((c) => `${q(c)} = ?`).join(' OR ')}`, credentialCols.map(() => ref)); + if (still > 0) continue; + const where = `${ssql.q('id', secrets)} = ?`; + const before = await ssql.count(secrets, `SELECT COUNT(*) AS n FROM ${ssql.q(secrets, secrets)} WHERE ${where}`, [secretId]); + if (before === 0) continue; + await execute(run, secrets, `DELETE FROM ${ssql.q(secrets, secrets)} WHERE ${where}`, [secretId]); + summary.secretsDeleted += before; + } + } + + // Verified: no seeded row holds a declared name any more. + const names = [...declared.keys()]; + for (let i = 0; i < names.length; i += IN_CHUNK) { + const chunk = names.slice(i, i + IN_CHUNK); + const left = await sql.count( + table, + `SELECT COUNT(*) AS n FROM ${q(table)} WHERE (${seeded.text}) AND ${q('name')} IN (${chunk.map(() => '?').join(', ')})`, + [...seeded.params, ...chunk], + ); + if (left !== 0) throw new Error(`${left} seeded ${table} row(s) still hold a declared webhook's name after the settlement.`); + } + run.deleted.set(WEBHOOKS, (run.deleted.get(WEBHOOKS) ?? 0) + summary.mirrorsDeleted); + if (summary.deliveriesResourced > 0) { + notes.push( + `${DELIVERIES}: ${summary.deliveriesResourced} delivery(ies) now name their declared webhook (declared:NAME). A ` + + 'pending one keeps the partition it was enqueued under: it is still claimed and sent, by that partition\'s worker.', + ); + } + run.opts.onProgress?.( + `declared webhooks: ${summary.mirrorsDeleted} copy(ies) deleted, ${summary.clones} cloned, ` + + `${summary.deliveriesResourced} delivery(ies) re-sourced`, + ); + return { step, status: 'done', summary, blockers: [], notes }; +} + +// ── Step 2d: the sibling moves (18.x step 5b; ADR-0131 D7) ────────────────── + +/** One id-preserving move of deployment-level rows into the sibling that holds them now. */ +interface SiblingMove { + /** The inventory departure on `from` that names these rows. */ + id: string; + from: string; + to: string; + /** The rows that move, by SHAPE — never by a NULL organization (F3). */ + where(sql: Sql, table: string, cols: ReadonlySet): { text: string; params: unknown[] }; +} + +/** + * The body of a magic-link mail: the sign-in URL better-auth's magic-link + * plugin builds (`/magic-link/verify?token=`). An 18.0 magic-link row carries + * no related record and no template column, so its link is the one structural + * trace of what it is; every rendering of `auth.magic_link`, customized or + * not, carries it, because the mail exists to deliver it. + */ +const MAGIC_LINK_BODY = '%/magic-link/verify?token=%'; + +export const SIBLING_MOVES: readonly SiblingMove[] = [ + { + id: 'global-audit-move', + from: 'sys_setting_audit', + to: 'sys_platform_setting_audit', + where: (sql, table, cols) => predicateSql(sql, table, cols, [{ column: 'scope', equals: 'global' }]), + }, + { + // Identity mail: mail ABOUT a person (reset, verification, account + // invitation, change-email notice — `related_object = 'sys_user'`, the + // four plugin-auth sites that name one) and the magic link, which names no + // record. The organization invitation (`related_object = 'sys_invitation'`) + // is tenant mail and stays. + id: 'identity-mail-move', + from: 'sys_email', + to: 'sys_identity_email', + where: (sql, table, cols) => { + requireColumn(table, cols, 'related_object'); + const c = (name: string): string => sql.q(name, table); + const bodies = ['body_text', 'body_html'].filter((b) => cols.has(b)); + const magic = bodies.length && cols.has('related_id') + ? ` OR (${c('related_object')} IS NULL AND ${c('related_id')} IS NULL AND (${bodies.map((b) => `${c(b)} LIKE ?`).join(' OR ')}))` + : ''; + return { text: `(${c('related_object')} = ?${magic})`, params: ['sys_user', ...(magic ? bodies.map(() => MAGIC_LINK_BODY) : [])] }; + }, + }, +]; + +/** The mail queues whose 18.0 jobs name a `sys_email` row by id alone (plugin-email). */ +const MAIL_JOB_QUEUES = ['email.send.async', 'email.attachment.reclaim'] as const; + +/** + * Before identity mail leaves `sys_email`: point every pending or running mail + * job that names a moving row at the sibling. An 18.0 job carries `{ rowId }` + * with no `object`, which its consumer reads as `sys_email` — a send would find + * no row and never deliver, and an attachment reclaim would take the row-gone + * branch and delete the mail's attachment content. The job gains + * `object: 'sys_identity_email'`, in its payload and metadata, and its + * idempotency key the sibling's spelling (`:`, `_attachments:`). + */ +async function rewriteMailJobs(run: Run, from: string, to: string, ids: ReadonlySet): Promise { + let rewritten = 0; + for (const physical of presentTables(run, JOB_QUEUE)) { + const cols = await columnsOf(run, physical); + if (!cols?.has('queue') || !cols.has('payload_json')) continue; + const { sql } = databaseFor(run, physical); + const q = (c: string): string => sql.q(c, physical); + const statusClause = cols.has('status') ? ` AND ${q('status')} IN (?, ?)` : ''; + const jobs = await readAll( + run, + physical, + ['payload_json', ...['metadata_json', 'idempotency_key'].filter((c) => cols.has(c))], + { + text: `${q('queue')} IN (${MAIL_JOB_QUEUES.map(() => '?').join(', ')})${statusClause}`, + params: [...MAIL_JOB_QUEUES, ...(statusClause ? ['pending', 'running'] : [])], + }, + ); + for (const job of jobs) { + const payload = parseJsonObject(job.payload_json); + const rowId = payload?.rowId; + if (!payload || typeof rowId !== 'string' || !ids.has(rowId) || (payload.object !== undefined && payload.object !== from)) continue; + const sets: Array<[string, unknown]> = [['payload_json', JSON.stringify({ ...payload, object: to })]]; + const metadata = parseJsonObject(job.metadata_json); + if (metadata) sets.push(['metadata_json', JSON.stringify({ ...metadata, object: to })]); + const key = text(job.idempotency_key); + if (key?.startsWith(`${from}:`)) sets.push(['idempotency_key', `${to}:${key.slice(from.length + 1)}`]); + else if (key?.startsWith(`${from}_attachments:`)) sets.push(['idempotency_key', `${to}_attachments:${key.slice(from.length + 13)}`]); + await execute(run, physical, `UPDATE ${q(physical)} SET ${sets.map(([c]) => `${q(c)} = ?`).join(', ')} WHERE ${q('id')} = ?`, [...sets.map(([, v]) => v), job.id]); + rewritten++; + } + } + return rewritten; +} + +function parseJsonObject(value: unknown): Record | null { + let parsed: unknown = value; + if (typeof value === 'string') { + try { + parsed = JSON.parse(value); + } catch { + return null; + } + } + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record) : null; +} + +/** + * [18.x step 5b; ADR-0131 D7] Move the deployment-level rows an 18.0 database + * still keeps in a tenant table into the sibling this release writes them to + * ({@link SIBLING_MOVES}): the settings audit trail's global rung + * (`sys_setting_audit WHERE scope = 'global'` → `sys_platform_setting_audit`) + * and identity mail (`sys_email` → `sys_identity_email`). Each row keeps its + * id: it is copied (the columns both tables carry — never `organization_id`, + * which the sibling does not have), read back, and only then deleted from its + * old table, by id with its selector re-asserted. A row already in the sibling + * (a run stopped between copy and delete) is not copied twice. + * + * Before identity mail moves, the mail jobs naming it are pointed at the + * sibling ({@link rewriteMailJobs}). After each move, the activity timeline + * rows about a moved row (`sys_activity.object_name`, every rotation shard) + * name its new object, so the record's history follows it; the audit ledger + * (`sys_audit_log`) is a ledger and is not rewritten. + */ +async function runSiblingMoves(run: Run): Promise { + const step: CeremonyStep = 'sibling-moves'; + const summary: Record = { moved: 0, alreadyMoved: 0, jobsRewritten: 0, activityRepointed: 0 }; + const blockers: string[] = []; + for (const move of SIBLING_MOVES) { + const [from] = presentTables(run, move.from); + const fromCols = from ? await columnsOf(run, from) : null; + if (!from || !fromCols) continue; + const { sql } = databaseFor(run, from); + const where = move.where(sql, from, fromCols); + const ids = (await readAll(run, from, [], where)).map((r) => String(r.id)); + if (ids.length === 0) continue; + const [to] = presentTables(run, move.to); + const toCols = to ? await columnsOf(run, to) : null; + if (!to || !toCols) { + blockers.push( + `${ids.length} ${move.from} row(s) move to ${move.to}, which this database does not have. Boot this release ` + + 'against it once (its schema sync creates the table), then re-run.', + ); + continue; + } + const { sql: tsql } = databaseFor(run, to); + const shared = [...fromCols].filter((c) => toCols.has(c) && c !== ORG); + if (move.from === 'sys_email') summary.jobsRewritten += await rewriteMailJobs(run, move.from, move.to, new Set(ids)); + for (let i = 0; i < ids.length; i += IN_CHUNK) { + const chunk = ids.slice(i, i + IN_CHUNK); + const inChunk = (s: Sql, table: string): string => `${s.q('id', table)} IN (${chunk.map(() => '?').join(', ')})`; + const there = new Set( + (await tsql.read(to, `SELECT ${tsql.q('id', to)} AS ${tsql.q('id', to)} FROM ${tsql.q(to, to)} WHERE ${inChunk(tsql, to)}`, chunk)) + .map((r) => String(r.id)), + ); + const missing = chunk.filter((id) => !there.has(id)); + summary.alreadyMoved += chunk.length - missing.length; + if (missing.length > 0) { + const source = await sql.read( + from, + `SELECT ${shared.map((c) => `${sql.q(c, from)} AS ${sql.q(c, from)}`).join(', ')} FROM ${sql.q(from, from)} ` + + `WHERE ${sql.q('id', from)} IN (${missing.map(() => '?').join(', ')})`, + missing, + ); + for (const row of source) { + await execute( + run, + to, + `INSERT INTO ${tsql.q(to, to)} (${shared.map((c) => tsql.q(c, to)).join(', ')}) VALUES (${shared.map(() => '?').join(', ')})`, + shared.map((c) => row[c] ?? null), + ); + } + } + // The source leaves only once every row reads back in the sibling. + const landed = await tsql.count(to, `SELECT COUNT(*) AS n FROM ${tsql.q(to, to)} WHERE ${inChunk(tsql, to)}`, chunk); + if (landed !== chunk.length) { + throw new Error(`${chunk.length - landed} ${move.from} row(s) did not read back in ${move.to}; they were kept in ${move.from}.`); + } + summary.activityRepointed += await repointActivity(run, move.from, move.to, chunk); + await execute(run, from, `DELETE FROM ${sql.q(from, from)} WHERE (${where.text}) AND ${inChunk(sql, from)}`, [...where.params, ...chunk]); + const left = await sql.count(from, `SELECT COUNT(*) AS n FROM ${sql.q(from, from)} WHERE ${inChunk(sql, from)}`, chunk); + if (left !== 0) throw new Error(`${left} moved ${move.from} row(s) were not removed after they read back in ${move.to}.`); + summary.moved += missing.length; + run.movedOut.set(move.from, (run.movedOut.get(move.from) ?? 0) + chunk.length); + run.movedIn.set(move.to, (run.movedIn.get(move.to) ?? 0) + missing.length); + } + run.opts.onProgress?.(`sibling move: ${ids.length} ${move.from} row(s) now in ${move.to}`); + } + return { step, status: blockers.length ? 'blocked' : 'done', summary, blockers, notes: [] }; +} + +/** Point the activity rows about `ids` of `from` at `to` (every rotation shard); the count re-pointed. */ +async function repointActivity(run: Run, from: string, to: string, ids: readonly string[]): Promise { + let n = 0; + for (const physical of presentTables(run, ACTIVITY)) { + const cols = await columnsOf(run, physical); + if (!cols?.has('object_name') || !cols.has('record_id')) continue; + const { sql } = databaseFor(run, physical); + const where = `${sql.q('object_name', physical)} = ? AND ${sql.q('record_id', physical)} IN (${ids.map(() => '?').join(', ')})`; + const count = (object: string): Promise => + sql.count(physical, `SELECT COUNT(*) AS n FROM ${sql.q(physical, physical)} WHERE ${where}`, [object, ...ids]); + const before = await count(from); + if (before === 0) continue; + await execute(run, physical, `UPDATE ${sql.q(physical, physical)} SET ${sql.q('object_name', physical)} = ? WHERE ${where}`, [to, from, ...ids]); + if ((await count(from)) !== 0) throw new Error(`Activity rows on ${physical} still name ${from} for a moved row after the re-point.`); + n += before; + } + return n; +} + +// ── Step 2f: the declared sharing rules' stored copies (18.x step 5c, 9c) ─── + +/** + * [ADR-0131 C3 stage 9c; ruling A on the sharing-rule regime card, item 3] + * Every `sys_sharing_rule` row that holds the name of a rule a package + * declares — a CUSTOMIZED copy the retired seeder wrote, or an administrator's + * rule a later package declaration collided with — answers for that name + * today, while plugin-sharing withholds the declaration and reports it at + * load. The regime is a clone under a new name plus the base switched off, so + * each becomes exactly that, with what it grants unchanged: + * + * - the row is renamed in place to a free name (`_custom`, …) and owned + * by the administrator (`managed_by 'admin'`, not `customized`) — its id is + * unchanged, so every grant naming it (`sys_record_share.source_id`, a row + * id) stays its own; + * - the declaration is switched off: its `sys_metadata_activation` row + * (`sharing_rule`, NAME) says `active: false`, so it does not start granting + * beside its clone — the withholding the row caused, now recorded; + * - grants still naming the declaration itself (`source_id` = NAME) are + * removed, as the switch's own reconcile would remove them. + * + * The UNCUSTOMIZED copies are not touched here: they are mirrors, deleted by + * the mirror-deletion step once their grants name the rule. Under a wall the + * switch is deployment-wide (ruling A item 1: no per-organization switch-off), + * so the copies block, named, instead of switching a rule off for every + * organization on the operator's behalf. Idempotent: a second run finds no + * row under a declared name. + */ +async function runDeclaredRuleCopies(run: Run): Promise { + const step: CeremonyStep = 'declared-rule-copies'; + const summary: Record = { clones: 0, activationRows: 0, grantsRemoved: 0 }; + const notes: string[] = []; + const tables: Array<{ physical: string; cols: ReadonlySet }> = []; + for (const physical of presentTables(run, SHARING_RULES)) { + const cols = await columnsOf(run, physical); + if (cols?.has('name')) tables.push({ physical, cols }); + } + if (tables.length === 0) return { step, status: 'done', summary, blockers: [], notes }; + + // A copy is a row that is not a pristine mirror. + const copyWhere = (physical: string, cols: ReadonlySet): { text: string; params: unknown[] } => { + const { sql } = databaseFor(run, physical); + if (!cols.has('managed_by')) return { text: '1 = 1', params: [] }; + const mirror = predicateSql(sql, physical, cols, cols.has('customized') + ? [{ column: 'managed_by', in: SEEDED_PROVENANCE }, { column: 'customized', notTrue: true }] + : [{ column: 'managed_by', in: SEEDED_PROVENANCE }]); + return { text: `(CASE WHEN ${mirror.text} THEN 1 WHEN NOT (${mirror.text}) THEN 0 ELSE 1 END) = 0`, params: [...mirror.params, ...mirror.params] }; + }; + if (!run.opts.declaredSharingRules) { + let customized = 0; + for (const { physical, cols } of tables) { + if (!cols.has('customized') || !cols.has('managed_by')) continue; + const { sql } = databaseFor(run, physical); + const where = predicateSql(sql, physical, cols, [{ column: 'managed_by', in: SEEDED_PROVENANCE }, { column: 'customized', equals: true }]); + customized += await sql.count(physical, `SELECT COUNT(*) AS n FROM ${sql.q(physical, physical)} WHERE ${where.text}`, where.params); + } + if (customized === 0) return { step, status: 'done', summary, blockers: [], notes }; + return blockedStep(step, summary, [ + `${SHARING_RULES} holds ${customized} customized copy(ies) of a declared sharing rule, settled only against the ` + + 'rules the deployment declares, and this run has no registry to read them from. Run the command against the ' + + "deployment's own composition, then re-run.", + ]); + } + const declared = await run.opts.declaredSharingRules.rules(); + const packageOf = new Map(declared.map((r) => [r.name, r.packageId ?? null] as const)); + const names = [...packageOf.keys()]; + if (names.length === 0) return { step, status: 'done', summary, blockers: [], notes }; + + const copies: Array<{ physical: string; cols: ReadonlySet; id: string; name: string }> = []; + for (const { physical, cols } of tables) { + const { sql } = databaseFor(run, physical); + const copy = copyWhere(physical, cols); + for (let i = 0; i < names.length; i += IN_CHUNK) { + const chunk = names.slice(i, i + IN_CHUNK); + const rows = await readAll(run, physical, ['name'], { + text: `(${copy.text}) AND ${sql.q('name', physical)} IN (${chunk.map(() => '?').join(', ')})`, + params: [...copy.params, ...chunk], + }); + for (const row of rows) copies.push({ physical, cols, id: String(row.id), name: String(row.name) }); + } + } + if (copies.length === 0) return { step, status: 'done', summary, blockers: [], notes }; + if (run.opts.posture !== 'single') { + return blockedStep(step, summary, [ + `${SHARING_RULES} holds ${copies.length} stored rule(s) under the name of a rule a package declares ` + + `(${copies.map((c) => `${c.id} ${c.name}`).sort().join(', ')}). Under the ${run.opts.posture} posture the ` + + 'declaration\'s switch is deployment-wide — there is no per-organization switch-off — so it is not switched ' + + 'off for every organization on the operator\'s behalf, and nothing was written. Rename each row to a name no ' + + 'package declares (it stays that organization\'s own rule), switch the declared rule on or off through the ' + + 'activation door as the deployment wants, then re-run.', + ]); + } + + const shares: string[] = []; + for (const physical of presentTables(run, RECORD_SHARES)) { + const cols = await columnsOf(run, physical); + if (cols?.has('source') && cols.has('source_id')) shares.push(physical); + } + const taken = new Set(names); + for (const copy of copies) { + const { sql } = databaseFor(run, copy.physical); + const q = (c: string): string => sql.q(c, copy.physical); + const clone = await freeName(run, copy.physical, copy.name, taken); + const sets: Array<[string, unknown]> = [ + ['name', clone], + ...(copy.cols.has('managed_by') ? [['managed_by', 'admin'] as [string, unknown]] : []), + ...(copy.cols.has('customized') ? [['customized', sql.bind(false)] as [string, unknown]] : []), + ]; + await execute( + run, + copy.physical, + `UPDATE ${q(copy.physical)} SET ${sets.map(([c]) => `${q(c)} = ?`).join(', ')} WHERE ${q('id')} = ? AND ${q('name')} = ?`, + [...sets.map(([, v]) => v), copy.id, copy.name], + ); + const [landed] = await sql.read(copy.physical, `SELECT ${q('name')} AS ${q('name')} FROM ${q(copy.physical)} WHERE ${q('id')} = ?`, [copy.id]); + if (text(landed?.name) !== clone) throw new Error(`The clone of the declared sharing rule ${copy.name} (${copy.id}) did not read back as ${clone}.`); + summary.clones++; + notes.push(`${SHARING_RULES} ${copy.id}: now the rule ${clone}, its grants with it; the declared ${copy.name} is switched off.`); + } + for (const name of [...new Set(copies.map((c) => c.name))].sort()) { + if (await setActivation(run, 'sharing_rule', name, packageOf.get(name) ?? null, false)) summary.activationRows++; + for (const physical of shares) { + const { sql } = databaseFor(run, physical); + const where = `${sql.q('source', physical)} = ? AND ${sql.q('source_id', physical)} = ?`; + const count = (): Promise => sql.count(physical, `SELECT COUNT(*) AS n FROM ${sql.q(physical, physical)} WHERE ${where}`, ['rule', name]); + const before = await count(); + if (before === 0) continue; + await execute(run, physical, `DELETE FROM ${sql.q(physical, physical)} WHERE ${where}`, ['rule', name]); + if ((await count()) !== 0) throw new Error(`Grants of the switched-off declared rule ${name} were left on ${physical}.`); + summary.grantsRemoved += before; + } + } + run.opts.onProgress?.(`declared sharing rules: ${summary.clones} stored copy(ies) now clones, their declarations switched off`); + return { step, status: 'done', summary, blockers: [], notes }; +} + +// ── Step 6: organization_id NOT NULL (18.x step 5d, 8e) ───────────────────── + +/** One NOT NULL the ceremony applies: the tenant column of a table it cleared. */ +export interface NotNullColumn { + table: string; + column: string; +} + +/** One NOT NULL, as the schema door answered it. */ +export interface NotNullOutcome extends NotNullColumn { + /** + * `applied`; `not-declared` — the composition this run booted declares no + * object for the table, so nothing rebuilds it against a declaration; + * `unsupported` — the datasource's transport cannot apply it (the Turso + * remote face cannot rebuild a table); `failed`. + */ + outcome: 'applied' | 'not-declared' | 'unsupported' | 'failed'; + detail?: string; +} + +/** + * Applies NOT NULL to a tenant column through the schema-drift ceremony's own + * apply (ADR-0120 D4), never a hand `ALTER`: on SQLite the table rebuild, on + * PostgreSQL / MySQL `ALTER COLUMN … SET NOT NULL`. The differ owns no + * proposal for it — the column's nullability is the ceremony's (ruling record + * 6269955919 on the v18 epic, ② A), and the differ reads it as no drift. + */ +export interface NotNullPort { + apply(columns: readonly NotNullColumn[]): Promise; +} + +/** + * [18.x step 5d; ADR-0131 8e; ruling ② A] After the column drops, every table + * whose fate keeps `organization_id` and whose post-step plan reads it ready + * for NOT NULL (`TablePlan.notNull.willReceive`, zero NULL rows) receives it, + * on every physical table (rotation shards included). The ceremony owns that + * nullability; no metadata says it. + * + * A table the plan does not clear — rows still NULL and reported, a fate-4 + * table whose column is not the tenancy anchor — is not touched and its reason + * is reported. A run with no schema door (`notNull` absent) applies nothing + * and says so; the command always passes one. A transport that cannot apply + * it is reported, never certified as applied; any other failure blocks. + */ +async function runNotNull(run: Run): Promise { + const step: CeremonyStep = 'not-null'; + const summary: Record = { applied: 0, due: 0, notDeclared: 0, unsupported: 0 }; + const blockers: string[] = []; + const notes: string[] = []; + const plan = await planFor(run); + const due: NotNullColumn[] = []; + for (const table of plan.tables) { + if (table.fate !== 'attribution' && table.fate !== 'report') continue; + if (table.physical !== 'present' || table.organizationColumn !== 'present' || !table.rows) continue; + if (!table.notNull.willReceive || table.rows.organizationNull !== 0) continue; + due.push(...table.tables.map((t) => ({ table: t, column: ORG }))); + } + summary.due = due.length; + if (due.length === 0) return { step, status: 'done', summary, blockers, notes }; + if (!run.opts.notNull) { + notes.push(`${due.length} table(s) are ready for NOT NULL on ${ORG}, and this run has no schema door to apply it: nothing was applied.`); + return { step, status: 'done', summary, blockers, notes }; + } + for (const outcome of await run.opts.notNull.apply(due)) { + if (outcome.outcome === 'applied') summary.applied++; + else if (outcome.outcome === 'not-declared') { + summary.notDeclared++; + notes.push( + `${outcome.table}.${outcome.column} stays nullable: the composition this run booted declares no object for ` + + `${outcome.table}. Its rows hold no NULL; re-run the ceremony where the deployment's composition is declared to apply it.`, + ); + } else if (outcome.outcome === 'unsupported') { + summary.unsupported++; + notes.push(`${outcome.table}.${outcome.column} stays nullable: ${outcome.detail ?? 'this transport cannot apply NOT NULL'}.`); + } else { + blockers.push(`NOT NULL on ${outcome.table}.${outcome.column} failed: ${outcome.detail ?? 'no detail'}. Re-run once the cause is fixed.`); + } + } + if (summary.applied) run.opts.onProgress?.(`not null: ${summary.applied} table(s) now hold ${ORG} NOT NULL`); + return { step, status: blockers.length ? 'blocked' : 'done', summary, blockers, notes }; +} + // ── The run ────────────────────────────────────────────────────────────────── const RUNNERS: Record Promise> = { @@ -2933,10 +3765,14 @@ const RUNNERS: Record Promise> = { 'metadata-promotion': runMetadataPromotion, 'template-promotion': runTemplatePromotion, 'settings-move': runSettingsMove, + 'sibling-moves': runSiblingMoves, + 'declared-webhooks': runDeclaredWebhooks, + 'declared-rule-copies': runDeclaredRuleCopies, rewrite: runRewrite, 'mirror-deletion': runMirrorDeletion, 'history-renumber': runHistoryRenumber, 'column-drops': runColumnDrops, + 'not-null': runNotNull, }; /** @@ -3118,8 +3954,8 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise c.name))))); return out; diff --git a/packages/platform-objects/src/system/sys-secret.object.ts b/packages/platform-objects/src/system/sys-secret.object.ts index 6c099986490..a5e0418d53a 100644 --- a/packages/platform-objects/src/system/sys-secret.object.ts +++ b/packages/platform-objects/src/system/sys-secret.object.ts @@ -42,6 +42,11 @@ export const SysSecret = ObjectSchema.create({ icon: 'key', isSystem: true, managedBy: 'engine-owned', + // [ADR-0131 D7] Deployment-level: every read is by id under the system + // context, never by organization, so the store carries no organization + // column. The flip lands with the open upgrade ceremony (18.x step 6, line + // fact F2), whose column-drops step takes an upgraded database's column. + systemFields: { tenant: false }, // [ADR-0066 D2/④] Secure-by-default: the environment's encrypted-secrets // store (settings/datasource credentials). Not covered by the wildcard `'*'` // grant — ordinary members get 403 from the generic data layer. Platform diff --git a/packages/plugins/plugin-webhooks/src/declared-webhooks.ts b/packages/plugins/plugin-webhooks/src/declared-webhooks.ts index 23e538264e1..e1bbbe9e982 100644 --- a/packages/plugins/plugin-webhooks/src/declared-webhooks.ts +++ b/packages/plugins/plugin-webhooks/src/declared-webhooks.ts @@ -54,7 +54,7 @@ * `sys_webhook` row holding a declared name is judged by what the 18.0 seeder * would have done with it on this boot ({@link classifyDeclaredNameHolder}): * - * - **A pristine mirror** (`managed_by: 'package'`, not `customized`): the + * - **A pristine mirror** (`managed_by` `package` or `platform`, not `customized`): the * seeder would have overwritten it from the declaration. The row is ignored * and the declaration answers. * - **Any other row** (`customized: true`, or `managed_by: 'admin'`): the @@ -251,5 +251,16 @@ export async function readWebhookActivation(engine: unknown, logger?: Logger): P * declaration is withheld. */ export function classifyDeclaredNameHolder(row: { managed_by?: unknown; customized?: unknown }): 'mirror' | 'row' { - return row.managed_by === 'package' && row.customized !== true ? 'mirror' : 'row'; + return isSeededProvenance(row.managed_by) && row.customized !== true ? 'mirror' : 'row'; +} + +/** + * The provenance values a seeded copy carries: `package` and `platform`, the + * same pair the provenance hook stamps `customized` on + * (`webhook-provenance.ts`) and the upgrade ceremony settles. One predicate, + * so the mirror skip, the redeliver guard and the ceremony never disagree on + * which rows are copies of a declaration. + */ +export function isSeededProvenance(managedBy: unknown): boolean { + return managedBy === 'package' || managedBy === 'platform'; } diff --git a/packages/plugins/plugin-webhooks/src/webhook-provenance.ts b/packages/plugins/plugin-webhooks/src/webhook-provenance.ts index 5e542a58497..61166dc41c7 100644 --- a/packages/plugins/plugin-webhooks/src/webhook-provenance.ts +++ b/packages/plugins/plugin-webhooks/src/webhook-provenance.ts @@ -49,6 +49,8 @@ * stamp exists to remember. */ +import { isSeededProvenance } from './declared-webhooks.js'; + interface MinimalEngine { registerHook(event: string, handler: (ctx: any) => any, options?: Record): void; unregisterHooksByPackage(packageId: string): number; @@ -80,7 +82,7 @@ export function bindWebhookProvenanceStamp(engine: MinimalEngine, logger?: Minim // had just read. const previous = ctx?.previous as Record | undefined; if (!previous || typeof previous !== 'object') return; - if ((previous.managed_by === 'package' || previous.managed_by === 'platform') && previous.customized !== true) { + if (isSeededProvenance(previous.managed_by) && previous.customized !== true) { (data as any).customized = true; } }, diff --git a/packages/services/service-automation/src/sys-flow-credential.object.ts b/packages/services/service-automation/src/sys-flow-credential.object.ts index daa0672b0b4..b912b59b1d5 100644 --- a/packages/services/service-automation/src/sys-flow-credential.object.ts +++ b/packages/services/service-automation/src/sys-flow-credential.object.ts @@ -47,6 +47,11 @@ export const SysFlowCredential = ObjectSchema.create({ icon: 'key', isSystem: true, managedBy: 'engine-owned', + // [ADR-0131 D7] Deployment-level: a credential is read by its flow under the + // system context, never by organization, so the store carries no + // organization column. The flip lands with the open upgrade ceremony (18.x + // step 6, line fact F2), whose column-drops step takes an upgraded database's column. + systemFields: { tenant: false }, // ADR-0066 secure-by-default: a credential store is not covered by the // wildcard grant. Everything reads and writes it through the engine under a // system context. diff --git a/packages/services/service-settings/src/settings-service.ts b/packages/services/service-settings/src/settings-service.ts index cfeb5fe9fed..c17c8841450 100644 --- a/packages/services/service-settings/src/settings-service.ts +++ b/packages/services/service-settings/src/settings-service.ts @@ -2118,11 +2118,9 @@ export class SettingsService { alg: handle.alg, version: handle.version, ciphertext: handle.ciphertext, - // [ADR-0131 D3/D9] The ciphertext of a tenant or user value is - // that organization's, as its `sys_setting` row is. A global - // value's is deployment-level and names none (see - // `SettingsSecretStore.insert`). - ...(organizationId ? { organization_id: organizationId } : {}), + // [ADR-0131 D7] No organization: `sys_secret` is deployment-level + // (`systemFields.tenant: false`); the owning `sys_setting` row + // carries the organization, and the cipher is read by its id. }); storedEnc = handle.id; digest = await this.secretAuditDigest(namespace, key, plain); diff --git a/packages/services/service-settings/src/settings-service.types.ts b/packages/services/service-settings/src/settings-service.types.ts index ecb09446119..df76ac8461e 100644 --- a/packages/services/service-settings/src/settings-service.types.ts +++ b/packages/services/service-settings/src/settings-service.types.ts @@ -242,13 +242,6 @@ export interface SettingsSecretStore { alg: string; version: number; ciphertext: string; - /** - * [ADR-0131 D3/D9] The organization that owns the secret — the owning - * `sys_setting` row's, for a tenant or user value. Absent for a global - * value: a deployment-level secret, whose fate in this tenant-column - * store is not settled yet. - */ - organization_id?: string; }): Promise<{ id: string }>; /** Look up the latest ciphertext for a handle id; null when missing. */ get(id: string): Promise<{ diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json index 3f1b7f05bfb..6569474eebe 100644 --- a/packages/spec/authorable-surface/system.json +++ b/packages/spec/authorable-surface/system.json @@ -865,6 +865,10 @@ "system/OpenTelemetryCompatibility:resource", "system/OpenTelemetryCompatibility:sdkVersion", "system/OpenTelemetryCompatibility:semanticConventionsVersion", + "system/OrganizationOwnershipBirthMarker:commit", + "system/OrganizationOwnershipBirthMarker:createdAt", + "system/OrganizationOwnershipBirthMarker:platformVersion", + "system/OrganizationOwnershipBirthMarker:shapeGeneration", "system/OrganizationOwnershipCategory:citation", "system/OrganizationOwnershipCategory:conflictBy", "system/OrganizationOwnershipCategory:id", diff --git a/packages/spec/json-schema.manifest/system.json b/packages/spec/json-schema.manifest/system.json index 4efde58da7d..c5510b88b0a 100644 --- a/packages/spec/json-schema.manifest/system.json +++ b/packages/spec/json-schema.manifest/system.json @@ -174,6 +174,7 @@ "system/OnceSchedule", "system/OpenTelemetryCompatibility", "system/OrganizationOwnershipAnchor", + "system/OrganizationOwnershipBirthMarker", "system/OrganizationOwnershipCategory", "system/OrganizationOwnershipDeparture", "system/OrganizationOwnershipFate", From 50a2d34cadbfd2b37a5d9b2609ca10399e50cf75 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:59:34 +0000 Subject: [PATCH 14/44] wip: open the ceremony command, ports for webhooks and NOT NULL --- ...organization-ownership.apply-flags.test.ts | 4 +- ....declared-rule-mirrors.integration.test.ts | 4 +- .../organization-ownership.dialect.test.ts | 4 +- ...ership.fate-supplement.integration.test.ts | 4 +- ...ownership.journal-mode.integration.test.ts | 4 +- ...ership.no-organization.integration.test.ts | 4 +- ...on-ownership.occupancy.integration.test.ts | 4 +- ...nization-ownership.release-refusal.test.ts | 151 ------------------ ...-ownership.remote-face.integration.test.ts | 2 +- ...anization-ownership.remote-posture.test.ts | 4 +- ...-ownership.report-file.integration.test.ts | 4 +- ...ation-ownership.report-reservation.test.ts | 4 +- .../migrate/organization-ownership.ts | 130 +++++++-------- .../migrate/sqlite-wasm.integration.test.ts | 2 +- ...ownership-not-declared.integration.test.ts | 2 +- .../utils/organization-ownership-not-null.ts | 69 ++++++++ ...orm-global-column-drop.integration.test.ts | 2 +- ...igrate.one-shot-family.integration.test.ts | 2 +- .../schema-migrate.sql-driver-lookup.test.ts | 4 +- 19 files changed, 162 insertions(+), 242 deletions(-) delete mode 100644 packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts create mode 100644 packages/cli/src/utils/organization-ownership-not-null.ts diff --git a/packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts b/packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts index b17cdff8df0..3816bac4702 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts @@ -28,13 +28,13 @@ vi.mock('../../utils/migrate-occupancy-gate.js', () => ({ probeMigrationTarget: vi.fn(async () => ({ status: 'free' })), })); -type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony']; +type Command = (typeof import('./organization-ownership.js'))['default']; let MigrateOrganizationOwnership: Command; // The command's module graph is large, and oclif reads the package's config on // the first run: both are paid outside the 5 s default (CASE_TIMEOUT_MS). beforeAll(async () => { - ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); + ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); }, 60_000); const CASE_TIMEOUT_MS = 60_000; diff --git a/packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts index 96eb4e942f2..e5d14c95370 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.declared-rule-mirrors.integration.test.ts @@ -57,7 +57,7 @@ import { DECLARED_SHARING_RULE_SEEDER_RUNS } from '../../utils/organization-owne import '@objectstack/objectql'; import '@objectstack/metadata-protocol'; -type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony']; +type Command = (typeof import('./organization-ownership.js'))['default']; let MigrateOrganizationOwnership: Command; const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url)); @@ -158,7 +158,7 @@ describe('os migrate organization-ownership on a database whose packages declare savedEnv[key] = process.env[key]; delete process.env[key]; } - ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); + ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); dir = mkdtempSync(join(tmpdir(), 'os-ceremony-declared-rules-')); file = join(dir, 'fixture.db'); // The project the command runs in: the ceremony boots ITS composition, so diff --git a/packages/cli/src/commands/migrate/organization-ownership.dialect.test.ts b/packages/cli/src/commands/migrate/organization-ownership.dialect.test.ts index 7a4c21eeb45..7e0f8266b1b 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.dialect.test.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.dialect.test.ts @@ -44,13 +44,13 @@ vi.mock('../../utils/organization-ownership-apply.js', async (importOriginal) => applyOrganizationOwnership: apply, })); -type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony']; +type Command = (typeof import('./organization-ownership.js'))['default']; let MigrateOrganizationOwnership: Command; // The command's module graph is large, and oclif reads the package's config on // the first run: both are paid outside the 5 s default (CASE_TIMEOUT_MS). beforeAll(async () => { - ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); + ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); }, 60_000); const CASE_TIMEOUT_MS = 60_000; diff --git a/packages/cli/src/commands/migrate/organization-ownership.fate-supplement.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.fate-supplement.integration.test.ts index fd185cdd090..d300a7b90cf 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.fate-supplement.integration.test.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.fate-supplement.integration.test.ts @@ -34,13 +34,13 @@ vi.mock('../../utils/organization-ownership-apply.js', async (importOriginal) => applyOrganizationOwnership: apply, })); -type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony']; +type Command = (typeof import('./organization-ownership.js'))['default']; let MigrateOrganizationOwnership: Command; // The command's module graph is large, and oclif reads the package's config on // the first run: both are paid outside the 5 s default (CASE_TIMEOUT_MS). beforeAll(async () => { - ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); + ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); }, 60_000); const CASE_TIMEOUT_MS = 60_000; diff --git a/packages/cli/src/commands/migrate/organization-ownership.journal-mode.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.journal-mode.integration.test.ts index 9a0b629c3e3..e09e732bb5f 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.journal-mode.integration.test.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.journal-mode.integration.test.ts @@ -32,13 +32,13 @@ import { JOURNAL_MODE_SWITCH_NOTE } from '../../utils/sqlite-occupancy.js'; import '@objectstack/runtime'; import '@objectstack/metadata-protocol'; -type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony']; +type Command = (typeof import('./organization-ownership.js'))['default']; let MigrateOrganizationOwnership: Command; // The command's module graph is large, and oclif reads the package's config on // the first run: both are paid outside the case timeout. beforeAll(async () => { - ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); + ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); }, 60_000); const CASE_TIMEOUT_MS = 60_000; diff --git a/packages/cli/src/commands/migrate/organization-ownership.no-organization.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.no-organization.integration.test.ts index 7f95510d87a..661896a2e69 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.no-organization.integration.test.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.no-organization.integration.test.ts @@ -61,7 +61,7 @@ import '@objectstack/objectql'; import '@objectstack/metadata-protocol'; import '@objectstack/plugin-auth'; -type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony']; +type Command = (typeof import('./organization-ownership.js'))['default']; let MigrateOrganizationOwnership: Command; const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url)); @@ -162,7 +162,7 @@ describe('os migrate organization-ownership on a single-posture database that ho savedEnv[key] = process.env[key]; delete process.env[key]; } - ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); + ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); dir = mkdtempSync(join(tmpdir(), 'os-ceremony-no-organization-')); file = join(dir, 'fixture.db'); diff --git a/packages/cli/src/commands/migrate/organization-ownership.occupancy.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.occupancy.integration.test.ts index 13832f2173f..179a65dfaa8 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.occupancy.integration.test.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.occupancy.integration.test.ts @@ -40,13 +40,13 @@ vi.mock('../../utils/schema-migrate.js', async (importOriginal) => ({ bootSchemaStack: boot, })); -type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony']; +type Command = (typeof import('./organization-ownership.js'))['default']; let MigrateOrganizationOwnership: Command; // The command's module graph is large, and oclif reads the package's config on // the first run: both are paid outside the case timeout. beforeAll(async () => { - ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); + ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); }, 60_000); const CASE_TIMEOUT_MS = 60_000; diff --git a/packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts b/packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts deleted file mode 100644 index 20929cce72d..00000000000 --- a/packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * `os migrate organization-ownership` refuses in 18.0, plan and apply alike - * (ruling record 6259608309 on the v18 epic, item 1). 18.0 supports fresh - * installs only. Its ceremony is knowingly incomplete, and an incomplete - * ceremony must never certify an old database. - * - * The refusal comes first, so it reads nothing. These cases set every input - * the ceremony would judge before its boot to a value the ceremony refuses: - * an unrecognised posture, a fate supplement that does not exist, a plan file - * that does not exist. They also stub the occupancy probe and the boot so that - * either one throws. Only the release refusal can answer. - * - * The ceremony itself (`OrganizationOwnershipCeremony`) is pinned by the - * sibling `organization-ownership.*.test.ts` files, which run it directly. - */ - -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { existsSync, mkdtempSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const boot = vi.hoisted(() => vi.fn(async () => { - throw new Error('the database was opened'); -})); -const probe = vi.hoisted(() => vi.fn(async () => { - throw new Error('the database was probed'); -})); -vi.mock('../../utils/schema-migrate.js', () => ({ bootSchemaStack: boot })); -vi.mock('../../utils/migrate-occupancy-gate.js', () => ({ - OCCUPANCY_STOP_HINT: 'occupancy hint', - probeMigrationTarget: probe, -})); - -type Module = typeof import('./organization-ownership.js'); -let mod: Module; - -// The command's module graph is large, and oclif reads the package's config on -// the first run: both are paid outside the 5 s default. -beforeAll(async () => { - mod = await import('./organization-ownership.js'); -}, 60_000); - -const CASE_TIMEOUT_MS = 60_000; -const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url)); -const DIR = mkdtempSync(join(tmpdir(), 'os-ownership-release-refusal-')); -const DB_FILE = join(DIR, 'v17.db'); -const OUT = join(DIR, 'out.json'); -const MISSING_SUPPLEMENT = join(DIR, 'missing-supplement.json'); -const MISSING_PLAN = join(DIR, 'missing-plan.json'); - -let savedPosture: string | undefined; -beforeEach(() => { - savedPosture = process.env.OS_TENANCY_POSTURE; - // The ceremony refuses an unrecognised posture before anything else; the release refusal never reads it. - process.env.OS_TENANCY_POSTURE = 'not-a-posture'; -}); -afterEach(() => { - if (savedPosture === undefined) delete process.env.OS_TENANCY_POSTURE; - else process.env.OS_TENANCY_POSTURE = savedPosture; - boot.mockClear(); - probe.mockClear(); - vi.restoreAllMocks(); -}); - -async function run(argv: string[]): Promise<{ stdout: string; exitCode: unknown }> { - const chunks: string[] = []; - const savedExit = process.exitCode; - vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => { - chunks.push(String(chunk)); - const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined; - if (cb) cb(); - return true; - }) as typeof process.stdout.write); - vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as typeof process.stderr.write); - vi.spyOn(console, 'log').mockImplementation((...args: unknown[]) => { - chunks.push(`${args.map(String).join(' ')}\n`); - }); - let exitCode: unknown; - try { - await mod.default.run(argv, { root: CLI_ROOT }); - exitCode = process.exitCode; - } catch (error) { - const oclif = (error as { oclif?: { exit?: unknown } }).oclif; - if (oclif === undefined) throw error; - exitCode = oclif.exit; - } - process.exitCode = savedExit; - vi.restoreAllMocks(); - return { stdout: chunks.join(''), exitCode }; -} - -function expectNothingTouched(): void { - expect(boot).not.toHaveBeenCalled(); - expect(probe).not.toHaveBeenCalled(); - expect(existsSync(DB_FILE), 'the database file was brought into existence').toBe(false); - expect(existsSync(OUT), 'a plan or report file was written').toBe(false); -} - -describe('os migrate organization-ownership — refused in 18.0', () => { - it('the plan refuses with NOT_IMPLEMENTED, exit 1, reading and writing nothing', async () => { - const { stdout, exitCode } = await run([ - '--database-url', `file:${DB_FILE}`, '--out', OUT, '--fate-supplement', MISSING_SUPPLEMENT, '--json', - ]); - expect(JSON.parse(stdout.trim())).toEqual({ - error: 'plan_refused', - reason: 'upgrade-unavailable', - code: 'NOT_IMPLEMENTED', - detail: mod.ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE, - }); - expect(exitCode).toBe(1); - expectNothingTouched(); - }, CASE_TIMEOUT_MS); - - it('the apply refuses with NOT_IMPLEMENTED, exit 1, reading and writing nothing', async () => { - const { stdout, exitCode } = await run([ - '--apply', '--plan-file', MISSING_PLAN, '--backup-taken', - '--database-url', `file:${DB_FILE}`, '--out', OUT, '--fate-supplement', MISSING_SUPPLEMENT, '--json', - ]); - expect(JSON.parse(stdout.trim())).toMatchObject({ - error: 'apply_refused', - reason: 'upgrade-unavailable', - code: 'NOT_IMPLEMENTED', - }); - expect(exitCode).toBe(1); - expectNothingTouched(); - }, CASE_TIMEOUT_MS); - - it('without --json it prints the refusal with its code and exits 1', async () => { - const { stdout, exitCode } = await run(['--database-url', `file:${DB_FILE}`, '--out', OUT]); - expect(stdout).toContain('✗'); - expect(stdout).toContain(mod.ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE); - expect(stdout).toContain('(NOT_IMPLEMENTED)'); - expect(exitCode).toBe(1); - expectNothingTouched(); - }, CASE_TIMEOUT_MS); - - it('the refusal names the release that will carry the upgrade and what 18.0 supports', () => { - expect(mod.ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE).toMatch(/^os migrate organization-ownership is not available in 18\.0\./); - expect(mod.ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE).toContain('fresh installs only'); - expect(mod.ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE).toContain('an 18.x release'); - }); - - it('the registered command keeps the ceremony class whole underneath: same flags, its own run', () => { - expect(Object.getPrototypeOf(mod.default)).toBe(mod.OrganizationOwnershipCeremony); - expect(mod.default.flags).toBe(mod.OrganizationOwnershipCeremony.flags); - expect(mod.default.prototype.run).not.toBe(mod.OrganizationOwnershipCeremony.prototype.run); - }); -}); diff --git a/packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts index 46889c5777c..ad7fa489d5e 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.remote-face.integration.test.ts @@ -59,7 +59,7 @@ import { TursoDriver } from '@objectstack/driver-turso'; import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system'; import { bootSchemaStack } from '../../utils/schema-migrate.js'; import { isExitSignal } from '../../utils/format.js'; -import { OrganizationOwnershipCeremony as MigrateOrganizationOwnership } from './organization-ownership.js'; +import MigrateOrganizationOwnership from './organization-ownership.js'; // Paid at module load, not inside a clocked case (`check:test-source-alias`). import '@objectstack/runtime'; diff --git a/packages/cli/src/commands/migrate/organization-ownership.remote-posture.test.ts b/packages/cli/src/commands/migrate/organization-ownership.remote-posture.test.ts index 5ae3f969d55..fba272b1e5d 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.remote-posture.test.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.remote-posture.test.ts @@ -31,11 +31,11 @@ vi.mock('../../utils/migrate-occupancy-gate.js', () => ({ probeMigrationTarget: vi.fn(async () => ({ status: 'free' })), })); -type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony']; +type Command = (typeof import('./organization-ownership.js'))['default']; let MigrateOrganizationOwnership: Command; beforeAll(async () => { - ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); + ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); }, 60_000); const CASE_TIMEOUT_MS = 60_000; diff --git a/packages/cli/src/commands/migrate/organization-ownership.report-file.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.report-file.integration.test.ts index 6db272bc3ef..94900ed5a57 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.report-file.integration.test.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.report-file.integration.test.ts @@ -53,7 +53,7 @@ vi.mock('../../utils/schema-migrate.js', async (importOriginal) => { }; }); -type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony']; +type Command = (typeof import('./organization-ownership.js'))['default']; let MigrateOrganizationOwnership: Command; const HERE = dirname(fileURLToPath(import.meta.url)); @@ -182,7 +182,7 @@ const apply = (prepared: Prepared, out: string): Promise => ]); beforeAll(async () => { - ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); + ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); for (const key of [...OVERRIDING_ENV, 'NODE_ENV', 'OS_SECRET_KEY'] as const) savedEnv[key] = process.env[key]; for (const key of OVERRIDING_ENV) delete process.env[key]; // The deployed posture: no telemetry sibling, and the crypto provider's key. diff --git a/packages/cli/src/commands/migrate/organization-ownership.report-reservation.test.ts b/packages/cli/src/commands/migrate/organization-ownership.report-reservation.test.ts index 89b2f6d505c..cd533c69ccf 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.report-reservation.test.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.report-reservation.test.ts @@ -28,11 +28,11 @@ vi.mock('../../utils/migrate-occupancy-gate.js', () => ({ probeMigrationTarget: vi.fn(async () => ({ status: 'free' })), })); -type Command = (typeof import('./organization-ownership.js'))['OrganizationOwnershipCeremony']; +type Command = (typeof import('./organization-ownership.js'))['default']; let MigrateOrganizationOwnership: Command; beforeAll(async () => { - ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); + ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js')); }, 60_000); const CASE_TIMEOUT_MS = 60_000; diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts index e9f30602b3a..29cc57e260a 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.ts @@ -204,11 +204,10 @@ class ReportFileReservation { } /** - * The v18 organization-ownership ceremony behind `os migrate - * organization-ownership` — in 18.0 the registered command refuses before - * this class runs (see {@link MigrateOrganizationOwnership} at the end of this - * file). This class is the ceremony itself, kept whole for the 18.x release - * that carries the upgrade path; its tests run it directly. + * `os migrate organization-ownership` — the v18 organization-ownership + * ceremony, the upgrade path of a v17 or an 18.0 database (18.x step 6): the + * boot guard refuses such a database until this ceremony has verified it at the + * current ownership shape generation. * * The ceremony's preflight (ADR-0131 D10, ceremony item 1): per table, the fate, * the row counts each fate will touch, the rows whose owner cannot be derived @@ -261,8 +260,10 @@ class ReportFileReservation { * through plugin-auth's boot invariant, as the plan's warnings say; ADR-0131 * D3), the authored catalog conversion, * the promotion of organization customizations and public-form withdrawals, - * the template promotion, the settings global-rung move, the verified id→name - * rewrite, mirror deletion, column drops — through `../../utils/organization-ownership-apply.ts`, + * the template promotion, the settings global-rung move, the sibling moves, + * the settlement of declared webhooks and of declared sharing rules' stored + * copies, the verified id→name rewrite, mirror deletion, column drops, NOT + * NULL on `organization_id` — through `../../utils/organization-ownership-apply.ts`, * checkpointing each finished step in the deployment ledger (`sys_migration`, * row {@link ORGANIZATION_OWNERSHIP_MIGRATION_ID}) so an interrupted run * resumes where it stopped, and ends with the post-check. The ledger row is @@ -316,16 +317,16 @@ class ReportFileReservation { * the drops go through the remote face's own drift detector and migration * door (`@objectstack/driver-turso`). */ -export class OrganizationOwnershipCeremony extends Command { +export default class OrganizationOwnershipCeremony extends Command { static override description = 'The v18 organization-ownership ceremony (ADR-0131 D10). Without --apply, the read-only plan: per table, the fate, ' + 'the row counts, the rows whose owner cannot be derived (by id), and which tables will receive NOT NULL, written ' + 'to a file — never a row or a column changed; it refuses, naming it, any table it cannot enumerate. With --apply ' + '(the reviewed plan file in --plan-file, and a backup), only that plan: attribution, the authored catalog ' + 'conversion, the promotion of organization customizations and form withdrawals, the template promotion, the ' + - 'settings move, the ' + - 'verified id-to-name rewrite, mirror deletion and column drops, in that order, ' + - 'resumable from a recorded checkpoint, then the post-check.'; + 'settings move, the sibling moves (global settings audit, identity mail), the settlement of declared webhooks and ' + + 'of declared sharing rules\' stored copies, the verified id-to-name rewrite, mirror deletion, column drops and ' + + 'NOT NULL on organization_id, in that order, resumable from a recorded checkpoint, then the post-check.'; static override examples = [ '$ os migrate organization-ownership', @@ -357,8 +358,9 @@ export class OrganizationOwnershipCeremony extends Command { }), apply: Flags.boolean({ description: - 'Perform the ceremony: attribution, the verified id-to-name rewrite, mirror deletion, column drops, then the ' + - 'post-check. Idempotent; an interrupted run resumes from its recorded checkpoint. Requires a backup', + 'Perform the ceremony: attribution, the moves and settlements, the verified id-to-name rewrite, mirror deletion, ' + + 'column drops and NOT NULL, then the post-check. Idempotent; an interrupted run resumes from its recorded ' + + 'checkpoint. Requires a backup', default: false, }), 'plan-file': Flags.string({ @@ -830,6 +832,57 @@ export class OrganizationOwnershipCeremony extends Command { return judgeDeclaredSharingRules(items); }, }, + declaredWebhooks: { + // [18.x step 5a] What plugin-webhooks serves: the registry's declared + // webhooks, else the metadata service's, each parsed by the spec's + // own schema — an item the plugin cannot parse is not served, so a + // copy is never settled onto it. + list: async () => { + const { WebhookSchema } = await import('@objectstack/spec/automation'); + const registry = (engine.registry ?? (engine as { _registry?: unknown })._registry) as + | { listItems?(type: string): unknown[] | undefined } + | undefined; + let items: unknown[] = registry?.listItems?.('webhook') ?? []; + if (items.length === 0) { + const metadata = kernel.getService('metadata') as { list?(type: string): unknown } | undefined; + const listed = await metadata?.list?.('webhook'); + items = Array.isArray(listed) ? listed : []; + } + const out = new Map(); + for (const item of items) { + const parsed = WebhookSchema.safeParse(item); + if (!parsed.success || out.has(parsed.data.name)) continue; + const packageId = (item as { _packageId?: unknown })._packageId; + out.set(parsed.data.name, typeof packageId === 'string' && packageId ? packageId : null); + } + return [...out].map(([name, packageId]) => ({ name, packageId })); + }, + }, + notNull: { + // [18.x step 5d] NOT NULL through the drift apply door of the driver + // that holds the table, for a table this composition declares. + apply: async (columns) => { + const { StorageNameMapping } = await import('@objectstack/spec/system'); + const { rotationBaseOf } = await import('../../utils/unmanaged-tables.js'); + const { applyNotNullThroughDrift } = await import('../../utils/organization-ownership-not-null.js'); + const declared = new Set( + (liveStack.allObjects() as Array<{ name?: unknown } | null>) + .map((o) => o?.name) + .filter((name): name is string => typeof name === 'string' && name.length > 0) + .map((name) => StorageNameMapping.resolveTableName({ name })), + ); + return applyNotNullThroughDrift( + { + isDeclared: (table) => declared.has(rotationBaseOf(table) ?? table), + driverFor: (table) => + (homeOf(rotationBaseOf(table) ?? table) === TELEMETRY_DATASOURCE && liveStack.telemetryDatabase + ? liveStack.drivers[1] + : liveStack.driver) as never, + }, + columns, + ); + }, + }, door: { save: async (type, name, body, target) => { if (typeof protocol?.saveMetaItem !== 'function') { @@ -1093,54 +1146,3 @@ export class OrganizationOwnershipCeremony extends Command { if (verdictCode !== 0) this.exit(1); } } - -/** - * What `os migrate organization-ownership` answers in 18.0, plan and apply - * alike (ruling record 6259608309 on the v18 epic, item 1). - * - * 18.0 supports fresh installs only. Its ceremony is knowingly incomplete: - * several fates, and the moves they carry, were deferred to 18.x. An - * incomplete ceremony must never certify an old database, because the boot - * guard (`OrganizationOwnershipBootGuardPlugin`) would then serve it. So the - * registered command refuses before it reads anything. It opens no database, - * reads no plan, supplement or report file, writes nothing, and boots nothing. - * The code is the standard `NOT_IMPLEMENTED` (ADR-0112): the capability is - * not in this release. No flag and no environment variable lifts the refusal. - */ -export const ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE = - 'os migrate organization-ownership is not available in 18.0. 18.0 supports fresh installs only; the v17 → v18 ' + - 'upgrade path ships in an 18.x release. Keep a v17 database on a 17.x release until then. Nothing was read or written.'; - -/** The ADR-0112 code the 18.0 refusal carries: the standard code for a capability this release does not implement. */ -export const ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE_CODE = 'NOT_IMPLEMENTED' as const; - -/** - * `os migrate organization-ownership` in 18.0: the ceremony's flags and help, - * and a refusal instead of the ceremony - * ({@link ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE}). The 18.x release that - * carries the upgrade path deletes this class and registers the ceremony. - */ -export default class MigrateOrganizationOwnership extends OrganizationOwnershipCeremony { - static override description = - 'Refused in 18.0, which supports fresh installs only: the v17 → v18 upgrade path ships in an 18.x release. ' + - OrganizationOwnershipCeremony.description; - - override async run(): Promise { - const { flags } = await this.parse(MigrateOrganizationOwnership); - if (flags.json) { - await emitJson( - { - error: flags.apply ? 'apply_refused' : 'plan_refused', - reason: 'upgrade-unavailable', - code: ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE_CODE, - detail: ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE, - }, - 1, - { compact: true }, - ); - return; - } - printError(`${ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE} (${ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE_CODE})`); - this.exit(1); - } -} diff --git a/packages/cli/src/commands/migrate/sqlite-wasm.integration.test.ts b/packages/cli/src/commands/migrate/sqlite-wasm.integration.test.ts index 7dde27230b6..4b1f4dc4d0d 100644 --- a/packages/cli/src/commands/migrate/sqlite-wasm.integration.test.ts +++ b/packages/cli/src/commands/migrate/sqlite-wasm.integration.test.ts @@ -36,7 +36,7 @@ import { fileURLToPath } from 'node:url'; import { isExitSignal } from '../../utils/format.js'; import MigrateApply from './apply.js'; import MigratePlan from './plan.js'; -import { OrganizationOwnershipCeremony as MigrateOrganizationOwnership } from './organization-ownership.js'; +import MigrateOrganizationOwnership from './organization-ownership.js'; // [#10126] Pay the first transform of the dist-resolved workspace deps the // commands reach through dynamic `import()`s at MODULE LOAD, not inside a case. diff --git a/packages/cli/src/utils/organization-ownership-not-declared.integration.test.ts b/packages/cli/src/utils/organization-ownership-not-declared.integration.test.ts index feccb913771..75398dcd2fe 100644 --- a/packages/cli/src/utils/organization-ownership-not-declared.integration.test.ts +++ b/packages/cli/src/utils/organization-ownership-not-declared.integration.test.ts @@ -27,7 +27,7 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { isExitSignal } from './format.js'; -import { OrganizationOwnershipCeremony as MigrateOrganizationOwnership } from '../commands/migrate/organization-ownership.js'; +import MigrateOrganizationOwnership from '../commands/migrate/organization-ownership.js'; import '@objectstack/runtime'; import '@objectstack/objectql'; diff --git a/packages/cli/src/utils/organization-ownership-not-null.ts b/packages/cli/src/utils/organization-ownership-not-null.ts new file mode 100644 index 00000000000..c27bb503fe4 --- /dev/null +++ b/packages/cli/src/utils/organization-ownership-not-null.ts @@ -0,0 +1,69 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { NotNullColumn, NotNullOutcome } from './organization-ownership-apply.js'; +import type { SqlDriverLike } from './schema-migrate.js'; + +type DriftEntry = Parameters[0][number]; + +export interface NotNullTarget { + /** The driver whose database holds `table` (the primary, or the `telemetry` sibling). */ + driverFor(table: string): Pick | undefined; + /** Whether the booted composition declares an object for this table (a rotation shard judged by its base). */ + isDeclared(table: string): boolean; +} + +/** + * The ceremony's `notNull` port (18.x step 5d; ruling record 6269955919 on the + * v18 epic, ② A): each tenant column goes NOT NULL through the driver's own + * drift apply door — the SQLite table rebuild, or `ALTER COLUMN … SET NOT + * NULL` — never a hand `ALTER`. + * + * The differ proposes no entry for it (the column's nullability is the + * ceremony's, not a declaration's), so the entry is the ceremony's own + * `tighten_not_null`, handed to the one driver that holds the table. A table + * the composition does not declare is not rebuilt (`not-declared`); a + * transport whose apply door cannot perform the op (the Turso remote face, + * `driftApplyLimit`) is `unsupported`, never attempted. + */ +export async function applyNotNullThroughDrift(target: NotNullTarget, columns: readonly NotNullColumn[]): Promise { + const outcomes: NotNullOutcome[] = []; + for (const { table, column } of columns) { + if (!target.isDeclared(table)) { + outcomes.push({ table, column, outcome: 'not-declared' }); + continue; + } + const driver = target.driverFor(table); + if (!driver) { + outcomes.push({ table, column, outcome: 'failed', detail: 'no driver holds the table' }); + continue; + } + const limit = driver.driftApplyLimit?.('tighten_not_null') ?? null; + if (limit) { + outcomes.push({ table, column, outcome: 'unsupported', detail: limit }); + continue; + } + const entry = { + kind: 'nullability_mismatch', + remoteName: table, + table, + column, + expected: 'NOT NULL', + actual: 'NULL', + severity: 'error', + category: 'destructive', + op: { type: 'tighten_not_null', table, column }, + message: `${table}.${column}: NOT NULL, applied by the organization-ownership ceremony (ADR-0131 D10, 8e).`, + } as unknown as DriftEntry; + try { + const { applied, skipped } = await driver.applyMigrationEntries([entry], { allowDestructive: true }); + outcomes.push( + applied.length === 1 + ? { table, column, outcome: 'applied' } + : { table, column, outcome: 'failed', detail: `the drift apply door did not apply it (${skipped.length} skipped)` }, + ); + } catch (error) { + outcomes.push({ table, column, outcome: 'failed', detail: error instanceof Error ? error.message : String(error) }); + } + } + return outcomes; +} diff --git a/packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts b/packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts index 9f7bd1482e9..32d1da43618 100644 --- a/packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts +++ b/packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts @@ -67,7 +67,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { ORGANIZATION_OWNERSHIP_MIGRATION_ID } from '@objectstack/spec/system'; import { isExitSignal } from './format.js'; import MigrateApply from '../commands/migrate/apply.js'; -import { OrganizationOwnershipCeremony as MigrateOrganizationOwnership } from '../commands/migrate/organization-ownership.js'; +import MigrateOrganizationOwnership from '../commands/migrate/organization-ownership.js'; // Paid at module load, not inside a clocked case (`check:test-source-alias`). import '@objectstack/runtime'; diff --git a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts index efbed27e1e0..defe0dace82 100644 --- a/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts @@ -64,7 +64,7 @@ import MigrateDuplicates from '../commands/migrate/duplicates.js'; import MigrateFilesToReferences from '../commands/migrate/files-to-references.js'; import MigrateMeta from '../commands/migrate/meta.js'; import MigrateMultiValueColumns from '../commands/migrate/multi-value-columns.js'; -import { OrganizationOwnershipCeremony as MigrateOrganizationOwnership } from '../commands/migrate/organization-ownership.js'; +import MigrateOrganizationOwnership from '../commands/migrate/organization-ownership.js'; import MigratePlan from '../commands/migrate/plan.js'; import MigrateRecordedBy from '../commands/migrate/recorded-by.js'; import MigrateResume from '../commands/migrate/resume.js'; diff --git a/packages/cli/src/utils/schema-migrate.sql-driver-lookup.test.ts b/packages/cli/src/utils/schema-migrate.sql-driver-lookup.test.ts index b4a8932615f..0ec2adc531d 100644 --- a/packages/cli/src/utils/schema-migrate.sql-driver-lookup.test.ts +++ b/packages/cli/src/utils/schema-migrate.sql-driver-lookup.test.ts @@ -53,13 +53,13 @@ import { findSqlDriverForKernel } from './schema-migrate.js'; import '@objectstack/metadata-protocol'; import '@objectstack/platform-objects/system'; -type Command = (typeof import('../commands/migrate/organization-ownership.js'))['OrganizationOwnershipCeremony']; +type Command = (typeof import('../commands/migrate/organization-ownership.js'))['default']; let MigrateOrganizationOwnership: Command; // The command's module graph is large, and oclif reads the package's config on // the first run: both are paid outside the case timeout. beforeAll(async () => { - ({ OrganizationOwnershipCeremony: MigrateOrganizationOwnership } = await import('../commands/migrate/organization-ownership.js')); + ({ default: MigrateOrganizationOwnership } = await import('../commands/migrate/organization-ownership.js')); }, 60_000); const CASE_TIMEOUT_MS = 60_000; From 9ea4312cb3f74f925deb1c7c21bee095c281272e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 21:15:35 +0000 Subject: [PATCH 15/44] wip: existing ceremony tests follow the new step set; NOT NULL routed per database --- .../migrate/organization-ownership.ts | 10 ++++--- ...ership-apply.declared-rule-mirrors.test.ts | 28 +++++++++++++++---- .../organization-ownership-apply.test.ts | 14 ++++++---- .../src/utils/organization-ownership-apply.ts | 4 ++- .../utils/organization-ownership-not-null.ts | 10 +++---- 5 files changed, 46 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts index 29cc57e260a..191d42a8f33 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.ts @@ -874,10 +874,12 @@ export default class OrganizationOwnershipCeremony extends Command { return applyNotNullThroughDrift( { isDeclared: (table) => declared.has(rotationBaseOf(table) ?? table), - driverFor: (table) => - (homeOf(rotationBaseOf(table) ?? table) === TELEMETRY_DATASOURCE && liveStack.telemetryDatabase - ? liveStack.drivers[1] - : liveStack.driver) as never, + driverFor: (database) => + (database === undefined + ? liveStack.driver + : database === TELEMETRY_DATASOURCE && liveStack.telemetryDatabase + ? liveStack.drivers[1] + : undefined) as never, }, columns, ); diff --git a/packages/cli/src/utils/organization-ownership-apply.declared-rule-mirrors.test.ts b/packages/cli/src/utils/organization-ownership-apply.declared-rule-mirrors.test.ts index b2f5abbf81e..bc68b8c32fa 100644 --- a/packages/cli/src/utils/organization-ownership-apply.declared-rule-mirrors.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.declared-rule-mirrors.test.ts @@ -9,8 +9,11 @@ * * The fixture is HotCRM's measured shape: eleven uncustomized declared rules * the seeder wrote organization-less (`managed_by 'package'`, `customized 0`), - * beside an organization-authored rule and a customized declared rule with no - * organization, plus a column-drop table still carrying `organization_id` — + * beside an organization-authored rule and a customized seeded rule with no + * organization whose declaration its package has since dropped (a customized + * copy of a rule still declared is the `declared-rule-copies` step's, pinned in + * `organization-ownership-apply.declared-rule-copies.test.ts`), plus a + * column-drop table still carrying `organization_id` — * and the grants those rules materialised, each naming its rule row by id. * * - The mirror-deletion step renames every grant naming a mirror to the @@ -123,8 +126,8 @@ function ledger(): CeremonyLedgerPort & { verdicts: Verdict[] } { }; } -/** Every rule the fixture's packages declare: the eleven mirrors' names and the customized one's. */ -const DECLARED = [...Array.from({ length: MIRRORS }, (_, i) => `share_declared_${i + 1}`), 'share_custom']; +/** Every rule the fixture's packages declare: the eleven mirrors' names (the customized rule's is no longer declared). */ +const DECLARED = Array.from({ length: MIRRORS }, (_, i) => `share_declared_${i + 1}`); /** * Plan, then apply that reviewed plan, under `single`, as the command does. @@ -352,11 +355,26 @@ describe('the declared sharing-rule mirrors, once their seeder has retired', () const l = ledger(); const report = await apply(l, undefined, null); expect(report.complete).toBe(false); + // The customized seeded rule stops it first: whether it is a copy of a + // declared rule is read from the same registry. + expect(report.steps.find((s) => s.step === 'declared-rule-copies')).toMatchObject({ + status: 'blocked', + blockers: [expect.stringMatching(/no registry to read them from/)], + }); + expect(report.steps.find((s) => s.step === 'mirror-deletion')).toMatchObject({ status: 'not-reached' }); + expect(rows(`SELECT COUNT(*) AS n FROM sys_sharing_rule`)).toEqual([{ n: MIRRORS + 2 }]); + }); + + it('with no customized copy to stop it first, the mirror-deletion step blocks on the missing registry itself', async () => { + db.exec(`DELETE FROM sys_sharing_rule WHERE id = 'rule_customized'`); + const report = await apply(ledger(), undefined, null); + expect(report.complete).toBe(false); + expect(report.steps.find((s) => s.step === 'declared-rule-copies')).toMatchObject({ status: 'done' }); expect(report.steps.find((s) => s.step === 'mirror-deletion')).toMatchObject({ status: 'blocked', blockers: [expect.stringMatching(/no registry to read the declared sharing rules from/)], }); - expect(rows(`SELECT COUNT(*) AS n FROM sys_sharing_rule`)).toEqual([{ n: MIRRORS + 2 }]); + expect(rows(`SELECT COUNT(*) AS n FROM sys_sharing_rule`)).toEqual([{ n: MIRRORS + 1 }]); }); it('an organization-stamped mirror (a walled seeder\'s copy) goes too, its grant renamed; a customized one stays', async () => { diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts index 82ee81fae79..40b3a0181d7 100644 --- a/packages/cli/src/utils/organization-ownership-apply.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.test.ts @@ -530,10 +530,14 @@ describe('ADR-0131 D10 --apply — the acceptance fixture', () => { ['metadata-promotion', 'done'], ['template-promotion', 'done'], ['settings-move', 'done'], + ['sibling-moves', 'done'], + ['declared-webhooks', 'done'], + ['declared-rule-copies', 'done'], ['rewrite', 'done'], ['mirror-deletion', 'done'], ['history-renumber', 'done'], ['column-drops', 'done'], + ['not-null', 'done'], ]); expect(report.complete).toBe(true); // Ruling C: the fate-4 orphan is the one discrepancy — the marker is not verified. @@ -781,7 +785,7 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { const again = await apply(c, l); expect(again.resumedFrom).toEqual([ - 'attribution', 'authored-conversion', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite', 'mirror-deletion', 'history-renumber', 'column-drops', + 'attribution', 'authored-conversion', 'metadata-promotion', 'template-promotion', 'settings-move', 'sibling-moves', 'declared-webhooks', 'declared-rule-copies', 'rewrite', 'mirror-deletion', 'history-renumber', 'column-drops', 'not-null', ]); expect(again.steps.every((s) => s.status === 'checkpointed')).toBe(true); expect(again.complete).toBe(true); @@ -793,14 +797,14 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { const c = catalog(); const l = ledger(); await expect(apply(c, l, { executor: executor({ failOnceOn: /^DELETE FROM "sys_capability"/ }) })).rejects.toThrow(/killed mid-step/); - expect(Object.keys(l.checkpoint!.completed)).toEqual(['attribution', 'authored-conversion', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite']); + expect(Object.keys(l.checkpoint!.completed)).toEqual(['attribution', 'authored-conversion', 'metadata-promotion', 'template-promotion', 'settings-move', 'sibling-moves', 'declared-webhooks', 'declared-rule-copies', 'rewrite']); expect(rows('SELECT COUNT(*) AS n FROM sys_position_permission_set')).toEqual([{ n: 3 }]); writes = []; const resumed = await apply(c, l); - expect(resumed.resumedFrom).toEqual(['attribution', 'authored-conversion', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite']); - expect(resumed.steps.map((s) => s.status)).toEqual(['checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'done', 'done', 'done']); + expect(resumed.resumedFrom).toEqual(['attribution', 'authored-conversion', 'metadata-promotion', 'template-promotion', 'settings-move', 'sibling-moves', 'declared-webhooks', 'declared-rule-copies', 'rewrite']); + expect(resumed.steps.map((s) => s.status)).toEqual([...Array(9).fill('checkpointed'), 'done', 'done', 'done', 'done']); expect(writes.some((w) => /^UPDATE/.test(w))).toBe(false); expect(resumed.complete).toBe(true); expect(rows('SELECT COUNT(*) AS n FROM sys_position_permission_set')).toEqual([{ n: 0 }]); @@ -1067,7 +1071,7 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { // (a declared object the differ proposed no drop for). const notProposed = await answering('not-proposed'); expect(notProposed.resumedFrom).toEqual([ - 'attribution', 'authored-conversion', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite', 'mirror-deletion', 'history-renumber', + 'attribution', 'authored-conversion', 'metadata-promotion', 'template-promotion', 'settings-move', 'sibling-moves', 'declared-webhooks', 'declared-rule-copies', 'rewrite', 'mirror-deletion', 'history-renumber', ]); const control = notProposed.steps.find((s) => s.step === 'column-drops')!; expect(control.status).toBe('blocked'); diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index 53149c8e586..380ef3b0ae2 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -3681,6 +3681,8 @@ async function runDeclaredRuleCopies(run: Run): Promise { export interface NotNullColumn { table: string; column: string; + /** The database beside the primary that holds the table (`ApplySibling.name`); absent for the primary. */ + database?: string; } /** One NOT NULL, as the schema door answered it. */ @@ -3730,7 +3732,7 @@ async function runNotNull(run: Run): Promise { if (table.fate !== 'attribution' && table.fate !== 'report') continue; if (table.physical !== 'present' || table.organizationColumn !== 'present' || !table.rows) continue; if (!table.notNull.willReceive || table.rows.organizationNull !== 0) continue; - due.push(...table.tables.map((t) => ({ table: t, column: ORG }))); + due.push(...table.tables.map((t) => ({ table: t, column: ORG, ...(table.database ? { database: table.database } : {}) }))); } summary.due = due.length; if (due.length === 0) return { step, status: 'done', summary, blockers, notes }; diff --git a/packages/cli/src/utils/organization-ownership-not-null.ts b/packages/cli/src/utils/organization-ownership-not-null.ts index c27bb503fe4..bff82a871cf 100644 --- a/packages/cli/src/utils/organization-ownership-not-null.ts +++ b/packages/cli/src/utils/organization-ownership-not-null.ts @@ -6,8 +6,8 @@ import type { SqlDriverLike } from './schema-migrate.js'; type DriftEntry = Parameters[0][number]; export interface NotNullTarget { - /** The driver whose database holds `table` (the primary, or the `telemetry` sibling). */ - driverFor(table: string): Pick | undefined; + /** The driver of the database the plan placed the table in (`database` absent: the primary). */ + driverFor(database: string | undefined): Pick | undefined; /** Whether the booted composition declares an object for this table (a rotation shard judged by its base). */ isDeclared(table: string): boolean; } @@ -27,14 +27,14 @@ export interface NotNullTarget { */ export async function applyNotNullThroughDrift(target: NotNullTarget, columns: readonly NotNullColumn[]): Promise { const outcomes: NotNullOutcome[] = []; - for (const { table, column } of columns) { + for (const { table, column, database } of columns) { if (!target.isDeclared(table)) { outcomes.push({ table, column, outcome: 'not-declared' }); continue; } - const driver = target.driverFor(table); + const driver = target.driverFor(database); if (!driver) { - outcomes.push({ table, column, outcome: 'failed', detail: 'no driver holds the table' }); + outcomes.push({ table, column, outcome: 'failed', detail: `no driver serves the database ${database ?? 'primary'} the table is in` }); continue; } const limit = driver.driftApplyLimit?.('tighten_not_null') ?? null; From 719ab1347d51f1297d7f8981e3028881b71f6832 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 21:18:44 +0000 Subject: [PATCH 16/44] wip: the guard's pins follow the birth marker and shapeGeneration --- ...zation-ownership-boot-guard.kernel.test.ts | 51 +++-- .../organization-ownership-boot-guard.test.ts | 208 ++++++++++++------ 2 files changed, 178 insertions(+), 81 deletions(-) diff --git a/packages/runtime/src/organization-ownership-boot-guard.kernel.test.ts b/packages/runtime/src/organization-ownership-boot-guard.kernel.test.ts index 8ddb05f43f8..6b875cab121 100644 --- a/packages/runtime/src/organization-ownership-boot-guard.kernel.test.ts +++ b/packages/runtime/src/organization-ownership-boot-guard.kernel.test.ts @@ -13,7 +13,9 @@ * column still carries a physical `organization_id`) is refused, with the * refusal's code, and the kernel never reaches `running`; * 2. the same database with the ceremony's marker verified boots; - * 3. a fresh database boots; + * 3. a fresh database boots, the guard writing its birth marker, and boots + * again on that marker; a database an earlier boot created WITHOUT the + * guard (an 18.0 install's: no retired column, no marker) is refused; * 4. a database the ceremony ran on to the end (no column left) whose marker * is NOT verified — its post-check found rows still without their * organization — is refused, naming the re-run; @@ -34,6 +36,7 @@ import { ObjectKernel } from '@objectstack/core'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectQLPlugin } from '@objectstack/objectql'; import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; +import { ORGANIZATION_OWNERSHIP_SHAPE_GENERATION } from '@objectstack/spec/system'; import { createIdentityObjectsPlugin } from '@objectstack/plugin-auth'; import { AppPlugin } from './app-plugin.js'; import { DriverPlugin } from './driver-plugin.js'; @@ -87,10 +90,12 @@ function seedV17Shape(file: string, marker?: { verifiedAt: string | null }): voi db.close(); } +/** The ceremony's marker, its checkpoint at this release's shape generation. */ function writeMarker(db: DatabaseSync, verifiedAt: string | null, blocking = 0): void { - db.exec('CREATE TABLE IF NOT EXISTS sys_migration (id TEXT PRIMARY KEY, last_run_at TEXT, verified_at TEXT, applied_at TEXT, blocking INTEGER)'); - db.prepare('INSERT INTO sys_migration (id, last_run_at, verified_at, applied_at, blocking) VALUES (?, ?, ?, ?, ?)') - .run(MARKER, '2026-10-14T00:00:00.000Z', verifiedAt, '2026-10-14T00:00:00.000Z', blocking); + db.exec('CREATE TABLE IF NOT EXISTS sys_migration (id TEXT PRIMARY KEY, last_run_at TEXT, verified_at TEXT, applied_at TEXT, blocking INTEGER, details TEXT)'); + const details = JSON.stringify({ checkpoint: { ceremony: 'ADR-0131 D10', inventoryDigest: 'x', completed: {}, shapeGeneration: ORGANIZATION_OWNERSHIP_SHAPE_GENERATION } }); + db.prepare('INSERT INTO sys_migration (id, last_run_at, verified_at, applied_at, blocking, details) VALUES (?, ?, ?, ?, ?, ?)') + .run(MARKER, '2026-10-14T00:00:00.000Z', verifiedAt, '2026-10-14T00:00:00.000Z', blocking, details); } /** An app whose inline seed writes two rows of an organization-owned object. */ @@ -166,19 +171,35 @@ describe('the v18 boot refusal on a real kernel (ADR-0131 D10 item 5)', () => { expect(refusalIn(error)?.code).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); }); - it('⭐ a fresh database boots: the guard reads the ledger, finds no marker (absent, never unreadable), and starts', async () => { - const { kernel, error } = await boot(databaseFile()); + it('⭐ R1: a fresh database boots, the guard writing its birth marker; the next boot reads it and boots', async () => { + const file = databaseFile(); + const { kernel, error } = await boot(file); expect(error).toBeNull(); expect(kernel.getState()).toBe('running'); - // The read the guard made is a real one: the ledger object is registered and - // its table answers, so the guard took the read, not the "no ledger" branch, - // and a throwing read would have refused the boot as 'unreadable'. - const engine = kernel.getService('objectql') as { - getObject(name: string): unknown; - findOne(object: string, query: Record): Promise; - }; - expect(engine.getObject('sys_migration')).toBeTruthy(); - await expect(engine.findOne('sys_migration', { where: { id: MARKER }, context: { isSystem: true } })).resolves.toBeNull(); + const [row] = sql<{ verified_at: string | null; blocking: number; details: string }>( + file, + `SELECT verified_at, blocking, details FROM sys_migration WHERE id = '${MARKER}'`, + ); + expect(row?.verified_at).toBeTruthy(); + expect(row?.blocking).toBe(0); + expect(JSON.parse(String(row!.details)).birth).toMatchObject({ shapeGeneration: ORGANIZATION_OWNERSHIP_SHAPE_GENERATION }); + await kernel.shutdown(); + // The same store, found this time: the marker carries it. + expect((await boot(file)).error).toBeNull(); + expect(sql(file, `SELECT id FROM sys_migration WHERE id = '${MARKER}'`)).toHaveLength(1); + }); + + it('⭐ a database an earlier boot created without the guard (an 18.0 install: no retired column, no marker) is refused', async () => { + const file = databaseFile(); + const db = new DatabaseSync(file); + db.exec('CREATE TABLE sys_migration (id TEXT PRIMARY KEY, last_run_at TEXT, verified_at TEXT, applied_at TEXT, blocking INTEGER)'); + db.close(); + const { kernel, error } = await boot(file); + const refusal = refusalIn(error); + expect(refusal?.code, String(error)).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); + expect(refusal?.message).toMatch(/^This database carries no organization-ownership marker/); + expect(kernel.getState()).not.toBe('running'); + expect(sql(file, `SELECT id FROM sys_migration`)).toEqual([]); }); it('⭐ a ceremony that ran every step and did not verify (no v17 column left, marker unverified) is refused, naming the re-run', async () => { diff --git a/packages/runtime/src/organization-ownership-boot-guard.test.ts b/packages/runtime/src/organization-ownership-boot-guard.test.ts index 921e49e8d1a..3d792bdf473 100644 --- a/packages/runtime/src/organization-ownership-boot-guard.test.ts +++ b/packages/runtime/src/organization-ownership-boot-guard.test.ts @@ -1,18 +1,29 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [ADR-0131 D10 item 5; ruling 6163114098, Q3 A] The v18 boot refusal's pins: - * a database still in the v17 shape is refused and the command named; a - * database whose ceremony verified boots; a fresh database boots. Plus the - * fail-closed read, and a driver with no physical columns left inert. + * [ADR-0131 D10 item 5; ruling 6163114098, Q3 A; ruling record 6269955919 on + * the v18 epic, ① A] The v18 boot refusal's pins. The guard decides on ONE + * field, the ownership shape generation the ceremony's `sys_migration` row + * attests: + * + * - a verified marker at the current generation boots — the ceremony's, or + * the BIRTH MARKER the guard writes once on a store created from empty; + * - no marker on any other store (a v17 database, an 18.0 one) is refused, + * naming the ceremony and the tables still carrying a retired column; + * - a verified marker below the current generation (a shorter ceremony's) is + * refused, naming the ceremony again; + * - an unverified or unreadable marker is refused; a failing column read is + * refused; a store with no physical columns is inert. * * The columns are read from a real SQLite database through the guard's own - * raw-SQL probe; the engine is a minimal double exposing the three seams the - * guard reads (the registry, the driver an object binds to, `findOne`). + * raw-SQL probe; the engine is a minimal double exposing the seams the guard + * reads (the registry, the driver an object binds to and its schema-sync + * stats, `findOne`, `insert`). */ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { DatabaseSync } from 'node:sqlite'; +import { ORGANIZATION_OWNERSHIP_SHAPE_GENERATION } from '@objectstack/spec/system'; import { AppPlugin } from './app-plugin.js'; import { assertOrganizationOwnershipMigrated, @@ -24,14 +35,17 @@ import { let db: DatabaseSync; -/** The objects the registry serves: two no-column objects and one tenant-walled one. */ +/** The objects the registry serves: the three spellings of "no organization column", and one tenant-walled object. */ const OBJECTS = [ { name: 'sys_job', systemFields: { tenant: false }, fields: { status: {} } }, { name: 'sys_metadata', systemFields: { tenant: false }, fields: { type: {}, name: {} } }, + { name: 'sys_api_key', tenancy: { enabled: false }, fields: { key: {} } }, { name: 'crm_deal', fields: { amount: {} } }, ]; -function engine(opts: { client?: unknown; dialectName?: string; failRead?: boolean } = {}) { +type Stats = { created: number; existing: number } | undefined; + +function engine(opts: { client?: unknown; dialectName?: string; failRead?: boolean; stats?: Stats; failInsert?: boolean } = {}) { const driver = { ...(opts.dialectName ? { dialectName: opts.dialectName } : {}), config: { client: opts.client ?? 'better-sqlite3' }, @@ -39,6 +53,7 @@ function engine(opts: { client?: unknown; dialectName?: string; failRead?: boole if (opts.failRead) throw new Error('SQLITE_BUSY: database is locked'); return db.prepare(sql).all(...(params as string[])); }, + getSchemaSyncStats: () => opts.stats, }; return { registry: { getAllObjects: () => OBJECTS }, @@ -49,25 +64,56 @@ function engine(opts: { client?: unknown; dialectName?: string; failRead?: boole const id = String((query.where as { id?: unknown } | undefined)?.id); return db.prepare(`SELECT * FROM ${object} WHERE id = ?`).get(id) ?? null; }, + async insert(object: string, data: Record) { + if (opts.failInsert) throw new Error('SQLITE_READONLY: attempt to write a readonly database'); + const keys = Object.keys(data).filter((k) => !['created_at', 'updated_at'].includes(k)); + db.prepare(`INSERT INTO ${object} (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`).run( + ...(keys.map((k) => data[k]) as never[]), + ); + return data; + }, }; } +const LEDGER = 'CREATE TABLE sys_migration (id TEXT PRIMARY KEY, last_run_at TEXT, verified_at TEXT, applied_at TEXT, blocking INTEGER, advisory INTEGER, details TEXT);'; + /** The tables a v17 runtime created: every one carries the injected organization column. */ function v17Shape(): void { db.exec(` CREATE TABLE sys_job (id TEXT PRIMARY KEY, status TEXT, organization_id TEXT); CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, type TEXT, name TEXT, organization_id TEXT); + CREATE TABLE sys_api_key (id TEXT PRIMARY KEY, key TEXT, organization_id TEXT); + CREATE TABLE crm_deal (id TEXT PRIMARY KEY, amount REAL, organization_id TEXT); + ${LEDGER} + `); +} + +/** What an 18.0 install left, or what the ceremony's column-drops step leaves: no table in the v17 shape. */ +function columnlessShape(): void { + db.exec(` + CREATE TABLE sys_job (id TEXT PRIMARY KEY, status TEXT); + CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, type TEXT, name TEXT); + CREATE TABLE sys_api_key (id TEXT PRIMARY KEY, key TEXT); CREATE TABLE crm_deal (id TEXT PRIMARY KEY, amount REAL, organization_id TEXT); - CREATE TABLE sys_migration (id TEXT PRIMARY KEY, last_run_at TEXT, verified_at TEXT, applied_at TEXT, blocking INTEGER, advisory INTEGER); + ${LEDGER} `); } -const marker = (verifiedAt: string | null, blocking = 0): void => { - db.prepare('INSERT INTO sys_migration VALUES (?, ?, ?, ?, ?, ?)').run( - 'adr-0131-organization-ownership', '2026-10-14T00:00:00Z', verifiedAt, '2026-10-14T00:00:00Z', blocking, 0, +/** The ceremony's checkpoint marker, at `generation` (`null`: a marker written before generations existed). */ +const marker = (verifiedAt: string | null, blocking = 0, generation: number | null = ORGANIZATION_OWNERSHIP_SHAPE_GENERATION): void => { + const details = JSON.stringify({ checkpoint: { ceremony: 'ADR-0131 D10', inventoryDigest: 'x', completed: {}, ...(generation === null ? {} : { shapeGeneration: generation }) } }); + db.prepare('INSERT INTO sys_migration VALUES (?, ?, ?, ?, ?, ?, ?)').run( + 'adr-0131-organization-ownership', '2026-10-14T00:00:00Z', verifiedAt, '2026-10-14T00:00:00Z', blocking, 0, details, ); }; +const markerRow = (): Record | undefined => + db.prepare(`SELECT * FROM sys_migration WHERE id = 'adr-0131-organization-ownership'`).get() as Record | undefined; + +/** The schema-sync stats of a store this boot created from empty, and of one it found. */ +const CREATED_EMPTY: Stats = { created: 40, existing: 0 }; +const FOUND: Stats = { created: 0, existing: 40 }; + async function refusalOf(run: () => Promise): Promise { try { await run(); @@ -84,70 +130,104 @@ afterEach(() => { db.close(); }); -describe('the v18 boot refusal (ADR-0131 D10 item 5)', () => { - it('⭐ a v17-shape database is refused, naming the command and every table still carrying the column', async () => { +describe('the v18 boot refusal (ADR-0131 D10 item 5; the birth marker, ruling ① A)', () => { + it('⭐ a v17-shape database is refused, naming the command and every table still carrying the column — tenancy.enabled: false included', async () => { v17Shape(); - const err = await refusalOf(() => assertOrganizationOwnershipMigrated(engine())); + const err = await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ stats: FOUND }))); expect(isOrganizationOwnershipBootRefusal(err)).toBe(true); expect(err.code).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); - expect(err.tables).toEqual(['sys_job', 'sys_metadata']); - expect(err.message).toMatch(/still in the v17 organization-ownership shape: sys_job, sys_metadata/); - // The apply the refusal names is one the ceremony accepts: a reviewed plan file and the backup acknowledged. + expect(err.tables).toEqual(['sys_api_key', 'sys_job', 'sys_metadata']); + expect(err.message).toMatch(/carries no organization-ownership marker: an earlier release created it \(a v17 install, or an 18\.0 one\)/); + expect(err.message).toContain('sys_api_key, sys_job, sys_metadata still carry a physical organization_id column its object no longer declares.'); expect(err.message).toMatch(/os migrate organization-ownership --apply --plan-file PLAN --backup-taken/); - expect(err.message).toMatch(/the ceremony has not run on it/); - // In 18.0 the command it names refuses too, so the refusal names the release answer itself, ahead of the command. - const release = err.message.indexOf('18.0 does not upgrade a v17 database'); - expect(release).toBeGreaterThan(-1); - expect(err.message.indexOf('17.x release until the 18.x release', release)).toBeGreaterThan(release); - expect(release).toBeLessThan(err.message.indexOf("Run 'os migrate organization-ownership --out PLAN'")); + // An 18.0 database takes the same ceremony: the text names no release line to stay on. + expect(err.message).not.toMatch(/17\.x|keep this one|does not upgrade/); }); it('a development boot names the ceremony with --dev, so it boots the way the refused boot did', async () => { v17Shape(); - const dev = await refusalOf(() => assertOrganizationOwnershipMigrated(engine(), undefined, { dev: true })); + const dev = await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ stats: FOUND }), undefined, { dev: true })); expect(dev.message).toContain( "Run 'os migrate organization-ownership --dev --out PLAN' to write and review its plan, take a backup, then run " + "'os migrate organization-ownership --dev --apply --plan-file PLAN --backup-taken'", ); - const served = await refusalOf(() => assertOrganizationOwnershipMigrated(engine())); + const served = await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ stats: FOUND }))); expect(served.message).not.toContain('--dev'); }); + it('⭐ R1: a store created from empty gets its birth marker, exactly once, and boots — again and again', async () => { + columnlessShape(); + const fixed = new Date('2026-10-19T12:00:00.000Z'); + await expect(assertOrganizationOwnershipMigrated(engine({ stats: CREATED_EMPTY }), undefined, { now: () => fixed })).resolves.toBeUndefined(); + const row = markerRow()!; + expect(row).toMatchObject({ verified_at: fixed.toISOString(), blocking: 0 }); + const details = JSON.parse(String(row.details)); + expect(Object.keys(details)).toEqual(['birth']); + expect(details.birth).toMatchObject({ shapeGeneration: ORGANIZATION_OWNERSHIP_SHAPE_GENERATION, createdAt: fixed.toISOString() }); + expect(typeof details.birth.platformVersion).toBe('string'); + // The next boot finds its own store and the marker: it boots, and writes nothing. + await expect(assertOrganizationOwnershipMigrated(engine({ stats: FOUND }))).resolves.toBeUndefined(); + expect(markerRow()).toEqual(row); + }); + + it('⭐ an 18.0 database — no retired column, no marker, a store this boot found — is refused, naming the ceremony', async () => { + columnlessShape(); + const err = await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ stats: FOUND }))); + expect(err.code).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); + expect(err.tables).toEqual([]); + expect(err.message).toMatch(/^This database carries no organization-ownership marker/); + expect(markerRow()).toBeUndefined(); + }); + + it('"cannot say" is no: a store whose driver reports no stats, on an engine that cannot say either, is refused and no marker written', async () => { + columnlessShape(); + expect(isOrganizationOwnershipBootRefusal(await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ stats: undefined }))))).toBe(true); + expect(markerRow()).toBeUndefined(); + }); + + it('a birth marker that cannot be written refuses the boot, saying the next boot would take the store for an old one', async () => { + columnlessShape(); + const err = await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ stats: CREATED_EMPTY, failInsert: true }))); + expect(err.code).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); + expect(err.message).toMatch(/birth marker .* could not be written \(SQLITE_READONLY/); + }); + + it('⭐ F5: a verified marker below the current generation — a shorter ceremony, or one written before generations — is refused, naming the ceremony', async () => { + columnlessShape(); + marker('2026-10-14T01:00:00Z', 0, null); + const err = await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ stats: FOUND }))); + expect(err.code).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); + expect(err.message).toContain( + `certified by an organization-ownership ceremony of shape generation 0, and this release requires generation ${ORGANIZATION_OWNERSHIP_SHAPE_GENERATION}`, + ); + expect(err.message).toMatch(/os migrate organization-ownership --apply --plan-file PLAN --backup-taken/); + }); + it('a marker that is not verified (an unfinished ceremony, or one whose post-check found discrepancies) is refused', async () => { v17Shape(); marker(null); - expect((await refusalOf(() => assertOrganizationOwnershipMigrated(engine()))).message).toMatch(/not finished on it/); + expect((await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ stats: FOUND })))).message).toMatch(/has not finished on this database/); db.exec(`DELETE FROM sys_migration`); marker('2026-10-14T01:00:00Z', 2); - expect(isOrganizationOwnershipBootRefusal(await refusalOf(() => assertOrganizationOwnershipMigrated(engine())))).toBe(true); + expect(isOrganizationOwnershipBootRefusal(await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ stats: FOUND }))))).toBe(true); }); - it('⭐ a database whose ceremony verified boots', async () => { + it('⭐ a database whose ceremony verified at the current generation boots, whatever its columns', async () => { v17Shape(); marker('2026-10-14T01:00:00Z'); - await expect(assertOrganizationOwnershipMigrated(engine())).resolves.toBeUndefined(); + await expect(assertOrganizationOwnershipMigrated(engine({ stats: FOUND }))).resolves.toBeUndefined(); }); - /** What the ceremony's column-drops step leaves: no table in the v17 shape, the ledger beside it. */ - const migrated = (): void => { - db.exec(` - CREATE TABLE sys_job (id TEXT PRIMARY KEY, status TEXT); - CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, type TEXT, name TEXT); - CREATE TABLE crm_deal (id TEXT PRIMARY KEY, amount REAL, organization_id TEXT); - CREATE TABLE sys_migration (id TEXT PRIMARY KEY, last_run_at TEXT, verified_at TEXT, applied_at TEXT, blocking INTEGER, advisory INTEGER); - `); - }; - it('⭐ a migrated database (the columns dropped) whose ceremony verified boots', async () => { - migrated(); + columnlessShape(); marker('2026-10-14T01:00:00Z'); - await expect(assertOrganizationOwnershipMigrated(engine())).resolves.toBeUndefined(); + await expect(assertOrganizationOwnershipMigrated(engine({ stats: FOUND }))).resolves.toBeUndefined(); }); it('⭐ a migrated database (the columns dropped) whose ceremony did NOT verify is refused: its post-check found rows it has not carried', async () => { - migrated(); + columnlessShape(); marker(null, 602); - const err = await refusalOf(() => assertOrganizationOwnershipMigrated(engine())); + const err = await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ stats: FOUND }))); expect(isOrganizationOwnershipBootRefusal(err)).toBe(true); expect(err.code).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); expect(err.tables).toEqual([]); @@ -159,15 +239,16 @@ describe('the v18 boot refusal (ADR-0131 D10 item 5)', () => { // A verified_at with blocking left is not verified either (`isDataMigrationFlagVerified`). db.exec('DELETE FROM sys_migration'); marker('2026-10-14T01:00:00Z', 3); - expect(isOrganizationOwnershipBootRefusal(await refusalOf(() => assertOrganizationOwnershipMigrated(engine())))).toBe(true); + expect(isOrganizationOwnershipBootRefusal(await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ stats: FOUND }))))).toBe(true); }); - it('a migrated database whose marker cannot be read is refused, never passed', async () => { - migrated(); - const failing = { ...engine(), findOne: async () => { throw new Error('SQLITE_BUSY: database is locked'); } }; + it('a marker that cannot be read is refused, never passed — and never overwritten with a birth marker', async () => { + columnlessShape(); + const failing = { ...engine({ stats: CREATED_EMPTY }), findOne: async () => { throw new Error('SQLITE_BUSY: database is locked'); } }; const err = await refusalOf(() => assertOrganizationOwnershipMigrated(failing)); expect(err.code).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); - expect(err.message).toMatch(/its marker \(sys_migration 'adr-0131-organization-ownership'\) could not be read/); + expect(err.message).toMatch(/marker \(sys_migration 'adr-0131-organization-ownership'\) of this database could not be read/); + expect(markerRow()).toBeUndefined(); }); it('a boot with no storage — no driver serves the ledger, and the engine refuses its read — has no database to judge, and boots', async () => { @@ -179,36 +260,31 @@ describe('the v18 boot refusal (ADR-0131 D10 item 5)', () => { await expect(assertOrganizationOwnershipMigrated(storageless)).resolves.toBeUndefined(); }); - it('a migrated database with no marker (no ceremony ran on it) boots; so does a composition with no ledger object', async () => { - migrated(); - await expect(assertOrganizationOwnershipMigrated(engine())).resolves.toBeUndefined(); - marker(null, 1); - const noLedger = { ...engine(), getObject: () => undefined }; + it('a composition with no ledger object has nothing to judge, and boots', async () => { + columnlessShape(); + const noLedger = { ...engine({ stats: FOUND }), getObject: () => undefined }; await expect(assertOrganizationOwnershipMigrated(noLedger)).resolves.toBeUndefined(); }); - it('⭐ a fresh database boots: no table it creates carries a retired column, and no marker is needed', async () => { - await expect(assertOrganizationOwnershipMigrated(engine())).resolves.toBeUndefined(); - }); - it('a column read that fails is refused, never taken for a migrated database', async () => { v17Shape(); - const err = await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ failRead: true }))); + const err = await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ failRead: true, stats: FOUND }))); expect(isOrganizationOwnershipBootRefusal(err)).toBe(true); - expect(err.message).toMatch(/could not read whether this database is still in the v17/); + expect(err.message).toMatch(/could not read whether this database is still in an earlier organization-ownership shape/); }); it('⭐ a driver whose knex client is a dialect CLASS (SqliteWasmDriver, the default for a served file database) is read by its dialectName, and refused', async () => { v17Shape(); class Client_WasmSqlite {} - const err = await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ client: Client_WasmSqlite, dialectName: 'sqlite' }))); + const err = await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ client: Client_WasmSqlite, dialectName: 'sqlite', stats: FOUND }))); expect(isOrganizationOwnershipBootRefusal(err)).toBe(true); - expect(err.tables).toEqual(['sys_job', 'sys_metadata']); + expect(err.tables).toEqual(['sys_api_key', 'sys_job', 'sys_metadata']); }); - it('a driver with no physical columns (memory, MongoDB) is inert', async () => { + it('a driver with no physical columns (memory, MongoDB) is inert: nothing refused, no marker written', async () => { v17Shape(); - await expect(assertOrganizationOwnershipMigrated(engine({ client: 'memory' }))).resolves.toBeUndefined(); + await expect(assertOrganizationOwnershipMigrated(engine({ client: 'memory', stats: CREATED_EMPTY }))).resolves.toBeUndefined(); + expect(markerRow()).toBeUndefined(); }); it('⭐ the plugin refuses in its own start(), registering no hook: a refused boot stops before any later plugin starts', async () => { @@ -216,7 +292,7 @@ describe('the v18 boot refusal (ADR-0131 D10 item 5)', () => { const hooks = new Map Promise>(); const ctx: any = { hook: (event: string, handler: () => Promise) => hooks.set(event, handler), - getService: (name: string) => (name === 'objectql' ? engine() : undefined), + getService: (name: string) => (name === 'objectql' ? engine({ stats: FOUND }) : undefined), logger: {}, }; const plugin = new OrganizationOwnershipBootGuardPlugin(); @@ -245,7 +321,7 @@ describe('the v18 boot refusal (ADR-0131 D10 item 5)', () => { for (const key of ['OS_ALLOW_UNMIGRATED_ORGANIZATION_OWNERSHIP', 'OS_SKIP_ORGANIZATION_OWNERSHIP_CHECK', 'OS_ALLOW_DEGRADED_TENANCY']) { process.env[key] = '1'; } - expect(isOrganizationOwnershipBootRefusal(await refusalOf(() => assertOrganizationOwnershipMigrated(engine())))).toBe(true); + expect(isOrganizationOwnershipBootRefusal(await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ stats: FOUND }))))).toBe(true); } finally { process.env = saved; } From 036a2a18fb54a5b901e6b4aa5e728d11e1a6cd47 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 21:20:58 +0000 Subject: [PATCH 17/44] wip: the birth half in every CLI boot, so a CLI-created fresh store boots --- packages/runtime/src/index.ts | 5 +- .../organization-ownership-boot-guard.test.ts | 23 ++++++ .../src/organization-ownership-boot-guard.ts | 77 +++++++++++++++++++ packages/runtime/src/standalone-stack.ts | 8 ++ 4 files changed, 112 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index a82a2c8b2de..31b907cd78a 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -69,6 +69,9 @@ export { DriverPlugin } from './driver-plugin.js'; export { MigrationRecoveryPlugin, describeInterruptedRun } from './migration-recovery-plugin.js'; export { OrganizationOwnershipBootGuardPlugin, + OrganizationOwnershipBirthMarkerPlugin, + ORGANIZATION_OWNERSHIP_BIRTH_MARKER_PLUGIN_NAME, + recordOrganizationOwnershipBirth, OrganizationOwnershipBootRefusal, ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE, assertOrganizationOwnershipMigrated, @@ -76,7 +79,7 @@ export { isOrganizationOwnershipBootRefusal, organizationOwnershipRefusalMessage, } from './organization-ownership-boot-guard.js'; -export type { OrganizationOwnershipBootGuardOptions } from './organization-ownership-boot-guard.js'; +export type { OrganizationOwnershipBootGuardOptions, OrganizationOwnershipMarkerState } from './organization-ownership-boot-guard.js'; export { DefaultDatasourcePlugin } from './default-datasource-plugin.js'; export type { DefaultDatasourceDefinition, DefaultDatasourcePluginOptions } from './default-datasource-plugin.js'; export { AppPlugin, collectBundleHooks, collectBundleFunctions, collectBundleFunctionEntries, collectBundleActions } from './app-plugin.js'; diff --git a/packages/runtime/src/organization-ownership-boot-guard.test.ts b/packages/runtime/src/organization-ownership-boot-guard.test.ts index 3d792bdf473..350dfc47718 100644 --- a/packages/runtime/src/organization-ownership-boot-guard.test.ts +++ b/packages/runtime/src/organization-ownership-boot-guard.test.ts @@ -28,7 +28,9 @@ import { AppPlugin } from './app-plugin.js'; import { assertOrganizationOwnershipMigrated, isOrganizationOwnershipBootRefusal, + OrganizationOwnershipBirthMarkerPlugin, OrganizationOwnershipBootGuardPlugin, + recordOrganizationOwnershipBirth, ORGANIZATION_OWNERSHIP_BOOT_GUARD_PLUGIN_NAME, ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE, } from './organization-ownership-boot-guard.js'; @@ -287,6 +289,27 @@ describe('the v18 boot refusal (ADR-0131 D10 item 5; the birth marker, ruling expect(markerRow()).toBeUndefined(); }); + it('⭐ the birth half alone (every CLI boot): writes the marker on a store it created from empty, and never refuses', async () => { + columnlessShape(); + // A store this boot found: nothing owed, nothing written, no refusal. + await expect(recordOrganizationOwnershipBirth(engine({ stats: FOUND }))).resolves.toBeNull(); + expect(markerRow()).toBeUndefined(); + // A v17-shape store is never given a birth marker, even one reported created. + db.exec(`DROP TABLE sys_job; CREATE TABLE sys_job (id TEXT PRIMARY KEY, status TEXT, organization_id TEXT);`); + await expect(recordOrganizationOwnershipBirth(engine({ stats: CREATED_EMPTY }))).resolves.toBeNull(); + expect(markerRow()).toBeUndefined(); + db.exec(`DROP TABLE sys_job; CREATE TABLE sys_job (id TEXT PRIMARY KEY, status TEXT);`); + // Created from empty: the marker, once; the plugin over a failing write logs and starts. + const ctx: any = { getService: () => engine({ stats: CREATED_EMPTY }), logger: {} }; + await new OrganizationOwnershipBirthMarkerPlugin().start(ctx); + expect(JSON.parse(String(markerRow()!.details)).birth.shapeGeneration).toBe(ORGANIZATION_OWNERSHIP_SHAPE_GENERATION); + db.exec('DELETE FROM sys_migration'); + const errors: string[] = []; + const failing: any = { getService: () => engine({ stats: CREATED_EMPTY, failInsert: true }), logger: { error: (m: string) => errors.push(m) } }; + await expect(new OrganizationOwnershipBirthMarkerPlugin().start(failing)).resolves.toBeUndefined(); + expect(errors).toEqual([expect.stringMatching(/the first 'os serve' will refuse it/)]); + }); + it('⭐ the plugin refuses in its own start(), registering no hook: a refused boot stops before any later plugin starts', async () => { v17Shape(); const hooks = new Map Promise>(); diff --git a/packages/runtime/src/organization-ownership-boot-guard.ts b/packages/runtime/src/organization-ownership-boot-guard.ts index f16aebe51ba..2465c8f7cf4 100644 --- a/packages/runtime/src/organization-ownership-boot-guard.ts +++ b/packages/runtime/src/organization-ownership-boot-guard.ts @@ -91,6 +91,9 @@ import { resolveRuntimeVersion } from './runtime-version.js'; * `MigrationRecoveryPlugin`. ⛔ Never in `createStandaloneStack`'s own list: * every CLI data and schema boot shares that builder, the ceremony's own boot * among them, and a guard there would refuse the one command that clears it. + * That builder composes the birth half alone + * ({@link OrganizationOwnershipBirthMarkerPlugin}), which writes the marker on + * a store it created from empty and never refuses. * * A store with no physical columns to read (memory, MongoDB), or a * composition with no ledger or no store serving it, has nothing to judge: @@ -352,6 +355,80 @@ type MarkerReading = | { state: 'unverified'; generation: number } | { state: 'verified'; generation: number }; +/** + * [Ruling record 6269955919, ① A] The birth half alone, for a boot that + * composes no guard: every CLI data and schema boot (`createStandaloneStack`), + * which can be the boot that creates a fresh install's store (`os migrate + * apply`, `os seed` before the first `os serve`). It writes the birth marker + * under exactly the guard's conditions — a ledger served by a SQL store this + * boot created from empty, no marker on it, no table in an earlier shape — and + * never refuses: judging the database stays the served boot's. Without it, the + * first `os serve` of an install whose store a CLI boot created would find no + * marker on a store it did not create, and refuse a fresh install. + * + * The ceremony's own boots never trip it: they hold their DDL back + * (`deferSchemaDdl`) or skip schema sync, so their store reports nothing + * created. + */ +export class OrganizationOwnershipBirthMarkerPlugin implements Plugin { + readonly name = ORGANIZATION_OWNERSHIP_BIRTH_MARKER_PLUGIN_NAME; + readonly type = 'standard'; + readonly version = '1.0.0'; + readonly dependencies: string[] = []; + /** As the guard: after the engine's schema sync and the ledger's registration. */ + readonly optionalDependencies: string[] = ['com.objectstack.engine.objectql', 'com.objectstack.platform-objects']; + + async init(_ctx: PluginContext): Promise {} + + async start(ctx: PluginContext): Promise { + let engine: GuardEngine | undefined; + try { + engine = (ctx as any).getService?.('objectql'); + } catch { + return; + } + if (!engine || typeof engine.getDriverForObject !== 'function') return; + const logger = (ctx as any)?.logger as { info?: (msg: string) => void; error?: (msg: string) => void } | undefined; + try { + await recordOrganizationOwnershipBirth(engine, logger); + } catch (error) { + // Durability: the store keeps looking healthy, and its first served boot + // will refuse it as one an earlier release created. + logger?.error?.( + `[organization-ownership] this store was created from empty and its birth marker (sys_migration ` + + `'${ORGANIZATION_OWNERSHIP_MIGRATION_ID}') could not be written ` + + `(${error instanceof Error ? error.message : String(error)}): the first 'os serve' will refuse it as a ` + + `database an earlier release created. Fix the write and run this command again, or run 'os migrate ` + + `organization-ownership' on it.`, + ); + } + } +} + +/** The birth-marker plugin's kernel name. */ +export const ORGANIZATION_OWNERSHIP_BIRTH_MARKER_PLUGIN_NAME = 'com.objectstack.organization-ownership-birth-marker' as const; + +/** + * Write the birth marker when, and only when, this boot created the ledger's + * store from empty and no marker and no table in an earlier shape is there. + * The marker written, or `null` when none was owed. Throws when the write fails. + */ +export async function recordOrganizationOwnershipBirth( + engine: GuardEngine, + logger?: { info?: (msg: string) => void }, + now: () => Date = () => new Date(), +): Promise { + if (!ledgerStoreCreatedFromEmpty(engine)) return null; + if ((await readMarker(engine)).state !== 'absent') return null; + if ((await findV17ShapeTables(engine)).length > 0) return null; + const birth = await writeBirthMarker(engine, now()); + logger?.info?.( + `[organization-ownership] a datastore created from empty: birth marker written (shape generation ` + + `${birth.shapeGeneration}, platform ${birth.platformVersion}).`, + ); + return birth; +} + /** * The ceremony's marker. `inert` when this composition registers no ledger * object, no store serves it (`os serve` with no OS_DATABASE_URL and no driver diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index 8b74041be49..d22ba210c4d 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -79,6 +79,7 @@ import { loadArtifactBundle, isHttpUrl } from './load-artifact-bundle.js'; import { makeDirectorySync } from './make-directory.js'; import { loadTursoDriverFactory } from './turso-driver-factory.js'; import { devAutoMigrateConfig } from './dev-auto-migrate.js'; +import { OrganizationOwnershipBirthMarkerPlugin } from './organization-ownership-boot-guard.js'; import { namesRetiredMemoryEngine, resolveProjectDatabaseUrl, @@ -1143,6 +1144,13 @@ export async function createStandaloneStack(config?: StandaloneStackConfig): Pro // hands the plugin exactly the options it always did. ...(cfg.armLifecycleSweep === false ? { lifecycle: { enabled: false } } : {}), }), + // [ADR-0131 D10; ruling record 6269955919, ① A] A store this boot + // creates from empty is born in the current ownership shape: its + // birth marker is written here too, so a fresh install whose first + // boot is a CLI one (`os migrate apply`, `os seed`) is not refused + // by its first `os serve`. It never refuses — the served boot's guard + // judges (⛔ the guard itself stays out of this list). + new OrganizationOwnershipBirthMarkerPlugin(), ]; if (artifactBundle) { plugins.push(new AppPlugin(artifactBundle, undefined, { From 0128f07172294d81b7f4543df6e77fdf9a6e456a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 21:23:38 +0000 Subject: [PATCH 18/44] test(cli): pin the generation-1 ceremony steps over an 18.0-shaped database --- ...ation-ownership-apply.generation-1.test.ts | 433 ++++++++++++++++++ packages/plugins/plugin-webhooks/src/index.ts | 3 + 2 files changed, 436 insertions(+) create mode 100644 packages/cli/src/utils/organization-ownership-apply.generation-1.test.ts diff --git a/packages/cli/src/utils/organization-ownership-apply.generation-1.test.ts b/packages/cli/src/utils/organization-ownership-apply.generation-1.test.ts new file mode 100644 index 00000000000..a58296de0e4 --- /dev/null +++ b/packages/cli/src/utils/organization-ownership-apply.generation-1.test.ts @@ -0,0 +1,433 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The four ceremony steps of ownership shape generation 1 (18.x steps 5a–5d), + * pinned in the ceremony's own terms, over an 18.0-SHAPED database: every row + * stamped with the Default Organization (line fact F3), so each step must + * select by shape — a NULL selector would find nothing here. + * + * - `sibling-moves` (5b): the global settings-audit rows and identity mail + * (reset by `related_object`, the magic link by its sign-in link) move id + * for id into their siblings, the organization invitation stays; a queued + * 18.0 mail job is pointed at the sibling first; the activity rows about a + * moved row name its new object. + * - `declared-webhooks` (5a): a pristine copy is deleted, its deliveries + * re-sourced to `declared:NAME` and its orphaned cipher deleted; a + * switch-only edit becomes the declaration's activation row; any other + * edit becomes a clone under a new name, the declaration switched off, its + * deliveries still its own; a seeded row no package declares stays. + * - `declared-rule-copies` (5c, 9c): a customized copy and an admin rule under + * a declared name become clones, the declaration switched off, their grants + * kept; the stale name-keyed grant goes. + * - `not-null` (5d, 8e): every cleared table receives NOT NULL through the + * port, after the column drops. + * + * Plus: the checkpoint carries this release's shape generation; a walled + * posture blocks on a customized copy rather than switch a declaration off + * for every organization; every step is idempotent. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import Database from 'better-sqlite3'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ORGANIZATION_OWNERSHIP_SHAPE_GENERATION } from '@objectstack/spec/system'; +import { declaredWebhookRefId } from '@objectstack/plugin-webhooks'; +import { buildOrganizationOwnershipPlan, inventoryDigest } from './organization-ownership-plan.js'; +import { + applyOrganizationOwnership, + CEREMONY_STEPS, + DECLARED_WEBHOOK_REF_PREFIX, + reviewedConflictsOf, + type ApplyExecutor, + type CeremonyCheckpoint, + type CeremonyLedgerPort, + type ColumnDropPort, + type NotNullColumn, + type NotNullPort, + type OrganizationOwnershipApplyReport, +} from './organization-ownership-apply.js'; + +const ORG = 'org_default'; + +let dir: string; +let db: Database.Database; +let writes: string[]; +let tightened: NotNullColumn[]; + +function executor(): ApplyExecutor { + return { + dialect: 'sqlite', + client: 'better-sqlite3', + async query(sql, params = []) { + return db.prepare(sql).all(...(params as unknown[])) as Record[]; + }, + async execute(sql, params = []) { + writes.push(sql); + db.prepare(sql).run(...(params as unknown[])); + }, + }; +} + +const columnDrops: ColumnDropPort = { + async drop(columns) { + return columns.map(({ table, column }) => { + writes.push(`ALTER TABLE ${table} DROP COLUMN ${column}`); + db.exec(`ALTER TABLE "${table}" DROP COLUMN "${column}"`); + return { table, column, outcome: 'dropped' as const }; + }); + }, +}; + +/** The schema door: records what it was asked, and answers `applied`. */ +const notNull: NotNullPort = { + async apply(columns) { + tightened.push(...columns); + return columns.map((c) => ({ ...c, outcome: 'applied' as const })); + }, +}; + +function ledger(): CeremonyLedgerPort & { stored: () => CeremonyCheckpoint | null } { + let stored: CeremonyCheckpoint | null = null; + return { + stored: () => stored, + async read() { + return stored ? (JSON.parse(JSON.stringify(stored)) as CeremonyCheckpoint) : null; + }, + async write(checkpoint) { + stored = JSON.parse(JSON.stringify(checkpoint)) as CeremonyCheckpoint; + }, + }; +} + +/** The webhooks and sharing rules the fixture's packages declare. */ +const WEBHOOKS = [ + { name: 'task_changed', packageId: 'com.example.app' }, + { name: 'deal_won', packageId: 'com.example.app' }, + { name: 'lead_scored', packageId: 'com.example.app' }, +]; +const RULES = ['share_custom', 'share_collides']; + +async function apply( + l: ReturnType = ledger(), + opts: { posture?: string; webhooks?: boolean; rules?: boolean; notNull?: boolean } = {}, +): Promise { + const posture = opts.posture ?? 'single'; + const database = join(dir, 'fixture.db'); + const reviewed = await buildOrganizationOwnershipPlan({ reader: executor(), posture, database }); + return applyOrganizationOwnership({ + executor: executor(), + posture, + database, + reviewedPlan: { + file: 'plan.json', + generatedAt: reviewed.generatedAt, + database, + inventoryDigest: inventoryDigest(), + posture, + conflicts: reviewedConflictsOf(reviewed), + }, + catalog: { async resolve() { return undefined; } }, + ...(opts.webhooks === false ? {} : { declaredWebhooks: { async list() { return WEBHOOKS; } } }), + ...(opts.rules === false + ? {} + : { declaredSharingRules: { async rules() { return RULES.map((name) => ({ name, refused: null, packageId: 'com.example.app' })); } } }), + ...(opts.notNull === false ? {} : { notNull }), + door: { + async save(type, name) { + throw new Error(`no environment save is expected here (${type} ${name})`); + }, + }, + templates: { async find() { return undefined; } }, + views: { async list() { return []; } }, + lock: { async verdict() { return { editable: true }; } }, + columnDrops, + ledger: l, + now: () => new Date('2026-10-19T00:00:00Z'), + }); +} + +const rows = (sql: string): Record[] => db.prepare(sql).all() as Record[]; +const step = (report: OrganizationOwnershipApplyReport, name: string) => report.steps.find((s) => s.step === name)!; + +/** A seeded 18.0 webhook row: the seeder's mapping of the envelope, plus the snapshot it wrote. */ +function seededWebhook(id: string, name: string, over: Record = {}): void { + const envelope = { name, label: `Label ${name}`, object: 'crm_deal', triggers: ['create', 'update'], url: `https://hooks.example/${name}`, method: 'POST', timeoutMs: 30000, isActive: false, _provenance: 'package', _packageId: 'com.example.app', _packageVersion: '1.0.0' }; + const row: Record = { + id, organization_id: ORG, name, label: envelope.label, object_name: 'crm_deal', triggers: JSON.stringify(envelope.triggers), + url: envelope.url, method: 'post', description: null, active: 0, definition_json: JSON.stringify(envelope), + headers_secret: null, signing_secret: null, managed_by: 'package', customized: 0, ...over, + }; + const keys = Object.keys(row); + db.prepare(`INSERT INTO sys_webhook (${keys.join(', ')}) VALUES (${keys.map(() => '?').join(', ')})`).run(...keys.map((k) => row[k])); +} + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'os-d10-generation-1-')); + db = new Database(join(dir, 'fixture.db')); + writes = []; + tightened = []; + db.exec(` + CREATE TABLE sys_organization (id TEXT PRIMARY KEY, slug TEXT); + INSERT INTO sys_organization VALUES ('${ORG}', 'default'); + CREATE TABLE crm_deal (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT); + INSERT INTO crm_deal VALUES ('deal_1', '${ORG}', 'Acme'); + CREATE TABLE sys_user (id TEXT PRIMARY KEY, email TEXT); + INSERT INTO sys_user VALUES ('usr_b', 'b@example.test'); + + CREATE TABLE sys_metadata_activation (id TEXT PRIMARY KEY, created_at TEXT, updated_at TEXT, metadata_type TEXT, + name TEXT, package_id TEXT, active INTEGER DEFAULT 1, UNIQUE (metadata_type, name)); + + CREATE TABLE sys_setting_audit (id TEXT PRIMARY KEY, created_at TEXT, organization_id TEXT, namespace TEXT, key TEXT, + scope TEXT, action TEXT, new_hash TEXT, encrypted INTEGER); + INSERT INTO sys_setting_audit VALUES + ('aud_tenant', '2026-10-19T12:00:00Z', '${ORG}', 'branding', 'workspace_name', 'tenant', 'set', 'fnv32:1', 0), + ('aud_global', '2026-10-19T12:00:01Z', '${ORG}', 'auth', 'api_key_policy', 'global', 'set', 'fnv32:2', 0), + ('aud_global_enc', '2026-10-19T12:00:02Z', '${ORG}', 'ai', 'gateway_api_key', 'global', 'set', 'hmac:3', 1); + CREATE TABLE sys_platform_setting_audit (id TEXT PRIMARY KEY, created_at TEXT, namespace TEXT, key TEXT, scope TEXT, + action TEXT, new_hash TEXT, encrypted INTEGER); + + CREATE TABLE sys_email (id TEXT PRIMARY KEY, organization_id TEXT, subject TEXT, body_text TEXT, body_html TEXT, + status TEXT, related_object TEXT, related_id TEXT); + INSERT INTO sys_email VALUES + ('mail_invite', '${ORG}', 'You are invited to Default Organization', 'join', NULL, 'sent', 'sys_invitation', 'inv_1'), + ('mail_reset', '${ORG}', 'Reset your password', 'reset at /reset-password/tok', NULL, 'sent', 'sys_user', 'usr_b'), + ('mail_magic', '${ORG}', 'Your sign-in link', 'open https://app.example/api/auth/magic-link/verify?token=abc', NULL, 'pending', NULL, NULL), + ('mail_app', '${ORG}', 'Your invoice', 'see attached', NULL, 'sent', 'crm_deal', 'deal_1'); + CREATE TABLE sys_identity_email (id TEXT PRIMARY KEY, subject TEXT, body_text TEXT, body_html TEXT, status TEXT, + related_object TEXT, related_id TEXT); + + CREATE TABLE sys_job_queue (id TEXT PRIMARY KEY, queue TEXT, idempotency_key TEXT, payload_json TEXT, metadata_json TEXT, status TEXT); + INSERT INTO sys_job_queue VALUES + ('job_magic', 'email.send.async', 'sys_email:mail_magic', '{"rowId":"mail_magic"}', '{"object":"sys_email","rowId":"mail_magic"}', 'pending'), + ('job_app', 'email.send.async', 'sys_email:mail_app', '{"rowId":"mail_app"}', '{"object":"sys_email","rowId":"mail_app"}', 'pending'), + ('job_done', 'email.attachment.reclaim', 'sys_email_attachments:mail_reset', '{"rowId":"mail_reset","keys":[]}', NULL, 'completed'); + + CREATE TABLE sys_activity (id TEXT PRIMARY KEY, organization_id TEXT, object_name TEXT, record_id TEXT, summary TEXT); + INSERT INTO sys_activity VALUES + ('act_reset', '${ORG}', 'sys_email', 'mail_reset', 'mail sent'), + ('act_audit', '${ORG}', 'sys_setting_audit', 'aud_global', 'setting changed'), + ('act_deal', '${ORG}', 'crm_deal', 'deal_1', 'deal updated'); + + CREATE TABLE sys_webhook (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, label TEXT, object_name TEXT, + triggers TEXT, url TEXT, method TEXT, description TEXT, active INTEGER, definition_json TEXT, headers_secret TEXT, + signing_secret TEXT, managed_by TEXT, customized INTEGER); + CREATE TABLE sys_http_delivery (id TEXT PRIMARY KEY, organization_id TEXT, source TEXT, ref_id TEXT, dedup_key TEXT, status TEXT); + CREATE TABLE sys_secret (id TEXT PRIMARY KEY, organization_id TEXT, namespace TEXT, key TEXT, ciphertext TEXT); + INSERT INTO sys_secret VALUES + ('sec_pristine', '${ORG}', 'sys_webhook', 'signing_secret', 'c1'), + ('sec_setting', '${ORG}', 'ai', 'gateway_api_key', 'c2'); + + CREATE TABLE sys_sharing_rule (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, object_name TEXT, managed_by TEXT, customized INTEGER); + INSERT INTO sys_sharing_rule VALUES + ('rule_custom', '${ORG}', 'share_custom', 'crm_deal', 'package', 1), + ('rule_collides', '${ORG}', 'share_collides', 'crm_deal', 'admin', 0), + ('rule_own', '${ORG}', 'mine', 'crm_deal', 'admin', 0); + CREATE TABLE sys_record_share (id TEXT PRIMARY KEY, organization_id TEXT, object_name TEXT, record_id TEXT, source TEXT, source_id TEXT); + INSERT INTO sys_record_share VALUES + ('grant_custom', '${ORG}', 'crm_deal', 'deal_1', 'rule', 'rule_custom'), + ('grant_stale', '${ORG}', 'crm_deal', 'deal_1', 'rule', 'share_custom'), + ('grant_own', '${ORG}', 'crm_deal', 'deal_1', 'rule', 'rule_own'); + `); + // A pristine copy holding a cipher; a switch-only edit; an edit beyond the switch; a row no package declares. + seededWebhook('whk_pristine', 'task_changed', { signing_secret: 'secret:sec_pristine' }); + seededWebhook('whk_switched', 'deal_won', { active: 1, customized: 1 }); + seededWebhook('whk_edited', 'lead_scored', { url: 'https://hooks.example/admin-moved-it', customized: 1 }); + seededWebhook('whk_orphan', 'retired_hook'); + db.exec(` + INSERT INTO sys_http_delivery VALUES + ('dlv_1', '${ORG}', 'webhook', 'whk_pristine', 'whk_pristine:e1', 'pending'), + ('dlv_2', '${ORG}', 'webhook', 'whk_switched', 'whk_switched:e2', 'failed'), + ('dlv_3', '${ORG}', 'webhook', 'whk_edited', 'whk_edited:e3', 'sent'), + ('dlv_4', '${ORG}', 'webhook', 'whk_orphan', 'whk_orphan:e4', 'sent'); + `); +}); + +afterEach(() => { + db.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('ownership shape generation 1 — the ceremony steps an 18.0 database owes', () => { + it('runs every step in order, certifies, and records this release\'s shape generation in the checkpoint', async () => { + const l = ledger(); + const report = await apply(l); + expect(report.steps.map((s) => [s.step, s.status])).toEqual(CEREMONY_STEPS.map((s) => [s, 'done'])); + expect([report.complete, report.blocking]).toEqual([true, 0]); + expect(l.stored()?.shapeGeneration).toBe(ORGANIZATION_OWNERSHIP_SHAPE_GENERATION); + // The new steps run where the ruling put them: the moves after settings-move, NOT NULL after the column drops. + expect(CEREMONY_STEPS.indexOf('sibling-moves')).toBe(CEREMONY_STEPS.indexOf('settings-move') + 1); + expect(CEREMONY_STEPS.at(-1)).toBe('not-null'); + expect(CEREMONY_STEPS.indexOf('not-null')).toBeGreaterThan(CEREMONY_STEPS.indexOf('column-drops')); + }); + + it('5b ⭐ the global audit rows and identity mail move id for id — selected by shape, though stamped — and the rest stays', async () => { + const report = await apply(); + expect(step(report, 'sibling-moves')).toMatchObject({ status: 'done', summary: { moved: 4, alreadyMoved: 0, jobsRewritten: 1, activityRepointed: 2 } }); + expect(rows(`SELECT id FROM sys_platform_setting_audit ORDER BY id`).map((r) => r.id)).toEqual(['aud_global', 'aud_global_enc']); + expect(rows(`SELECT id FROM sys_setting_audit`).map((r) => r.id)).toEqual(['aud_tenant']); + expect(rows(`SELECT encrypted, new_hash FROM sys_platform_setting_audit WHERE id = 'aud_global_enc'`)).toEqual([{ encrypted: 1, new_hash: 'hmac:3' }]); + // Identity mail: the reset (a person's mail) and the magic link (its sign-in link); the invitation and app mail stay. + expect(rows(`SELECT id FROM sys_identity_email ORDER BY id`).map((r) => r.id)).toEqual(['mail_magic', 'mail_reset']); + expect(rows(`SELECT id FROM sys_email ORDER BY id`).map((r) => r.id)).toEqual(['mail_app', 'mail_invite']); + }); + + it('5b ⭐ a queued 18.0 mail job names its moved row\'s sibling; jobs of mail that stays, and finished jobs, are untouched', async () => { + await apply(); + expect(rows(`SELECT id, idempotency_key, payload_json, metadata_json FROM sys_job_queue ORDER BY id`)).toEqual([ + { id: 'job_app', idempotency_key: 'sys_email:mail_app', payload_json: '{"rowId":"mail_app"}', metadata_json: '{"object":"sys_email","rowId":"mail_app"}' }, + { id: 'job_done', idempotency_key: 'sys_email_attachments:mail_reset', payload_json: '{"rowId":"mail_reset","keys":[]}', metadata_json: null }, + { + id: 'job_magic', + idempotency_key: 'sys_identity_email:mail_magic', + payload_json: '{"rowId":"mail_magic","object":"sys_identity_email"}', + metadata_json: '{"object":"sys_identity_email","rowId":"mail_magic"}', + }, + ]); + // The job is rewritten BEFORE its row leaves sys_email. + const job = writes.findIndex((w) => /^UPDATE "sys_job_queue"/.test(w)); + const leave = writes.findIndex((w) => /^DELETE FROM "sys_email"/.test(w)); + expect(job).toBeGreaterThan(-1); + expect(job).toBeLessThan(leave); + }); + + it('5b the activity rows about a moved row name its new object; the rest of the timeline is untouched', async () => { + await apply(); + expect(rows(`SELECT id, object_name FROM sys_activity ORDER BY id`)).toEqual([ + { id: 'act_audit', object_name: 'sys_platform_setting_audit' }, + { id: 'act_deal', object_name: 'crm_deal' }, + { id: 'act_reset', object_name: 'sys_identity_email' }, + ]); + }); + + it('5b a run stopped between copy and delete finishes: the row already in the sibling is not copied twice', async () => { + db.exec(`INSERT INTO sys_identity_email (id, subject, status, related_object, related_id) VALUES ('mail_reset', 'Reset your password', 'sent', 'sys_user', 'usr_b')`); + const report = await apply(); + expect(step(report, 'sibling-moves')).toMatchObject({ status: 'done', summary: { moved: 3, alreadyMoved: 1 } }); + expect(rows(`SELECT COUNT(*) AS n FROM sys_identity_email WHERE id = 'mail_reset'`)).toEqual([{ n: 1 }]); + expect(rows(`SELECT COUNT(*) AS n FROM sys_email WHERE id = 'mail_reset'`)).toEqual([{ n: 0 }]); + }); + + it('5a ⭐ a pristine copy is deleted once its deliveries name the declaration, and its orphaned cipher goes with it', async () => { + const report = await apply(); + expect(step(report, 'declared-webhooks')).toMatchObject({ + status: 'done', + summary: { mirrorsDeleted: 2, activationRows: 2, clones: 1, deliveriesResourced: 2, secretsDeleted: 1, undeclaredKept: 1 }, + }); + expect(rows(`SELECT ref_id FROM sys_http_delivery WHERE id = 'dlv_1'`)).toEqual([{ ref_id: declaredWebhookRefId('task_changed') }]); + expect(rows(`SELECT id FROM sys_secret ORDER BY id`).map((r) => r.id)).toEqual(['sec_setting']); + // The re-source precedes the delete. + const resource = writes.findIndex((w) => /^UPDATE "sys_http_delivery"/.test(w)); + const remove = writes.findIndex((w) => /^DELETE FROM "sys_webhook"/.test(w)); + expect(resource).toBeGreaterThan(-1); + expect(resource).toBeLessThan(remove); + }); + + it('5a ⭐ a switch-only edit becomes the declaration\'s activation row; an edit beyond it a clone, the declaration switched off', async () => { + await apply(); + expect(rows(`SELECT metadata_type, name, package_id, active FROM sys_metadata_activation ORDER BY name`)).toEqual([ + { metadata_type: 'webhook', name: 'deal_won', package_id: 'com.example.app', active: 1 }, + { metadata_type: 'webhook', name: 'lead_scored', package_id: 'com.example.app', active: 0 }, + { metadata_type: 'sharing_rule', name: 'share_collides', package_id: 'com.example.app', active: 0 }, + { metadata_type: 'sharing_rule', name: 'share_custom', package_id: 'com.example.app', active: 0 }, + ].sort((a, b) => a.name.localeCompare(b.name))); + expect(rows(`SELECT ref_id FROM sys_http_delivery WHERE id = 'dlv_2'`)).toEqual([{ ref_id: 'declared:deal_won' }]); + // The clone keeps its id, its edit and its deliveries; it is the administrator's own now. + const [clone] = rows(`SELECT id, name, url, managed_by, customized, definition_json FROM sys_webhook WHERE id = 'whk_edited'`); + expect(clone).toMatchObject({ name: 'lead_scored_custom', url: 'https://hooks.example/admin-moved-it', managed_by: 'admin', customized: 0 }); + const def = JSON.parse(String(clone!.definition_json)); + expect(def.name).toBe('lead_scored_custom'); + expect(def).not.toHaveProperty('_packageId'); + expect(rows(`SELECT ref_id FROM sys_http_delivery WHERE id = 'dlv_3'`)).toEqual([{ ref_id: 'whk_edited' }]); + // A seeded row no package declares stays as it is, dispatching as itself. + expect(rows(`SELECT id, name, managed_by FROM sys_webhook ORDER BY id`)).toEqual([ + { id: 'whk_edited', name: 'lead_scored_custom', managed_by: 'admin' }, + { id: 'whk_orphan', name: 'retired_hook', managed_by: 'package' }, + ]); + expect(rows(`SELECT ref_id FROM sys_http_delivery WHERE id = 'dlv_4'`)).toEqual([{ ref_id: 'whk_orphan' }]); + }); + + it('5a the delivery spelling is plugin-webhooks\' own', () => { + expect(`${DECLARED_WEBHOOK_REF_PREFIX}x`).toBe(declaredWebhookRefId('x')); + }); + + it('5a a credential on a customized copy is never read as switch-only: it is cloned, which keeps it', async () => { + db.exec(`UPDATE sys_webhook SET headers_secret = 'secret:sec_setting' WHERE id = 'whk_switched'`); + await apply(); + expect(rows(`SELECT name, headers_secret FROM sys_webhook WHERE id = 'whk_switched'`)).toEqual([{ name: 'deal_won_custom', headers_secret: 'secret:sec_setting' }]); + expect(rows(`SELECT id FROM sys_secret WHERE id = 'sec_setting'`)).toHaveLength(1); + }); + + it('5c ⭐ a customized copy and an admin rule under a declared name become clones, their grants theirs; the stale name grant goes', async () => { + const report = await apply(); + expect(step(report, 'declared-rule-copies')).toMatchObject({ status: 'done', summary: { clones: 2, activationRows: 2, grantsRemoved: 1 } }); + expect(rows(`SELECT id, name, managed_by, customized FROM sys_sharing_rule ORDER BY id`)).toEqual([ + { id: 'rule_collides', name: 'share_collides_custom', managed_by: 'admin', customized: 0 }, + { id: 'rule_custom', name: 'share_custom_custom', managed_by: 'admin', customized: 0 }, + { id: 'rule_own', name: 'mine', managed_by: 'admin', customized: 0 }, + ]); + expect(rows(`SELECT id, source_id FROM sys_record_share ORDER BY id`)).toEqual([ + { id: 'grant_custom', source_id: 'rule_custom' }, + { id: 'grant_own', source_id: 'rule_own' }, + ]); + }); + + it('5c/5a under a wall a customized copy blocks, named, and nothing of it is written: the switch is deployment-wide', async () => { + db.exec(`DELETE FROM sys_webhook WHERE customized = 1`); + const report = await apply(ledger(), { posture: 'isolated' }); + expect(report.complete).toBe(false); + const rules = step(report, 'declared-rule-copies'); + expect(rules.status).toBe('blocked'); + expect(rules.blockers[0]).toMatch(/Under the isolated posture the declaration's switch is deployment-wide/); + expect(rows(`SELECT name FROM sys_sharing_rule WHERE id = 'rule_custom'`)).toEqual([{ name: 'share_custom' }]); + expect(rows(`SELECT COUNT(*) AS n FROM sys_metadata_activation WHERE metadata_type = 'sharing_rule'`)).toEqual([{ n: 0 }]); + }); + + it('5a without the declared webhooks to read, a seeded row blocks the step and nothing is deleted', async () => { + const report = await apply(ledger(), { webhooks: false }); + expect(step(report, 'declared-webhooks')).toMatchObject({ status: 'blocked', blockers: [expect.stringMatching(/no registry to read them from/)] }); + expect(rows(`SELECT COUNT(*) AS n FROM sys_webhook`)).toEqual([{ n: 4 }]); + }); + + it('5d ⭐ after the column drops, every cleared table is handed to the schema door for NOT NULL — the moved-from tables among them', async () => { + const report = await apply(); + expect(step(report, 'not-null').status).toBe('done'); + const tables = tightened.map((c) => c.table).sort(); + for (const table of ['crm_deal', 'sys_email', 'sys_setting_audit', 'sys_http_delivery', 'sys_webhook', 'sys_sharing_rule', 'sys_record_share', 'sys_activity']) { + expect(tables, table).toContain(table); + } + expect(tightened.every((c) => c.column === 'organization_id')).toBe(true); + // A column-drop table is never handed to it: its column is gone. + expect(tables).not.toContain('sys_secret'); + expect(rows(`SELECT name FROM pragma_table_info('sys_secret') WHERE name = 'organization_id'`)).toEqual([]); + // The door is asked only once the drops have run. + expect(writes.some((w) => /^ALTER TABLE sys_secret DROP COLUMN organization_id$/.test(w))).toBe(true); + }); + + it('5d a table that still holds a NULL organization row is not handed to the door; the cleared ones are', async () => { + // Under a wall nothing owns an orphan row; the copies a wall would block on are gone. + db.exec(` + INSERT INTO crm_deal VALUES ('deal_orphan', NULL, 'nobody'); + DELETE FROM sys_webhook WHERE customized = 1; + DELETE FROM sys_sharing_rule WHERE id IN ('rule_custom', 'rule_collides'); + `); + const report = await apply(ledger(), { posture: 'isolated' }); + expect(step(report, 'not-null').status).toBe('done'); + expect(tightened.map((c) => c.table)).not.toContain('crm_deal'); + expect(tightened.map((c) => c.table)).toContain('sys_email'); + }); + + it('5d a run with no schema door applies nothing and says so; it does not block', async () => { + const report = await apply(ledger(), { notNull: false }); + expect(step(report, 'not-null')).toMatchObject({ status: 'done', notes: [expect.stringMatching(/no schema door to apply it: nothing was applied/)] }); + }); + + it('every new step is idempotent: a second run on a fresh checkpoint writes nothing to the tables they own', async () => { + await apply(); + writes = []; + const again = await apply(); + expect([again.complete, again.blocking]).toEqual([true, 0]); + expect(writes.filter((w) => /sys_webhook|sys_http_delivery|sys_identity_email|sys_platform_setting_audit|sys_job_queue|sys_sharing_rule|sys_metadata_activation/.test(w))).toEqual([]); + }); +}); diff --git a/packages/plugins/plugin-webhooks/src/index.ts b/packages/plugins/plugin-webhooks/src/index.ts index 9f89b7beb22..2f82357f3ea 100644 --- a/packages/plugins/plugin-webhooks/src/index.ts +++ b/packages/plugins/plugin-webhooks/src/index.ts @@ -27,6 +27,9 @@ export { export { AutoEnqueuer, type AutoEnqueuerOptions, type HttpEnqueueFn } from './auto-enqueuer.js'; export { SysWebhook } from './sys-webhook.object.js'; +// [ADR-0131 D2] How a delivery names a declared webhook (`declared:NAME`); the upgrade ceremony +// re-sources an 18.0 copy's deliveries to it and pins its own spelling equal to this one. +export { DECLARED_WEBHOOK_REF_PREFIX, declaredWebhookRefId } from './declared-webhooks.js'; /** * [#7799] The signing-secret seam. Exported so a host that boots the pieces From 8e6a33a2d41d1d6d4bd603b21bc8968bccb49774 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 21:29:06 +0000 Subject: [PATCH 19/44] test: the tenant column's NOT NULL is the ceremony's, not drift; the NOT NULL door end to end --- .../organization-ownership-not-null.test.ts | 107 ++++++++++++++++++ ...722-sqlite-rebuild-applied-honesty.test.ts | 10 +- .../src/sql-driver-schema-drift.test.ts | 49 +++++--- 3 files changed, 146 insertions(+), 20 deletions(-) create mode 100644 packages/cli/src/utils/organization-ownership-not-null.test.ts diff --git a/packages/cli/src/utils/organization-ownership-not-null.test.ts b/packages/cli/src/utils/organization-ownership-not-null.test.ts new file mode 100644 index 00000000000..4068e01a594 --- /dev/null +++ b/packages/cli/src/utils/organization-ownership-not-null.test.ts @@ -0,0 +1,107 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The ceremony's `notNull` port over a REAL SQLite driver (18.x step 5d; + * ruling record 6269955919 on the v18 epic, ② A): + * + * - a declared table's `organization_id` goes NOT NULL through the driver's + * own drift apply door (the SQLite table rebuild), its rows kept; + * - ⭐ the next drift scan reports NOTHING for it — the ceremony owns that + * nullability, so no `relax_not_null` is ever proposed to undo it — while + * the same constraint on any other undeclared-NOT-NULL column still is; + * - a table the composition does not declare is `not-declared`, untouched; + * - a transport whose apply door declares it cannot perform the op is + * `unsupported`, never attempted. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SqlDriver } from '@objectstack/driver-sql'; +import { applyNotNullThroughDrift } from './organization-ownership-not-null.js'; + +let dir: string; +let driver: SqlDriver; +let knex: any; + +beforeEach(async () => { + dir = mkdtempSync(join(tmpdir(), 'os-d10-not-null-')); + driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: join(dir, 'db.sqlite') }, useNullAsDefault: true }); + (driver as any).logger = { warn: () => {}, info: () => {}, error: () => {}, debug: () => {} }; + knex = (driver as any).knex; + await driver.initObjects([ + { name: 'crm_deal', fields: { name: { type: 'text' }, organization_id: { type: 'lookup', reference: 'sys_organization', required: false } } }, + ] as never); + await knex('crm_deal').insert([ + { id: 'deal_1', name: 'Acme', organization_id: 'org_default' }, + { id: 'deal_2', name: 'Globex', organization_id: 'org_default' }, + ]); + await knex.schema.createTable('legacy_table', (t: any) => { + t.string('id').primary(); + t.string('organization_id'); + }); +}); + +afterEach(async () => { + await driver.disconnect?.(); + rmSync(dir, { recursive: true, force: true }); +}); + +const nullable = async (table: string, column: string): Promise => (await knex(table).columnInfo())[column].nullable; + +describe('organization_id NOT NULL through the drift apply door', () => { + it('⭐ applies it on a declared table, keeps its rows, and the next drift scan reports nothing for it', async () => { + expect(await nullable('crm_deal', 'organization_id')).toBe(true); + const outcomes = await applyNotNullThroughDrift( + { isDeclared: (t) => t === 'crm_deal', driverFor: () => driver as never }, + [{ table: 'crm_deal', column: 'organization_id' }], + ); + expect(outcomes).toEqual([{ table: 'crm_deal', column: 'organization_id', outcome: 'applied' }]); + expect(await nullable('crm_deal', 'organization_id')).toBe(false); + expect(await knex('crm_deal').select('id', 'organization_id').orderBy('id')).toEqual([ + { id: 'deal_1', organization_id: 'org_default' }, + { id: 'deal_2', organization_id: 'org_default' }, + ]); + const drift = (await driver.detectManagedDrift()).filter((d) => d.table === 'crm_deal' && d.kind === 'nullability_mismatch'); + expect(drift).toEqual([]); + // A NULL can no longer land. + await expect(knex('crm_deal').insert({ id: 'deal_3', name: 'Initech', organization_id: null })).rejects.toThrow(/NOT NULL/); + }); + + it('a table the booted composition does not declare is not-declared, and nothing touches it', async () => { + const outcomes = await applyNotNullThroughDrift( + { isDeclared: (t) => t === 'crm_deal', driverFor: () => driver as never }, + [{ table: 'legacy_table', column: 'organization_id' }], + ); + expect(outcomes).toEqual([{ table: 'legacy_table', column: 'organization_id', outcome: 'not-declared' }]); + expect(await nullable('legacy_table', 'organization_id')).toBe(true); + }); + + it('a transport whose apply door cannot perform it is unsupported, never attempted', async () => { + let attempted = 0; + const remote = { + driftApplyLimit: (op: string) => (op === 'tighten_not_null' ? 'the remote face runs no SQLite table rebuild' : null), + applyMigrationEntries: async () => { + attempted++; + return { applied: [], skipped: [] }; + }, + }; + const outcomes = await applyNotNullThroughDrift( + { isDeclared: () => true, driverFor: () => remote as never }, + [{ table: 'crm_deal', column: 'organization_id', database: 'telemetry' }], + ); + expect(outcomes).toEqual([ + { table: 'crm_deal', column: 'organization_id', outcome: 'unsupported', detail: 'the remote face runs no SQLite table rebuild' }, + ]); + expect(attempted).toBe(0); + }); + + it('no driver for the table\'s database fails, naming the database', async () => { + const [outcome] = await applyNotNullThroughDrift( + { isDeclared: () => true, driverFor: () => undefined }, + [{ table: 'crm_deal', column: 'organization_id', database: 'telemetry' }], + ); + expect(outcome).toMatchObject({ outcome: 'failed', detail: expect.stringMatching(/database telemetry/) }); + }); +}); diff --git a/packages/drivers/driver-sql/src/sql-driver-11722-sqlite-rebuild-applied-honesty.test.ts b/packages/drivers/driver-sql/src/sql-driver-11722-sqlite-rebuild-applied-honesty.test.ts index 2d0a4da7298..7bb8fd726c0 100644 --- a/packages/drivers/driver-sql/src/sql-driver-11722-sqlite-rebuild-applied-honesty.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-11722-sqlite-rebuild-applied-honesty.test.ts @@ -159,15 +159,15 @@ describe('SqlDriver SQLite reconcile reports only what the rebuild honoured (#11 t.timestamp('created_at'); t.timestamp('updated_at'); t.string('name'); - t.string('organization_id').notNullable(); + t.string('region_code').notNullable(); }); - await knexInstance('biz_unit').insert({ id: '1', name: 'Acme', organization_id: 'org1' }); + await knexInstance('biz_unit').insert({ id: '1', name: 'Acme', region_code: 'emea' }); await driver.initObjects([ { name: 'biz_unit', fields: { name: { type: 'string' }, - organization_id: { type: 'string', required: false }, + region_code: { type: 'string', required: false }, }, }, ]); @@ -192,10 +192,10 @@ describe('SqlDriver SQLite reconcile reports only what the rebuild honoured (#11 // The rebuild genuinely ran: the constraint is gone and the row survived. const info = await knexInstance('biz_unit').columnInfo(); - expect(info.organization_id.nullable).toBe(true); + expect(info.region_code.nullable).toBe(true); expect(info).toHaveProperty('name'); expect(await knexInstance('biz_unit').select('*')).toMatchObject([ - { id: '1', name: 'Acme', organization_id: 'org1' }, + { id: '1', name: 'Acme', region_code: 'emea' }, ]); }); }); diff --git a/packages/drivers/driver-sql/src/sql-driver-schema-drift.test.ts b/packages/drivers/driver-sql/src/sql-driver-schema-drift.test.ts index 26e45041764..3d15987ac8a 100644 --- a/packages/drivers/driver-sql/src/sql-driver-schema-drift.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-schema-drift.test.ts @@ -30,38 +30,57 @@ describe('SqlDriver managed-schema drift (#2186)', () => { await knexInstance?.destroy(); }); - // Build an "existing DB" table where organization_id is NOT NULL (the shape - // a DB created while the field was `required: true` has — the #2178 repro). + // Build an "existing DB" table where an optional field's column is NOT NULL + // (the shape a DB created while the field was `required: true` has — the + // #2178 repro, re-keyed off `region_code`: the tenant column's + // nullability is the ownership ceremony's, never drift — pinned below). const seedLegacyTable = async (driver: SqlDriver) => { await knexInstance.schema.createTable('biz_unit', (t: any) => { t.string('id').primary(); t.timestamp('created_at'); t.timestamp('updated_at'); t.string('name').notNullable(); - t.string('organization_id').notNullable(); + t.string('region_code').notNullable(); }); - await knexInstance('biz_unit').insert({ id: '1', name: 'Acme', organization_id: 'org1' }); + await knexInstance('biz_unit').insert({ id: '1', name: 'Acme', region_code: 'emea' }); }; - // Metadata after #2178: organization_id is now optional. + // Metadata after #2178: the field is now optional. const relaxedMeta = [ { name: 'biz_unit', fields: { name: { type: 'string', required: true }, - organization_id: { type: 'string', required: false }, + region_code: { type: 'string', required: false }, }, }, ]; describe('detectManagedDrift', () => { + it('a NOT NULL on the table\'s tenant column is no drift: its nullability is the ownership ceremony\'s (ruling ② A)', async () => { + const driver = makeDriver(); + await knexInstance.schema.createTable('biz_owned', (t: any) => { + t.string('id').primary(); + t.timestamp('created_at'); + t.timestamp('updated_at'); + t.string('organization_id').notNullable(); + t.string('note').notNullable(); + }); + await driver.initObjects([ + { name: 'biz_owned', fields: { organization_id: { type: 'string', required: false }, note: { type: 'string', required: false } } }, + ]); + const drift = (await driver.detectManagedDrift()).filter((d) => d.table === 'biz_owned' && d.kind === 'nullability_mismatch'); + // The ceremony's NOT NULL on organization_id is not reported; the same constraint on any other column still is. + expect(drift.map((d) => [d.column, d.op.type])).toEqual([['note', 'relax_not_null']]); + }); + it('flags a NOT NULL column on a non-required optional field as needs_confirm (ADR-0113 — ratify or relax, never auto-applied)', async () => { const driver = makeDriver(); await seedLegacyTable(driver); await driver.initObjects(relaxedMeta); const drift = await driver.detectManagedDrift(); - const orgDrift = drift.find((d) => d.column === 'organization_id'); + const orgDrift = drift.find((d) => d.column === 'region_code'); expect(orgDrift).toBeDefined(); expect(orgDrift!.kind).toBe('nullability_mismatch'); expect(orgDrift!.category).toBe('needs_confirm'); @@ -148,16 +167,16 @@ describe('SqlDriver managed-schema drift (#2186)', () => { // The column is NOT auto-relaxed... const info = await knexInstance('biz_unit').columnInfo(); - expect(info.organization_id.nullable).toBe(false); + expect(info.region_code.nullable).toBe(false); // ...the pre-existing row is untouched... const rows = await knexInstance('biz_unit').select('*'); expect(rows).toHaveLength(1); - expect(rows[0]).toMatchObject({ id: '1', name: 'Acme', organization_id: 'org1' }); + expect(rows[0]).toMatchObject({ id: '1', name: 'Acme', region_code: 'emea' }); // ...and the divergence is reported for a human decision. const drift = await driver.detectManagedDrift(); - const d = drift.find((x) => x.column === 'organization_id'); + const d = drift.find((x) => x.column === 'region_code'); expect(d?.category).toBe('needs_confirm'); expect(d?.op.type).toBe('relax_not_null'); }); @@ -188,7 +207,7 @@ describe('SqlDriver managed-schema drift (#2186)', () => { await seedLegacyTable(driver); await driver.initObjects(relaxedMeta); const info = await knexInstance('biz_unit').columnInfo(); - expect(info.organization_id.nullable).toBe(false); // unchanged + expect(info.region_code.nullable).toBe(false); // unchanged } finally { process.env.NODE_ENV = prev; } @@ -198,8 +217,8 @@ describe('SqlDriver managed-schema drift (#2186)', () => { describe('in-place ALTER SQL (Postgres / MySQL)', () => { // No live PG/MySQL in CI — assert the generated DDL by spying on knex.raw. const opFor = (type: string): any => ({ - kind: 'x', table: 'biz_unit', column: 'organization_id', severity: 'warning', - category: 'safe', message: 'm', op: { type, table: 'biz_unit', column: 'organization_id', to: 120 }, + kind: 'x', table: 'biz_unit', column: 'region_code', severity: 'warning', + category: 'safe', message: 'm', op: { type, table: 'biz_unit', column: 'region_code', to: 120 }, }); const spyDriver = (client: string, columnInfo?: any) => { @@ -227,7 +246,7 @@ describe('SqlDriver managed-schema drift (#2186)', () => { }); it('MySQL MODIFY reconstructs char length so a nullability change keeps it', async () => { - const { d, calls } = spyDriver('mysql2', { organization_id: { type: 'varchar', maxLength: 255 } }); + const { d, calls } = spyDriver('mysql2', { region_code: { type: 'varchar', maxLength: 255 } }); expect(await (d as any).applyDriftOpInPlace(opFor('relax_not_null').op)).toBe(true); expect(calls[0]).toMatch(/MODIFY \?\? varchar\(255\) NULL/); }); @@ -243,7 +262,7 @@ describe('SqlDriver managed-schema drift (#2186)', () => { const { applied, skipped } = await driver.applyMigrationEntries(drift, { allowDestructive: false }); expect(applied.some((d) => d.op.type === 'relax_not_null')).toBe(true); expect(skipped).toHaveLength(0); - expect((await knexInstance('biz_unit').columnInfo()).organization_id.nullable).toBe(true); + expect((await knexInstance('biz_unit').columnInfo()).region_code.nullable).toBe(true); }); it('skips destructive drops unless allowDestructive, then drops with it', async () => { From 32d1e229a4a5c474de22f188e53b2cfcc447291e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 21:31:15 +0000 Subject: [PATCH 20/44] =?UTF-8?q?test(dogfood):=20R2=20and=20R3=20restated?= =?UTF-8?q?=20=E2=80=94=20the=2018.0=20and=20v17=20databases=20take=20the?= =?UTF-8?q?=20ceremony,=20then=20boot;=20boot-refusal-order=20step=202=20b?= =?UTF-8?q?ack=20on=20the=20binary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...nership-boot-refusal-order.dogfood.test.ts | 42 +-- .../test/upgrade-18x-harness.dogfood.test.ts | 251 +++++++++++++----- 2 files changed, 196 insertions(+), 97 deletions(-) diff --git a/packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts b/packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts index d6ce5afc1e8..f60f6c2e2be 100644 --- a/packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts +++ b/packages/qa/dogfood/test/organization-ownership-boot-refusal-order.dogfood.test.ts @@ -23,28 +23,20 @@ // refusal arrives as the `cause` of the kernel's start-failure error); // 2. `os migrate organization-ownership` writes its plan, and // `--apply --plan-file PLAN` completes with no boot in between, and -// verifies its marker; +// verifies its marker — through the real CLI binary, with the arguments, +// working directory and environment an operator's run has; // 3. the next v18 boot succeeds. // -// 18.0 ships for fresh installs only (ruling record 6259608309 on the v18 -// epic): the registered `os migrate organization-ownership` refuses, and -// step 2a pins that through the real CLI binary, reading and writing nothing. -// Step 2 runs the ceremony class the command keeps for 18.x -// (`OrganizationOwnershipCeremony`), from the CLI's built module in its own -// process, with the same arguments, working directory and environment an -// operator's run has. The 18.x release that re-registers the ceremony puts -// step 2 back on the binary and drops 2a. -// // ⚠ Needs the `sys_metadata` family's no-column schema beside the refusal: // against a release whose family still declares the column, step 2 stops at // `column-drops` (the declaration keeps it). The two land together. import { spawnSync } from 'node:child_process'; -import { copyFileSync, existsSync, mkdtempSync, rmSync } from 'node:fs'; +import { copyFileSync, mkdtempSync, rmSync } from 'node:fs'; import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; -import { fileURLToPath, pathToFileURL } from 'node:url'; +import { fileURLToPath } from 'node:url'; import { DatabaseSync } from 'node:sqlite'; import { afterAll, describe, expect, it } from 'vitest'; import { SecurityPlugin, securityDefaultPermissionSets } from '@objectstack/plugin-security'; @@ -57,7 +49,6 @@ import { bootShowcase } from './showcase-boot.js'; const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); const CLI_ROOT = dirname(createRequire(import.meta.url).resolve('@objectstack/cli/package.json')); const CLI_BIN = join(CLI_ROOT, 'bin', 'run.js'); -const CEREMONY_MODULE = pathToFileURL(join(CLI_ROOT, 'dist', 'commands', 'migrate', 'organization-ownership.js')).href; const columnsOf = (file: string, table: string): string[] => { const db = new DatabaseSync(file); @@ -130,32 +121,11 @@ describe('[ADR-0131 D10 item 5] a 17.x database upgrades through the boot refusa expect(columnsOf(file, 'sys_user_permission_set')).toContain('permission_set'); }); - it('⭐ 2a. in 18.0 the real command refuses with NOT_IMPLEMENTED, and the database is untouched', () => { - const before = columnsOf(file, 'sys_metadata'); - const refused = spawnSync(process.execPath, [ - CLI_BIN, 'migrate', 'organization-ownership', '--database-url', `file:${file}`, '--out', join(dir, 'refused-plan.json'), '--json', - ], { cwd: SHOWCASE_DIR, encoding: 'utf8', timeout: 300_000, env: { ...process.env, NO_COLOR: '1' } }); - expect(refused.status, `${refused.stdout}\n${refused.stderr}`).toBe(1); - expect(JSON.parse(refused.stdout.trim())).toMatchObject({ error: 'plan_refused', code: 'NOT_IMPLEMENTED' }); - expect(existsSync(join(dir, 'refused-plan.json'))).toBe(false); - expect(columnsOf(file, 'sys_metadata')).toEqual(before); - }, 320_000); - it('⭐ 2. the ceremony completes with no boot in between, and verifies its marker', () => { // As an operator's run has it (S4, #23437): the plan first, then the apply - // of that reviewed plan file — through the ceremony class (see the header). + // of that reviewed plan file — through the real binary. const ceremony = (args: string[]) => spawnSync(process.execPath, [ - '--input-type=module', '-e', - // The built module, as `bin/run.js` runs it: oclif's auto-transpile off, - // so an inherited NODE_ENV=test cannot send it to `src/` under tsx. - 'const [root, url, ...argv] = process.argv.slice(1);' + - " const { createRequire } = await import('node:module');" + - " const { pathToFileURL } = await import('node:url');" + - " const oclif = await import(pathToFileURL(createRequire(root + '/package.json').resolve('@oclif/core')).href);" + - ' (oclif.settings ?? oclif.default.settings).enableAutoTranspile = false;' + - ' const { OrganizationOwnershipCeremony } = await import(url);' + - ' await OrganizationOwnershipCeremony.run(argv, { root });', - CLI_ROOT, CEREMONY_MODULE, '--database-url', `file:${file}`, ...args, + CLI_BIN, 'migrate', 'organization-ownership', '--database-url', `file:${file}`, ...args, ], { cwd: SHOWCASE_DIR, encoding: 'utf8', timeout: 300_000, env: { ...process.env, NO_COLOR: '1' } }); const planFile = join(dir, 'plan.json'); const plan = ceremony(['--out', planFile]); diff --git a/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts b/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts index 313b9fe89b2..7b1a66a1042 100644 --- a/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts +++ b/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts @@ -1,9 +1,12 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// [#24522] The 18.x line's upgrade harness: a database an 18.0 install created -// upgrades to THIS commit, boots, and keeps its data (R2), and a v17-shaped one -// is still refused (R3). Ruling record 6266750393 on the v18 epic makes R2 a -// check every PR on the line passes before it merges. +// [#24522] The 18.x line's upgrade harness, RESTATED by 18.x step 6 (ruling +// records 6266750393, 6269955919 and 6270035914 on the v18 epic): a database +// an 18.0 install created takes the upgrade ceremony and then boots THIS +// commit with its data (R2), and so does a v17-shaped one (R3). The ceremony +// is open now, and the boot guard recognises a database by its birth marker: +// 18.0 wrote none, so an 18.0 database is refused until the ceremony has +// certified it at this release's ownership shape generation. // // The fixture (`upgrade-harness/fixture-18.0/`) is a showcase install of the // 18.0 code after real use — sign-ups, an invitation, settings with a secret, a @@ -15,34 +18,52 @@ // // What is asserted, on a fresh copy of the fixture: // -// 1. HEAD boots over it, composed the way `os serve` composes it — the -// ownership boot guard included — and nothing refuses it. +// 1. R2, restated: HEAD refuses it at boot, composed the way `os serve` +// composes it, naming the ceremony — and the refused boot writes no +// marker. +// 1b. `os migrate organization-ownership` — the real binary, in the +// showcase's directory — plans it and applies that plan, and the marker +// verifies at this release's shape generation. +// 1c. HEAD then boots over it, and nothing refuses it. // 2. It serves the fixture's data: the fixture's people sign in with their // 18.0 passwords and read the fixture's own task. -// 3. Once HEAD has stopped, every reshaped table still holds every row the -// fixture gave it, with the same key columns (`organization_id` among -// them): a step that moved, deleted or re-stamped 18.0 rows outside the -// upgrade ceremony goes red here, naming the table and the row. -// 4. The same fixture with one v17 trait added — a physical `organization_id` -// on `sys_metadata`, an object that declares none — is refused at boot -// with the guard's code. Until step 6 opens the ceremony that is the -// answer a v17 database gets; step 6 rewrites this case, by name. +// 2b. The fixture's declared webhook — customized in 18.0 only by switching +// it on — now answers from its declaration, switched on by the activation +// row the ceremony wrote: one delivery per task change, naming the +// declaration. +// 3. Once HEAD has stopped, every 18.0 row of every reshaped table is where +// the ceremony put it, with its key columns: kept in place, moved to its +// sibling (identity mail, the global audit rows), re-sourced (the +// deliveries), or deleted as a copy of a declaration (the webhook) — and +// `organization_id` dropped where the table became deployment-level, NOT +// NULL where it stayed. +// 4. R3, restated: the same fixture with one v17 trait added — a physical +// `organization_id` on `sys_metadata`, an object that declares none — is +// refused at boot with the guard's code, takes the ceremony, and boots. // // ⛔ Additive change passes: a row HEAD's boot ADDS (an activity shard, a // seeder's row) is not a failure, so counts are compared as "at least", and // every fixture row is looked up by id. -import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { spawnSync } from 'node:child_process'; +import { copyFileSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; import { DatabaseSync } from 'node:sqlite'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { isOrganizationOwnershipBootRefusal } from '@objectstack/runtime'; +import { ORGANIZATION_OWNERSHIP_SHAPE_GENERATION } from '@objectstack/spec/system'; import type { VerifyStack } from '@objectstack/verify'; -import { columnsOf, keyRowsOf, type FixtureManifest } from './upgrade-harness/fixture-manifest.js'; +import { columnsOf, type FixtureManifest } from './upgrade-harness/fixture-manifest.js'; import { FIXTURE_NAMES, FIXTURE_PEOPLE, bootFixture } from './upgrade-harness/populate-fixture.js'; import { loadSqlIntoSqlite } from './upgrade-harness/sqlite-text-dump.js'; const FIXTURE = new URL('./upgrade-harness/fixture-18.0/', import.meta.url); +const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); +const CLI_ROOT = dirname(createRequire(import.meta.url).resolve('@objectstack/cli/package.json')); +const CLI_BIN = join(CLI_ROOT, 'bin', 'run.js'); +const MARKER = 'adr-0131-organization-ownership'; const manifest = JSON.parse(readFileSync(new URL('manifest.json', FIXTURE), 'utf8')) as FixtureManifest; const fixtureSql = readFileSync(new URL('database.sql', FIXTURE), 'utf8'); @@ -54,22 +75,72 @@ function refusalIn(error: unknown): unknown { return undefined; } +/** A boot of HEAD over `file`; the stack, or the error that stopped it. */ +async function tryBoot(file: string): Promise<{ stack?: VerifyStack; error?: unknown }> { + try { + return { stack: await bootFixture(file) }; + } catch (error) { + return { error }; + } +} + +/** + * The ceremony on `file`, as an operator runs it: the plan, then the apply of + * that reviewed plan file — the real binary, in the showcase's directory, so it + * composes what the deployment serves. The apply report. + */ +function ceremony(dir: string, file: string, tag: string): Record { + const run = (args: string[]) => spawnSync(process.execPath, [ + CLI_BIN, 'migrate', 'organization-ownership', '--database-url', `file:${file}`, ...args, + ], { cwd: SHOWCASE_DIR, encoding: 'utf8', timeout: 300_000, env: { ...process.env, NO_COLOR: '1' } }); + const planFile = join(dir, `${tag}-plan.json`); + const plan = run(['--out', planFile]); + expect(plan.status, `the plan: ${plan.stdout}\n${plan.stderr}`).toBe(0); + const reportFile = join(dir, `${tag}-apply.json`); + const apply = run(['--apply', '--plan-file', planFile, '--backup-taken', '--out', reportFile, '--json']); + expect(apply.status, `the apply: ${apply.stdout}\n${apply.stderr}`).toBe(0); + return JSON.parse(readFileSync(reportFile, 'utf8')) as Record; +} + +/** The ceremony's marker on `file`: verified, blocking 0, at this release's generation. */ +function expectCertified(file: string): void { + const db = new DatabaseSync(file, { readOnly: true }); + try { + const marker = db.prepare('SELECT verified_at, blocking, details FROM sys_migration WHERE id = ?').get(MARKER) as + | { verified_at: unknown; blocking: unknown; details: unknown } + | undefined; + expect(marker?.verified_at, 'the ceremony verified its marker').toBeTruthy(); + expect(Number(marker?.blocking)).toBe(0); + expect(JSON.parse(String(marker?.details)).checkpoint?.shapeGeneration).toBe(ORGANIZATION_OWNERSHIP_SHAPE_GENERATION); + } finally { + db.close(); + } +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any const rowsOf = (body: any): any[] => (Array.isArray(body) ? body : (body?.records ?? body?.data ?? [])); -describe(`[#24522] an 18.0 database (fixture from ${manifest.ref} @ ${manifest.sha.slice(0, 12)}) upgrades to HEAD and keeps its data`, () => { +describe(`[#24522] an 18.0 database (fixture from ${manifest.ref} @ ${manifest.sha.slice(0, 12)}) takes the ceremony, upgrades to HEAD and keeps its data`, () => { const dir = mkdtempSync(join(tmpdir(), 'dogfood-upgrade-18x-')); const file = join(dir, 'upgraded.db'); + /** + * The ceremony's output, copied once it verified, and booted. A refused boot + * lives on in this process, its SQLite driver never torn down, and a later + * boot over the SAME path can meet its stale image (measured on the + * boot-refusal-order dogfood); booting the copied bytes is the operator's + * sequence — the refused `os serve` process has exited. + */ + const migrated = join(dir, 'upgraded.migrated.db'); let stack: VerifyStack | undefined; + let refusedError: unknown; let bootError: unknown; + let report: Record | undefined; beforeAll(async () => { loadSqlIntoSqlite(fixtureSql, file); - try { - stack = await bootFixture(file); - } catch (error) { - bootError = error; - } + const refused = await tryBoot(file); + await refused.stack?.stop(); + refusedError = refused.error ?? new Error('the 18.0 database booted without the ceremony'); }, 240_000); afterAll(async () => { @@ -81,11 +152,36 @@ describe(`[#24522] an 18.0 database (fixture from ${manifest.ref} @ ${manifest.s for (const t of manifest.reshaped) expect(t.count, `${t.table} holds rows in the fixture`).toBeGreaterThan(0); }); - it('1. HEAD boots over the 18.0 database, and no ownership refusal stops it', () => { + it('1. R2, restated: HEAD refuses the 18.0 database at boot, naming the ceremony, and writes no marker', () => { + const refusal = refusalIn(refusedError) as { message?: string } | undefined; + expect(refusal, `the boot was not refused by the ownership guard: ${String((refusedError as Error)?.stack ?? refusedError)}`).toBeDefined(); + expect(refusal!.message).toContain('os migrate organization-ownership'); + expect(refusal!.message).not.toMatch(/17\.x/); + const db = new DatabaseSync(file, { readOnly: true }); + try { + expect(db.prepare('SELECT id FROM sys_migration WHERE id = ?').all(MARKER)).toEqual([]); + } finally { + db.close(); + } + }); + + it('1b. R2, restated: the ceremony plans and applies it, and certifies it at this release\'s shape generation', () => { + report = ceremony(dir, file, 'r2'); + expect(report.complete).toBe(true); + expect(report.blocking).toBe(0); + expectCertified(file); + copyFileSync(file, migrated); + }, 640_000); + + it('1c. R2, restated: HEAD then boots over it, and no ownership refusal stops it', async () => { + expect(report, 'the ceremony ran').toBeDefined(); + const booted = await tryBoot(migrated); + stack = booted.stack; + bootError = booted.error; const refusal = refusalIn(bootError); - expect(refusal, `the ownership boot guard refused the 18.0 database: ${String((refusal as Error | undefined)?.message)}`).toBeUndefined(); + expect(refusal, `the ownership boot guard refused the migrated database: ${String((refusal as Error | undefined)?.message)}`).toBeUndefined(); expect(bootError, `the boot failed: ${String((bootError as Error | undefined)?.stack ?? bootError)}`).toBeUndefined(); - }); + }, 240_000); it('2. it serves the 18.0 data: the fixture\'s people sign in, and the fixture\'s task is there', async () => { expect(stack).toBeDefined(); @@ -98,24 +194,17 @@ describe(`[#24522] an 18.0 database (fixture from ${manifest.ref} @ ${manifest.s expect(titles).toContain(`${FIXTURE_NAMES.task} — edited`); }); - // [#24529, step 1] Declared webhooks are read from the registry, and the 18.0 - // seeder's rows stay until the ceremony. The fixture's mirror of - // `showcase_task_changed` was activated in 18.0, which stamped it - // `customized`, so it answers as itself and the declaration of the same name - // is withheld even while the declaration is switched on: one delivery per - // task change, from the row, never a second one from the declaration. - it('2b. the fixture\'s customized declared webhook dispatches exactly once on a task change', async () => { + // [18.x step 5a] The fixture's mirror of `showcase_task_changed` was only + // switched on in 18.0 (customized by its `active` alone), so the ceremony + // kept that switch as the declaration's activation row and deleted the + // copy. The declaration now answers, once: a reader that still served the + // row would deliver a second time. + it('2b. the fixture\'s declared webhook dispatches exactly once on a task change, from its declaration', async () => { expect(stack).toBeDefined(); const adminTok = await stack!.signIn(FIXTURE_PEOPLE.admin.email, FIXTURE_PEOPLE.admin.password); const hooks = rowsOf(await (await stack!.apiAs(adminTok, 'GET', `/data/sys_webhook?name=${FIXTURE_NAMES.webhook}`)).json()) .filter((h) => h?.name === FIXTURE_NAMES.webhook); - expect(hooks, 'the 18.0 mirror row is still there').toHaveLength(1); - expect(hooks[0].customized, 'the fixture activated it in 18.0, which marks it customized').toBe(true); - // Switch the declaration ON as well (the showcase ships it inactive), so a - // reader that served both the row and the declaration WOULD deliver twice: - // without this the case could not fail on the rule it pins. - const on = await stack!.apiAs(adminTok, 'POST', `/security/_activation/webhook/${FIXTURE_NAMES.webhook}`, { enabled: true }); - expect(on.status, await on.clone().text()).toBe(200); + expect(hooks, 'the 18.0 copy is gone').toEqual([]); const deliveries = async () => rowsOf(await (await stack!.apiAs(adminTok, 'GET', '/data/sys_http_delivery?limit=500')).json()) .filter((d) => d?.source === 'webhook'); const before = new Set((await deliveries()).map((d) => String(d.id))); @@ -136,31 +225,60 @@ describe(`[#24522] an 18.0 database (fixture from ${manifest.ref} @ ${manifest.s } await new Promise((r) => setTimeout(r, 1000)); fresh = (await deliveries()).filter((d) => !before.has(String(d.id))); - expect(fresh.map((d) => d.ref_id), 'one delivery, from the customized row').toEqual([hooks[0].id]); + expect(fresh.map((d) => d.ref_id), 'one delivery, from the declaration').toEqual([`declared:${FIXTURE_NAMES.webhook}`]); }); - it('3. every reshaped table keeps every 18.0 row, with its key columns, once HEAD has stopped', async () => { + it('3. every 18.0 row of every reshaped table is where the ceremony put it, with its key columns', async () => { await stack?.stop(); stack = undefined; - const db = new DatabaseSync(file, { readOnly: true }); + const db = new DatabaseSync(migrated, { readOnly: true }); try { + // Where a row may be now: its own table, or the deployment-level sibling it moved to. + const SIBLING: Record = { sys_email: 'sys_identity_email', sys_setting_audit: 'sys_platform_setting_audit' }; + // Tables that became deployment-level: their organization column is gone. + const DEPLOYMENT_LEVEL = new Set(['sys_secret', 'sys_flow_credential']); + const rowById = (table: string, id: string): Record | undefined => + db.prepare(`SELECT * FROM "${table}" WHERE id = ?`).get(id) as Record | undefined; + const project = (row: Record, cols: readonly string[]): Record => + Object.fromEntries(cols.map((c) => [c, row[c] === null || row[c] === undefined ? null : String(row[c])])); for (const t of manifest.reshaped) { - for (const physical of t.physical) { - const cols = columnsOf(db, physical); - expect(cols.length, `${physical} still exists`).toBeGreaterThan(0); - for (const c of t.keyColumns) expect(cols, `${physical}.${c} still exists`).toContain(c); + for (const fixtureRow of t.rows) { + const id = String(fixtureRow.id); + if (t.table === 'sys_webhook') { + // The declared webhook's copy: deleted, its switch kept as the declaration's activation row. + for (const physical of t.physical) expect(rowById(physical, id), `sys_webhook copy ${id} is deleted`).toBeUndefined(); + continue; + } + const home = t.physical.find((physical) => rowById(physical, id)); + const moved = !home && SIBLING[t.table] ? SIBLING[t.table] : undefined; + const now = home ? rowById(home, id) : moved ? rowById(moved, id) : undefined; + expect(now, `${t.table} row ${id} is kept${SIBLING[t.table] ? ` or moved to ${SIBLING[t.table]}` : ''}`).toBeDefined(); + const cols = moved || DEPLOYMENT_LEVEL.has(t.table) ? t.keyColumns.filter((c) => c !== 'organization_id') : t.keyColumns; + const expected = { ...project(fixtureRow, cols) }; + if (t.table === 'sys_http_delivery') expected.ref_id = `declared:${FIXTURE_NAMES.webhook}`; + expect(project(now!, cols), `${t.table} row ${id}`).toEqual(expected); } - const now = keyRowsOf(db, t.physical, t.keyColumns); - const byId = new Map(now.map((r) => [r.id, r])); - expect(now.length, `${t.table}: ${now.length} rows now, ${t.count} in the fixture`).toBeGreaterThanOrEqual(t.count); - for (const row of t.rows) expect(byId.get(row.id), `${t.table} row ${row.id}`).toEqual(row); + } + // The identity mail and the global audit rows moved; tenant mail and tenant audit stayed. + const reset = manifest.reshaped.find((t) => t.table === 'sys_email')!.rows; + expect(reset.filter((r) => rowById('sys_identity_email', String(r.id))).length, 'the reset mail moved').toBeGreaterThan(0); + expect(reset.filter((r) => rowById('sys_email', String(r.id))).length, 'the invitation mail stayed').toBeGreaterThan(0); + expect(db.prepare(`SELECT COUNT(*) AS n FROM sys_setting_audit WHERE scope = 'global'`).get()).toEqual({ n: 0 }); + // The webhook's switch, kept. + expect(db.prepare(`SELECT active FROM sys_metadata_activation WHERE metadata_type = 'webhook' AND name = ?`).get(FIXTURE_NAMES.webhook)).toEqual({ active: 1 }); + // Deployment-level tables lost the column; the tenant tables hold it NOT NULL. + for (const table of DEPLOYMENT_LEVEL) expect(columnsOf(db, table), table).not.toContain('organization_id'); + const notNull = (table: string): unknown => + (db.prepare(`SELECT "notnull" AS nn FROM pragma_table_info(?) WHERE name = 'organization_id'`).get(table) as { nn?: unknown } | undefined)?.nn; + for (const table of ['sys_email', 'sys_setting_audit', 'sys_share_link', 'sys_member', 'sys_user_permission_set']) { + expect(notNull(table), `${table}.organization_id is NOT NULL`).toBe(1); } } finally { db.close(); } }); - it('4. R3: the same database with a v17 trait is still refused at boot, with the guard\'s code', async () => { + it('4. R3, restated: the same database with a v17 trait is refused at boot, takes the ceremony, and boots', async () => { const v17 = join(dir, 'v17-shaped.db'); loadSqlIntoSqlite(fixtureSql, v17); const db = new DatabaseSync(v17); @@ -170,15 +288,26 @@ describe(`[#24522] an 18.0 database (fixture from ${manifest.ref} @ ${manifest.s } finally { db.close(); } - let error: unknown; - try { - const refused = await bootFixture(v17); - await refused.stop(); - } catch (e) { - error = e; - } - const refusal = refusalIn(error); + const refused = await tryBoot(v17); + await refused.stack?.stop(); + const refusal = refusalIn(refused.error); expect(refusal, 'the v17-shaped database was refused by the ownership boot guard').toBeDefined(); expect((refusal as { tables?: string[] }).tables).toContain('sys_metadata'); - }, 240_000); + + const v17Report = ceremony(dir, v17, 'r3'); + expect([v17Report.complete, v17Report.blocking]).toEqual([true, 0]); + expectCertified(v17); + const check = new DatabaseSync(v17, { readOnly: true }); + try { + expect(columnsOf(check, 'sys_metadata')).not.toContain('organization_id'); + } finally { + check.close(); + } + const v17Migrated = join(dir, 'v17-shaped.migrated.db'); + copyFileSync(v17, v17Migrated); + const booted = await tryBoot(v17Migrated); + await booted.stack?.stop(); + expect(refusalIn(booted.error), String((booted.error as Error | undefined)?.message)).toBeUndefined(); + expect(booted.error).toBeUndefined(); + }, 900_000); }); From 13446cb0e3b38b66575e8e425f726e3a9f6ee1ae Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 21:35:47 +0000 Subject: [PATCH 21/44] fix(cli): a rotation shard's NOT NULL is reported unsupported, never rebuilt --- .../commands/migrate/organization-ownership.ts | 1 + .../organization-ownership-not-null.test.ts | 16 ++++++++++++++++ .../utils/organization-ownership-not-null.ts | 17 ++++++++++++++++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts index 191d42a8f33..815e995a907 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.ts @@ -874,6 +874,7 @@ export default class OrganizationOwnershipCeremony extends Command { return applyNotNullThroughDrift( { isDeclared: (table) => declared.has(rotationBaseOf(table) ?? table), + shardBaseOf: (table) => rotationBaseOf(table) ?? null, driverFor: (database) => (database === undefined ? liveStack.driver diff --git a/packages/cli/src/utils/organization-ownership-not-null.test.ts b/packages/cli/src/utils/organization-ownership-not-null.test.ts index 4068e01a594..39770a71e11 100644 --- a/packages/cli/src/utils/organization-ownership-not-null.test.ts +++ b/packages/cli/src/utils/organization-ownership-not-null.test.ts @@ -97,6 +97,22 @@ describe('organization_id NOT NULL through the drift apply door', () => { expect(attempted).toBe(0); }); + it('a rotation shard is unsupported, never rebuilt: it has no declaration of its own', async () => { + let attempted = 0; + const outcomes = await applyNotNullThroughDrift( + { + isDeclared: () => true, + shardBaseOf: (t) => (t.startsWith('sys_activity__') ? 'sys_activity' : null), + driverFor: () => ({ applyMigrationEntries: async () => { attempted++; return { applied: [], skipped: [] }; } }) as never, + }, + [{ table: 'sys_activity__r20261019', column: 'organization_id' }], + ); + expect(outcomes).toEqual([ + { table: 'sys_activity__r20261019', column: 'organization_id', outcome: 'unsupported', detail: expect.stringMatching(/rotation shard of sys_activity/) }, + ]); + expect(attempted).toBe(0); + }); + it('no driver for the table\'s database fails, naming the database', async () => { const [outcome] = await applyNotNullThroughDrift( { isDeclared: () => true, driverFor: () => undefined }, diff --git a/packages/cli/src/utils/organization-ownership-not-null.ts b/packages/cli/src/utils/organization-ownership-not-null.ts index bff82a871cf..056dc2262be 100644 --- a/packages/cli/src/utils/organization-ownership-not-null.ts +++ b/packages/cli/src/utils/organization-ownership-not-null.ts @@ -10,6 +10,8 @@ export interface NotNullTarget { driverFor(database: string | undefined): Pick | undefined; /** Whether the booted composition declares an object for this table (a rotation shard judged by its base). */ isDeclared(table: string): boolean; + /** The declared table a rotation shard belongs to (`sys_activity__r20261019` → `sys_activity`), or `null`. */ + shardBaseOf?(table: string): string | null; } /** @@ -23,7 +25,10 @@ export interface NotNullTarget { * `tighten_not_null`, handed to the one driver that holds the table. A table * the composition does not declare is not rebuilt (`not-declared`); a * transport whose apply door cannot perform the op (the Turso remote face, - * `driftApplyLimit`) is `unsupported`, never attempted. + * `driftApplyLimit`) is `unsupported`, never attempted; so is a rotation + * shard: the apply door rebuilds a table against its own declaration, which a + * shard does not have, and every shard rotated in later is created from the + * base's declaration, nullable — the shard family holds NOT NULL nowhere. */ export async function applyNotNullThroughDrift(target: NotNullTarget, columns: readonly NotNullColumn[]): Promise { const outcomes: NotNullOutcome[] = []; @@ -32,6 +37,16 @@ export async function applyNotNullThroughDrift(target: NotNullTarget, columns: r outcomes.push({ table, column, outcome: 'not-declared' }); continue; } + const base = target.shardBaseOf?.(table) ?? null; + if (base !== null && base !== table) { + outcomes.push({ + table, + column, + outcome: 'unsupported', + detail: `${table} is a rotation shard of ${base}: the drift apply door rebuilds only a declared table, and each new shard is created with ${base}'s nullable column`, + }); + continue; + } const driver = target.driverFor(database); if (!driver) { outcomes.push({ table, column, outcome: 'failed', detail: `no driver serves the database ${database ?? 'primary'} the table is in` }); From 06e055631c542f4079e4e91a2c4f05d756294bc0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 21:40:37 +0000 Subject: [PATCH 22/44] docs,qa: the open ceremony and the birth marker in the CLI docs and the checklist; changeset --- ...81-organization-ownership-ceremony-open.md | 28 +++++++++++ content/docs/deployment/cli.mdx | 37 +++++++------- docs/qa/platform-checklist/areas/cli.json | 49 ++++++++++++------- 3 files changed, 76 insertions(+), 38 deletions(-) create mode 100644 .changeset/24581-organization-ownership-ceremony-open.md diff --git a/.changeset/24581-organization-ownership-ceremony-open.md b/.changeset/24581-organization-ownership-ceremony-open.md new file mode 100644 index 00000000000..e0b48ac7556 --- /dev/null +++ b/.changeset/24581-organization-ownership-ceremony-open.md @@ -0,0 +1,28 @@ +--- +'@objectstack/cli': minor +'@objectstack/runtime': minor +'@objectstack/spec': minor +'@objectstack/driver-sql': minor +'@objectstack/plugin-webhooks': minor +'@objectstack/platform-objects': minor +'@objectstack/service-automation': minor +'@objectstack/service-settings': minor +--- + +feat(cli,runtime)!: `os migrate organization-ownership` upgrades v17 and 18.0 databases; a server serves only a database it created or one the ceremony certified + +Clause-②: yes (narrowing) + + + +**BREAKING** for a database an earlier release created, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D10). + +- **The ceremony is open.** `os migrate organization-ownership` runs again (18.0 refused it with `NOT_IMPLEMENTED`): a bare run writes the plan, and `--apply --plan-file PLAN --backup-taken` applies that reviewed plan. Its new steps carry what an 18.0 database still owes, each selected by the rows' shape, never by a NULL organization: + - `sibling-moves`: the global settings-audit rows move to `sys_platform_setting_audit`, and identity mail (reset, verification, account invitation, change-email notice, magic link) to `sys_identity_email`, id for id. Their queued mail jobs and their activity rows follow them. The organization invitation stays in `sys_email`. + - `declared-webhooks`: each `sys_webhook` copy a package's seeder wrote under a name a package still declares is settled. A pristine copy is deleted once its deliveries name the declaration (`declared:NAME`), with the cipher it referenced. A copy customized only in its switch becomes the declaration's activation row. Any other edit becomes the administrator's own webhook under a new name (`NAME_custom`), and the declaration is switched off. + - `declared-rule-copies`: each stored sharing rule under a declared rule's name becomes a clone under a new name, its grants kept, and the declaration is switched off. + - `not-null`: after the column drops, every table the post-step plan clears takes `organization_id` NOT NULL. The ceremony owns that nullability, so the schema differ no longer reports it as `relax_not_null` drift. +- **The boot guard decides on a marker.** The first boot that creates a datastore from empty writes a birth marker (`sys_migration` row `adr-0131-organization-ownership`, `details.birth.shapeGeneration`). Every CLI data boot writes it too, so a fresh install whose first command is `os migrate apply` or `os seed` still boots. The ceremony's verified marker carries the same `shapeGeneration` (now `1`, `ORGANIZATION_OWNERSHIP_SHAPE_GENERATION` in `@objectstack/spec/system`). A server refuses a database with no marker (every 18.0 database), one certified at an older generation, or an uncertified one. The refusal names the command and no longer says to stay on 17.x. The v17-shape probe also reads `tenancy: { enabled: false }` and `systemFields: false`. +- **`sys_secret` and `sys_flow_credential` are deployment-level** (`systemFields: { tenant: false }`). Their `organization_id` is dropped by the ceremony's column-drops step. The settings service no longer writes one, and `SettingsSecretStore.insert` no longer takes it. + +**What an operator does.** Before serving an 18.0 or v17 database with this release: take a backup, run `os migrate organization-ownership --out plan.json`, review the plan, then run `os migrate organization-ownership --apply --plan-file plan.json --backup-taken`, and start the server. `os dev` does this for a local SQLite development database. On a walled database, a customized copy of a declared webhook or sharing rule blocks its step, naming the row: rename it to a name no package declares, or delete it, then re-run. diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index 4a9160f8908..ad3b1091472 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -196,18 +196,13 @@ os dev --database file:./data/test.db --auth-secret $(openssl rand -hex 32) | `--seed-admin` / `--no-seed-admin` | — | Seed a dev admin (`admin@objectos.ai` / `admin123`) on an empty DB — default on; override with `--admin-email` / `--admin-password` | | `-v, --verbose` | — | Verbose output | -##### A 17.x development database is upgraded in place +##### A 17.x or 18.0 development database is upgraded in place -A v18 server refuses to start on a database still in the v17 -organization-ownership shape until the [ownership ceremony](#os-migrate-organization-ownership) +A v18 server refuses to start on a database an earlier release created (a v17 +one, or an 18.0 one) until the [ownership ceremony](#os-migrate-organization-ownership) has run on it. `os dev` runs that ceremony for you when the database is a local SQLite file: - -18.0 supports fresh `single` installs; upgrades and walled postures arrive in 18.x. In 18.0 the ceremony refuses, so `os dev` stops at step 2 with its -refusal and changes nothing. - - 1. It copies the database file and its `telemetry` sibling (each with its `-wal` / `-shm`) into `/backups/organization-ownership-/`. 2. It runs `os migrate organization-ownership --dev` (the plan), then applies @@ -1202,7 +1197,7 @@ where the data lives. | `os migrate value-shapes` | Scan stored reference and structured-JSON field values against the platform's value contract, and record the deployment's migration flag when clean | | `os migrate summary-nulls` | Backfill roll-up `count` / `sum` columns still stored as `NULL` on parent rows created before the insert-time seed. Repairs values; no flag, nothing depends on it having run | | `os migrate comment-reactions` | Carry the emoji reactions a 17.x deployment stored in the retired `sys_comment.reactions` column into `sys_comment_reaction` records, list every stored value that does not read, and record the verified run that lets `os migrate apply --allow-destructive` drop the column | -| `os migrate organization-ownership` | The v18 ownership ceremony (ADR-0131 D10): write a plan of what each table's rows will become, then `--apply` that reviewed plan. A v18 server refuses to start until it has run. 18.0 supports fresh `single` installs; upgrades and walled postures arrive in 18.x. In 18.0 the command refuses. See [below](#os-migrate-organization-ownership) | +| `os migrate organization-ownership` | The v18 ownership ceremony (ADR-0131 D10): write a plan of what each table's rows will become, then `--apply` that reviewed plan. It upgrades a v17 or an 18.0 database; a v18 server refuses to start on one until it has run. See [below](#os-migrate-organization-ownership) | | `os migrate activity-changed-fields` | Stamp the two read-rule columns (`changed_fields`, `milestone_key`) on the `sys_activity` rows written before they existed. Until it runs, those rows are withheld from every reader but the system, so run it at once after upgrading. No flag; idempotent | | `os migrate comment-parent-pair` | Stamp the parent pair (`object_name`, `record_id`) on the `sys_comment` rows written before it existed. Until it runs, a broad comment read past 2,000 candidate rows omits comments the reader may see, and its total agrees with the short list, so run it at once after upgrading. No flag; idempotent | | `os migrate meta --stored` | Replay the metadata conversion chain over this deployment's `sys_metadata` rows and rewrite the ones still carrying a pre-protocol shape. Hygiene, not a gate — nothing depends on it having run | @@ -1476,17 +1471,19 @@ reports the failed read and exits `1`. A failed read or write exits `1`. #### `os migrate organization-ownership` -The v18 ownership ceremony (ADR-0131 D10). A v18 `os serve` / `os start` refuses -to start on a database still in the v17 organization-ownership shape until this -has run. Under `os dev` it runs on its own for a local SQLite database (see -[`os dev`](#os-dev)). - - -18.0 supports fresh `single` installs; upgrades and walled postures arrive in 18.x. In 18.0 this command refuses, plan and apply alike: it exits `1` with -`NOT_IMPLEMENTED` (under `--json`, `code` beside `error`) and reads and -writes nothing. Keep a v17 database on a 17.x release until the 18.x release -that carries the upgrade path. - +The v18 ownership ceremony (ADR-0131 D10), and the upgrade path of a v17 or an +18.0 database. A v18 `os serve` / `os start` serves a database this release +created itself, or one this ceremony certified; it refuses any other until the +ceremony has run. Under `os dev` it runs on its own for a local SQLite +database (see [`os dev`](#os-dev)). + +How a database is recognised: the first boot that creates a datastore from +empty writes a **birth marker** into `sys_migration` (row +`adr-0131-organization-ownership`) naming the ownership shape generation it was +born in. The ceremony writes the same row, verified, when its post-check +passes. A server decides on that generation alone: a database with no marker +(every 18.0 database), or one a shorter ceremony certified at an older +generation, is refused, and the refusal names this command. ```bash os migrate organization-ownership --out plan.json # the plan: reads only diff --git a/docs/qa/platform-checklist/areas/cli.json b/docs/qa/platform-checklist/areas/cli.json index e871c5652a9..06263fcfe31 100644 --- a/docs/qa/platform-checklist/areas/cli.json +++ b/docs/qa/platform-checklist/areas/cli.json @@ -1832,10 +1832,10 @@ }, { "id": "cli.organization-ownership-ceremony", - "title": "`os migrate organization-ownership` in 18.0, which supports fresh installs only: plan and apply alike refuse NOT_IMPLEMENTED before reading anything and leave the database unchanged, and `os migrate apply --allow-destructive` still withholds every organization_id drop on a v17-shaped database; the ceremony class the refusal keeps underneath (plan, refusals, checkpoints, drop gate) and `os migrate comment-reactions --apply` stay pinned — the v17 → v18 ceremony itself ships in an 18.x release", + "title": "`os migrate organization-ownership` upgrades a v17 or an 18.0 database (18.x): a bare run writes the reviewed plan and changes nothing; `--apply --plan-file PLAN --backup-taken` runs every step — attribution through the moves, the declared webhook and sharing-rule settlements, the column drops and NOT NULL — and certifies the marker at this release's ownership shape generation; the served boot refuses an uncertified database, naming the command, and boots a certified one; `os migrate apply --allow-destructive` still withholds every organization_id drop the ceremony owns, and `os migrate comment-reactions --apply` stays pinned", "since": "v18", "status": "active", - "revision": 2, + "revision": 3, "priority": "P0", "surface": "cli", "personas": [ @@ -1848,55 +1848,62 @@ "a COPY of a v17-shaped SQLite DB (platform-core.v18-boot-refusals names how to get one) — never the original" ], "knownGaps": [ - "WITHDRAWN IN 18.0 and deliberately not scored here: the ceremony's own arms — the plan file a bare run writes, its plan-file-required / plan-*-mismatch / database-busy refusals, the sys_migration adr-0131-organization-ownership checkpoint a reviewed apply writes, and a migrated copy booting under v18 — are not clauses of this revision. QA run #24551: \"ruling records 6259608309 (item 1) and 6259698357 then cut 18.0 to fresh installs on the single posture, carried by .changeset/pre/15212-180-guards-fresh-single-only.md, the ADR-0131 release-scope note and organization-ownership.ts …\"; the registered command (packages/cli/src/commands/migrate/organization-ownership.ts#MigrateOrganizationOwnership) refuses before the ceremony runs. A runner scores none of these arms — neither `fail` (QA run #24551's reading of revision 1) nor `skipped` (QA run #24556's, c1/c2/c4): they are not on the item. The 18.x release that ships the upgrade path restores them as clauses in the revision that re-registers the ceremony; their revision-1 wording is in this file's git history", - "acceptance[3] scores the drop gate's exit code, `message` and `tables`, not its prose: that prose still prescribes `os migrate organization-ownership --out PLAN` and `--apply --plan-file PLAN`, which 18.0 refuses — a product defect tracked on #24567, not a fail of this clause" + "a walled database (group or isolated posture) holding a CUSTOMIZED copy of a declared webhook or sharing rule blocks at its settlement step, by design: the declaration's switch is deployment-wide, so the ceremony does not switch it off for every organization on the operator's behalf — that arm is scored by the unit pins (organization-ownership-apply.generation-1.test.ts), not by a step here" ] }, "steps": [ - "`os migrate organization-ownership --database-url file:COPY` on the v17-shaped copy, plain and with `--json`; checksum the DB before and after, and list the working directory for organization-ownership-plan-*.json", - "`--apply` without --plan-file; `--apply --plan-file P --backup-taken` with P any path (18.0 writes no plan, and the refusal reads none); `--apply` with the DB held open by a second process; each with `--json` too", - "`os migrate apply --allow-destructive --yes --json` on the v17-shaped copy; checksum the DB before and after", + "`os migrate organization-ownership --database-url file:COPY --out /tmp/RUN/plan.json` on the v17-shaped copy, in the showcase's directory; checksum the DB before and after", + "`--apply` without --plan-file; then `--apply --plan-file /tmp/RUN/plan.json --backup-taken --out /tmp/RUN/apply.json --json`; then read `SELECT verified_at, blocking, details FROM sys_migration WHERE id = 'adr-0131-organization-ownership'`", + "boot the showcase (`os serve`) over the migrated copy, and over a second, unmigrated copy", + "`os migrate apply --allow-destructive --yes --json` on a fresh unmigrated v17-shaped copy; checksum the DB before and after", "`os migrate comment-reactions --apply` twice" ], "acceptance": [ { - "clause": "the 18.0 release refusal of the registered command, and the ceremony class it keeps whole underneath (plan, refusals, checkpoints, drop gate, reactions conversion) — the permanent pins pass at the run subject sha (RUNNER rule 6: cite their output, never re-derive by hand)", + "clause": "the ceremony and the guard — plan, refusals, checkpoints, the generation-1 steps, the drop gate, the birth marker and the reactions conversion — the permanent pins pass at the run subject sha (RUNNER rule 6: cite their output, never re-derive by hand)", "oracle": "test", "verify": "run each file named in automated.ref with its package's vitest (`pnpm --filter PKG exec vitest run --maxWorkers=2 FILE`, never through a bare `--`); every named file reports ≥1 test and 0 failed. A file that reports 0 tests or is not found is NOT a pass — it is a stale ref, fix the item", "evidence": "the vitest summary line per pin file, with the package name and the subject sha" }, { - "clause": "a bare run (plan mode, plain or `--json`) is refused before it reads anything: exit 1 with NOT_IMPLEMENTED — `--json` answers {error: plan_refused, reason: upgrade-unavailable, code: NOT_IMPLEMENTED, detail}, the plain form prints the refusal with `(NOT_IMPLEMENTED)` — no organization-ownership-plan-*.json is written, and the DB checksum is unchanged", + "clause": "a bare run writes the plan file and changes nothing: exit 0, the plan JSON names every table's fate, and the DB checksum is unchanged; `--apply` without --plan-file is refused before the boot (exit 1, plan-file-required)", "oracle": "build", - "verify": "exit code + the JSON object (or the printed refusal) per form + a directory listing + checksums before/after", - "evidence": "the refusal per form, the listing, the checksum pair" + "verify": "exit codes + the plan file's kind/ceremony/inventoryDigest + checksums before/after", + "evidence": "the plan excerpt, the refusal line, the checksum pair" }, { - "clause": "every --apply arm — no --plan-file, a --plan-file with --backup-taken, the DB held open by a second process — is refused the same way before reading anything: exit 1, `--json` {error: apply_refused, reason: upgrade-unavailable, code: NOT_IMPLEMENTED}; none answers the ceremony's own plan-file-required, plan-*-mismatch or database-busy, which nothing reaches in 18.0, and nothing is applied", + "clause": "`--apply --plan-file PLAN --backup-taken` completes: exit 0, every step done (the report's steps), blocking 0, and the sys_migration marker is verified with details.checkpoint.shapeGeneration equal to this release's generation", "oracle": "build", - "verify": "exit code + error / reason / code per arm + checksums before/after", - "evidence": "per-arm lines" + "verify": "the apply report's steps/complete/blocking + the marker row", + "evidence": "the report excerpt and the marker row" + }, + { + "clause": "the served boot refuses the unmigrated copy with ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED, naming `os migrate organization-ownership` and no 17.x release line, and boots the migrated copy", + "oracle": "build", + "verify": "the refused boot's error line + the migrated boot's ready line", + "evidence": "both lines" }, { - "clause": "the drop gate still holds on a v17-shaped DB: `os migrate apply --allow-destructive` refuses every organization_id drop the ceremony owns — exit 1, `--json` message refused_organization_ownership_unmigrated with `tables` naming them — and applies nothing", + "clause": "the drop gate still holds on an unmigrated v17-shaped DB: `os migrate apply --allow-destructive` refuses every organization_id drop the ceremony owns — exit 1, `--json` message refused_organization_ownership_unmigrated with `tables` naming them — and applies nothing", "oracle": "build", "verify": "exit code + the JSON message and tables + checksums before/after", "evidence": "the excerpt and the checksum pair" } ], "negative": [ - "any ceremony arm that alters the DB while refusing is a data-integrity FAIL" + "any ceremony arm that alters the DB while refusing, or a plan run that alters it at all, is a data-integrity FAIL" ], "traps": [ "destructive-in-place" ], "automated": { "kind": "unit", - "ref": "packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts + packages/cli/src/utils/organization-ownership-apply.test.ts + packages/cli/src/commands/migrate/organization-ownership.occupancy.integration.test.ts + packages/cli/src/commands/migrate/organization-ownership.dialect.test.ts + packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts + packages/cli/src/commands/migrate/comment-reactions.integration.test.ts + packages/cli/src/utils/organization-ownership-host-supplement.integration.test.ts + packages/cli/src/commands/migrate/organization-ownership.release-refusal.test.ts" + "ref": "packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts + packages/cli/src/utils/organization-ownership-apply.test.ts + packages/cli/src/commands/migrate/organization-ownership.occupancy.integration.test.ts + packages/cli/src/commands/migrate/organization-ownership.dialect.test.ts + packages/cli/src/commands/migrate/apply-organization-ownership-gate.integration.test.ts + packages/cli/src/commands/migrate/comment-reactions.integration.test.ts + packages/cli/src/utils/organization-ownership-host-supplement.integration.test.ts + packages/cli/src/utils/organization-ownership-apply.generation-1.test.ts + packages/runtime/src/organization-ownership-boot-guard.test.ts" }, "source": [ "packages/cli/src/commands/migrate/organization-ownership.ts", - "packages/cli/src/commands/migrate/organization-ownership.ts#ORGANIZATION_OWNERSHIP_UPGRADE_UNAVAILABLE", + "packages/runtime/src/organization-ownership-boot-guard.ts", + "packages/spec/src/system/migration.zod.ts#ORGANIZATION_OWNERSHIP_SHAPE_GENERATION", "packages/spec/src/system/fate-supplement.zod.ts#OrganizationOwnershipSupplementSchema", "packages/cli/src/utils/organization-ownership-drop-gate.ts", "docs/adr/0131-total-organization-ownership-no-null-organization-id.md", @@ -1914,6 +1921,12 @@ "date": "2026-10-19", "change": "re-pointed to the 18.0 release refusal (QA run #24551 acceptance[1]–[4] fail, disposition stale-spec, RUNNER rule 7 agreeing; parked on #24579 §1). 18.0 was narrowed to fresh installs by ruling — #24551: \"ruling records 6259608309 (item 1) and 6259698357 then cut 18.0 to fresh installs on the single posture\" — and the registered `os migrate organization-ownership` refuses plan and apply alike with NOT_IMPLEMENTED before reading anything. acceptance[1] and [2] now score that refusal (no plan written, the DB unchanged, every apply arm refused the same way); acceptance[3] keeps the drop-gate half and drops the sys_migration checkpoint half; revision 1's acceptance[4] (the migrated copy boots) is removed. The ledger has no clause-level skip, so the ceremony's own arms are recorded in fixtures.knownGaps[0] as withdrawn and deliberately not scored, the shape knownGaps already carries for a half an item does not score. That is the one reading for both runs: a runner scores those arms neither `fail` (#24551, revision 1) nor `skipped` (QA run #24556, c1/c2/c4), and both runs captured the refusal each re-pointed clause now asserts. automated.ref gains organization-ownership.release-refusal.test.ts, the pin of the registered command (the other pins drive the ceremony class beneath it); acceptance[0], the title, the steps and source follow; re-derived from main 5a871e4e41, where every source cited here is byte-identical to the run subject 5b14b7f3", "ref": "#24622" + }, + { + "revision": 3, + "date": "2026-10-19", + "change": "re-pointed to the opened ceremony (18.x steps 5 and 6, one landing by ruling record 6270035914 on the v18 epic): the registered command runs the ceremony again, the 18.0 refusal and its pin (organization-ownership.release-refusal.test.ts) are deleted, and the boot guard recognises a database by its birth marker and its ownership shape generation (ruling record 6269955919, ① A). Revision 1's arms are restated against it: the plan written, the reviewed apply certifying at the current generation, the served boot refusing an unmigrated copy and booting the migrated one; the drop-gate clause is kept; fixtures.knownGaps now records the one deliberate block (a walled customized copy). automated.ref drops the deleted refusal pin and gains the generation-1 steps' pins and the guard's", + "ref": "#24581" } ] }, From e0c1d2bd500bf90d19e9071d6b86683edfcbfa4c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 21:45:07 +0000 Subject: [PATCH 23/44] chore(spec): regenerate the api-surface, export-origin, declaration-map, docs and strictness-ledger artifacts --- content/docs/references/index.mdx | 10 +++++----- content/docs/references/system/migration.mdx | 20 +++++++++++++++++-- .../system.md | 2 +- packages/spec/api-surface/system.json | 4 ++++ packages/spec/declaration-map/system.json | 2 ++ packages/spec/export-origins/system.json | 4 ++++ 6 files changed, 34 insertions(+), 8 deletions(-) diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx index 178df51d814..7dad531e931 100644 --- a/content/docs/references/index.mdx +++ b/content/docs/references/index.mdx @@ -1,7 +1,7 @@ --- title: Protocol reference — every schema by module navTitle: Protocol Reference -description: Every schema published by @objectstack/spec — 1541 schemas across 14 protocol modules +description: Every schema published by @objectstack/spec — 1542 schemas across 14 protocol modules --- {/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} @@ -32,9 +32,9 @@ counts are sums of the rows they head. Regenerate with | [Security Protocol](/docs/references/security) | 5 | 31 | Permission sets, row-level security, sharing rules, tenancy posture. | | [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. | | [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. | -| [System Protocol](/docs/references/system) | 36 | 284 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | +| [System Protocol](/docs/references/system) | 36 | 285 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. | | [UI Protocol](/docs/references/ui) | 16 | 169 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. | -| **Total** | **197** | **1541** | 14 protocol modules | +| **Total** | **197** | **1542** | 14 protocol modules | --- @@ -317,7 +317,7 @@ Studio designer metadata — the authoring surfaces for the protocols above. ## System Protocol -**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **36 pages, 284 schemas** +**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **36 pages, 285 schemas** The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. @@ -344,7 +344,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a | [`logging.zod.ts`](/docs/references/system/logging) | `ConsoleDestinationConfig`, `ExtendedLogLevel`, `ExternalServiceDestinationConfig`, `FileDestinationConfig`, `HttpDestinationConfig`, `LogDestination`, `LogDestinationType`, `LogEnrichmentConfig`, `LogEntry`, `LogFormat`, `LogLevel`, `LoggerConfig`, `LoggingConfig`, `StructuredLogEntry` | | [`metadata-persistence.zod.ts`](/docs/references/system/metadata-persistence) | `MetadataCollectionInfo`, `MetadataDiffResult`, `MetadataFallbackStrategy`, `MetadataFormat`, `MetadataHistoryQueryOptions`, `MetadataHistoryQueryResult`, `MetadataHistoryRecord`, `MetadataHistoryRetentionPolicy`, `MetadataLoadOptions`, `MetadataLoadResult`, `MetadataLoaderContract`, `MetadataManagerConfig`, `MetadataRecord`, `MetadataSaveOptions`, `MetadataSaveResult`, `MetadataScope`, `MetadataSource`, `MetadataState`, `MetadataStats`, `MetadataWatchEvent`, `PackagePublishResult` | | [`metrics.zod.ts`](/docs/references/system/metrics) | `HistogramBucketConfig`, `MetricAggregationConfig`, `MetricAggregationType`, `MetricDataPoint`, `MetricDefinition`, `MetricExportConfig`, `MetricLabels`, `MetricType`, `MetricUnit`, `MetricsConfig`, `ServiceLevelIndicator`, `ServiceLevelObjective`, `TimeSeries`, `TimeSeriesDataPoint` | -| [`migration.zod.ts`](/docs/references/system/migration) | `AddFieldOperation`, `ChangeSet`, `CreateObjectOperation`, `DataMigrationFlag`, `DeleteObjectOperation`, `ExecuteSqlOperation`, `MigrationDependency`, `MigrationJournalEvent`, `MigrationOperation`, `ModifyFieldOperation`, `RemoveFieldOperation`, `RenameObjectOperation` | +| [`migration.zod.ts`](/docs/references/system/migration) | `AddFieldOperation`, `ChangeSet`, `CreateObjectOperation`, `DataMigrationFlag`, `DeleteObjectOperation`, `ExecuteSqlOperation`, `MigrationDependency`, `MigrationJournalEvent`, `MigrationOperation`, `ModifyFieldOperation`, `OrganizationOwnershipBirthMarker`, `RemoveFieldOperation`, `RenameObjectOperation` | | [`notification.zod.ts`](/docs/references/system/notification) | `NotificationChannel` | | [`object-storage.zod.ts`](/docs/references/system/object-storage) | `AccessControlConfig`, `BucketConfig`, `FileMetadata`, `LifecycleAction`, `LifecyclePolicyConfig`, `LifecyclePolicyRule`, `MultipartUploadConfig`, `ObjectMetadata`, `ObjectStorageConfig`, `PresignedUrlConfig`, `StorageAcl`, `StorageClass`, `StorageConnection`, `StorageProvider`, `StorageScope` | | [`registry-config.zod.ts`](/docs/references/system/registry-config) | `RegistryConfig`, `RegistrySyncPolicy`, `RegistryUpstream` | diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 9b491b4cca8..a81135d3b22 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -27,8 +27,8 @@ irreversibly on migrated data gate on the flag instead of the version. ## TypeScript Usage ```typescript -import { AddFieldOperation, ChangeSetSchema, CreateObjectOperation, DataMigrationFlagSchema, DeleteObjectOperation, ExecuteSqlOperation, MigrationDependencySchema, MigrationJournalEventSchema, MigrationOperationSchema, ModifyFieldOperation, RemoveFieldOperation, RenameObjectOperation } from '@objectstack/spec/system'; -import type { ChangeSet, DataMigrationFlag, DeleteObjectOperation, ExecuteSqlOperation, MigrationDependency, MigrationJournalEvent, MigrationOperation, ModifyFieldOperation, RemoveFieldOperation, RenameObjectOperation } from '@objectstack/spec/system'; +import { AddFieldOperation, ChangeSetSchema, CreateObjectOperation, DataMigrationFlagSchema, DeleteObjectOperation, ExecuteSqlOperation, MigrationDependencySchema, MigrationJournalEventSchema, MigrationOperationSchema, ModifyFieldOperation, OrganizationOwnershipBirthMarkerSchema, RemoveFieldOperation, RenameObjectOperation } from '@objectstack/spec/system'; +import type { ChangeSet, DataMigrationFlag, DeleteObjectOperation, ExecuteSqlOperation, MigrationDependency, MigrationJournalEvent, MigrationOperation, ModifyFieldOperation, OrganizationOwnershipBirthMarker, RemoveFieldOperation, RenameObjectOperation } from '@objectstack/spec/system'; // Validate data const result = AddFieldOperation.parse(data); @@ -728,6 +728,22 @@ Modify properties of an existing field | **changes** | `Record` | ✅ | Partial field definition updates | +--- + +## OrganizationOwnershipBirthMarker + +Birth marker of a datastore created from empty: the ownership shape generation it was born in + +### Properties + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **shapeGeneration** | `integer` | ✅ | The organization-ownership shape generation the store was created in; the only field the boot guard decides on | +| **platformVersion** | `string` | ✅ | The exact platform version that created the store (provenance only, never compared) | +| **commit** | `string` | optional | The source commit of that build, when known (provenance only) | +| **createdAt** | `string` | ✅ | When the guard observed the store created from empty | + + --- ## RemoveFieldOperation diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md index 6aaedaaf246..fee2a67d822 100644 --- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md +++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md @@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate. | Dir | Sites | |---|---| -| `system/` | 374 | +| `system/` | 375 | diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index 2d42ed594a4..bdccab68144 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -447,6 +447,7 @@ "OPERATION_MESSAGE_FALLBACK_LOCALE (const)", "OPERATION_MESSAGE_KEY_PREFIX (const)", "ORGANIZATION_OWNERSHIP_MIGRATION_ID (const)", + "ORGANIZATION_OWNERSHIP_SHAPE_GENERATION (const)", "ORSet (type)", "ORSetElement (type)", "ORSetElementParsed (type)", @@ -481,6 +482,8 @@ "OpenTelemetryCompatibilitySchema (const)", "OrganizationOwnershipAnchor (type)", "OrganizationOwnershipAnchorSchema (const)", + "OrganizationOwnershipBirthMarker (type)", + "OrganizationOwnershipBirthMarkerSchema (const)", "OrganizationOwnershipCategory (type)", "OrganizationOwnershipCategorySchema (const)", "OrganizationOwnershipDeparture (type)", @@ -833,6 +836,7 @@ "objectLabelKey (function)", "objectValidationMessageKey (function)", "operationMessageTranslationKey (function)", + "organizationOwnershipShapeGenerationOf (function)", "preferredLocaleFromHeader (function)", "renderOperationMessage (function)", "renderValidationMessage (function)", diff --git a/packages/spec/declaration-map/system.json b/packages/spec/declaration-map/system.json index 748819da1b7..f1c6736d29c 100644 --- a/packages/spec/declaration-map/system.json +++ b/packages/spec/declaration-map/system.json @@ -319,6 +319,8 @@ "OpenTelemetryCompatibilitySchema": "system/OpenTelemetryCompatibility", "OrganizationOwnershipAnchor": "system/OrganizationOwnershipAnchor", "OrganizationOwnershipAnchorSchema": "system/OrganizationOwnershipAnchor", + "OrganizationOwnershipBirthMarker": "system/OrganizationOwnershipBirthMarker", + "OrganizationOwnershipBirthMarkerSchema": "system/OrganizationOwnershipBirthMarker", "OrganizationOwnershipCategory": "system/OrganizationOwnershipCategory", "OrganizationOwnershipCategorySchema": "system/OrganizationOwnershipCategory", "OrganizationOwnershipDeparture": "system/OrganizationOwnershipDeparture", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index bb2cc677f2b..a694e7f5edd 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -428,6 +428,7 @@ "OPERATION_MESSAGE_FALLBACK_LOCALE": "src/system/operation-message.ts#OPERATION_MESSAGE_FALLBACK_LOCALE (const)", "OPERATION_MESSAGE_KEY_PREFIX": "src/system/operation-message.ts#OPERATION_MESSAGE_KEY_PREFIX (const)", "ORGANIZATION_OWNERSHIP_MIGRATION_ID": "src/system/migration.zod.ts#ORGANIZATION_OWNERSHIP_MIGRATION_ID (const)", + "ORGANIZATION_OWNERSHIP_SHAPE_GENERATION": "src/system/migration.zod.ts#ORGANIZATION_OWNERSHIP_SHAPE_GENERATION (const)", "ORSet": "src/system/collaboration.zod.ts#ORSet (type)", "ORSetElement": "src/system/collaboration.zod.ts#ORSetElement (type)", "ORSetElementParsed": "src/system/collaboration.zod.ts#ORSetElementParsed (type)", @@ -461,6 +462,8 @@ "OpenTelemetryCompatibilitySchema": "src/system/tracing.zod.ts#OpenTelemetryCompatibilitySchema (const)", "OrganizationOwnershipAnchor": "src/system/fate-supplement.zod.ts#OrganizationOwnershipAnchor (type)", "OrganizationOwnershipAnchorSchema": "src/system/fate-supplement.zod.ts#OrganizationOwnershipAnchorSchema (const)", + "OrganizationOwnershipBirthMarker": "src/system/migration.zod.ts#OrganizationOwnershipBirthMarker (type)", + "OrganizationOwnershipBirthMarkerSchema": "src/system/migration.zod.ts#OrganizationOwnershipBirthMarkerSchema (const)", "OrganizationOwnershipCategory": "src/system/fate-supplement.zod.ts#OrganizationOwnershipCategory (type)", "OrganizationOwnershipCategorySchema": "src/system/fate-supplement.zod.ts#OrganizationOwnershipCategorySchema (const)", "OrganizationOwnershipDeparture": "src/system/fate-supplement.zod.ts#OrganizationOwnershipDeparture (type)", @@ -794,6 +797,7 @@ "objectLabelKey": "src/system/i18n-resolver.ts#objectLabelKey (function)", "objectValidationMessageKey": "src/system/i18n-resolver.ts#objectValidationMessageKey (function)", "operationMessageTranslationKey": "src/system/operation-message.ts#operationMessageTranslationKey (function)", + "organizationOwnershipShapeGenerationOf": "src/system/migration.zod.ts#organizationOwnershipShapeGenerationOf (function)", "preferredLocaleFromHeader": "src/system/i18n-resolver.ts#preferredLocaleFromHeader (function)", "renderOperationMessage": "src/system/operation-message.ts#renderOperationMessage (function)", "renderValidationMessage": "src/system/validation-message.ts#renderValidationMessage (function)", From a0fa816ea92078cb23ab23bee99d85b6fd5703cf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 21:53:13 +0000 Subject: [PATCH 24/44] =?UTF-8?q?test(platform-objects,service-settings):?= =?UTF-8?q?=20sys=5Fsecret=20is=20deployment-level=20=E2=80=94=20the=20cip?= =?UTF-8?q?her=20names=20no=20organization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V Co-authored-by: Claude --- .../deployment-level-no-tenant-column.test.ts | 26 +++++++++++++------ .../src/settings-secret-organization.test.ts | 24 +++++++++-------- 2 files changed, 31 insertions(+), 19 deletions(-) diff --git a/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts b/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts index 8461ed39aeb..379b23a357e 100644 --- a/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts +++ b/packages/platform-objects/src/deployment-level-no-tenant-column.test.ts @@ -4,7 +4,7 @@ import { describe, it, expect } from 'vitest'; import { resolveInjectedSystemColumns } from '@objectstack/spec/data'; import { PLATFORM_CAPABILITIES } from '@objectstack/spec/security'; import { SysJob, SysJobRun, SysJobQueue } from './audit/index.js'; -import { SysMigration, SysMigrationJournal, SysPlatformSettingAudit, SysSecret } from './system/index.js'; +import { SysMigration, SysMigrationJournal, SysPlatformSettingAudit, SysSecret, SysSettingAudit } from './system/index.js'; /** * [ADR-0131 D7] The deployment-level plumbing this package declares carries no @@ -16,9 +16,10 @@ import { SysMigration, SysMigrationJournal, SysPlatformSettingAudit, SysSecret } * on `fields` alone is a phantom check: an object that merely omits the field * still gets the column. `resolveInjectedSystemColumns` is the derivation * `applySystemFields` consumes to do the injecting, so it is the authority on - * whether the column exists. The `sys_secret` control below is the same - * question asked of an object that DOES keep the column (its writer stamps the - * caller's organization), so the predicate is shown able to answer `true`. + * whether the column exists. The `sys_setting_audit` control below is the + * same question asked of an object that DOES keep the column (its writer + * stamps the caller's organization), so the predicate is shown able to answer + * `true`. * * ## Why the capability is pinned beside the column * @@ -80,11 +81,20 @@ describe('[ADR-0131 D7] deployment-level plumbing: no tenant column, platform-on }); it('control: a tenant-attributed object keeps the column, so the predicate can answer true', () => { - // `sys_secret` is written with the business write's driver options, so the - // SQL driver stamps the caller's organization on it. It is NOT - // deployment-level today; its fate is decided by the C7 inventory. - const plan = resolveInjectedSystemColumns(SysSecret); + // `sys_setting_audit` keeps a tenant or user setting's history: its writer + // stamps the caller's organization on every row it writes there. + const plan = resolveInjectedSystemColumns(SysSettingAudit); expect(plan.tenant).toBe(true); expect([...plan.names]).toContain('organization_id'); }); + + it('sys_secret is deployment-level too: no tenant column (every read is by id); its reads stay private by access', () => { + // 18.x step 6 (line fact F2): the flip lands with the open upgrade + // ceremony, whose column-drops step takes an upgraded database's column. + const plan = resolveInjectedSystemColumns(SysSecret); + expect(plan.tenant).toBe(false); + expect([...plan.names]).not.toContain('organization_id'); + expect((SysSecret as { systemFields?: unknown }).systemFields).toEqual({ tenant: false }); + expect((SysSecret as { access?: unknown }).access).toEqual({ default: 'private' }); + }); }); diff --git a/packages/services/service-settings/src/settings-secret-organization.test.ts b/packages/services/service-settings/src/settings-secret-organization.test.ts index 0f3f75cf5b2..6e4db84c608 100644 --- a/packages/services/service-settings/src/settings-secret-organization.test.ts +++ b/packages/services/service-settings/src/settings-secret-organization.test.ts @@ -1,15 +1,16 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [ADR-0131 D3/D9] The two rows a settings write lands BESIDE its `sys_setting` - * row — the `sys_secret` ciphertext of an encrypted value and the per-key - * `sys_setting_audit` history row — belong to the organization the setting - * row does: the caller's, for a `tenant` or `user` value. A `global` value is - * deployment-level (its row lives in `sys_platform_setting`, which has no - * organization), so neither row names one. Its history row goes to - * `sys_platform_setting_audit`, the audit trail's deployment-level global rung - * (ADR-0131 D7, ruling record 6257510973 · Q4 A); the secret's fate is the - * 18.x ceremony's. + * [ADR-0131 D3/D7/D9] The two rows a settings write lands BESIDE its + * `sys_setting` row. The per-key `sys_setting_audit` history row belongs to the + * organization the setting row does: the caller's, for a `tenant` or `user` + * value. The `sys_secret` ciphertext of an encrypted value names no + * organization under any rung: the cipher store is deployment-level (18.x step + * 6, `systemFields.tenant: false`) and is read by its id, so the owning + * `sys_setting` row alone carries the organization. A `global` value is + * deployment-level throughout (its row lives in `sys_platform_setting`), and its + * history row goes to `sys_platform_setting_audit`, the audit trail's + * deployment-level global rung (ADR-0131 D7, ruling record 6257510973 · Q4 A). */ import { randomBytes } from 'node:crypto'; @@ -57,12 +58,13 @@ function boot() { } describe('[ADR-0131 D3/D9] settings secret and history rows name their setting\'s organization', () => { - it('a tenant value: the ciphertext and the history row carry the caller\'s organization', async () => { + it('a tenant value: the history row carries the caller\'s organization; the ciphertext names none', async () => { const { svc, secrets, history } = boot(); await svc.setMany('tenant_vault', { token: 's3cret' }, { tenantId: 'org-1', userId: 'usr-1' }); expect(secrets).toHaveLength(1); - expect(secrets[0]).toMatchObject({ namespace: 'tenant_vault', key: 'token', organization_id: 'org-1' }); + expect(secrets[0]).toMatchObject({ namespace: 'tenant_vault', key: 'token' }); + expect('organization_id' in secrets[0]).toBe(false); expect(history.filter((e) => e.key === 'token')).toEqual([ expect.objectContaining({ scope: 'tenant', encrypted: true, organizationId: 'org-1' }), ]); From e7a788dd35cab5cbb1ecf4573c1f8c49cf85b28d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 21:54:12 +0000 Subject: [PATCH 25/44] chore: regenerate the platform-object tenancy census (sys_secret and sys_flow_credential leave reach) Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V Co-authored-by: Claude --- scripts/platform-object-tenancy-census.json | 22 ++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/scripts/platform-object-tenancy-census.json b/scripts/platform-object-tenancy-census.json index 177a3745cab..5064e0a3c67 100644 --- a/scripts/platform-object-tenancy-census.json +++ b/scripts/platform-object-tenancy-census.json @@ -25,12 +25,12 @@ "population": "every object registered by a tracked packages/**/*.object.ts module whose name carries a platform prefix (sys_ / cloud_ / ai_). The cloud repository's own cloud_ objects are not in this tree and so not in this census.", "totals": { "registered": 85, - "inReach": 44, - "outOfReach": 41 + "inReach": 42, + "outOfReach": 43 }, "reasonTotals": { "managedBy: 'better-auth'": 25, - "systemFields.tenant: false": 16, + "systemFields.tenant: false": 18, "tenancy.enabled: false": 2 }, "unexplained": [], @@ -187,9 +187,11 @@ { "name": "sys_flow_credential", "file": "packages/services/service-automation/src/sys-flow-credential.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] + "reach": "out", + "tenantField": null, + "reasons": [ + "systemFields.tenant: false" + ] }, { "name": "sys_flow_dispatch", @@ -589,9 +591,11 @@ { "name": "sys_secret", "file": "packages/platform-objects/src/system/sys-secret.object.ts", - "reach": "in", - "tenantField": "organization_id", - "reasons": [] + "reach": "out", + "tenantField": null, + "reasons": [ + "systemFields.tenant: false" + ] }, { "name": "sys_session", From f1264d72e679f1cff7afbe430c4b8f845d09dc27 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 22:30:03 +0000 Subject: [PATCH 26/44] =?UTF-8?q?chore:=20the=20gates=20the=20sweep=20name?= =?UTF-8?q?d=20=E2=80=94=20the=20guard=20double's=20findOne=20predicate,?= =?UTF-8?q?=20the=20birth=20marker's=20parsed=20alias,=20the=20plugin-webh?= =?UTF-8?q?ooks=20test=20alias?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V Co-authored-by: Claude --- packages/cli/vitest.config.ts | 8 ++++++++ .../runtime/src/organization-ownership-boot-guard.test.ts | 2 ++ packages/spec/api-surface/system.json | 1 + packages/spec/export-origins/system.json | 1 + packages/spec/src/system/migration.zod.ts | 3 ++- scripts/engine-double-contract.pinned.json | 5 +++++ 6 files changed, 19 insertions(+), 1 deletion(-) diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index f7245834057..373b69f73e3 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -806,6 +806,14 @@ export default defineConfig({ // driver is its storage. Resolved to source for the reason the entry above // gives: a verdict about the checkout, not about the last build. Anchored // like its neighbours (driver-sqlite-wasm publishes only `"."`). + // [18.x step 5a] `utils/organization-ownership-apply.generation-1.test.ts` + // pins the ceremony's `declared:NAME` spelling equal to plugin-webhooks' + // own `declaredWebhookRefId`; resolved to source, so the pin is a verdict + // about the checkout's spelling, not the last build's. + { + find: /^@objectstack\/plugin-webhooks$/, + replacement: path.resolve(__dirname, '../plugins/plugin-webhooks/src/index.ts'), + }, { find: /^@objectstack\/driver-sqlite-wasm$/, replacement: path.resolve(__dirname, '../drivers/driver-sqlite-wasm/src/index.ts'), diff --git a/packages/runtime/src/organization-ownership-boot-guard.test.ts b/packages/runtime/src/organization-ownership-boot-guard.test.ts index 350dfc47718..b880599b86b 100644 --- a/packages/runtime/src/organization-ownership-boot-guard.test.ts +++ b/packages/runtime/src/organization-ownership-boot-guard.test.ts @@ -23,6 +23,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { DatabaseSync } from 'node:sqlite'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; import { ORGANIZATION_OWNERSHIP_SHAPE_GENERATION } from '@objectstack/spec/system'; import { AppPlugin } from './app-plugin.js'; import { @@ -61,6 +62,7 @@ function engine(opts: { client?: unknown; dialectName?: string; failRead?: boole registry: { getAllObjects: () => OBJECTS }, getDriverForObject: () => driver, async findOne(object: string, query: Record) { + assertEngineFindOnePredicate(object, query); const exists = db.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`).all(object).length > 0; if (!exists) return null; const id = String((query.where as { id?: unknown } | undefined)?.id); diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json index bdccab68144..2540e5f18dd 100644 --- a/packages/spec/api-surface/system.json +++ b/packages/spec/api-surface/system.json @@ -483,6 +483,7 @@ "OrganizationOwnershipAnchor (type)", "OrganizationOwnershipAnchorSchema (const)", "OrganizationOwnershipBirthMarker (type)", + "OrganizationOwnershipBirthMarkerParsed (type)", "OrganizationOwnershipBirthMarkerSchema (const)", "OrganizationOwnershipCategory (type)", "OrganizationOwnershipCategorySchema (const)", diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json index a694e7f5edd..ffbde5c794f 100644 --- a/packages/spec/export-origins/system.json +++ b/packages/spec/export-origins/system.json @@ -463,6 +463,7 @@ "OrganizationOwnershipAnchor": "src/system/fate-supplement.zod.ts#OrganizationOwnershipAnchor (type)", "OrganizationOwnershipAnchorSchema": "src/system/fate-supplement.zod.ts#OrganizationOwnershipAnchorSchema (const)", "OrganizationOwnershipBirthMarker": "src/system/migration.zod.ts#OrganizationOwnershipBirthMarker (type)", + "OrganizationOwnershipBirthMarkerParsed": "src/system/migration.zod.ts#OrganizationOwnershipBirthMarkerParsed (type)", "OrganizationOwnershipBirthMarkerSchema": "src/system/migration.zod.ts#OrganizationOwnershipBirthMarkerSchema (const)", "OrganizationOwnershipCategory": "src/system/fate-supplement.zod.ts#OrganizationOwnershipCategory (type)", "OrganizationOwnershipCategorySchema": "src/system/fate-supplement.zod.ts#OrganizationOwnershipCategorySchema (const)", diff --git a/packages/spec/src/system/migration.zod.ts b/packages/spec/src/system/migration.zod.ts index 7a16973168f..784607298ba 100644 --- a/packages/spec/src/system/migration.zod.ts +++ b/packages/spec/src/system/migration.zod.ts @@ -230,7 +230,8 @@ export const OrganizationOwnershipBirthMarkerSchema = lazySchema(() => z.object( createdAt: z.string().datetime() .describe('When the guard observed the store created from empty'), }).describe('Birth marker of a datastore created from empty: the ownership shape generation it was born in')); -export type OrganizationOwnershipBirthMarker = z.infer; +export type OrganizationOwnershipBirthMarker = z.input; +export type OrganizationOwnershipBirthMarkerParsed = z.infer; /** * The shape generation an {@link ORGANIZATION_OWNERSHIP_MIGRATION_ID} row's diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 49077c1c4d5..9d3a4424bc5 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -4221,6 +4221,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/runtime/src/organization-ownership-boot-guard.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts", "verb": "findOne", From a3064efbf62288e8957ec0cded4f3cbbe3058bf7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 22:55:28 +0000 Subject: [PATCH 27/44] test(cli): fixtures standing in for an earlier release's database carry no birth marker; the #2178 NOT NULL drift re-keyed off the tenant column Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V Co-authored-by: Claude --- ...-ownership.report-file.integration.test.ts | 9 +++++++++ ...orm-global-column-drop.integration.test.ts | 9 +++++++++ .../utils/schema-migrate.integration.test.ts | 20 +++++++++++-------- 3 files changed, 30 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/migrate/organization-ownership.report-file.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.report-file.integration.test.ts index 94900ed5a57..2e3756a30bd 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.report-file.integration.test.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.report-file.integration.test.ts @@ -114,6 +114,15 @@ async function bootServed(projectDir: string, dbFile: string): Promise { await kernel.use(new PlatformObjectsPlugin() as any); await runtime.start(); await kernel.shutdown(); + // This boot of THIS tree created the store from empty, so it wrote this + // release's birth marker; the database it stands in for (an earlier + // release's) carries none, and the cases below read the marker's absence. + const db = new Database(dbFile); + try { + db.prepare('DELETE FROM sys_migration WHERE id = ?').run(ORGANIZATION_OWNERSHIP_MIGRATION_ID); + } finally { + db.close(); + } } interface Prepared { diff --git a/packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts b/packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts index 32d1da43618..f13045f2b54 100644 --- a/packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts +++ b/packages/cli/src/utils/organization-ownership-platform-global-column-drop.integration.test.ts @@ -193,6 +193,15 @@ async function bootV17(dbFile: string): Promise { await kernel.use(osgcObjectsPlugin() as any); await runtime.start(); await kernel.shutdown(); + // This boot of THIS tree created the store from empty, so it wrote this + // release's birth marker; the v17 database it stands in for carries none, + // and the drop gate below reads that absence. + const db = new Database(dbFile); + try { + db.prepare(`DELETE FROM sys_migration WHERE id = 'adr-0131-organization-ownership'`).run(); + } finally { + db.close(); + } } /** A supplement row: `column-drop` for `drop`, `report` for every other host object. */ diff --git a/packages/cli/src/utils/schema-migrate.integration.test.ts b/packages/cli/src/utils/schema-migrate.integration.test.ts index 19c9b4a31fb..eb734fa9c0c 100644 --- a/packages/cli/src/utils/schema-migrate.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.integration.test.ts @@ -42,7 +42,8 @@ describe('bootSchemaStack + migrate engine (integration)', () => { name: 'mig_biz_unit', fields: { name: { type: 'text', required: true }, - organization_id: { type: 'text', required: false }, // optional now + organization_id: { type: 'text', required: false }, + region: { type: 'text', required: false }, // optional now code: { type: 'text', unique: true }, // tenant-scoped since #3696 }, }, @@ -50,8 +51,10 @@ describe('bootSchemaStack + migrate engine (integration)', () => { }), ); - // Seed a "legacy" DB: organization_id NOT NULL (the #2178 shape) plus the - // platform-wide unique index on `code` the pre-#3696 driver emitted. + // Seed a "legacy" DB: an optional field's column NOT NULL (the #2178 shape, + // re-keyed off `organization_id`, whose NOT NULL is the ownership + // ceremony's and never drift) plus the platform-wide unique index on + // `code` the pre-#3696 driver emitted. const seed = new SqlDriver({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true }); const k = (seed as any).knex; await k.schema.createTable('mig_biz_unit', (t: any) => { @@ -59,11 +62,12 @@ describe('bootSchemaStack + migrate engine (integration)', () => { t.timestamp('created_at'); t.timestamp('updated_at'); t.string('name').notNullable(); - t.string('organization_id').notNullable(); + t.string('organization_id'); + t.string('region').notNullable(); t.string('code'); }); await k.raw('CREATE UNIQUE INDEX mig_biz_unit_code_unique ON mig_biz_unit (code)'); - await k('mig_biz_unit').insert({ id: '1', name: 'Acme', organization_id: 'org1', code: 'BU-00001' }); + await k('mig_biz_unit').insert({ id: '1', name: 'Acme', organization_id: 'org1', region: 'emea', code: 'BU-00001' }); await k.destroy(); savedEnv.OS_ARTIFACT_PATH = process.env.OS_ARTIFACT_PATH; @@ -87,8 +91,8 @@ describe('bootSchemaStack + migrate engine (integration)', () => { // Boot changed nothing — this is exactly what `os migrate plan` renders. const drift = await stack.driver!.detectManagedDrift(); - const org = drift.find((d) => d.table === 'mig_biz_unit' && d.column === 'organization_id'); - expect(org, 'expected drift on mig_biz_unit.organization_id').toBeDefined(); + const org = drift.find((d) => d.table === 'mig_biz_unit' && d.column === 'region'); + expect(org, 'expected drift on mig_biz_unit.region').toBeDefined(); // ADR-0113: a column stricter than its declaration is needs_confirm — // reported by plan, applied by an explicit `os migrate apply` (the human // IS the confirmation), but never by dev auto-reconcile. @@ -111,7 +115,7 @@ describe('bootSchemaStack + migrate engine (integration)', () => { expect(skipped).toHaveLength(0); const after = await stack.driver!.detectManagedDrift(); - expect(after.find((d) => d.table === 'mig_biz_unit' && d.column === 'organization_id')).toBeUndefined(); + expect(after.find((d) => d.table === 'mig_biz_unit' && d.column === 'region')).toBeUndefined(); expect(after.find((d) => d.op.type === 'replace_unique_index')).toBeUndefined(); // The seeded row survived the reconcile. From 02321084fc90276ef297814e454b9ba669e7afe6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:13:32 +0000 Subject: [PATCH 28/44] docs(qa): the ceremony item's revision 3 cites its PR Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V Co-authored-by: Claude --- docs/qa/platform-checklist/areas/cli.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/qa/platform-checklist/areas/cli.json b/docs/qa/platform-checklist/areas/cli.json index 89aafcf4a35..5048fb40dac 100644 --- a/docs/qa/platform-checklist/areas/cli.json +++ b/docs/qa/platform-checklist/areas/cli.json @@ -1958,7 +1958,7 @@ "revision": 3, "date": "2026-10-19", "change": "re-pointed to the opened ceremony (18.x steps 5 and 6, one landing by ruling record 6270035914 on the v18 epic): the registered command runs the ceremony again, the 18.0 refusal and its pin (organization-ownership.release-refusal.test.ts) are deleted, and the boot guard recognises a database by its birth marker and its ownership shape generation (ruling record 6269955919, ① A). Revision 1's arms are restated against it: the plan written, the reviewed apply certifying at the current generation, the served boot refusing an unmigrated copy and booting the migrated one; the drop-gate clause is kept; fixtures.knownGaps now records the one deliberate block (a walled customized copy). automated.ref drops the deleted refusal pin and gains the generation-1 steps' pins and the guard's", - "ref": "#24581" + "ref": "#24681" } ] }, From dceaa5646970b309c1c979b6a8f40bcf2e505c3e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:35:39 +0000 Subject: [PATCH 29/44] fix(driver-turso): the remote face counts what its schema doors create and find, so a fresh libsql:// install gets its birth marker and boots The contract review (B1) measured an R1 regression: the remote face answered getSchemaSyncStats {0, 0} ("cannot say") by contract, so the boot guard never saw a fresh remote store as created from empty and refused a fresh install as "an earlier release created it". The transport's two schema doors already probe sqlite_master per table; they now count created vs found, as SqlDriver.initObjects counts from hasTable, and the remote face answers with that count. Pinned on a real kernel over the transport's file double: a fresh remote database boots on its birth marker, and an 18.0-shaped one is refused. Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V Co-authored-by: Claude --- .../driver-turso/src/remote-transport.ts | 30 +++++ .../drivers/driver-turso/src/turso-driver.ts | 18 ++- .../turso-remote-inherited-members.test.ts | 25 +++- packages/runtime/package.json | 1 + ...hip-boot-guard.turso-remote.kernel.test.ts | 118 ++++++++++++++++++ pnpm-lock.yaml | 7 +- 6 files changed, 190 insertions(+), 9 deletions(-) create mode 100644 packages/runtime/src/organization-ownership-boot-guard.turso-remote.kernel.test.ts diff --git a/packages/drivers/driver-turso/src/remote-transport.ts b/packages/drivers/driver-turso/src/remote-transport.ts index 039d3cd860c..43b26640187 100644 --- a/packages/drivers/driver-turso/src/remote-transport.ts +++ b/packages/drivers/driver-turso/src/remote-transport.ts @@ -1038,6 +1038,16 @@ function isRemoteReadableIdsSemiJoin(value: unknown): value is RemoteReadableIds */ export class RemoteTransport { private client: Client | null = null; + /** + * The tables this transport's schema doors created, and the ones they found + * already present (one not created here first), since construction — the + * remote face's answer to `IDataDriver.getSchemaSyncStats`, counted from the + * `sqlite_master` probe each door already makes, exactly as + * `SqlDriver.initObjects` counts from its `hasTable` probe. A table counts as + * created only once its DDL was applied. + */ + private readonly tablesCreatedHere = new Set(); + private readonly tablesFoundExisting = new Set(); /** * Factory function for lazy (re)connection. @@ -2084,9 +2094,11 @@ export class RemoteTransport { args: [tableName], }); const exists = checkResult.rows.length > 0; + if (exists) this.noteFoundExisting(tableName); if (!exists) { await this.client!.execute(this.buildCreateTableSQL(tableName, objectDef)); + this.tablesCreatedHere.add(tableName); // [#8413 · #17609] The table was created empty microseconds ago, so its // declared indexes cannot fail on existing data — no isolation needed, // and any error here is a real DDL fault that should surface. @@ -2253,6 +2265,9 @@ export class RemoteTransport { if (ddlStatements.length > 0) { await this.client!.batch(ddlStatements, 'write'); } + // The batch is one transaction: its CREATE TABLEs landed together, or none did. + for (const { object } of newSchemas) this.tablesCreatedHere.add(object); + for (const { object } of existingSchemas) this.noteFoundExisting(object); // Phase 4 [#8413 · #17609]: retrofit the existing tables' missing declared // indexes, outside the batch above for the blast-radius reason stated at @@ -2266,6 +2281,21 @@ export class RemoteTransport { await this.client!.execute(`DROP TABLE IF EXISTS "${object}"`); } + /** + * Tables the schema doors created vs found since construction — the remote + * face's `IDataDriver.getSchemaSyncStats` (`TursoDriver` answers with it). + * `existing === 0 && created > 0` is the observation that the remote + * database was empty when this process reached it. + */ + schemaSyncStats(): { created: number; existing: number } { + return { created: this.tablesCreatedHere.size, existing: this.tablesFoundExisting.size }; + } + + /** A table found present, unless this transport created it itself (a re-sync is not a find). */ + private noteFoundExisting(table: string): void { + if (!this.tablesCreatedHere.has(table)) this.tablesFoundExisting.add(table); + } + // =================================== // Internal Helpers // =================================== diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index 1a3a5461af6..fb56e0d7998 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -1078,9 +1078,11 @@ export const REMOTE_FACE_ANSWERS = { deferredSchemaObjectCount: 'inherited', previewDeferredSchemaWork: 'inherited', flushDeferredSchemaDdl: 'inherited', - // `{ created: 0, existing: 0 }`: the remote doors count nothing, and both - // counts at zero is the `IDataDriver` contract's "cannot say". - getSchemaSyncStats: 'inherited', + // The remote schema doors count what their `sqlite_master` probe answers — + // created vs found — so a fresh remote database is observed as one (the + // organization-ownership birth marker reads it). A remote boot that ran no + // schema door still answers `{0, 0}`, the contract's "cannot say". + getSchemaSyncStats: 'remote', // [#23816] Drift and migration: the shared differ over the remote catalog, // and the index / column-drop entries applied through the libSQL client (see // "Remote schema drift" above for which entries, and why only those). @@ -4199,6 +4201,16 @@ export class TursoDriver extends SqlDriver { * statement and no round-trip. The parameter repeats the base's declared * shape key for key (`check:object-def-param-keys` arm C). */ + /** + * `IDataDriver.getSchemaSyncStats` — on the REMOTE face, what the transport's + * schema doors counted (`RemoteTransport.schemaSyncStats`); the local and + * embedded-replica faces answer with the inherited Knex count. + */ + override getSchemaSyncStats(): { created: number; existing: number } { + if (this.isRemote) return this.remoteTransport!.schemaSyncStats(); + return super.getSchemaSyncStats(); + } + override registerObjectMetadata( objects: Array<{ name: string; fields?: Record; tenancy?: any; indexes?: any[] }>, ): void { diff --git a/packages/drivers/driver-turso/src/turso-remote-inherited-members.test.ts b/packages/drivers/driver-turso/src/turso-remote-inherited-members.test.ts index aab6f9cac2e..061bf6e7625 100644 --- a/packages/drivers/driver-turso/src/turso-remote-inherited-members.test.ts +++ b/packages/drivers/driver-turso/src/turso-remote-inherited-members.test.ts @@ -402,6 +402,27 @@ describe('members whose remote answer is a declaration', () => { }); }); +describe('getSchemaSyncStats on the remote face: what its schema doors counted', () => { + it('a fresh remote database is observed as created from empty; the same database reached again is found; the local control counts too', async () => { + const file = nextFile(); + expect((await seeded('remote', file)).getSchemaSyncStats()).toEqual({ created: 2, existing: 0 }); + // A second process over the same remote database: every table is found. + const again = new TursoDriver({ url: 'libsql://probe.example.turso.io', client: createClient({ url: `file:${file}` }) }); + open.push({ driver: again, client: null }); + await again.connect(); + await again.initObjects([PROBE, TENANT]); + expect(again.getSchemaSyncStats()).toEqual({ created: 0, existing: 2 }); + // A re-sync of the tables this process created is not a find. + await again.syncSchemasBatch([{ object: 'probe_t', schema: PROBE }]); + expect(again.getSchemaSyncStats()).toEqual({ created: 0, existing: 2 }); + expect((await seeded('local')).getSchemaSyncStats()).toEqual({ created: 2, existing: 0 }); + }); + + it("a remote boot that ran no schema door says nothing: both counts at zero, the contract's \"cannot say\"", () => { + expect(bareRemote().getSchemaSyncStats()).toEqual({ created: 0, existing: 0 }); + }); +}); + describe('members inherited unchanged, and true on the remote face', () => { it('the deferred-DDL readers report nothing deferred, and arming stays refused', async () => { const remote = await seeded('remote'); @@ -411,10 +432,6 @@ describe('members inherited unchanged, and true on the remote face', () => { await expect(remote.flushDeferredSchemaDdl()).resolves.toEqual([]); }); - it("getSchemaSyncStats: both counts at zero, the contract's \"cannot say\"; the local control counts", async () => { - expect((await seeded('remote')).getSchemaSyncStats()).toEqual({ created: 0, existing: 0 }); - expect((await seeded('local')).getSchemaSyncStats()).toEqual({ created: 2, existing: 0 }); - }); it('the bookkeeping and dialect members answer as on the local face', async () => { const remote = await seeded('remote'); diff --git a/packages/runtime/package.json b/packages/runtime/package.json index d0a6147c567..a42edced741 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -61,6 +61,7 @@ "@objectstack/driver-mongodb": "workspace:*" }, "devDependencies": { + "@libsql/client": "^0.18.0", "@objectstack/platform-objects": "workspace:*", "@objectstack/plugin-hono-server": "workspace:*", "@objectstack/plugin-sharing": "workspace:*", diff --git a/packages/runtime/src/organization-ownership-boot-guard.turso-remote.kernel.test.ts b/packages/runtime/src/organization-ownership-boot-guard.turso-remote.kernel.test.ts new file mode 100644 index 00000000000..95ae8bde112 --- /dev/null +++ b/packages/runtime/src/organization-ownership-boot-guard.turso-remote.kernel.test.ts @@ -0,0 +1,118 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D10 item 5; ruling record 6269955919, ① A] The boot guard on the + * Turso REMOTE face (`libsql://`), on a real kernel: the birth marker needs the + * ledger's store to read as created from empty, and the remote face answers + * that from what its own schema doors counted + * (`RemoteTransport.schemaSyncStats`) — so + * + * 1. ⭐ R1: a fresh remote database boots, the guard writing its birth marker, + * and the next boot over it boots on that marker; + * 2. ⭐ a remote database an earlier boot created without the guard (an 18.0 + * install's: tables present, no marker) is refused, naming the ceremony. + * + * The remote transport talks to a SQLite file through `@libsql/client`, the + * double the driver's own remote-face pins use (`turso-remote-*.test.ts`). + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { afterEach, describe, expect, it } from 'vitest'; +import { createClient, type Client } from '@libsql/client'; +import { ObjectKernel } from '@objectstack/core'; +import { TursoDriver } from '@objectstack/driver-turso'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; +import { ORGANIZATION_OWNERSHIP_SHAPE_GENERATION } from '@objectstack/spec/system'; +import { DriverPlugin } from './driver-plugin.js'; +import { + isOrganizationOwnershipBootRefusal, + OrganizationOwnershipBootGuardPlugin, + ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE, +} from './organization-ownership-boot-guard.js'; + +const MARKER = 'adr-0131-organization-ownership'; +const dirs: string[] = []; +const kernels: ObjectKernel[] = []; +const clients: Client[] = []; + +function databaseFile(): string { + const dir = mkdtempSync(join(tmpdir(), 'os-ownership-guard-remote-')); + dirs.push(dir); + return join(dir, 'remote.db'); +} + +function refusalIn(error: unknown): { code?: string; message?: string } | undefined { + for (let e = error, depth = 0; !!e && typeof e === 'object' && depth < 8; e = (e as { cause?: unknown }).cause, depth++) { + if (isOrganizationOwnershipBootRefusal(e)) return e as { code?: string; message?: string }; + } + return undefined; +} + +/** `os serve`'s data spine over a REMOTE Turso database (the transport's file double), the guard composed. */ +async function boot(file: string): Promise<{ kernel: ObjectKernel; error: unknown }> { + const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false }); + kernels.push(kernel); + const client = createClient({ url: `file:${file}` }); + clients.push(client); + await kernel.use(new DriverPlugin(new TursoDriver({ url: 'libsql://ownership-guard.example.turso.io', client }))); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new OrganizationOwnershipBootGuardPlugin()); + await kernel.use(new PlatformObjectsPlugin() as never); + try { + await kernel.bootstrap(); + return { kernel, error: null }; + } catch (error) { + return { kernel, error }; + } +} + +const markerRows = (file: string): Array<{ verified_at: string | null; details: string }> => { + const db = new DatabaseSync(file, { readOnly: true }); + try { + return db.prepare('SELECT verified_at, details FROM sys_migration WHERE id = ?').all(MARKER) as never; + } finally { + db.close(); + } +}; + +afterEach(async () => { + for (const kernel of kernels.splice(0)) { + if (kernel.getState() === 'running') await kernel.shutdown(); + } + for (const client of clients.splice(0)) client.close(); + for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe('[ADR-0131 D10 item 5] the boot guard on the Turso remote face', () => { + it('⭐ R1: a fresh remote database boots, the guard writing its birth marker; the next boot reads it and boots', async () => { + const file = databaseFile(); + const first = await boot(file); + expect(first.error, String((first.error as Error | null)?.message)).toBeNull(); + expect(first.kernel.getState()).toBe('running'); + const [row] = markerRows(file); + expect(row?.verified_at).toBeTruthy(); + expect(JSON.parse(String(row!.details)).birth).toMatchObject({ shapeGeneration: ORGANIZATION_OWNERSHIP_SHAPE_GENERATION }); + await first.kernel.shutdown(); + + const second = await boot(file); + expect(second.error, String((second.error as Error | null)?.message)).toBeNull(); + expect(markerRows(file)).toHaveLength(1); + }, 60_000); + + it('⭐ a remote database an earlier boot created without the guard (an 18.0 install: no marker) is refused, naming the ceremony', async () => { + const file = databaseFile(); + const db = new DatabaseSync(file); + db.exec('CREATE TABLE sys_migration (id TEXT PRIMARY KEY, last_run_at TEXT, verified_at TEXT, applied_at TEXT, blocking INTEGER)'); + db.close(); + const { kernel, error } = await boot(file); + const refusal = refusalIn(error); + expect(refusal?.code, String(error)).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); + expect(refusal?.message).toMatch(/^This database carries no organization-ownership marker/); + expect(kernel.getState()).not.toBe('running'); + expect(markerRows(file)).toEqual([]); + }, 60_000); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5edd43cbf57..80bad6a4bf8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -405,7 +405,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.1.1(@noble/hashes@2.4.0))(msw@2.14.6(@types/node@26.6.3)(typescript@6.0.3))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.1.1(@noble/hashes@2.4.0))(msw@2.14.6(@types/node@26.6.3)(typescript@6.0.3))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1)) packages/apps/setup: dependencies: @@ -427,7 +427,7 @@ importers: version: 6.0.3 vitest: specifier: ^4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.1.1(@noble/hashes@2.4.0))(msw@2.14.6(@types/node@26.6.3)(typescript@6.0.3))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.2)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@26.6.3)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.2)(jsdom@30.1.1(@noble/hashes@2.4.0))(msw@2.14.6(@types/node@26.6.3)(typescript@6.0.3))(vite@8.0.16(@types/node@26.6.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.23.15(patch_hash=4d1d35da1809be2a945519cb26890e48107810b9b66c6913a6a89abb5ad6a1e6))(yaml@2.9.1)) packages/apps/studio: dependencies: @@ -2409,6 +2409,9 @@ importers: specifier: ^4.6.5 version: 4.6.5 devDependencies: + '@libsql/client': + specifier: ^0.18.0 + version: 0.18.0 '@objectstack/platform-objects': specifier: workspace:* version: link:../platform-objects From 33b794350c03bb2c0ac19e3b1dc7acf6194288f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:40:41 +0000 Subject: [PATCH 30/44] test(runtime): the remote-face guard pin reads driver-turso's source Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V Co-authored-by: Claude --- packages/runtime/vitest.config.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/runtime/vitest.config.ts b/packages/runtime/vitest.config.ts index 85e81630e13..85e39ce7a1d 100644 --- a/packages/runtime/vitest.config.ts +++ b/packages/runtime/vitest.config.ts @@ -186,6 +186,10 @@ export default defineConfig({ replacement: path.join(path.resolve(__dirname, '..'), 'spec/src/$1/index.ts'), }, { find: /^@objectstack\/spec$/, replacement: path.resolve(__dirname, '../spec/src/index.ts') }, + // [ADR-0131 D10] `organization-ownership-boot-guard.turso-remote.kernel.test.ts` + // boots the guard over the Turso REMOTE face: the face's schema-sync count + // is the pin's subject, so the driver is the checkout's source. + { find: /^@objectstack\/driver-turso$/, replacement: path.resolve(__dirname, '../drivers/driver-turso/src/index.ts') }, // Subpath BEFORE the bare package, same prefix-match reason: `./node` is a // published subpath served by a FILE (`types/src/node.ts` — the node-only slice // the root export deliberately excludes), so the bare entry would resolve it to From d28f4cff56ee14c06afa52c7753e2f30537ccff9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:41:46 +0000 Subject: [PATCH 31/44] chore(changeset): the remote face's schema-sync count Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V Co-authored-by: Claude --- .changeset/24581-organization-ownership-ceremony-open.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/24581-organization-ownership-ceremony-open.md b/.changeset/24581-organization-ownership-ceremony-open.md index e0b48ac7556..35be01b64db 100644 --- a/.changeset/24581-organization-ownership-ceremony-open.md +++ b/.changeset/24581-organization-ownership-ceremony-open.md @@ -3,6 +3,7 @@ '@objectstack/runtime': minor '@objectstack/spec': minor '@objectstack/driver-sql': minor +'@objectstack/driver-turso': minor '@objectstack/plugin-webhooks': minor '@objectstack/platform-objects': minor '@objectstack/service-automation': minor @@ -22,7 +23,7 @@ Clause-②: yes (narrowing) - `declared-webhooks`: each `sys_webhook` copy a package's seeder wrote under a name a package still declares is settled. A pristine copy is deleted once its deliveries name the declaration (`declared:NAME`), with the cipher it referenced. A copy customized only in its switch becomes the declaration's activation row. Any other edit becomes the administrator's own webhook under a new name (`NAME_custom`), and the declaration is switched off. - `declared-rule-copies`: each stored sharing rule under a declared rule's name becomes a clone under a new name, its grants kept, and the declaration is switched off. - `not-null`: after the column drops, every table the post-step plan clears takes `organization_id` NOT NULL. The ceremony owns that nullability, so the schema differ no longer reports it as `relax_not_null` drift. -- **The boot guard decides on a marker.** The first boot that creates a datastore from empty writes a birth marker (`sys_migration` row `adr-0131-organization-ownership`, `details.birth.shapeGeneration`). Every CLI data boot writes it too, so a fresh install whose first command is `os migrate apply` or `os seed` still boots. The ceremony's verified marker carries the same `shapeGeneration` (now `1`, `ORGANIZATION_OWNERSHIP_SHAPE_GENERATION` in `@objectstack/spec/system`). A server refuses a database with no marker (every 18.0 database), one certified at an older generation, or an uncertified one. The refusal names the command and no longer says to stay on 17.x. The v17-shape probe also reads `tenancy: { enabled: false }` and `systemFields: false`. +- **The boot guard decides on a marker.** The first boot that creates a datastore from empty writes a birth marker (`sys_migration` row `adr-0131-organization-ownership`, `details.birth.shapeGeneration`). Every CLI data boot writes it too, so a fresh install whose first command is `os migrate apply` or `os seed` still boots. On the Turso remote face (`libsql://`), `getSchemaSyncStats` now answers what its schema doors created and found (it answered `{ created: 0, existing: 0 }` before), so a fresh remote database is observed as one and gets its marker. The ceremony's verified marker carries the same `shapeGeneration` (now `1`, `ORGANIZATION_OWNERSHIP_SHAPE_GENERATION` in `@objectstack/spec/system`). A server refuses a database with no marker (every 18.0 database), one certified at an older generation, or an uncertified one. The refusal names the command and no longer says to stay on 17.x. The v17-shape probe also reads `tenancy: { enabled: false }` and `systemFields: false`. - **`sys_secret` and `sys_flow_credential` are deployment-level** (`systemFields: { tenant: false }`). Their `organization_id` is dropped by the ceremony's column-drops step. The settings service no longer writes one, and `SettingsSecretStore.insert` no longer takes it. **What an operator does.** Before serving an 18.0 or v17 database with this release: take a backup, run `os migrate organization-ownership --out plan.json`, review the plan, then run `os migrate organization-ownership --apply --plan-file plan.json --backup-taken`, and start the server. `os dev` does this for a local SQLite development database. On a walled database, a customized copy of a declared webhook or sharing rule blocks its step, naming the row: rename it to a name no package declares, or delete it, then re-run. From edb2db1e82b8cf7a7c7351779311caba11b358b9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 23:59:54 +0000 Subject: [PATCH 32/44] test(dogfood): the upgrade ceremony over the 18.0 fixture is a shared helper (upgrade-harness/ceremony.ts) Every suite that boots the 18.0 fixture under the ownership boot guard needs it: the guard refuses an 18.0 database until the ceremony has certified it. Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V Co-authored-by: Claude --- .../test/upgrade-18x-harness.dogfood.test.ts | 28 ++-------- .../dogfood/test/upgrade-harness/ceremony.ts | 55 +++++++++++++++++++ 2 files changed, 60 insertions(+), 23 deletions(-) create mode 100644 packages/qa/dogfood/test/upgrade-harness/ceremony.ts diff --git a/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts b/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts index 7b1a66a1042..0fcbb47c972 100644 --- a/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts +++ b/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts @@ -44,12 +44,9 @@ // ⛔ Additive change passes: a row HEAD's boot ADDS (an activity shard, a // seeder's row) is not a failure, so counts are compared as "at least", and // every fixture row is looked up by id. -import { spawnSync } from 'node:child_process'; import { copyFileSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; -import { createRequire } from 'node:module'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { join } from 'node:path'; import { DatabaseSync } from 'node:sqlite'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { isOrganizationOwnershipBootRefusal } from '@objectstack/runtime'; @@ -58,12 +55,10 @@ import type { VerifyStack } from '@objectstack/verify'; import { columnsOf, type FixtureManifest } from './upgrade-harness/fixture-manifest.js'; import { FIXTURE_NAMES, FIXTURE_PEOPLE, bootFixture } from './upgrade-harness/populate-fixture.js'; import { loadSqlIntoSqlite } from './upgrade-harness/sqlite-text-dump.js'; +import { OWNERSHIP_MARKER, runOwnershipCeremony } from './upgrade-harness/ceremony.js'; const FIXTURE = new URL('./upgrade-harness/fixture-18.0/', import.meta.url); -const SHOWCASE_DIR = fileURLToPath(new URL('../../../../examples/app-showcase/', import.meta.url)); -const CLI_ROOT = dirname(createRequire(import.meta.url).resolve('@objectstack/cli/package.json')); -const CLI_BIN = join(CLI_ROOT, 'bin', 'run.js'); -const MARKER = 'adr-0131-organization-ownership'; +const MARKER = OWNERSHIP_MARKER; const manifest = JSON.parse(readFileSync(new URL('manifest.json', FIXTURE), 'utf8')) as FixtureManifest; const fixtureSql = readFileSync(new URL('database.sql', FIXTURE), 'utf8'); @@ -84,22 +79,9 @@ async function tryBoot(file: string): Promise<{ stack?: VerifyStack; error?: unk } } -/** - * The ceremony on `file`, as an operator runs it: the plan, then the apply of - * that reviewed plan file — the real binary, in the showcase's directory, so it - * composes what the deployment serves. The apply report. - */ +/** The ceremony on `file`, as an operator runs it (`runOwnershipCeremony`). */ function ceremony(dir: string, file: string, tag: string): Record { - const run = (args: string[]) => spawnSync(process.execPath, [ - CLI_BIN, 'migrate', 'organization-ownership', '--database-url', `file:${file}`, ...args, - ], { cwd: SHOWCASE_DIR, encoding: 'utf8', timeout: 300_000, env: { ...process.env, NO_COLOR: '1' } }); - const planFile = join(dir, `${tag}-plan.json`); - const plan = run(['--out', planFile]); - expect(plan.status, `the plan: ${plan.stdout}\n${plan.stderr}`).toBe(0); - const reportFile = join(dir, `${tag}-apply.json`); - const apply = run(['--apply', '--plan-file', planFile, '--backup-taken', '--out', reportFile, '--json']); - expect(apply.status, `the apply: ${apply.stdout}\n${apply.stderr}`).toBe(0); - return JSON.parse(readFileSync(reportFile, 'utf8')) as Record; + return runOwnershipCeremony(dir, file, tag); } /** The ceremony's marker on `file`: verified, blocking 0, at this release's generation. */ diff --git a/packages/qa/dogfood/test/upgrade-harness/ceremony.ts b/packages/qa/dogfood/test/upgrade-harness/ceremony.ts new file mode 100644 index 00000000000..da7c6a63841 --- /dev/null +++ b/packages/qa/dogfood/test/upgrade-harness/ceremony.ts @@ -0,0 +1,55 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [18.x step 6] The upgrade ceremony over a copy of the 18.0 fixture, as an +// operator runs it — the plan, then the apply of that reviewed plan file, +// through the real `os migrate organization-ownership` binary in the +// showcase's directory, so it composes what the deployment serves. Shared by +// the upgrade harness and every suite that boots the 18.0 fixture under the +// ownership boot guard: the guard refuses an 18.0 database until the ceremony +// has certified it. +import { spawnSync } from 'node:child_process'; +import { copyFileSync, readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { loadSqlIntoSqlite } from './sqlite-text-dump.js'; + +const SHOWCASE_DIR = fileURLToPath(new URL('../../../../../examples/app-showcase/', import.meta.url)); +const CLI_ROOT = dirname(createRequire(import.meta.url).resolve('@objectstack/cli/package.json')); +const CLI_BIN = join(CLI_ROOT, 'bin', 'run.js'); + +/** The ceremony's sys_migration row: its marker. */ +export const OWNERSHIP_MARKER = 'adr-0131-organization-ownership'; + +/** + * Run the ceremony on `file`: the plan into `dir/-plan.json`, then + * `--apply --plan-file … --backup-taken`. The apply report. Throws, with both + * runs' output, when either exits non-zero. + */ +export function runOwnershipCeremony(dir: string, file: string, tag: string): Record { + const run = (args: string[]) => spawnSync(process.execPath, [ + CLI_BIN, 'migrate', 'organization-ownership', '--database-url', `file:${file}`, ...args, + ], { cwd: SHOWCASE_DIR, encoding: 'utf8', timeout: 300_000, env: { ...process.env, NO_COLOR: '1' } }); + const planFile = join(dir, `${tag}-plan.json`); + const plan = run(['--out', planFile]); + if (plan.status !== 0) throw new Error(`the ceremony's plan exited ${plan.status}:\n${plan.stdout}\n${plan.stderr}`); + const reportFile = join(dir, `${tag}-apply.json`); + const apply = run(['--apply', '--plan-file', planFile, '--backup-taken', '--out', reportFile, '--json']); + if (apply.status !== 0) throw new Error(`the ceremony's apply exited ${apply.status}:\n${apply.stdout}\n${apply.stderr}`); + return JSON.parse(readFileSync(reportFile, 'utf8')) as Record; +} + +/** + * A copy of the 18.0 fixture the ceremony has certified, at `dir/.db`, + * ready to boot under the guard. The ceremony runs on a working copy and the + * certified bytes are copied out, so no boot ever opens the path the + * ceremony's process held (the stale-image hazard the harness measured). + */ +export function ceremoniedFixture(dir: string, fixtureSql: string, tag: string): { file: string; report: Record } { + const work = join(dir, `${tag}.ceremony.db`); + loadSqlIntoSqlite(fixtureSql, work); + const report = runOwnershipCeremony(dir, work, tag); + const file = join(dir, `${tag}.db`); + copyFileSync(work, file); + return { file, report }; +} From b5010d9c038844cf36419ce9fee80433cdfa0a6d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 05:11:36 +0000 Subject: [PATCH 33/44] test(dogfood): ceremoniedFixture boots the 18.0 bytes once, refused, before the ceremony The ceremony's sibling-moves step asks for the sibling logs this release added (sys_identity_email, sys_platform_setting_audit) rather than creating them, so the operator's sequence is a refused boot of this release first, then the plan and its apply. The shared helper now takes that boot and throws unless the ownership guard refused it; #24688's principal x door matrix uses it (25 cases, green). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V --- .../identity-mail-read-gate.dogfood.test.ts | 2 +- .../dogfood/test/upgrade-harness/ceremony.ts | 27 ++++++++++++++++--- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/qa/dogfood/test/identity-mail-read-gate.dogfood.test.ts b/packages/qa/dogfood/test/identity-mail-read-gate.dogfood.test.ts index 9db7dc91087..6d4e5819ab3 100644 --- a/packages/qa/dogfood/test/identity-mail-read-gate.dogfood.test.ts +++ b/packages/qa/dogfood/test/identity-mail-read-gate.dogfood.test.ts @@ -115,7 +115,7 @@ describe('[#24625] identity-mail bodies over a ceremonied 18.0 database: princip }; beforeAll(async () => { - const { file } = ceremoniedFixture(dir, fixtureSql, 'identity-mail-gate'); + const { file } = await ceremoniedFixture(dir, fixtureSql, 'identity-mail-gate'); stack = await bootFixture(file); ql = await stack.kernel.getServiceAsync('objectql'); diff --git a/packages/qa/dogfood/test/upgrade-harness/ceremony.ts b/packages/qa/dogfood/test/upgrade-harness/ceremony.ts index da7c6a63841..d17bdae6f46 100644 --- a/packages/qa/dogfood/test/upgrade-harness/ceremony.ts +++ b/packages/qa/dogfood/test/upgrade-harness/ceremony.ts @@ -12,6 +12,8 @@ import { copyFileSync, readFileSync } from 'node:fs'; import { createRequire } from 'node:module'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { isOrganizationOwnershipBootRefusal } from '@objectstack/runtime'; +import { bootFixture } from './populate-fixture.js'; import { loadSqlIntoSqlite } from './sqlite-text-dump.js'; const SHOWCASE_DIR = fileURLToPath(new URL('../../../../../examples/app-showcase/', import.meta.url)); @@ -41,13 +43,30 @@ export function runOwnershipCeremony(dir: string, file: string, tag: string): Re /** * A copy of the 18.0 fixture the ceremony has certified, at `dir/.db`, - * ready to boot under the guard. The ceremony runs on a working copy and the - * certified bytes are copied out, so no boot ever opens the path the - * ceremony's process held (the stale-image hazard the harness measured). + * ready to boot under the guard — reached the way an operator reaches it. + * First a boot of this release over the 18.0 bytes: the guard refuses it, and + * its schema sync has already created the tables this release added (the + * sibling logs the ceremony's `sibling-moves` step moves rows into), which the + * ceremony asks for rather than creates. Then the plan and its apply. The + * ceremony runs on a working copy and the certified bytes are copied out, so + * no boot ever opens the path the ceremony's process held (the stale-image + * hazard the harness measured). Throws when the first boot is not refused by + * the ownership guard. */ -export function ceremoniedFixture(dir: string, fixtureSql: string, tag: string): { file: string; report: Record } { +export async function ceremoniedFixture(dir: string, fixtureSql: string, tag: string): Promise<{ file: string; report: Record }> { const work = join(dir, `${tag}.ceremony.db`); loadSqlIntoSqlite(fixtureSql, work); + let refusal: unknown; + try { + const stack = await bootFixture(work); + await stack.stop(); + } catch (error) { + for (let e = error, depth = 0; !!e && typeof e === 'object' && depth < 8; e = (e as { cause?: unknown }).cause, depth++) { + if (isOrganizationOwnershipBootRefusal(e)) refusal = e; + } + if (!refusal) throw error; + } + if (!refusal) throw new Error('the 18.0 fixture booted without the ceremony: the ownership guard did not refuse it'); const report = runOwnershipCeremony(dir, work, tag); const file = join(dir, `${tag}.db`); copyFileSync(work, file); From 0f83a8d6d437534d28bc2b7d27617acb7fab8ec0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 06:02:10 +0000 Subject: [PATCH 34/44] fix(text): no shipped text says the ownership ceremony is closed; an unmarked database is one 17.x or an 18.0 pre-release created Text, pins and changesets only. - The refusals #24710, #24709 and #24652 re-worded to "18.0 supports fresh installs only" name the ceremony again: LegacyOrganizationRowsUnreadable (metadata-core), the seed loader's unowned-holder refusal (metadata-protocol, now with the reviewed plan's --plan-file), the platform-admin WITHHELD warning and the unnamed-grant report line (plugin-security). Their pins assert the remedy and the absence of the closed-command sentence. - The guard refusal, cli.mdx, tenancy-modes.mdx, webhooks.mdx, the migration.zod.ts generation note, the cli.json item and this PR's changeset no longer say "every 18.0 database": a database with no birth marker is one 17.x or an 18.0 pre-release (18.0.0-next.*) created; 18.0.0 writes the marker itself. - Pending changesets that announce a refusal no longer shipped are retired: 24651, 24710, 24709, 24652, 24637 and 15214 (whose walled bullets step 4 superseded). 24567 keeps only its surviving --json detail change. - New gate check:closed-ceremony-text (lint.yml): no package source, docs page or pending changeset says "fresh installs only", "keep ... on a 17.x release", "the 18.x release that carries the upgrade path" or "the upgrade path arrives in a later 18.x release". Tests are out of its population. Ablation: #24710's sentence put back reds it (3 findings). Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V --- .../15214-c11-fresh-install-messages.md | 14 -- .changeset/24567-drop-gate-refusal-18-0.md | 8 +- ...81-organization-ownership-ceremony-open.md | 10 +- ...spec-upgrade-entries-fresh-install-only.md | 15 -- .../24651-boot-refusal-fresh-installs-only.md | 18 -- .changeset/24652-report-v18-upgrade-text.md | 11 - .../24709-platform-admin-withheld-18-text.md | 11 - ...ership-refusal-text-fresh-installs-only.md | 14 -- .github/workflows/lint.yml | 12 ++ content/docs/automation/webhooks.mdx | 2 +- content/docs/deployment/cli.mdx | 17 +- content/docs/deployment/tenancy-modes.mdx | 8 +- docs/qa/platform-checklist/areas/cli.json | 4 +- package.json | 1 + .../src/legacy-organization-row.test.ts | 14 +- .../src/legacy-organization-row.ts | 5 +- ...ed-loader-null-organization-holder.test.ts | 10 +- packages/metadata-protocol/src/seed-loader.ts | 8 +- .../src/bootstrap-platform-admin.ts | 4 +- .../src/catalog-reference-report.test.ts | 33 ++- .../src/catalog-reference-report.ts | 8 +- .../src/grant-readers-unnamed-grant.test.ts | 21 +- .../test/upgrade-18x-harness.dogfood.test.ts | 2 +- ...zation-ownership-boot-guard.kernel.test.ts | 4 +- .../organization-ownership-boot-guard.test.ts | 13 +- .../src/organization-ownership-boot-guard.ts | 16 +- ...hip-boot-guard.turso-remote.kernel.test.ts | 4 +- ...ion-holder-real-driver.integration.test.ts | 7 +- packages/spec/src/system/migration.zod.ts | 2 +- scripts/check-closed-ceremony-text.mjs | 197 ++++++++++++++++++ 30 files changed, 307 insertions(+), 186 deletions(-) delete mode 100644 .changeset/15214-c11-fresh-install-messages.md delete mode 100644 .changeset/24637-spec-upgrade-entries-fresh-install-only.md delete mode 100644 .changeset/24651-boot-refusal-fresh-installs-only.md delete mode 100644 .changeset/24652-report-v18-upgrade-text.md delete mode 100644 .changeset/24709-platform-admin-withheld-18-text.md delete mode 100644 .changeset/24710-ownership-refusal-text-fresh-installs-only.md create mode 100644 scripts/check-closed-ceremony-text.mjs diff --git a/.changeset/15214-c11-fresh-install-messages.md b/.changeset/15214-c11-fresh-install-messages.md deleted file mode 100644 index d3e3a6b38ee..00000000000 --- a/.changeset/15214-c11-fresh-install-messages.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/cli': patch -'@objectstack/runtime': patch ---- - -fix(cli, runtime): the 18.0 operator messages say that 18.0 supports fresh `single` installs only - -Clause-②: no - -- **The v18 boot refusal on a v17-shaped database** now says first that 18.0 does not upgrade a v17 database: keep it on a 17.x release until the 18.x release that carries the upgrade path. Before, it named only `os migrate organization-ownership`, which refuses in 18.0 as well, so the operator reached the answer in two hops. -- **The fix list for an unrecognized `OS_TENANCY_POSTURE`** (printed by `os serve` and `os doctor`) still lists `group` and `isolated`, and now says beside each that 18.0 refuses it at boot. -- **`os doctor` reports a walled posture.** Under `group` or `isolated` it prints a `Tenancy posture` warning: 18.0 supports `single` only, so the next `os serve` (and so `os dev` and `os start`) refuses the posture at boot. The row is a warning, so doctor's exit code does not change. Before, doctor printed nothing about a valid posture. - -Nothing else changes. The refusals themselves, their codes and their exit codes are as before. diff --git a/.changeset/24567-drop-gate-refusal-18-0.md b/.changeset/24567-drop-gate-refusal-18-0.md index e28c7313491..6da5a00994c 100644 --- a/.changeset/24567-drop-gate-refusal-18-0.md +++ b/.changeset/24567-drop-gate-refusal-18-0.md @@ -2,12 +2,10 @@ '@objectstack/cli': patch --- -fix(cli): `os migrate apply --allow-destructive`'s organization-ownership refusal says what 18.0 supports for a v17 database, and names no step of the command 18.0 refuses +fix(cli): `os migrate apply --allow-destructive`'s organization-ownership refusal states the ceremony's record in its `--json` `detail` Clause-②: no -- **`os migrate apply --allow-destructive` on a v17-shaped database.** When the run would drop `organization_id` from a table the organization-ownership ceremony owns, and the ceremony has not verified on the database, the refusal now says that 18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path, whose ceremony drops these columns itself. The v18 boot refusal and `os migrate organization-ownership`'s own refusal say the same. Before, it told the operator to run `os migrate organization-ownership --out PLAN` and then `--apply --plan-file PLAN`, which 18.0 refuses on every database (`NOT_IMPLEMENTED`). The `--json` refusal's `detail` no longer names that command either: it reads "No run of the organization-ownership ceremony is recorded on this database.", or names the last unverified run with its date and blocking count. -- **`os migrate organization-ownership --help`** shows what the command answers in 18.0: exit `1` with `NOT_IMPLEMENTED`, nothing read or written, and the same refusal as JSON. Before, it showed six plan and apply examples that 18.0 refuses. -- **`os dev` on a v17-shaped development database.** When it does not upgrade the database in place (the ceremony's plan is refused, or the database is not a local SQLite file), it now ends with the same position: keep a v17 development database on a 17.x release until the 18.x release that carries the upgrade path, and upgrade it there. Before, it told the developer to run `os migrate organization-ownership --dev` and then the same command with `--apply`. +- **`os migrate apply --allow-destructive --json` on a database the organization-ownership ceremony has not verified.** When the run would drop `organization_id` from a table the ceremony owns, the refusal's `detail` reads "No run of the organization-ownership ceremony is recorded on this database.", or names the last unverified run with its date and blocking count. The human message names the ceremony's plan and apply, which drop these columns themselves. -Nothing else changes. The refusals still hold back the whole run, and their message code (`refused_organization_ownership_unmigrated`), `tables`, `marker`, exit code `1` and every flag are as before. +Nothing else changes. The refusal still holds back the whole run, and its message code (`refused_organization_ownership_unmigrated`), `tables`, `marker`, exit code `1` and every flag are as before. diff --git a/.changeset/24581-organization-ownership-ceremony-open.md b/.changeset/24581-organization-ownership-ceremony-open.md index 35be01b64db..8bf47e71dcb 100644 --- a/.changeset/24581-organization-ownership-ceremony-open.md +++ b/.changeset/24581-organization-ownership-ceremony-open.md @@ -10,20 +10,20 @@ '@objectstack/service-settings': minor --- -feat(cli,runtime)!: `os migrate organization-ownership` upgrades v17 and 18.0 databases; a server serves only a database it created or one the ceremony certified +feat(cli,runtime)!: `os migrate organization-ownership` upgrades a database 17.x or an 18.0 pre-release created; a server serves only a database it created or one the ceremony certified Clause-②: yes (narrowing) -**BREAKING** for a database an earlier release created, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D10). +**BREAKING** for a database with no birth marker — one 17.x or an 18.0 pre-release (`18.0.0-next.*`) created — shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D10). -- **The ceremony is open.** `os migrate organization-ownership` runs again (18.0 refused it with `NOT_IMPLEMENTED`): a bare run writes the plan, and `--apply --plan-file PLAN --backup-taken` applies that reviewed plan. Its new steps carry what an 18.0 database still owes, each selected by the rows' shape, never by a NULL organization: +- **The ceremony is open.** `os migrate organization-ownership` runs again (the 18.0 pre-releases refused it with `NOT_IMPLEMENTED`): a bare run writes the plan, and `--apply --plan-file PLAN --backup-taken` applies that reviewed plan. Its new steps carry what an 18.0 pre-release database still owes, each selected by the rows' shape, never by a NULL organization: - `sibling-moves`: the global settings-audit rows move to `sys_platform_setting_audit`, and identity mail (reset, verification, account invitation, change-email notice, magic link) to `sys_identity_email`, id for id. Their queued mail jobs and their activity rows follow them. The organization invitation stays in `sys_email`. - `declared-webhooks`: each `sys_webhook` copy a package's seeder wrote under a name a package still declares is settled. A pristine copy is deleted once its deliveries name the declaration (`declared:NAME`), with the cipher it referenced. A copy customized only in its switch becomes the declaration's activation row. Any other edit becomes the administrator's own webhook under a new name (`NAME_custom`), and the declaration is switched off. - `declared-rule-copies`: each stored sharing rule under a declared rule's name becomes a clone under a new name, its grants kept, and the declaration is switched off. - `not-null`: after the column drops, every table the post-step plan clears takes `organization_id` NOT NULL. The ceremony owns that nullability, so the schema differ no longer reports it as `relax_not_null` drift. -- **The boot guard decides on a marker.** The first boot that creates a datastore from empty writes a birth marker (`sys_migration` row `adr-0131-organization-ownership`, `details.birth.shapeGeneration`). Every CLI data boot writes it too, so a fresh install whose first command is `os migrate apply` or `os seed` still boots. On the Turso remote face (`libsql://`), `getSchemaSyncStats` now answers what its schema doors created and found (it answered `{ created: 0, existing: 0 }` before), so a fresh remote database is observed as one and gets its marker. The ceremony's verified marker carries the same `shapeGeneration` (now `1`, `ORGANIZATION_OWNERSHIP_SHAPE_GENERATION` in `@objectstack/spec/system`). A server refuses a database with no marker (every 18.0 database), one certified at an older generation, or an uncertified one. The refusal names the command and no longer says to stay on 17.x. The v17-shape probe also reads `tenancy: { enabled: false }` and `systemFields: false`. +- **The boot guard decides on a marker.** The first boot that creates a datastore from empty writes a birth marker (`sys_migration` row `adr-0131-organization-ownership`, `details.birth.shapeGeneration`). Every CLI data boot writes it too, so a fresh install whose first command is `os migrate apply` or `os seed` still boots. On the Turso remote face (`libsql://`), `getSchemaSyncStats` now answers what its schema doors created and found (it answered `{ created: 0, existing: 0 }` before), so a fresh remote database is observed as one and gets its marker. The ceremony's verified marker carries the same `shapeGeneration` (now `1`, `ORGANIZATION_OWNERSHIP_SHAPE_GENERATION` in `@objectstack/spec/system`). A server refuses a database with no marker (one 17.x or an 18.0 pre-release created), one certified at an older generation, or an uncertified one. The refusal names the command and no longer says to stay on 17.x. The v17-shape probe also reads `tenancy: { enabled: false }` and `systemFields: false`. - **`sys_secret` and `sys_flow_credential` are deployment-level** (`systemFields: { tenant: false }`). Their `organization_id` is dropped by the ceremony's column-drops step. The settings service no longer writes one, and `SettingsSecretStore.insert` no longer takes it. -**What an operator does.** Before serving an 18.0 or v17 database with this release: take a backup, run `os migrate organization-ownership --out plan.json`, review the plan, then run `os migrate organization-ownership --apply --plan-file plan.json --backup-taken`, and start the server. `os dev` does this for a local SQLite development database. On a walled database, a customized copy of a declared webhook or sharing rule blocks its step, naming the row: rename it to a name no package declares, or delete it, then re-run. +**What an operator does.** Before serving a database 17.x or an 18.0 pre-release created with this release: take a backup, run `os migrate organization-ownership --out plan.json`, review the plan, then run `os migrate organization-ownership --apply --plan-file plan.json --backup-taken`, and start the server. `os dev` does this for a local SQLite development database. On a walled database, a customized copy of a declared webhook or sharing rule blocks its step, naming the row: rename it to a name no package declares, or delete it, then re-run. diff --git a/.changeset/24637-spec-upgrade-entries-fresh-install-only.md b/.changeset/24637-spec-upgrade-entries-fresh-install-only.md deleted file mode 100644 index 6107e9ce91e..00000000000 --- a/.changeset/24637-spec-upgrade-entries-fresh-install-only.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'@objectstack/spec': patch ---- - -fix(spec): the 18.0 upgrade-guide entries that name `os migrate organization-ownership` say first that 18.0 supports fresh installs only - -Clause-②: no - -In 18.0, `os migrate organization-ownership` refuses plan and apply alike on every database (exit 1, `NOT_IMPLEMENTED`, nothing read or written). Seven ADR-0087 semantic entries still told an operator to run `--out PLAN`, then `--apply --plan-file PLAN`, as if 18.0 upgraded a v17 database. Each now says, in the same words as the CLI and the boot refusal: 18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path. The plan and apply steps stay, marked as the 18.x release's ceremony. - -- `sys-metadata-organization-column-retired`, `sys-metadata-audit-organization-column-retired`, `sys-metadata-commit-organization-column-retired`, `sys-metadata-history-organization-column-retired`, `ups-permission-set-id-column-retired`: the replacement and the acceptance criteria. -- `sharing-rule-declared-mirror-retired`: the replacement and the acceptance criteria. -- `cli-organization-ownership-apply-plan-file-required`: the replacement now says that 18.0 refuses this invocation, as it refuses every invocation of the command, and keeps the plan-file rule as the 18.x release's. - -Only the text changes. Every entry keeps its `id`, its surface and its step. diff --git a/.changeset/24651-boot-refusal-fresh-installs-only.md b/.changeset/24651-boot-refusal-fresh-installs-only.md deleted file mode 100644 index a3a38f2f744..00000000000 --- a/.changeset/24651-boot-refusal-fresh-installs-only.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -'@objectstack/runtime': patch ---- - -fix(runtime): the organization-ownership boot refusals say what 18.0 supports for a v17 database and name no step of `os migrate organization-ownership`, which 18.0 refuses on every database - -Clause-②: no (refusal text only: the refusal code `ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED`, the refused tables and the boot outcome do not move) - -In 18.0, `os migrate organization-ownership` refuses plan and apply on every database (`NOT_IMPLEMENTED`): 18.0 supports fresh installs only. Three branches of the v18 boot refusal still told the operator to run that command's plan and its `--apply --plan-file PLAN --backup-taken`, so following them led to a second refusal. - -Each branch now closes on the sentence `os migrate apply`'s drop gate and `os dev` already use: "18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path, and upgrade it there." - -- **A table still in the v17 shape** (an object that declares no organization column, over a table that still carries `organization_id`). This branch already said that 18.0 does not upgrade a v17 database. It no longer follows that with the command's plan and apply steps. -- **No table left in the v17 shape, and the ceremony's marker is not verified.** The refusal no longer prescribes a re-plan and a re-apply, which 18.0 cannot run. -- **No table left in the v17 shape, and the marker could not be read.** The refusal now says to fix the read and start again: a database no ceremony ran on boots once the read answers. It no longer prescribes a re-plan and a re-apply. -- **The column read itself failed.** The refusal still says to fix the read. It no longer offers the command as the alternative. - -A development boot (`OrganizationOwnershipBootGuardPlugin({ dev: true })`, as `os serve --dev` composes it) says "a v17 development database" in place of "a v17 database". Before, it named the command with `--dev`. diff --git a/.changeset/24652-report-v18-upgrade-text.md b/.changeset/24652-report-v18-upgrade-text.md deleted file mode 100644 index 49839d91ac2..00000000000 --- a/.changeset/24652-report-v18-upgrade-text.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -fix(plugin-security): the boot report's unnamed-grant line says what 18.0 supports for a v17 database, not a command 18.0 refuses - -Clause-②: no - -- **The error line for `sys_user_permission_set` grants whose `permission_set` is empty** used to end with a fix: plan with `os migrate organization-ownership --out plan.json`, then run `os migrate organization-ownership --apply --backup-taken --plan-file plan.json`, restart, with a warning that granting the set again by name before it runs leaves a second grant for the ceremony to report. 18.0 refuses that command on every database, so the operator was sent to a refusal. The line now ends with the sentence the drop-gate refusal of `os migrate apply --allow-destructive` carries: "18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path." -- **Unchanged:** the count, the "permission_set is empty" fact, the ADR-0131 D4 reason ("each confers nothing"), "Nothing was changed.", the structured meta, the log level and the report's result. Such a grant still confers nothing and is still listed. -- **Removed from the line:** the two command steps and the restart step, and the advice about granting the set again by name. They described the 18.x upgrade ceremony, which 18.0 does not carry. diff --git a/.changeset/24709-platform-admin-withheld-18-text.md b/.changeset/24709-platform-admin-withheld-18-text.md deleted file mode 100644 index 2dc210351ba..00000000000 --- a/.changeset/24709-platform-admin-withheld-18-text.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'@objectstack/plugin-security': patch ---- - -fix(plugin-security): the platform-admin promotion WITHHELD warning says what 18.0 supports for a v17 database, not a migration 18.0 refuses - -Clause-②: no - -- **The boot warning "[security] platform-admin promotion WITHHELD: an unscoped grant exists whose permission-set name (sys_user_permission_set.permission_set) was never written"** used to end with a way on: "The organization-ownership migration's id-to-name rewrite (ADR-0131 D10) names it; the next pass after that reads it by name." 18.0 refuses that migration on every database, so the operator was sent to a refusal. The warning now ends with the sentence the boot refusal and the drop-gate refusal of `os migrate apply --allow-destructive` carry: "18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path." -- **Unchanged:** the withholding itself (an unscoped grant that names no set is still read as an administrator who may already exist, and no second platform-admin grant is minted), the facts the warning states (which column was never written, that the grant predates it, that the set it holds can no longer be read), the log level and the `info` fallback when the logger has no `warn`, and the returned `admin_grant_unnamed` result. -- **Removed from the warning:** the pointer to the organization-ownership migration's rewrite and the promise that the next pass reads the grant by name afterwards. Both described the 18.x upgrade path, which 18.0 does not carry. diff --git a/.changeset/24710-ownership-refusal-text-fresh-installs-only.md b/.changeset/24710-ownership-refusal-text-fresh-installs-only.md deleted file mode 100644 index a8afad52c74..00000000000 --- a/.changeset/24710-ownership-refusal-text-fresh-installs-only.md +++ /dev/null @@ -1,14 +0,0 @@ ---- -'@objectstack/metadata-core': patch -'@objectstack/metadata-protocol': patch ---- - -fix(metadata-core, metadata-protocol): two refusals say what 18.0 supports for a v17 database, not `os migrate organization-ownership`, which 18.0 refuses on every database - -Clause-②: no (text only: no refusal code, outcome, accept set or served shape moves) - -In 18.0, `os migrate organization-ownership` refuses plan and apply on every database (`NOT_IMPLEMENTED`): 18.0 supports fresh installs only. Two refusals still told the operator to run it, so following them led to a second refusal. - -- **`LEGACY_ORGANIZATION_ROWS_UNREADABLE`** (`LegacyOrganizationRowsUnreadable`, `@objectstack/metadata-core`). It used to close with "Run `os migrate organization-ownership` on this database first." This read runs on every SQL database, a fresh 18.0 one included, and several of its refusals are the driver's (a dialect it cannot name, no raw-SQL seam, a column list that is not a list or comes back empty). For those, the command was never the remedy. The message now closes: "A fresh 18.0 install holds no legacy organization rows: there, the cause is the one named above, and the fix is in the SQL driver or the caller that met it. A database that does hold them is a v17 one. 18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path." -- **The seed refusal for a natural key held by a row with no organization** (`SeedLoaderService`, `@objectstack/metadata-protocol`). It used to close with "Attribute the unowned rows first (`os migrate organization-ownership`, then the same command with --apply), then re-run this seed". Even before 18.0, that bare `--apply` was refused, since the command requires `--plan-file`. The message now closes: "A row with no organization predates organization stamping, so it is a v17 database's. 18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path." -- **Unchanged:** both refusal codes, "Nothing was listed or written.", the seed refusal's opening sentences (what was refused and why), its `errored` count, and the dropped record. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a6ac802cfea..6b7f4c3f059 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1081,6 +1081,18 @@ jobs: continue-on-error: true run: pnpm check:watch-hint-literal + # No shipped refusal, upgrade text or pending changeset says the + # organization-ownership ceremony is closed (#24581). The 18.0 + # pre-releases refused the command and taught a family of surfaces to say + # "fresh installs only / keep it on 17.x"; the ceremony is open, and one + # such sentence left anywhere sends an operator away from the command + # that upgrades their database. Tests are out of the population (they + # spell the sentence to pin its absence). Repo-wide text sweep; ~1s. + - name: No shipped text says the ownership ceremony is closed + id: closed-ceremony-text + continue-on-error: true + run: pnpm check:closed-ceremony-text + # ADR-0087 D4's per-release correctness gate (#17080). The REAL run needs # two published tarballs and npm, so it lives in the release lane and # cannot run on a PR; what runs here is its `--self-test`, which is diff --git a/content/docs/automation/webhooks.mdx b/content/docs/automation/webhooks.mdx index 2b0499a3c0d..5b642798588 100644 --- a/content/docs/automation/webhooks.mdx +++ b/content/docs/automation/webhooks.mdx @@ -100,7 +100,7 @@ A webhook reaches the dispatcher one of two ways: - **Authored by an organization** — a `sys_webhook` row (§3.1), switched by its own `active` field. -On a database an 18.0 install created, the rows that release's boot seeder +On a database an 18.0 pre-release (`18.0.0-next.*`) created, the rows that release's boot seeder wrote for declared webhooks (`managed_by: 'package'`) are still there. Until the ownership migration removes them, a pristine one is ignored in favour of its declaration, so the webhook is never delivered twice; one an administrator diff --git a/content/docs/deployment/cli.mdx b/content/docs/deployment/cli.mdx index a87a99944dc..7509740fbe5 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -196,10 +196,10 @@ os dev --database file:./data/test.db --auth-secret $(openssl rand -hex 32) | `--seed-admin` / `--no-seed-admin` | — | Seed a dev admin (`admin@objectos.ai` / `admin123`) on an empty DB — default on; override with `--admin-email` / `--admin-password` | | `-v, --verbose` | — | Verbose output | -##### A 17.x or 18.0 development database is upgraded in place +##### A 17.x or 18.0 pre-release development database is upgraded in place -A v18 server refuses to start on a database an earlier release created (a v17 -one, or an 18.0 one) until the [ownership ceremony](#os-migrate-organization-ownership) +A v18 server refuses to start on a database with no birth marker, which 17.x +and the 18.0 pre-releases (`18.0.0-next.*`) created, until the [ownership ceremony](#os-migrate-organization-ownership) has run on it. `os dev` runs that ceremony for you when the database is a local SQLite file: @@ -1201,7 +1201,7 @@ where the data lives. | `os migrate value-shapes` | Scan stored reference and structured-JSON field values against the platform's value contract, and record the deployment's migration flag when clean | | `os migrate summary-nulls` | Backfill roll-up `count` / `sum` columns still stored as `NULL` on parent rows created before the insert-time seed. Repairs values; no flag, nothing depends on it having run | | `os migrate comment-reactions` | Carry the emoji reactions a 17.x deployment stored in the retired `sys_comment.reactions` column into `sys_comment_reaction` records, list every stored value that does not read, and record the verified run that lets `os migrate apply --allow-destructive` drop the column | -| `os migrate organization-ownership` | The v18 ownership ceremony (ADR-0131 D10): write a plan of what each table's rows will become, then `--apply` that reviewed plan. It upgrades a v17 or an 18.0 database; a v18 server refuses to start on one until it has run. See [below](#os-migrate-organization-ownership) | +| `os migrate organization-ownership` | The v18 ownership ceremony (ADR-0131 D10): write a plan of what each table's rows will become, then `--apply` that reviewed plan. It upgrades a database 17.x or an 18.0 pre-release (`18.0.0-next.*`) created; a v18 server refuses to start on one until it has run. See [below](#os-migrate-organization-ownership) | | `os migrate activity-changed-fields` | Stamp the two read-rule columns (`changed_fields`, `milestone_key`) on the `sys_activity` rows written before they existed. Until it runs, those rows are withheld from every reader but the system, so run it at once after upgrading. No flag; idempotent | | `os migrate audit-mail-bodies` | Mask the mail bodies (`body_text`, `body_html`) that `sys_audit_log` and `sys_activity` copies of `sys_email` / `sys_identity_email` rows still hold in clear, written before the audit writer masked them. Reports counts only. No flag; idempotent. See [below](#os-migrate-audit-mail-bodies) | | `os migrate comment-parent-pair` | Stamp the parent pair (`object_name`, `record_id`) on the `sys_comment` rows written before it existed. Until it runs, a broad comment read past 2,000 candidate rows omits comments the reader may see, and its total agrees with the short list, so run it at once after upgrading. No flag; idempotent | @@ -1506,8 +1506,8 @@ upgrading rather than wait for that. #### `os migrate organization-ownership` -The v18 ownership ceremony (ADR-0131 D10), and the upgrade path of a v17 or an -18.0 database. A v18 `os serve` / `os start` serves a database this release +The v18 ownership ceremony (ADR-0131 D10), and the upgrade path of a database +17.x or an 18.0 pre-release (`18.0.0-next.*`) created. A v18 `os serve` / `os start` serves a database this release created itself, or one this ceremony certified; it refuses any other until the ceremony has run. Under `os dev` it runs on its own for a local SQLite database (see [`os dev`](#os-dev)). @@ -1516,8 +1516,9 @@ How a database is recognised: the first boot that creates a datastore from empty writes a **birth marker** into `sys_migration` (row `adr-0131-organization-ownership`) naming the ownership shape generation it was born in. The ceremony writes the same row, verified, when its post-check -passes. A server decides on that generation alone: a database with no marker -(every 18.0 database), or one a shorter ceremony certified at an older +passes. A server decides on that generation alone: a database with no birth +marker, which 17.x and the 18.0 pre-releases created, or one a shorter ceremony +certified at an older generation, is refused, and the refusal names this command. ```bash diff --git a/content/docs/deployment/tenancy-modes.mdx b/content/docs/deployment/tenancy-modes.mdx index ffc0d6cdc49..4f17d88f413 100644 --- a/content/docs/deployment/tenancy-modes.mdx +++ b/content/docs/deployment/tenancy-modes.mdx @@ -16,10 +16,12 @@ it generalizes the two-valued *tenancy mode* of whose `tenancy` service, membership lifecycle and degraded-tenancy boot guard all still apply — widened to the three postures. - + Fresh installs are supported under every posture: `single`, `group` and -`isolated`. The upgrade path for an existing database arrives in a later 18.x -release, and a database an 18.0 install created takes the same path as a v17 one. +`isolated`. A database with no birth marker, which 17.x and the 18.0 +pre-releases (`18.0.0-next.*`) created, is refused at boot until +[`os migrate organization-ownership`](/docs/deployment/cli#os-migrate-organization-ownership) +has upgraded it. diff --git a/docs/qa/platform-checklist/areas/cli.json b/docs/qa/platform-checklist/areas/cli.json index 78a5fa46c84..b85f263afc3 100644 --- a/docs/qa/platform-checklist/areas/cli.json +++ b/docs/qa/platform-checklist/areas/cli.json @@ -1871,7 +1871,7 @@ }, { "id": "cli.organization-ownership-ceremony", - "title": "`os migrate organization-ownership` upgrades a v17 or an 18.0 database (18.x): a bare run writes the reviewed plan and changes nothing; `--apply --plan-file PLAN --backup-taken` runs every step — attribution through the moves, the declared webhook and sharing-rule settlements, the column drops and NOT NULL — and certifies the marker at this release's ownership shape generation; the served boot refuses an uncertified database, naming the command, and boots a certified one; `os migrate apply --allow-destructive` still withholds every organization_id drop the ceremony owns, and `os migrate comment-reactions --apply` stays pinned", + "title": "`os migrate organization-ownership` upgrades a database 17.x or an 18.0 pre-release (18.0.0-next.*) created (18.x): a bare run writes the reviewed plan and changes nothing; `--apply --plan-file PLAN --backup-taken` runs every step — attribution through the moves, the declared webhook and sharing-rule settlements, the column drops and NOT NULL — and certifies the marker at this release's ownership shape generation; the served boot refuses an uncertified database, naming the command, and boots a certified one; `os migrate apply --allow-destructive` still withholds every organization_id drop the ceremony owns, and `os migrate comment-reactions --apply` stays pinned", "since": "v18", "status": "active", "revision": 3, @@ -1917,7 +1917,7 @@ "evidence": "the report excerpt and the marker row" }, { - "clause": "the served boot refuses the unmigrated copy with ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED, naming `os migrate organization-ownership` and no 17.x release line, and boots the migrated copy", + "clause": "the served boot refuses the unmigrated copy with ORGANIZATION_OWNERSHIP_MIGRATION_REQUIRED, naming `os migrate organization-ownership` and no 17.x release to stay on, and boots the migrated copy", "oracle": "build", "verify": "the refused boot's error line + the migrated boot's ready line", "evidence": "both lines" diff --git a/package.json b/package.json index 6457158513a..c059066f759 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "check:pm-label-desc-cap": "node scripts/pm/check-label-desc-cap.mjs --self-test && node scripts/pm/check-label-desc-cap.mjs", "check:pm-dispatch-gates": "node scripts/pm/check-dispatch-gates.mjs --self-test && node scripts/pm/check-dispatch-gates.mjs", "check:watch-hint-literal": "node scripts/check-watch-hint-literal.mjs --self-test && node scripts/check-watch-hint-literal.mjs", + "check:closed-ceremony-text": "node scripts/check-closed-ceremony-text.mjs --self-test && node scripts/check-closed-ceremony-text.mjs", "check:declared-population-live": "node scripts/check-declared-population-live.mjs --self-test && node scripts/check-declared-population-live.mjs", "check:pm-half-states": "node scripts/pm/check-half-states.mjs --self-test", "check:issue-citations": "node scripts/check-issue-citations.mjs --self-test", diff --git a/packages/metadata-core/src/legacy-organization-row.test.ts b/packages/metadata-core/src/legacy-organization-row.test.ts index ddff9cf1197..defc878c0b2 100644 --- a/packages/metadata-core/src/legacy-organization-row.test.ts +++ b/packages/metadata-core/src/legacy-organization-row.test.ts @@ -115,26 +115,26 @@ describe('readLegacyOrganizationRows — fails closed', () => { await expect(readLegacyOrganizationRows({ dialectName: 'sqlite' })).rejects.toThrow(/no raw-SQL seam/); }); - // What 18.0 supports, said to both readers: a driver-shaped refusal, which a - // fresh install meets too, and one only a database still carrying the - // column reaches. The ceremony 18.0 refuses is never the remedy. + // Said to both readers: a driver-shaped refusal, which a fresh install meets + // too, and one only a database still carrying the column reaches. The + // remedy for a database that holds them is the ceremony, which is open. it.each([ ['driver-shaped: no raw seam (any database, a fresh one included)', { dialectName: 'sqlite' }], ['database-shaped: the column is on disk, the legacy select answered nothing', sqlDriver({ dialectName: 'sqlite', columns: V17_COLUMNS, rows: null })], - ])('the refusal says what 18.0 supports, never the ceremony (%s)', async (_label, driver) => { + ])('the refusal tells a fresh install apart from a 17.x database, and names the ceremony for the second (%s)', async (_label, driver) => { const error = await readLegacyOrganizationRows(driver).then(() => null, (e: unknown) => e); expect(error).toBeInstanceOf(LegacyOrganizationRowsUnreadable); const refusal = error as LegacyOrganizationRowsUnreadable; expect(refusal.code).toBe('LEGACY_ORGANIZATION_ROWS_UNREADABLE'); expect(refusal.message).toContain('Nothing was listed or written.'); expect(refusal.message).toContain( - 'A fresh 18.0 install holds no legacy organization rows: there, the cause is the one named above, ' + + 'A fresh install holds no legacy organization rows: there, the cause is the one named above, ' + 'and the fix is in the SQL driver or the caller that met it.', ); expect(refusal.message).toMatch( - /A database that does hold them is a v17 one\. 18\.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17\.x release until the 18\.x release that carries the upgrade path\.$/, + /A database that does hold them was created by 17\.x: run `os migrate organization-ownership` on it first\.$/, ); - expect(refusal.message).not.toContain('os migrate organization-ownership'); + expect(refusal.message).not.toMatch(/fresh installs only|on a 17\.x release|carries the upgrade path/); }); it('a table outside the family is refused before anything runs', async () => { diff --git a/packages/metadata-core/src/legacy-organization-row.ts b/packages/metadata-core/src/legacy-organization-row.ts index e19b19399a0..c8179efb9f0 100644 --- a/packages/metadata-core/src/legacy-organization-row.ts +++ b/packages/metadata-core/src/legacy-organization-row.ts @@ -75,10 +75,9 @@ export class LegacyOrganizationRowsUnreadable extends Error { constructor(readonly table: string, detail: string) { super( `Could not tell ${table}'s legacy organization rows from the environment's: ${detail}. ` + - 'Nothing was listed or written. A fresh 18.0 install holds no legacy organization rows: there, the ' + + 'Nothing was listed or written. A fresh install holds no legacy organization rows: there, the ' + 'cause is the one named above, and the fix is in the SQL driver or the caller that met it. A database ' + - 'that does hold them is a v17 one. 18.0 supports fresh installs only and does not upgrade a v17 ' + - 'database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path.', + 'that does hold them was created by 17.x: run `os migrate organization-ownership` on it first.', ); this.name = 'LegacyOrganizationRowsUnreadable'; } diff --git a/packages/metadata-protocol/src/seed-loader-null-organization-holder.test.ts b/packages/metadata-protocol/src/seed-loader-null-organization-holder.test.ts index 0278178435e..54a75980ebd 100644 --- a/packages/metadata-protocol/src/seed-loader-null-organization-holder.test.ts +++ b/packages/metadata-protocol/src/seed-loader-null-organization-holder.test.ts @@ -135,15 +135,15 @@ describe('[#24301] a scoped load over a NULL-organization holder of a natural ke const [refusal] = refusalsOf(result); expect(refusal).toMatchObject({ sourceObject: 'tw_widget', targetField: 'sku', attemptedValue: 'W-HELD', recordIndex: 0 }); expect(refusal.message).toContain("'w_unowned'"); - // What 18.0 supports, never the ceremony it refuses; the first sentences + // The ceremony is the remedy, with its reviewed plan; the first sentences // (what was refused, and why) stay. expect(refusal.message).toContain('which has no organization (organization_id is NULL)'); expect(refusal.message).toContain('decided by the organization-ownership ceremony, not by a seed load.'); - expect(refusal.message).toMatch( - /A row with no organization predates organization stamping, so it is a v17 database's\. 18\.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a 17\.x release until the 18\.x release that carries the upgrade path\.$/, + expect(refusal.message).toContain( + 'Attribute the unowned rows first (`os migrate organization-ownership --out PLAN`, then the same command with ' + + '--apply --plan-file PLAN --backup-taken), then re-run this seed', ); - expect(refusal.message).not.toContain('os migrate organization-ownership'); - expect(refusal.message).not.toContain('--apply'); + expect(refusal.message).not.toMatch(/fresh installs only|on a 17\.x release|carries the upgrade path/); }); it("the organization's own row wins: a key held both by it and by an unowned row is matched, not refused", async () => { diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index a1382d99c46..3b0cb448f35 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -1848,10 +1848,10 @@ export class SeedLoaderService implements ISeedLoaderService { `which has no organization (organization_id is NULL), and this seed load writes for organization ` + `${config.organizationId}. Inserting the record would put a twin beside that row, and updating the ` + `row would hand it to this organization: which organization an unowned row belongs to is decided by ` + - `the organization-ownership ceremony, not by a seed load. A row with no organization predates ` + - `organization stamping, so it is a v17 database's. 18.0 supports fresh installs only and does not ` + - `upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries ` + - `the upgrade path.`, + `the organization-ownership ceremony, not by a seed load. Attribute the unowned rows first ` + + `(\`os migrate organization-ownership --out PLAN\`, then the same command with ` + + `--apply --plan-file PLAN --backup-taken), then re-run this seed: the row is this organization's own ` + + `by then, and the upsert matches it.`, }; errors.push(error); allErrors.push(error); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index eac7d8f87ba..f9123b5ffce 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -880,8 +880,8 @@ export async function bootstrapPlatformAdmin( '[security] platform-admin promotion WITHHELD: an unscoped grant exists whose permission-set name ' + '(sys_user_permission_set.permission_set) was never written — a grant from a release before that ' + 'column existed. Which set it holds can no longer be read, and it may be the platform administrator\'s, ' - + 'so this pass will not mint a second one. 18.0 supports fresh installs only and does not upgrade a v17 ' - + 'database: keep a v17 database on a 17.x release until the 18.x release that carries the upgrade path.'; + + 'so this pass will not mint a second one. The organization-ownership migration\'s id-to-name rewrite ' + + '(ADR-0131 D10) names it; the next pass after that reads it by name.'; if (logger?.warn) logger.warn(withheld); else logger?.info?.(withheld); return { diff --git a/packages/plugins/plugin-security/src/catalog-reference-report.test.ts b/packages/plugins/plugin-security/src/catalog-reference-report.test.ts index 20401e850bc..f5ddf50b64f 100644 --- a/packages/plugins/plugin-security/src/catalog-reference-report.test.ts +++ b/packages/plugins/plugin-security/src/catalog-reference-report.test.ts @@ -15,8 +15,8 @@ * * Every assertion reads the structured result, never the log's wording; the * wiring pin finds the plugin's lines by their structured meta. One pin reads - * wording on purpose: the unnamed-grant line is 18.0 release text an operator - * acts on, and that release says one sentence about a v17 database everywhere. + * wording on purpose: the unnamed-grant line is release text an operator acts + * on, and its fix is the ownership ceremony's reviewed plan and apply. */ import { describe, it, expect, afterEach, vi } from 'vitest'; @@ -322,19 +322,16 @@ describe('[ADR-0131 D4] the catalog reference report — references whose names }); }); -describe('[ADR-0131 D4] the unnamed-grant line says what 18.0 supports for a v17 database', () => { - // The one wording pin in this file. 18.0 supports fresh installs only and its - // `os migrate organization-ownership` refuses plan and apply on every - // database, so the line an operator reads at boot must not send them to it; - // it carries the sentence the drop-gate refusal of `os migrate apply - // --allow-destructive` carries (the boot refusal and the command's own - // refusal say the same in their own terms). Everything else in the line is - // the report's own: the count, the field, the D4 reason, and that nothing was - // changed. - const V17_SENTENCE = '18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a ' - + '17.x release until the 18.x release that carries the upgrade path.'; - - it('names the count, the empty field, the reason and "Nothing was changed", then the 18.0 sentence, and prescribes no command', async () => { +describe('[ADR-0131 D4] the unnamed-grant line prescribes the ownership ceremony', () => { + // The one wording pin in this file. The line an operator reads at boot sends + // them to the ceremony (open since #24581): its plan, then the apply of that + // reviewed plan, which names each legacy grant. It never says to keep the + // database on 17.x. Everything else in the line is the report's own: the + // count, the field, the D4 reason, and that nothing was changed. + const FIX = 'Fix: run the v18 ceremony. Plan it first, os migrate organization-ownership --out plan.json, and review ' + + 'the file it writes; then run os migrate organization-ownership --apply --backup-taken --plan-file plan.json'; + + it('names the count, the empty field, the reason and "Nothing was changed", then the ceremony\'s plan and apply', async () => { const b = await boot(); await insertUser(b, 'usr_a'); await insertUser(b, 'usr_b'); @@ -352,11 +349,9 @@ describe('[ADR-0131 D4] the unnamed-grant line says what 18.0 supports for a v17 expect(message).toContain( '[security] 2 sys_user_permission_set grant(s) name no permission set (permission_set is empty), ' - + 'so each confers nothing (ADR-0131 D4). Nothing was changed. ' + V17_SENTENCE, + + 'so each confers nothing (ADR-0131 D4). Nothing was changed. ' + FIX, ); - expect(message.endsWith(V17_SENTENCE), 'the sentence closes the line').toBe(true); - // The refused command and every step of it are gone, not merely qualified. - expect(message).not.toMatch(/organization-ownership|os migrate|--out|--apply|--plan-file|--backup-taken|plan\.json|ceremony/); + expect(message).not.toMatch(/fresh installs only|on a 17\.x release|carries the upgrade path/); }); }); diff --git a/packages/plugins/plugin-security/src/catalog-reference-report.ts b/packages/plugins/plugin-security/src/catalog-reference-report.ts index 14fa2de8049..8683807b938 100644 --- a/packages/plugins/plugin-security/src/catalog-reference-report.ts +++ b/packages/plugins/plugin-security/src/catalog-reference-report.ts @@ -433,9 +433,11 @@ function logReport(report: CatalogReferenceReport, logger?: CatalogReferenceRepo logError( logger, `[security] ${unnamed.count} ${GRANT_OBJECT} grant(s) name no permission set (${GRANT_SET_NAME_FIELD} is ` + - 'empty), so each confers nothing (ADR-0131 D4). Nothing was changed. 18.0 supports fresh installs only and ' + - 'does not upgrade a v17 database: keep a v17 database on a 17.x release until the 18.x release that carries ' + - 'the upgrade path.', + 'empty), so each confers nothing (ADR-0131 D4). Nothing was changed. Fix: run the v18 ceremony. Plan it ' + + 'first, os migrate organization-ownership --out plan.json, and review the file it writes; then run ' + + 'os migrate organization-ownership --apply --backup-taken --plan-file plan.json, which executes that plan: ' + + 'it names each legacy grant from its retired id and lists the ones it cannot name. Then restart. Granting ' + + 'the set again by name before it runs makes a second grant the ceremony will report, not name.', { count: unnamed.count, organizations: unnamed.organizations }, ); } diff --git a/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts b/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts index 51892bbd5ba..13633aaf183 100644 --- a/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts +++ b/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts @@ -222,17 +222,15 @@ describe('[ADR-0131 D4] a grant that names nothing — the platform-admin bootst expect(await findExistingPlatformAdmin(engine, defaultPermissionSets)).toBeUndefined(); }); - // The one wording pin on the withheld-promotion warning. 18.0 supports fresh - // installs only and its `os migrate organization-ownership` refuses plan and - // apply on every database, so the warning an operator reads at boot must not - // send them to it; it ends with the sentence the unnamed-grant line of the - // catalog reference report and the boot refusal carry, in the same terms. + // The one wording pin on the withheld-promotion warning. It ends with the + // ownership migration's id-to-name rewrite, which names the grant (the + // ceremony is open, #24581); it never says to keep the database on 17.x. // Everything before the sentence is the warning's own: what is withheld, why, // and that this pass mints no second grant. - const V17_SENTENCE = '18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a ' - + '17.x release until the 18.x release that carries the upgrade path.'; + const REWRITE_SENTENCE = 'The organization-ownership migration\'s id-to-name rewrite (ADR-0131 D10) names it; the next ' + + 'pass after that reads it by name.'; - it('unnamed, the withheld warning keeps what is withheld and why, ends with the 18.0 sentence, and prescribes no migration', async () => { + it('unnamed, the withheld warning keeps what is withheld and why, and ends with the rewrite that names the grant', async () => { const engine = await standingAdminUnnamed(); await unname(engine, { id: 'g_admin' }); // An existing database: the platform-bucket rows an earlier release seeded. @@ -252,11 +250,10 @@ describe('[ADR-0131 D4] a grant that names nothing — the platform-admin bootst '[security] platform-admin promotion WITHHELD: an unscoped grant exists whose permission-set name ' + '(sys_user_permission_set.permission_set) was never written — a grant from a release before that ' + 'column existed. Which set it holds can no longer be read, and it may be the platform administrator\'s, ' - + 'so this pass will not mint a second one. ' + V17_SENTENCE, + + 'so this pass will not mint a second one. ' + REWRITE_SENTENCE, ); - expect(message.endsWith(V17_SENTENCE), 'the sentence closes the warning').toBe(true); - // The refused command and the rewrite it performed are gone, not merely qualified. - expect(message).not.toMatch(/organization-ownership|os migrate|--out|--apply|--plan-file|ceremony|id-to-name/); + expect(message.endsWith(REWRITE_SENTENCE), 'the sentence closes the warning').toBe(true); + expect(message).not.toMatch(/fresh installs only|on a 17\.x release|carries the upgrade path/); }); }); diff --git a/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts b/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts index 0fcbb47c972..952026f4e68 100644 --- a/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts +++ b/packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts @@ -138,7 +138,7 @@ describe(`[#24522] an 18.0 database (fixture from ${manifest.ref} @ ${manifest.s const refusal = refusalIn(refusedError) as { message?: string } | undefined; expect(refusal, `the boot was not refused by the ownership guard: ${String((refusedError as Error)?.stack ?? refusedError)}`).toBeDefined(); expect(refusal!.message).toContain('os migrate organization-ownership'); - expect(refusal!.message).not.toMatch(/17\.x/); + expect(refusal!.message).not.toMatch(/on a 17\.x release|stay on 17|every 18\.0/); const db = new DatabaseSync(file, { readOnly: true }); try { expect(db.prepare('SELECT id FROM sys_migration WHERE id = ?').all(MARKER)).toEqual([]); diff --git a/packages/runtime/src/organization-ownership-boot-guard.kernel.test.ts b/packages/runtime/src/organization-ownership-boot-guard.kernel.test.ts index 6b875cab121..97735bd7e55 100644 --- a/packages/runtime/src/organization-ownership-boot-guard.kernel.test.ts +++ b/packages/runtime/src/organization-ownership-boot-guard.kernel.test.ts @@ -15,7 +15,7 @@ * 2. the same database with the ceremony's marker verified boots; * 3. a fresh database boots, the guard writing its birth marker, and boots * again on that marker; a database an earlier boot created WITHOUT the - * guard (an 18.0 install's: no retired column, no marker) is refused; + * guard (an 18.0 pre-release install's: no retired column, no marker) is refused; * 4. a database the ceremony ran on to the end (no column left) whose marker * is NOT verified — its post-check found rows still without their * organization — is refused, naming the re-run; @@ -189,7 +189,7 @@ describe('the v18 boot refusal on a real kernel (ADR-0131 D10 item 5)', () => { expect(sql(file, `SELECT id FROM sys_migration WHERE id = '${MARKER}'`)).toHaveLength(1); }); - it('⭐ a database an earlier boot created without the guard (an 18.0 install: no retired column, no marker) is refused', async () => { + it('⭐ a database an earlier boot created without the guard (an 18.0 pre-release install: no retired column, no marker) is refused', async () => { const file = databaseFile(); const db = new DatabaseSync(file); db.exec('CREATE TABLE sys_migration (id TEXT PRIMARY KEY, last_run_at TEXT, verified_at TEXT, applied_at TEXT, blocking INTEGER)'); diff --git a/packages/runtime/src/organization-ownership-boot-guard.test.ts b/packages/runtime/src/organization-ownership-boot-guard.test.ts index b880599b86b..3034d0ee34f 100644 --- a/packages/runtime/src/organization-ownership-boot-guard.test.ts +++ b/packages/runtime/src/organization-ownership-boot-guard.test.ts @@ -8,7 +8,7 @@ * * - a verified marker at the current generation boots — the ceremony's, or * the BIRTH MARKER the guard writes once on a store created from empty; - * - no marker on any other store (a v17 database, an 18.0 one) is refused, + * - no marker on any other store (a v17 database, an 18.0 pre-release one) is refused, * naming the ceremony and the tables still carrying a retired column; * - a verified marker below the current generation (a shorter ceremony's) is * refused, naming the ceremony again; @@ -92,7 +92,7 @@ function v17Shape(): void { `); } -/** What an 18.0 install left, or what the ceremony's column-drops step leaves: no table in the v17 shape. */ +/** What an 18.0 pre-release install left, or what the ceremony's column-drops step leaves: no table in the v17 shape. */ function columnlessShape(): void { db.exec(` CREATE TABLE sys_job (id TEXT PRIMARY KEY, status TEXT); @@ -141,11 +141,12 @@ describe('the v18 boot refusal (ADR-0131 D10 item 5; the birth marker, ruling expect(isOrganizationOwnershipBootRefusal(err)).toBe(true); expect(err.code).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); expect(err.tables).toEqual(['sys_api_key', 'sys_job', 'sys_metadata']); - expect(err.message).toMatch(/carries no organization-ownership marker: an earlier release created it \(a v17 install, or an 18\.0 one\)/); + expect(err.message).toMatch(/carries no organization-ownership marker: 17\.x or an 18\.0 pre-release \(18\.0\.0-next\.\*\) created it/); expect(err.message).toContain('sys_api_key, sys_job, sys_metadata still carry a physical organization_id column its object no longer declares.'); expect(err.message).toMatch(/os migrate organization-ownership --apply --plan-file PLAN --backup-taken/); - // An 18.0 database takes the same ceremony: the text names no release line to stay on. - expect(err.message).not.toMatch(/17\.x|keep this one|does not upgrade/); + // An 18.0 pre-release database takes the same ceremony: the text names no release line to stay on, + // and never says every 18.0 database (18.0.0 itself writes the birth marker). + expect(err.message).not.toMatch(/17\.x release|keep this one|does not upgrade|every 18\.0/); }); it('a development boot names the ceremony with --dev, so it boots the way the refused boot did', async () => { @@ -174,7 +175,7 @@ describe('the v18 boot refusal (ADR-0131 D10 item 5; the birth marker, ruling expect(markerRow()).toEqual(row); }); - it('⭐ an 18.0 database — no retired column, no marker, a store this boot found — is refused, naming the ceremony', async () => { + it('⭐ an 18.0 pre-release database — no retired column, no marker, a store this boot found — is refused, naming the ceremony', async () => { columnlessShape(); const err = await refusalOf(() => assertOrganizationOwnershipMigrated(engine({ stats: FOUND }))); expect(err.code).toBe(ORGANIZATION_OWNERSHIP_BOOT_REFUSAL_CODE); diff --git a/packages/runtime/src/organization-ownership-boot-guard.ts b/packages/runtime/src/organization-ownership-boot-guard.ts index 2465c8f7cf4..7f298a87f59 100644 --- a/packages/runtime/src/organization-ownership-boot-guard.ts +++ b/packages/runtime/src/organization-ownership-boot-guard.ts @@ -42,9 +42,9 @@ import { resolveRuntimeVersion } from './runtime-version.js'; * sync stats — never the sum over every driver: a `telemetry` sibling left * over from an earlier boot does not make a fresh primary look old, and a * fresh sibling does not make an old primary look new. - * 3. No row on any other store: a database an earlier release created (every - * 18.0 database and every v17 one) — refused until the ceremony has run on - * it. The tables still carrying a retired `organization_id` (an object that + * 3. No row on any other store: a database with no birth marker, which 17.x + * and the 18.0 pre-releases (18.0.0-next.*) created — refused until the + * ceremony has run on it. The tables still carrying a retired `organization_id` (an object that * declares no organization column — `systemFields.tenant: false`, * `systemFields: false` or `tenancy: { enabled: false }` — whose physical * table still has one) are named when there are any. @@ -264,9 +264,9 @@ export type OrganizationOwnershipMarkerState = 'absent' | 'unverified' | 'unread /** * The refusal text: what was found, that the server refuses to start, and the - * command. It names no release line to stay on: a database an 18.0 install - * created is refused here exactly as a v17 one is, and both take the same - * ceremony. + * command. It names no release line to stay on: a database an 18.0 + * pre-release (18.0.0-next.*) created is refused here exactly as a v17 one is, + * and both take the same ceremony. */ export function organizationOwnershipRefusalMessage( tables: readonly string[], @@ -291,8 +291,8 @@ export function organizationOwnershipRefusalMessage( } if (marker === 'absent') { return ( - 'This database carries no organization-ownership marker: an earlier release created it (a v17 install, or an ' + - '18.0 one), and this release serves only a database it created itself or one its ownership ceremony upgraded.' + + 'This database carries no organization-ownership marker: 17.x or an 18.0 pre-release (18.0.0-next.*) ' + + 'created it, and this release serves only a database it created itself or one its ownership ceremony upgraded.' + `${columns} The server refuses to start rather than serve it in a shape it was never migrated to. ${run}` ); } diff --git a/packages/runtime/src/organization-ownership-boot-guard.turso-remote.kernel.test.ts b/packages/runtime/src/organization-ownership-boot-guard.turso-remote.kernel.test.ts index 95ae8bde112..287044bfaed 100644 --- a/packages/runtime/src/organization-ownership-boot-guard.turso-remote.kernel.test.ts +++ b/packages/runtime/src/organization-ownership-boot-guard.turso-remote.kernel.test.ts @@ -10,7 +10,7 @@ * 1. ⭐ R1: a fresh remote database boots, the guard writing its birth marker, * and the next boot over it boots on that marker; * 2. ⭐ a remote database an earlier boot created without the guard (an 18.0 - * install's: tables present, no marker) is refused, naming the ceremony. + * pre-release install's: tables present, no marker) is refused, naming the ceremony. * * The remote transport talks to a SQLite file through `@libsql/client`, the * double the driver's own remote-face pins use (`turso-remote-*.test.ts`). @@ -103,7 +103,7 @@ describe('[ADR-0131 D10 item 5] the boot guard on the Turso remote face', () => expect(markerRows(file)).toHaveLength(1); }, 60_000); - it('⭐ a remote database an earlier boot created without the guard (an 18.0 install: no marker) is refused, naming the ceremony', async () => { + it('⭐ a remote database an earlier boot created without the guard (an 18.0 pre-release install: no marker) is refused, naming the ceremony', async () => { const file = databaseFile(); const db = new DatabaseSync(file); db.exec('CREATE TABLE sys_migration (id TEXT PRIMARY KEY, last_run_at TEXT, verified_at TEXT, applied_at TEXT, blocking INTEGER)'); diff --git a/packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts b/packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts index b8f361b1c77..6dd3470bef2 100644 --- a/packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts +++ b/packages/runtime/src/seed-loader-null-organization-holder-real-driver.integration.test.ts @@ -209,12 +209,11 @@ describe('scoped seed load over NULL-organization holders (sqlite-file)', () => expect(kickoff.targetObject).toBe('tw_event'); expect(kickoff.targetField).toBe('subject'); expect(kickoff.attemptedValue).toBe('Kickoff'); - // The message names the key, the unowned holder and what 18.0 supports: - // never the ceremony 18.0 refuses. + // The message names the key, the unowned holder and the remedy's command. expect(kickoff.message).toContain('subject=Kickoff'); expect(kickoff.message).toContain("'ev_kickoff'"); - expect(kickoff.message).toContain('18.0 supports fresh installs only and does not upgrade a v17 database'); - expect(kickoff.message).not.toContain('os migrate organization-ownership'); + expect(kickoff.message).toContain('os migrate organization-ownership --out PLAN'); + expect(kickoff.message).not.toMatch(/fresh installs only|on a 17\.x release|carries the upgrade path/); expect(result.success).toBe(false); // Loud: said at `error`, once per dataset. const lines = logger.error.mock.calls.map((c) => String(c[0])).filter((m) => refusals.some((e) => m.includes(e.message))); diff --git a/packages/spec/src/system/migration.zod.ts b/packages/spec/src/system/migration.zod.ts index 784607298ba..2812779e774 100644 --- a/packages/spec/src/system/migration.zod.ts +++ b/packages/spec/src/system/migration.zod.ts @@ -207,7 +207,7 @@ export const ORGANIZATION_OWNERSHIP_MIGRATION_ID = 'adr-0131-organization-owners * ceremony certified is never taken for one this release's ceremony did. * * Generations: - * - `1` — the ceremony that upgrades v17 and 18.0 databases: the declared + * - `1` — the ceremony that upgrades 17.x and 18.0 pre-release databases: the declared * webhook settlement, the two sibling moves, the declared sharing-rule * copies, the `sys_secret` / `sys_flow_credential` column drops and the * `organization_id` NOT NULL step. diff --git a/scripts/check-closed-ceremony-text.mjs b/scripts/check-closed-ceremony-text.mjs new file mode 100644 index 00000000000..6e0fe60aa0c --- /dev/null +++ b/scripts/check-closed-ceremony-text.mjs @@ -0,0 +1,197 @@ +#!/usr/bin/env node +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-closed-ceremony-text (#24581) -- no shipped refusal, upgrade text or + * pending changeset says the organization-ownership ceremony is closed. + * + * node scripts/check-closed-ceremony-text.mjs # the sweep (the gate) + * node scripts/check-closed-ceremony-text.mjs --self-test # the rule itself + * + * ## Why + * + * The 18.0 pre-releases refused `os migrate organization-ownership` on every + * database, and a family of refusals, boot warnings, upgrade-guide entries, + * docs and changesets learned to say so: "18.0 supports fresh installs only", + * "keep a v17 database on a 17.x release until the 18.x release that carries + * the upgrade path". Steps 5 and 6 of the 18.x line opened the ceremony, and a + * sentence like that left anywhere sends an operator away from the one command + * that upgrades their database. Each of those surfaces carries its own wording + * pin; this gate holds the property none of them can hold about the others: + * that the sentence is gone from all of them, including the one written next. + * + * ## The population + * + * What ships or is published: package sources (`packages/**`, never a test + * file, which may name the sentence to assert its absence), the docs site + * (`content/docs/**`) and the changesets still pending (`.changeset/*.md` at + * the root; `.changeset/pre/` holds the ones a pre-release already consumed, + * whose text is published history). Out of scope by rule: `docs/adr/**` and + * `content/docs/releases/**` (records of what was decided and released), and + * every `CHANGELOG.md` (release-owned). + */ + +import { readFileSync } from 'node:fs'; +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import process from 'node:process'; +import { isEntrypoint } from './invoked-as.mjs'; + +const ROOT = join(fileURLToPath(new URL('.', import.meta.url)), '..'); + +/** The population, for `scripts/pm/dispatch-gates.mjs` (the `ROOT_DIR_WATCH_HINTS` idiom). */ +export const ROOT_DIR_WATCH_HINTS = ['packages/**', 'content/docs/**', '.changeset/**']; + +/** What the closed ceremony's wording said. Each is a sentence an open ceremony never ships. */ +export const CLOSED_CEREMONY_PHRASES = Object.freeze([ + { id: 'fresh-installs-only', re: /fresh installs? only/i }, + { id: 'keep-on-17x', re: /keep (?:a|this|the|your) (?:v17 )?(?:development )?database on a 17\.x release/i }, + { id: 'carries-the-upgrade-path', re: /the 18\.x release that carries the upgrade path/i }, + { id: 'upgrade-path-arrives-later', re: /upgrade path (?:for an existing database )?arrives in a later 18\.x release/i }, +]); + +/** Is `path` (repo-relative, `/`-separated) in the population? */ +export function inPopulation(path) { + if (/(^|\/)(node_modules|dist)\//.test(path)) return false; + if (/(^|\/)CHANGELOG\.md$/.test(path)) return false; + if (path.startsWith('docs/adr/') || path.startsWith('content/docs/releases/')) return false; + if (path.startsWith('.changeset/')) return /^\.changeset\/[^/]+\.md$/.test(path); + if (path.startsWith('content/docs/')) return /\.(mdx?|json)$/.test(path); + if (path.startsWith('packages/')) { + if (/\.(test|spec)\.[cm]?[jt]sx?$/.test(path) || /\/(test|tests|__tests__)\//.test(path)) return false; + return /\.([cm]?[jt]sx?|mdx?|json)$/.test(path); + } + return false; +} + +/** Every line of `text` that says one of the phrases: `{ line, phrase }`. */ +export function findingsIn(text) { + const out = []; + text.split('\n').forEach((line, i) => { + for (const p of CLOSED_CEREMONY_PHRASES) if (p.re.test(line)) out.push({ line: i + 1, phrase: p.id }); + }); + return out; +} + +function trackedFiles() { + return execFileSync('git', ['ls-files', '-z', '--', ...ROOT_DIR_WATCH_HINTS.map((h) => h.replace(/\/\*\*$/, ''))], { cwd: ROOT, encoding: 'utf8' }) + .split('\0') + .filter(Boolean); +} + +export function sweep() { + const findings = []; + let scanned = 0; + for (const path of trackedFiles()) { + if (!inPopulation(path)) continue; + let text; + try { + text = readFileSync(join(ROOT, path), 'utf8'); + } catch { + continue; // deleted in the working tree + } + scanned++; + for (const f of findingsIn(text)) findings.push({ path, ...f }); + } + return { scanned, findings }; +} + +function main() { + const { scanned, findings } = sweep(); + if (scanned === 0) { + console.error('✗ check-closed-ceremony-text: the population is empty — nothing was read, so nothing is proven.'); + return 2; + } + if (findings.length === 0) { + console.log(`✓ check-closed-ceremony-text: ${scanned} shipped file(s) read; none says the ownership ceremony is closed.`); + return 0; + } + console.error(`✗ check-closed-ceremony-text: ${findings.length} line(s) say the organization-ownership ceremony is closed:\n`); + for (const f of findings) console.error(` ${f.path}:${f.line} [${f.phrase}]`); + console.error( + '\n The ceremony is open (#24581): `os migrate organization-ownership --out PLAN`, then\n' + + ' `--apply --plan-file PLAN --backup-taken`, upgrades a database 17.x or an 18.0 pre-release created.\n' + + ' Name that command instead. A test may spell the sentence to assert its absence; shipped text may not.', + ); + return 1; +} + +let selfTestReachedVerdict = false; + +function selfTest() { + let checked = 0; + const failures = []; + const t = (name, ok) => { + checked++; + if (!ok) failures.push(name); + }; + + // Each phrase reds on the sentence the 18.0 pre-releases shipped. + const SHIPPED = '18.0 supports fresh installs only and does not upgrade a v17 database: keep a v17 database on a ' + + '17.x release until the 18.x release that carries the upgrade path, and upgrade it there.'; + t('the shipped 18.0 sentence is found', findingsIn(SHIPPED).length >= 3); + t('…its development spelling too', findingsIn('keep a v17 development database on a 17.x release').some((f) => f.phrase === 'keep-on-17x')); + t('…and the docs callout', findingsIn('The upgrade path for an existing database arrives in a later 18.x release').some((f) => f.phrase === 'upgrade-path-arrives-later')); + t('a finding names its line', JSON.stringify(findingsIn('a\nfresh install only\nb')) === JSON.stringify([{ line: 2, phrase: 'fresh-installs-only' }])); + + // The open ceremony's own text is not a finding. + for (const open of [ + 'This database carries no organization-ownership marker: 17.x or an 18.0 pre-release (18.0.0-next.*) created it', + "Run 'os migrate organization-ownership --out PLAN' to write and review its plan, take a backup, then run it.", + 'A fresh install holds no legacy organization rows: there, the cause is the one named above.', + 'A database that does hold them was created by 17.x: run `os migrate organization-ownership` on it first.', + 'Fresh installs are supported under every posture: `single`, `group` and `isolated`.', + ]) t(`not a finding: ${open.slice(0, 50)}`, findingsIn(open).length === 0); + + // The population. + for (const [path, want] of [ + ['packages/runtime/src/organization-ownership-boot-guard.ts', true], + ['packages/runtime/src/organization-ownership-boot-guard.test.ts', false], + ['packages/qa/dogfood/test/upgrade-18x-harness.dogfood.test.ts', false], + ['packages/cli/test/helpers/config-miss-family.ts', false], + ['packages/spec/src/migrations/entries/semantic/18.sys-metadata-organization-column-retired.ts', true], + ['packages/cli/dist/index.js', false], + ['packages/cli/CHANGELOG.md', false], + ['content/docs/deployment/cli.mdx', true], + ['content/docs/releases/v18.mdx', false], + ['docs/adr/0131-total-organization-ownership-no-null-organization-id.md', false], + ['.changeset/24581-organization-ownership-ceremony-open.md', true], + ['.changeset/pre/15212-180-guards-fresh-single-only.md', false], + ['.changeset/pre.json', false], + ]) t(`population: ${path} → ${want}`, inPopulation(path) === want); + + // The watch hints declare what the sweep walks, and nothing else. + t('every walk root is declared', ['packages', 'content/docs', '.changeset'].every((r) => ROOT_DIR_WATCH_HINTS.includes(`${r}/**`))); + t('and nothing undeclared is walked', ROOT_DIR_WATCH_HINTS.every((h) => ['packages', 'content/docs', '.changeset'].includes(h.replace(/\/\*\*$/, '')))); + + // The tree: a non-empty population. + t('the population on this tree is not empty', sweep().scanned > 0); + + if (failures.length) { + for (const f of failures) console.error(` ✗ ${f}`); + console.error(`check-closed-ceremony-text --self-test: ${failures.length} of ${checked} assertion(s) FAILED.`); + selfTestReachedVerdict = true; + return 1; + } + console.log(`check-closed-ceremony-text --self-test: ${checked} assertion(s) passed.`); + selfTestReachedVerdict = true; + return 0; +} + +if (isEntrypoint(import.meta.url)) { + const argv = process.argv.slice(2); + if (argv.includes('--self-test')) { + const selfTestCode = selfTest(); + if (!selfTestReachedVerdict) { + console.error( + '\n✗ check-closed-ceremony-text self-test: selfTest() returned without reaching its verdict,\n' + + 'so no success line was printed. Exiting 0 here would report a self-test\n' + + 'that never finished as a self-test that passed.\n', + ); + process.exit(1); + } + process.exit(selfTestCode); + } + process.exit(main()); +} From cda96bf29d2c42a38e1e65a85e6873a682700f86 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 06:11:38 +0000 Subject: [PATCH 35/44] =?UTF-8?q?wip(security):=20the=20organization-less?= =?UTF-8?q?=20grant=20readers=20retire=20=E2=80=94=20a=20grant=20applies?= =?UTF-8?q?=20only=20in=20its=20own=20organization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01QzqbxJk3oD6sqe47Q84ET8 Co-authored-by: Claude --- .../src/security/resolve-authz-context.ts | 86 ++++++----- .../src/bootstrap-platform-admin.ts | 145 +++++++----------- .../src/delegated-admin-gate.ts | 33 ++-- .../src/grant-permission-set-name.ts | 14 -- .../plugin-security/src/organization-scope.ts | 98 ++++-------- 5 files changed, 147 insertions(+), 229 deletions(-) diff --git a/packages/core/src/security/resolve-authz-context.ts b/packages/core/src/security/resolve-authz-context.ts index df3aadb5b1e..d05eb6b42b4 100644 --- a/packages/core/src/security/resolve-authz-context.ts +++ b/packages/core/src/security/resolve-authz-context.ts @@ -792,11 +792,11 @@ export interface UserAuthzGrants { export interface ResolveUserAuthzGrantsOptions { /** - * Active org/tenant id — scopes org-bound grants (a null-org row is global). - * Omitted ⇒ NO active organization, so only the global grants apply: an - * organization-scoped position or permission-set row applies only while its - * organization is this tenant ({@link grantAppliesInTenant}). There is no - * "every organization" reading of an omitted tenant. + * Active org/tenant id — the organization whose grants apply. A position or + * permission-set row applies only while its organization is this tenant + * ({@link grantAppliesInTenant}). Omitted ⇒ NO active organization, so no + * stored grant row applies (the platform-standing anchor aside, §6b). There + * is no "every organization" reading of an omitted tenant. */ tenantId?: string; /** Clock injection for grant validity windows (tests). */ @@ -832,12 +832,20 @@ export interface ResolveUserAuthzGrantsOptions { } /** - * The ONE organization rule for a stored grant row — `sys_user_position` (§4), - * `sys_user_permission_set` (§6) and the `sys_position` rows whose bindings - * §6a collects all ask it, so none of them can disagree: a row with no - * `organization_id` is GLOBAL and applies in every resolution; a row scoped to - * an organization applies only when that organization is the ACTIVE tenant. - * With no active tenant, an organization-scoped row does not apply at all. + * The ONE organization rule for a stored grant row — `sys_user_position` (§4) + * and `sys_user_permission_set` (§6) both ask it, so neither can disagree: a + * row applies only when its organization is the ACTIVE tenant. With no active + * tenant, no row applies. + * + * `organizationId` is the row's organization as {@link grantOrganizationOf} + * normalizes it. A row with none (`null`) applies NOWHERE: the 18.0 ownership + * ceremony (ADR-0131 D10) attributes every organization-less row before an + * upgraded database boots, and makes the column NOT NULL, so an + * organization-less grant is invalid state rather than a "global" one. The + * reading that let such a row apply in every resolution retired with it. The + * grant that stands for the whole deployment is the Default Organization's + * `admin_full_access` anchor, which §6 reads through + * {@link grantAnchorsPlatformStanding}, not through this rule. * * The spelling both sites carried before, `org && tenantId && org !== tenantId` * as the SKIP condition, read "no tenant" as "every organization": it was false @@ -850,7 +858,7 @@ export interface ResolveUserAuthzGrantsOptions { * * ⛔ There is no "every organization" mode: no option selects one and nothing * falls back to one. A caller that has an organization passes it; a caller - * that has none gets the global grants and nothing else. + * that has none gets no stored grant at all. * * ⚠️ Not applied to §3's `sys_member` role projection, which reads the user's * OWN current memberships rather than a grant row. With no active organization @@ -858,8 +866,8 @@ export interface ResolveUserAuthzGrantsOptions { * CONFER arrives through §6a's per-organization position rows and §6's * organization-scoped grants, and both answer to this rule. */ -function grantAppliesInTenant(organizationId: unknown, tenantId: string | undefined): boolean { - return !organizationId || organizationId === tenantId; +function grantAppliesInTenant(organizationId: string | null, tenantId: string | undefined): boolean { + return organizationId !== null && organizationId === tenantId; } /** @@ -883,9 +891,10 @@ function grantSetNameOf(row: unknown): string | undefined { } /** - * The organization a stored grant or catalog row belongs to, or `null` for one - * that belongs to none — a blank value is the legacy organization-less - * spelling, read as {@link grantAppliesInTenant} reads it. + * The organization a stored grant row belongs to, or `null` for one that names + * none — `NULL`, absent or blank. `null` confers nothing: neither + * {@link grantAppliesInTenant} nor {@link grantAnchorsPlatformStanding} ever + * answers yes for it. */ function grantOrganizationOf(row: unknown): string | null { if (!row || typeof row !== 'object') return null; @@ -904,19 +913,19 @@ function grantOrganizationOf(row: unknown): string | null { * config-derived (`OS_PLATFORM_OWNER_EMAIL`, §6b-config) and nothing else * (#11663 L5); * - `single`: the grant the first-user promotion writes, which is owned by the - * Default Organization ({@link readDefaultOrganizationId}), and the - * organization-less row that promotion wrote before it was. + * Default Organization ({@link readDefaultOrganizationId}). * - * Any other organization's grant of the set confers the set, never standing. + * Any other organization's grant of the set confers the set, never standing. An + * organization-less grant anchors nothing: the ownership ceremony (ADR-0131 + * D10) attributes the one an earlier release's promotion wrote to the Default + * Organization, so the reading that took it as the anchor retired with it. */ function grantAnchorsPlatformStanding( grant: unknown, anchor: { walled: boolean; defaultOrganizationId: string | undefined }, ): boolean { - if (anchor.walled) return false; - const organizationId = grantOrganizationOf(grant); - return organizationId === null - || (anchor.defaultOrganizationId !== undefined && organizationId === anchor.defaultOrganizationId); + if (anchor.walled || anchor.defaultOrganizationId === undefined) return false; + return grantOrganizationOf(grant) === anchor.defaultOrganizationId; } /** @@ -1528,12 +1537,12 @@ export async function resolveUserAuthzGrants( } // 4. [ADR-0057 D4] Platform-owned RBAC role assignments (sys_user_position) — the - // source of truth for custom roles, decoupled from sys_member.role. - // `organization_id = null` = global (cross-tenant); else match active org — - // so with no active org, only the global rows ({@link grantAppliesInTenant}). + // source of truth for custom roles, decoupled from sys_member.role. A row + // applies only in its own organization, so with no active org none does + // ({@link grantAppliesInTenant}). // (read in Leg 1 above) for (const ur of userPositionRows) { - if (!grantAppliesInTenant(ur.organization_id, tenantId)) continue; + if (!grantAppliesInTenant(grantOrganizationOf(ur), tenantId)) continue; if (!isGrantActive(ur, nowMs)) continue; const r = ur.position; if (typeof r === 'string' && r && !grants.positions.includes(r)) grants.positions.push(r); @@ -1551,8 +1560,9 @@ export async function resolveUserAuthzGrants( grants.org_user_ids = Array.from(ids); } - // 6. Permission sets — user-scoped grants (null org = global, else active org; - // with no active org, only the global rows — {@link grantAppliesInTenant}). + // 6. Permission sets — user-scoped grants, each applying only in its own + // organization; with no active org, none does ({@link grantAppliesInTenant}), + // the platform-standing anchor below aside. // Rows outside their validity window are dropped BEFORE any derivation, so // an expired admin_full_access grant cannot yield platform_admin either. // @@ -1578,10 +1588,9 @@ export async function resolveUserAuthzGrants( // resolves to: the `admin_full_access` grant `bootstrapPlatformAdmin` mints for // the first human user, which D5 has OWNED BY THE DEFAULT ORGANIZATION (#11979; // maintainer ruling A on #18707 keeps the zero-config promotion and moves only the - // row's owner), and the organization-less row that promotion wrote before it was — - // the row D10's ceremony attributes to that same organization. ⛔ Reading only the - // organization-less row would leave every promoted `single` rig with NO platform - // administrator once its grant is the Default Organization's. + // row's owner). The organization-less row an earlier release's promotion wrote is + // not read: D10's ceremony attributes it to that same organization before an + // upgraded database boots. // // The REQUESTED posture is the input, not the effective one — the same source and the // same fail-stricter direction the boot side reads: a deployment that ASKED for a wall @@ -1614,8 +1623,8 @@ export async function resolveUserAuthzGrants( // grants, and a held name of either type was already in the question. So // every envelope is unchanged, and so is every read's predicate: the ledger's // and the organization's filters, `grantAppliesInTenant` and - // `grantAnchorsPlatformStanding` (the organization-less readings) are called - // as they were, never edited. Pinned in + // `grantAnchorsPlatformStanding` are called exactly as the envelope-building + // pass below calls them. Pinned in // `resolve-authz-context.request-rounds.test.ts`. const mayApply = (r: unknown): boolean => grantAppliesInTenant(grantOrganizationOf(r), tenantId) @@ -1670,9 +1679,8 @@ export async function resolveUserAuthzGrants( const platformAnchor = { walled, defaultOrganizationId }; const anchorsPlatformStanding = (grant: unknown): boolean => grantSetNameOf(grant) === ADMIN_FULL_ACCESS && grantAnchorsPlatformStanding(grant, platformAnchor); - // An anchoring grant applies in whichever organization the principal acts in, as the - // organization-less row it replaces always did: platform standing is the deployment's, - // never one tenant's. + // An anchoring grant applies in whichever organization the principal acts in, and with + // none active: platform standing is the deployment's, never one tenant's. const applyingGrants = upsRows.filter( (r) => grantAppliesInTenant(grantOrganizationOf(r), tenantId) || anchorsPlatformStanding(r), ); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts index f9123b5ffce..c6161b5e9f7 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin.ts @@ -13,8 +13,9 @@ * maintainer's ruling A on #18707): the zero-config promotion stays, * and ⛔ no NULL-organization grant row is written. The holder scan * and the derivation site (`resolve-authz-context.ts` §6) read that - * row — and the organization-less row an earlier release wrote, until - * D10's ceremony attributes it to the same organization. A deployment + * row and no other: the organization-less row an earlier release + * wrote is attributed to the same organization by D10's ceremony + * before an upgraded database boots. A deployment * whose Default Organization cannot be named is not promoted: it says * so, loudly, rather than writing the row without an owner. * [#14348] "First user" means the oldest @@ -474,19 +475,17 @@ const PLATFORM_ADMIN_PERMISSION_SET_NAME = 'admin_full_access'; * ## Whose grant is the anchor ([ADR-0131] D5) * * Under `single` the promotion writes its grant OWNED BY THE DEFAULT - * ORGANIZATION (`defaultOrganizationId`), so that row is a holder; so is the - * organization-less row an earlier release's promotion wrote, until D10's - * ceremony attributes it to the same organization. The derivation site reads - * the same two (`grantAnchorsPlatformStanding`, `resolve-authz-context.ts`), - * so the holder this scan finds is the account the resolver grades - * PLATFORM_ADMIN — ⛔ a scan reading only the NULL row would find nobody on a - * promoted deployment the ceremony has run on and mint a SECOND grant on every - * boot. A user holding both rows is one holder; whichever row sorts first by - * `id` answers. Any other organization's grant of the set is an organization - * administrator's and never a holder. `defaultOrganizationId` is `undefined` - * under a wall, where only the legacy organization-less row is asked about (and - * only to report it — it anchors nothing there), and where the Default - * Organization cannot be named. + * ORGANIZATION (`defaultOrganizationId`), so that row is the holder, and the + * only one. The derivation site reads the same one + * (`grantAnchorsPlatformStanding`, `resolve-authz-context.ts`), so the holder + * this scan finds is the account the resolver grades PLATFORM_ADMIN. The + * organization-less row an earlier release's promotion wrote is not read: D10's + * ceremony attributes it to the Default Organization before an upgraded + * database boots, and the column is NOT NULL after it. Any other organization's + * grant of the set is an organization administrator's and never a holder. + * `defaultOrganizationId` is `undefined` under a wall, where no grant anchors + * standing, and where the Default Organization cannot be named; the scan then + * asks nothing and finds nobody. * * The read was once `tryFind(ql, 'sys_user_permission_set', { * permission_set_id: adminPsId }, 50)` — no `orderBy`, cap 50 — with the @@ -503,38 +502,23 @@ const PLATFORM_ADMIN_PERMISSION_SET_NAME = 'admin_full_access'; * #14348 case D: 「Moving an already-granted platform admin is reserved to the * maintainer.」 * - * ## Why this is TWO reads and not `organization_id: null` in the `where` + * ## Why this is TWO reads * - * The card's suggested one-line narrowing was MEASURED before it was taken, - * and on its own it would have RELAXED this guard. Null matching itself is - * uniform across the families that can be measured — each answers "the column - * holds no value": - * - * driver-sql (better-sqlite3) via ObjectQL + these real objects -> the unscoped row only - * driver-sqlite-wasm via ObjectQL + these real objects -> the unscoped row only - * driver-memory driver face -> null-valued AND key-absent rows - * driver-mongodb translator (its own live suites -> `{organization_id: null}`, - * need a 123 MB binary download) Mongo's null-or-missing reading - * - * What is NOT uniform is the narrowed read against THIS code's own predicate. - * `organization_id: ''` is storable on both SQL families and reads back as - * `''`: `!organization_id` counts that row UNSCOPED, and `where: { - * organization_id: null }` does NOT return it. A narrowing that REPLACED the - * client-side predicate would therefore stop seeing a legacy unscoped holder - * stored that way, fire less often, and mint the second grant. ⛔ So the - * predicate is untouched and the READ is what changed: - * - * Leg A — ask the driver the narrow question. Independent of how many - * org-scoped grants exist, so no org-admin count can crowd the - * answer out of a window. + * Leg A — ask the driver the narrow question: the Default Organization's + * grants of the set. Independent of how many other organizations' + * grants exist, so no org-admin count can crowd the answer out of a + * window. * Leg B — only when leg A found nobody: scan the grant population for this * set, ORDERED so each page is a deterministic slice rather than * "whatever the driver produced first", bounded, and reporting - * `truncated` at the bound with the number of rows examined. This is - * the leg that still sees a `''`-shaped legacy row. + * `truncated` at the bound with the number of rows examined. It + * applies the exact predicate client-side, so a driver whose `where` + * matching differs from it cannot make the guard fire less often. * - * Both legs are strictly ADDITIVE to what the old read could see, so the - * guard can only fire MORE often than before, never less. + * Both legs ask the one predicate, so the guard can only fire MORE often than + * either leg alone, never less. (The organization-less reading — and with it + * the `''`-shaped legacy row only the scan could see — retired with the 18.0 + * ownership ceremony, ADR-0131 D10.) * * ## Several holders * @@ -582,9 +566,13 @@ async function findPlatformAdminGrantHolder( adminGrantRowsExamined: number; truncated: boolean; }> { + // No owner can be named (a wall, or no Default Organization): no grant + // anchors, so nothing is asked. + if (defaultOrganizationId === undefined) { + return { holder: undefined, unnamedHolder: undefined, adminGrantRowsExamined: 0, truncated: false }; + } const isAnchorHumanHolder = (r: any) => - (!r.organization_id || (defaultOrganizationId !== undefined && r.organization_id === defaultOrganizationId)) - && r.user_id !== SystemUserId.SYSTEM; + r.organization_id === defaultOrganizationId && r.user_id !== SystemUserId.SYSTEM; // Counted by row IDENTITY, not by read: the two legs overlap by construction // (leg A's rows are a subset of leg B's population), and a number that @@ -600,22 +588,17 @@ async function findPlatformAdminGrantHolder( }; let truncated = false; - // Leg A — the narrow question, asked of the driver: once per anchoring owner - // (the organization-less row, and the Default Organization's), each answer - // ordered by `id`, and the row whose `id` sorts first of the two wins. - let holder: any | undefined; - for (const organizationId of defaultOrganizationId === undefined ? [null] : [null, defaultOrganizationId]) { - const anchorGrantRows = await tryFind( - ql, - 'sys_user_permission_set', - { [GRANT_SET_NAME_FIELD]: PLATFORM_ADMIN_PERMISSION_SET_NAME, organization_id: organizationId }, - PLATFORM_ADMIN_GRANT_PAGE_SIZE, - ADMIN_GRANT_SCAN_ORDER, - ); - countExamined(anchorGrantRows); - const found = anchorGrantRows.find(isAnchorHumanHolder); - if (found && (!holder || String(found.id) < String(holder.id))) holder = found; - } + // Leg A — the narrow question, asked of the driver: the Default + // Organization's grants of the set, ordered by `id`. + const anchorGrantRows = await tryFind( + ql, + 'sys_user_permission_set', + { [GRANT_SET_NAME_FIELD]: PLATFORM_ADMIN_PERMISSION_SET_NAME, organization_id: defaultOrganizationId }, + PLATFORM_ADMIN_GRANT_PAGE_SIZE, + ADMIN_GRANT_SCAN_ORDER, + ); + countExamined(anchorGrantRows); + let holder: any | undefined = anchorGrantRows.find(isAnchorHumanHolder); /** * The ordered, bounded scan of one grant population, stopping at the first @@ -699,7 +682,7 @@ export async function findExistingPlatformAdmin( /** * [ADR-0131 D5] The owner of the grant the `single` promotion writes, and of - * the grant the holder scan reads beside the organization-less one: the + * the grant the holder scan reads: the * Default Organization, by the definition plugin-auth's boot invariant creates * it by (`resolveDefaultOrganizationId` — `slug = 'default'`, else the only * organization) and the derivation site reads it by (`resolve-authz-context.ts` @@ -733,7 +716,7 @@ export async function bootstrapPlatformAdmin( /** Count of seeded rows re-owned to the freshly-promoted admin. */ ownershipClaimed?: number; /** - * WHO holds the unscoped `admin_full_access` grant after this pass — the user + * WHO holds the anchoring `admin_full_access` grant after this pass — the user * this pass promoted, or the holder the `already_have_admin` short-circuit * found. Present on both, absent on every other return and under walled * postures (where no grant row exists and standing is config-derived at @@ -801,8 +784,8 @@ export async function bootstrapPlatformAdmin( const walled = postureEnforcesWall(resolveTenancyPosture()); // [ADR-0131 D5] Under `single` the promotion's grant is the Default - // Organization's, so that is the owner the holder scan asks about beside the - // organization-less row and the owner a promotion writes. Under a wall no + // Organization's, so that is the owner the holder scan asks about and the + // owner a promotion writes. Under a wall no // grant anchors standing and none is written, so nothing is asked. const defaultOrganizationId = walled ? undefined : await readPromotionGrantOwner(ql); @@ -829,7 +812,7 @@ export async function bootstrapPlatformAdmin( // ⛔ The truncation is never silent (commit 1c83ca226). Reaching the ceiling is the one // way this scan still answers "no platform admin yet" while one exists, and - // that answer does not merely skip a log line — it MINTS A SECOND unscoped + // that answer does not merely skip a log line — it MINTS A SECOND anchoring // grant and hands it the seeded business records. So it says the number it // examined rather than letting the promotion below read as a statement about // the whole table. @@ -868,7 +851,7 @@ export async function bootstrapPlatformAdmin( }; } - // [ADR-0131 D4] An unscoped grant that names no set yet — on an upgraded + // [ADR-0131 D4] An anchoring grant that names no set yet — on an upgraded // deployment, possibly its platform administrator's grant, written before // the name column existed. It is read as an administrator who may already // exist: promoting now could mint the second grant @@ -877,7 +860,7 @@ export async function bootstrapPlatformAdmin( // for a boot that reads the grant by its name. if (!walled && unnamedHolder) { const withheld = - '[security] platform-admin promotion WITHHELD: an unscoped grant exists whose permission-set name ' + '[security] platform-admin promotion WITHHELD: a Default Organization grant exists whose permission-set name ' + '(sys_user_permission_set.permission_set) was never written — a grant from a release before that ' + 'column existed. Which set it holds can no longer be read, and it may be the platform administrator\'s, ' + 'so this pass will not mint a second one. The organization-ownership migration\'s id-to-name rewrite ' @@ -904,9 +887,9 @@ export async function bootstrapPlatformAdmin( // posture, so on these rigs standing is CONFIG-DERIVED and nothing else. // // ⛔ Nothing here writes, deletes or re-owns that row — its ownership is - // ADR-0131 C3's, on the v18 line. `anchorHolder` therefore still means - // exactly what it meant: a row exists. What changed is what the row BUYS, - // which under a wall is now nothing. + // ADR-0131 D10's ceremony's. Under a wall the holder scan asks nothing + // (no grant anchors standing here), so `anchorHolder` is always undefined + // on this branch. // Fail-closed backstop for an unusable config (unset, blank, or a list // REFUSED for an unparseable entry — #11663 Choice 2B folds all three @@ -920,10 +903,10 @@ export async function bootstrapPlatformAdmin( // It used to be, and the reason it used to be has expired: L4's skip was // justified by 「the deployment HAS an administrator, on the old anchor」 and // by the deprecation pointer carrying the remedy instead. Both premises died - // with the walled dual read. A walled rig holding an unscoped - // `admin_full_access` row and declaring nobody now has ZERO platform - // administrators — exactly the state this line exists to announce — so - // staying quiet for it would be the silent half of a fail-closed guard. + // with the walled dual read. A walled rig holding an `admin_full_access` + // grant and declaring nobody has ZERO platform administrators — exactly + // the state this line exists to announce — so staying quiet for it would + // be the silent half of a fail-closed guard. const platformAdminConfig = resolvePlatformAdminEmails(); if (platformAdminConfig.emails.length === 0) { const message = @@ -933,19 +916,7 @@ export async function bootstrapPlatformAdmin( 'postures the first registrant is never promoted and no grant row is written; ' + `platform admin standing is derived from ${PLATFORM_OWNER_EMAIL_ENV} at request ` + "time. Set it to the operator's email address (or a comma-separated list of " + - 'addresses) and make sure the account verifies its email.' + - // [#11663 L5] The migration window's last word, said where an operator can - // act on it. This rig HAS an unscoped `admin_full_access` row and used to - // be administered through it; under a wall that row stopped conferring - // when the dual read retired, so saying only "declare somebody" would hide - // the fact that somebody has just lost standing. - (anchorHolder - ? " This deployment also holds a legacy unscoped 'admin_full_access' grant row" + - ` (user ${String(anchorHolder.user_id)}). Under a walled posture that row is NO LONGER` + - ` an anchor for platform admin standing — the migration window announced since 17.3.0 has` + - ` closed — so declaring that account's verified address in ${PLATFORM_OWNER_EMAIL_ENV} is` + - ' what restores it. The row itself is left exactly as it is.' - : ''); + 'addresses) and make sure the account verifies its email.'; if (logger?.error) logger.error(message); else logger?.warn?.(message); return { diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.ts index cd73f6e5b21..1b2491b0aec 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.ts @@ -123,9 +123,12 @@ function callerOrganizationId(context: any): string | undefined { * Does a `sys_user_position` row take effect in `organizationId`? * * The runtime resolver's own answer (`resolveAuthzContext` step 4, in - * `@objectstack/core`): a holding stamped with a DIFFERENT organization grants - * nothing there; an organization-less holding grants in every organization; - * `organizationId` undefined (a context carrying no organization) drops nothing. + * `@objectstack/core`, `grantAppliesInTenant`): a holding takes effect only in + * its OWN organization. A holding stamped with a different organization grants + * nothing here; an organization-less holding grants nowhere — the 18.0 + * ownership ceremony (ADR-0131 D10) attributes every such row before an + * upgraded database boots, so one is invalid state, not a "global" holding; + * and a context carrying no organization holds nothing. * * Every holding the gate reasons about goes through this, because * `sys_user_position.position` is a position NAME, the same string in every @@ -133,13 +136,12 @@ function callerOrganizationId(context: any): string | undefined { * a walled posture another organization's holding of the position is the same * string. The * gate must count exactly the holdings the runtime grants: counting another - * organization's lets a user self-delegate a position they do not hold there; - * dropping organization-less ones refuses holdings the runtime honours. + * organization's, or one the runtime reads as belonging to none, lets a user + * self-delegate a position they do not hold there. */ function holdingTakesEffectIn(row: any, organizationId?: string): boolean { - if (!organizationId) return true; const org = rowOrganizationId(row); - return !org || org === organizationId; + return !!organizationId && !!org && org === organizationId; } /** Coerce a stored timestamp to epoch ms; undefined = absent, NaN = unparseable. */ @@ -690,9 +692,8 @@ export class DelegatedAdminGate { * `sys_user_permission_set` rows of the caller's own organization: an insert, * or a delete of one row by id. A row inserted with no organization is * stamped with the caller's here, once admitted, so the right never mints an - * organization-less grant (one that applies in every organization); a row - * naming another organization, or a removal of an organization-less or - * foreign row, is refused. That the row's user is a member of the + * organization-less grant; a row naming another organization, or a removal + * of a row that is not the caller's organization's, is refused. That the row's user is a member of the * organization is judged for every writer by the grant-holder membership * hooks (`grant-holder-membership-refusal.ts`), not again here. * @@ -730,7 +731,7 @@ export class DelegatedAdminGate { return { reason: rowOrg ? `the row belongs to organization '${rowOrg}', not the one you administer` - : 'the existing row carries no organization — it applies in every organization, so only a platform administrator may remove it', + : 'the row carries no organization, so it is not one of the organization you administer', meta: { organizationId: rowOrg }, }; } @@ -1265,12 +1266,10 @@ export class DelegatedAdminGate { * `{ isSystem: true }`; * 2. the candidates that come back are reduced to the caller's OWN row by * {@link resolveOwnOrganizationRow}. That arm is load-bearing on its own: - * the driver's compatibility arm deliberately also returns - * organization-less rows, and a driver with no tenant scoping at all - * returns every organization's. Under a walled posture an - * organization-less business unit is invalid state - * (`organization-scope.ts`), so neither class may answer a - * delegation boundary. + * a driver with no tenant scoping at all returns every organization's + * rows, and an organization-less business unit is invalid state + * (`organization-scope.ts`), so neither may answer a delegation + * boundary. * * Fail closed on both axes: a name that resolves to no row of the caller's * own organization approves nothing, exactly as a misconfigured scope does. diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.ts index ab1b99a0a1d..95c551b04d9 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.ts @@ -176,20 +176,6 @@ export function grantSetNameOf(row: unknown): string | undefined { return name === '' ? undefined : name; } -/** - * The organization a stored grant (or catalog row) belongs to, or `null` for - * one that belongs to none. A blank value is the legacy organization-less - * spelling, read the way every grant reader already reads it - * (`!organization_id`). - */ -export function grantOrganizationOf(row: unknown): string | null { - if (!row || typeof row !== 'object') return null; - const r = row as Record; - const value = r.organization_id ?? r.organizationId; - if (value === null || value === undefined || value === '') return null; - return String(value); -} - interface GrantNameEngine { registerHook?: ( event: string, diff --git a/packages/plugins/plugin-security/src/organization-scope.ts b/packages/plugins/plugin-security/src/organization-scope.ts index 9dc7148da58..b7e2f9dee34 100644 --- a/packages/plugins/plugin-security/src/organization-scope.ts +++ b/packages/plugins/plugin-security/src/organization-scope.ts @@ -2,9 +2,12 @@ /** * Organization scoping for the RBAC catalog's per-organization passes: the - * system context one pass runs under and the one read that tells an - * organization's own row apart from organization-less residue (the sweep and - * the reap guard retired with their one caller, the seeder; #15204 stage 6b-2). + * system context one pass runs under and the one read that picks an + * organization's own row out of a scoped read (the sweep and the reap guard + * retired with their one caller, the seeder; #15204 stage 6b-2 — and the + * organization-less residue it reported retired with the 18.0 ownership + * ceremony, ADR-0131 D10, which attributes every such row before an upgraded + * database boots). * * ## Why the catalog is materialized per organization * @@ -30,52 +33,15 @@ * * ## The doctrine this file implements * - * An organization-less row is INVALID STATE under a walled posture — refuse or - * warn loudly, never treat it as a platform-wide default. The older reading, in - * which a NULL organization marked a platform row visible to every tenant, - * survives only as DRIVER-LEVEL COMPATIBILITY BEHAVIOUR: `applyTenantScope` - * still emits the `OR organization_id IS NULL` arm, and this module depends on - * that arm being there — it is precisely how a per-organization pass can still - * SEE a pre-fix organization-less row and therefore say something about it. - * - * ## What replaces a reap - * - * #8617 reaped its pre-fix organization-less rows. This catalog does not, and - * the difference is deliberate rather than an omission: - * - * - a fresh walled deployment never mints an organization-less catalog row - * FROM THESE FOUR SEEDERS once they run per organization, so there is nothing - * of theirs to migrate. It does NOT follow that a fresh walled deployment - * holds none, and this file used to claim it did (#11532). - * `bootstrapPlatformAdmin` is a FIFTH seeder, outside the four converted - * here, and it writes one organization-less `sys_permission_set` row per - * `defaultPermissionSets` entry on EVERY boot — before any organization - * exists (measured on a fresh walled rig: 8 rows, 1.3 s ahead of the first - * `sys_organization`). That is the RULED outcome rather than a leak: the - * 2026-08-20 maintainer ruling on #10103 keeps that platform bucket - * "unreaped and loudly warned about under walled posture", and PLATFORM_ADMIN - * is derived from an unscoped grant pointing at its `admin_full_access` row - * BY ROW ID, so a reap would silently demote every platform admin. The guard - * below therefore has to tell the two classes apart; - * - a `single`-posture deployment is where organization-less rows are the - * CORRECT shape, and the carve-out below leaves it byte-for-byte unchanged; - * - the rows a reap would delete are grant TARGETS — `sys_user_position`, - * `sys_position_permission_set` and `sys_user_permission_set` all point at - * them by row id — so deleting them revokes standing access with no signal at - * the moment of loss. #8617's reap could promise "NO grant changes" precisely - * because it never touched a junction table; here the junctions ARE the - * grants. - * - * So the pass said so instead. Its guard (retired with the seeder) named the - * rows and named the remedy, and — this is the load-bearing half — the pass - * still CREATES the organization's own copy. The failure shape it exists to - * prevent is the silent no-op: a tenant-threaded pass sees the pre-fix - * organization-less row through the driver's compatibility arm, reads the name - * as already represented, takes the update branch and creates nothing, leaving - * the deployment exactly as broken as before while reporting success. - * {@link resolveOwnOrganizationRow} is the one read that distinguishes "this - * organization has its row" from "somebody's organization-less row is visible - * here", and every seeder in this catalog routes through it. + * An organization-less row is INVALID STATE — never a platform-wide default. + * The 18.0 ownership ceremony (ADR-0131 D10) attributes every one an earlier + * release wrote before an upgraded database boots, and makes the column NOT + * NULL; a fresh install that registers the organization object never writes + * one (ADR-0131 D9: a system write with no organization of its own is stamped + * with the derivable one, or refused). So + * {@link resolveOwnOrganizationRow} answers one question — which row is this + * organization's own — and a row of any other shape that a read happens to + * return never answers for the organization. */ /** @@ -109,31 +75,19 @@ export function rowOrganizationId(row: any): string | null { /** * Resolve THIS organization's own row for a name, out of what a tenant-scoped - * read returned. - * - * A scoped read passes through `applyTenantScope`, whose compatibility arm - * returns the caller's rows AND any organization-less ones. Those two are not - * interchangeable and the seeders must never treat them as such: + * read returned: the row stamped with `organizationId`, or `null`. * - * - a row stamped with `organizationId` is this organization's — update it; - * - an organization-less row is a PRE-FIX residue that merely happens to be - * visible here. Reading it as "already seeded" is the silent no-op this - * catalog exists to prevent, so it is reported separately and never returned - * as the organization's own row. + * Any other row the read returned — another organization's, from a driver with + * no tenant scoping, or an organization-less one — is never the organization's + * own and never answers for it. An organization-less row is invalid state + * after the ownership ceremony (ADR-0131 D10), so it is not reported either: + * there is no residue to report. * - * Under a `single`-posture pass (`organizationId` undefined) the - * organization-less row IS the row, which is the carve-out, so it is returned - * as `own` and nothing is flagged. + * Under a pass with no organization (`organizationId` undefined) there is + * nothing to tell apart, and the first row is the row. */ -export function resolveOwnOrganizationRow( - rows: any[], - organizationId?: string, -): { own: any | null; organizationLessResidue: any | null } { +export function resolveOwnOrganizationRow(rows: any[], organizationId?: string): { own: any | null } { const list = Array.isArray(rows) ? rows : []; - if (!organizationId) { - return { own: list[0] ?? null, organizationLessResidue: null }; - } - const own = list.find((r) => rowOrganizationId(r) === organizationId) ?? null; - const residue = list.find((r) => rowOrganizationId(r) === null) ?? null; - return { own, organizationLessResidue: residue }; + if (!organizationId) return { own: list[0] ?? null }; + return { own: list.find((r) => rowOrganizationId(r) === organizationId) ?? null }; } From 83537b8af4fb593e369904eb89b05c6ad97ef622 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 06:56:05 +0000 Subject: [PATCH 36/44] wip(test): re-stamp the grant fixtures that relied on an organization-less row Claude-Session: https://claude.ai/code/session_01QzqbxJk3oD6sqe47Q84ET8 Co-authored-by: Claude --- ...organization-less-grant-readers-retired.md | 20 ++ ...authz-context.batch-equivalence.testkit.ts | 37 +- .../security/admin-standing-surface.test.ts | 22 +- .../security/authz-store-unavailable.test.ts | 20 +- ...uthz-context.batch-equivalence.golden.json | 8 + ...ve-authz-context.batch-equivalence.test.ts | 22 +- ...-context.default-organization-memo.test.ts | 10 +- ...ve-authz-context.grant-set-by-name.test.ts | 53 ++- ...uthz-context.platform-admin-config.test.ts | 95 +++-- ...-authz-context.request-grants-memo.test.ts | 12 +- .../security/resolve-authz-context.test.ts | 218 ++++++----- .../resolve-user-grants-cache.test.ts | 44 ++- .../src/admin-has-permission-endpoint.test.ts | 12 +- .../src/admin-impersonate-endpoint.test.ts | 15 +- .../plugin-auth/src/auth-manager.test.ts | 58 ++- .../src/organization-add-member.test.ts | 15 +- ...tform-admin-standing.consolidation.test.ts | 20 +- .../src/session-grants-resolved-once.test.ts | 8 +- ...sion-platform-admin-rung-agreement.test.ts | 43 ++- .../session-positions-security-axis.test.ts | 7 +- .../sso-register-platform-admin-gate.test.ts | 27 +- .../src/vendor-admin-refusal-envelope.test.ts | 6 +- ...latform-admin-existing-holder-scan.test.ts | 337 +++++++++++------- ...tstrap-platform-admin-walled-owner.test.ts | 38 +- ...m-seed-ownership-seed-settle-rerun.test.ts | 8 +- ...lled-by-parent-chain-middle-master.test.ts | 22 +- ...ed-admin-gate-holding-organization.test.ts | 26 +- .../src/delegated-admin-gate.test.ts | 53 ++- .../src/explain-asker-gate.test.ts | 36 +- .../src/explain-engine.test.ts | 216 +++++++---- .../explain-positions-name-authority.test.ts | 40 ++- .../src/explain-principal-person.test.ts | 32 +- .../src/grant-readers-unnamed-grant.test.ts | 6 +- .../default-row-scope-declarations.test.ts | 6 +- .../src/orgless-position-name-fold.test.ts | 87 +++-- .../src/position-catalog-refusal.test.ts | 27 +- .../src/security-plugin.test.ts | 15 +- .../tenant-wall-organization-removal.test.ts | 5 +- 38 files changed, 1166 insertions(+), 560 deletions(-) create mode 100644 .changeset/24583-7a2-organization-less-grant-readers-retired.md diff --git a/.changeset/24583-7a2-organization-less-grant-readers-retired.md b/.changeset/24583-7a2-organization-less-grant-readers-retired.md new file mode 100644 index 00000000000..e93381476d8 --- /dev/null +++ b/.changeset/24583-7a2-organization-less-grant-readers-retired.md @@ -0,0 +1,20 @@ +--- +'@objectstack/core': minor +'@objectstack/plugin-security': minor +--- + +feat(core,plugin-security)!: a grant applies only in its own organization — an organization-less grant row confers nothing, and the Default Organization's `admin_full_access` grant is the only platform-admin anchor + +Clause-②: yes (narrowing) + + + +**BREAKING** for a stored grant row that carries no `organization_id` (`NULL`, absent or blank), shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D10). After the ownership ceremony no such row exists: it attributes every one to an organization and makes the column NOT NULL, a server refuses a database it did not create or certify, and a fresh install that registers the organization object never writes one. So on a certified or fresh database nothing resolves differently. + +- **`resolveUserAuthzGrants` / `resolveAuthzContext` (`@objectstack/core`).** A `sys_user_position` or `sys_user_permission_set` row applies only while its organization is the active one. FROM: a row with no organization was "global" and applied in every resolution, including one with no active organization. TO: it applies nowhere, and a resolution with no active organization gets no stored grant at all. +- **Platform-admin standing.** FROM: under `single`, an organization-less `admin_full_access` grant anchored `platform_admin`, beside the Default Organization's. TO: only the Default Organization's grant anchors it (still in every organization, and with none active). Walled postures are unchanged: standing there comes from `OS_PLATFORM_OWNER_EMAIL` alone. +- **`bootstrapPlatformAdmin` (`@objectstack/plugin-security`).** The existing-admin check reads only the Default Organization's grant. When no Default Organization can be named (a walled posture, or none to name), it reads no grant at all. The walled "no owner email declared" error drops its sentence about a legacy unscoped grant row. +- **The delegated-administration gate.** A held position counts toward self-delegation only when its row belongs to the caller's active organization. An organization-less holding, or a caller with no active organization, holds nothing there, which is what the resolver grants. An organization administrator's removal of an organization-less row is refused as "not one of the organization you administer". +- **What a lean composition sees.** A composition that registers no organization object (`sys_organization`, which plugin-auth ships) still stores grants without an organization, and those grants now confer nothing. No shipped composition does this: `os serve` registers the security plugin only together with plugin-auth. + +**What an operator does.** Nothing beyond the upgrade itself: run `os migrate organization-ownership` before serving a database that 17.x or an 18.0 pre-release created. The server refuses such a database until you do. If an embedding composes `@objectstack/plugin-security` without plugin-auth, register the organization object so that grants carry an organization. diff --git a/packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts b/packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts index 1c9ad3c081e..d9aa1d30833 100644 --- a/packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts +++ b/packages/core/src/security/__tests__/resolve-authz-context.batch-equivalence.testkit.ts @@ -215,9 +215,10 @@ export const FIXTURES: Fixture[] = [ }, }, { - // Position-derived grants: a global row, an org-scoped row for ANOTHER org - // (must not resolve), a lapsed row, a not-yet-valid row, plus the ADR-0049 - // deactivated position whose NAME must leave `positions` too. + // Position-derived grants: rows owned by the active org, an org-scoped row + // for ANOTHER org (must not resolve), an organization-less row (applies + // nowhere, so must not resolve either), a lapsed row, a not-yet-valid row, + // plus the ADR-0049 deactivated position whose NAME must leave `positions` too. name: 'position-derived-grants', userId: 'u_pos', opts: { tenantId: 'org_a', seedEmail: 'pos@x.com', nowMs: T0 }, @@ -225,12 +226,13 @@ export const FIXTURES: Fixture[] = [ sys_user: [{ id: 'u_pos' }], sys_member: [{ user_id: 'u_pos', organization_id: 'org_a', role: 'member' }], sys_user_position: [ - { user_id: 'u_pos', position: 'contributor', organization_id: null }, + { user_id: 'u_pos', position: 'contributor', organization_id: 'org_a' }, { user_id: 'u_pos', position: 'auditor', organization_id: 'org_a' }, { user_id: 'u_pos', position: 'foreigner', organization_id: 'org_z' }, - { user_id: 'u_pos', position: 'expired_role', organization_id: null, valid_until: past }, - { user_id: 'u_pos', position: 'future_role', organization_id: null, valid_from: future }, - { user_id: 'u_pos', position: 'retired', organization_id: null }, + { user_id: 'u_pos', position: 'orgless_role', organization_id: null }, + { user_id: 'u_pos', position: 'expired_role', organization_id: 'org_a', valid_until: past }, + { user_id: 'u_pos', position: 'future_role', organization_id: 'org_a', valid_from: future }, + { user_id: 'u_pos', position: 'retired', organization_id: 'org_a' }, ], sys_position: [ { id: 'p_contrib', name: 'contributor' }, @@ -254,27 +256,32 @@ export const FIXTURES: Fixture[] = [ }, }, { - // Permission-set-derived grants: the UNSCOPED `admin_full_access` user grant - // is the ONLY thing that derives platform_admin, an org-scoped copy is not, - // a lapsed one is not, and a DEACTIVATED set grants nothing at all. + // Permission-set-derived grants: the Default Organization's `admin_full_access` + // user grant is the ONLY thing that derives platform_admin (the `single` + // posture's anchor, applying in whichever organization is active), another + // org's copy is not, an organization-less row confers nothing at all, a + // lapsed one is not, and a DEACTIVATED set grants nothing at all. name: 'permission-set-derived-grants', userId: 'u_ps', opts: { tenantId: 'org_a', seedEmail: 'ps@x.com', nowMs: T0 }, tables: { sys_user: [{ id: 'u_ps' }], + sys_organization: [{ id: 'org_default', slug: 'default' }, { id: 'org_a', slug: 'org-a' }], sys_member: [{ user_id: 'u_ps', organization_id: 'org_a', role: 'member' }], sys_user_position: [], sys_user_permission_set: [ - { user_id: 'u_ps', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, + { user_id: 'u_ps', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_default' }, { user_id: 'u_ps', permission_set_id: 'ps_org', permission_set: 'org_tools', organization_id: 'org_a' }, { user_id: 'u_ps', permission_set_id: 'ps_other', permission_set: 'other_org_tools', organization_id: 'org_z' }, - { user_id: 'u_ps', permission_set_id: 'ps_lapsed', permission_set: 'lapsed_set', organization_id: null, valid_until: past }, - { user_id: 'u_ps', permission_set_id: 'ps_dead', permission_set: 'dead_set', organization_id: null }, + { user_id: 'u_ps', permission_set_id: 'ps_orgless', permission_set: 'orgless_set', organization_id: null }, + { user_id: 'u_ps', permission_set_id: 'ps_lapsed', permission_set: 'lapsed_set', organization_id: 'org_a', valid_until: past }, + { user_id: 'u_ps', permission_set_id: 'ps_dead', permission_set: 'dead_set', organization_id: 'org_a' }, ], sys_permission_set: [ { id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_users'] }, { id: 'ps_org', name: 'org_tools', tab_permissions: { crm: 'hidden' } }, { id: 'ps_other', name: 'other_org_tools' }, + { id: 'ps_orgless', name: 'orgless_set', system_permissions: ['orgless_cap'] }, { id: 'ps_lapsed', name: 'lapsed_set' }, { id: 'ps_dead', name: 'dead_set', active: false }, ], @@ -291,7 +298,7 @@ export const FIXTURES: Fixture[] = [ sys_user: [{ id: 'u_ta' }], sys_member: [{ user_id: 'u_ta', organization_id: 'org_a', role: 'admin' }], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'u_ta', permission_set_id: 'ps_low', permission_set: 'low', organization_id: null }], + sys_user_permission_set: [{ user_id: 'u_ta', permission_set_id: 'ps_low', permission_set: 'low', organization_id: 'org_a' }], sys_position: [{ id: 'p_orgadmin', name: 'org_admin' }, { id: 'p_everyone', name: 'everyone' }], sys_position_permission_set: [{ position_id: 'p_orgadmin', permission_set_id: 'ps_oa' }], sys_permission_set: [ @@ -342,7 +349,7 @@ export const FIXTURES: Fixture[] = [ sys_user: [{ id: 'u_seed', email: 'ignored@x.com', ai_access: 1 }], sys_member: [{ user_id: 'u_seed', organization_id: 'org_a', role: 'member' }], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'u_seed', permission_set_id: 'ps_x', permission_set: 'extra', organization_id: null }], + sys_user_permission_set: [{ user_id: 'u_seed', permission_set_id: 'ps_x', permission_set: 'extra', organization_id: 'org_a' }], sys_permission_set: [{ id: 'ps_x', name: 'extra' }], sys_position: [], }, diff --git a/packages/core/src/security/admin-standing-surface.test.ts b/packages/core/src/security/admin-standing-surface.test.ts index 8472ffd8a57..20f76e75706 100644 --- a/packages/core/src/security/admin-standing-surface.test.ts +++ b/packages/core/src/security/admin-standing-surface.test.ts @@ -151,16 +151,19 @@ const VARIANTS: Record< platformAdminEmails?: string; } > = { - // Snake_case rows, unscoped in-window grant, active set: the happy platform-admin path. + // Snake_case rows, the Default Organization's in-window grant, active set: the + // happy platform-admin path, resolved in another organization (`org_1`), which + // owns the position grant. 'snake-case rows, standing intact': { org: 'org_1', tables: { sys_user: [{ id: 'usr_1', email: 'ada@example.com', ai_access: 1 }], + sys_organization: [{ id: 'org_default', slug: 'default' }, { id: 'org_1', slug: 'one' }], sys_member: [ { id: 'mem_1', user_id: 'usr_1', organization_id: 'org_1', role: 'owner', valid_from: null, valid_until: null }, ], sys_user_position: [ - { id: 'upo_1', user_id: 'usr_1', position: 'contributor', organization_id: null, valid_from: null, valid_until: null }, + { id: 'upo_1', user_id: 'usr_1', position: 'contributor', organization_id: 'org_1', valid_from: null, valid_until: null }, ], sys_position: [{ id: 'pos_1', name: 'contributor', active: true }], sys_position_permission_set: [{ position_id: 'pos_1', permission_set_id: 'pst_2' }], @@ -169,7 +172,7 @@ const VARIANTS: Record< id: 'ups_1', user_id: 'usr_1', permission_set_id: 'pst_1', permission_set: 'admin_full_access', - organization_id: null, + organization_id: 'org_default', valid_from: null, valid_until: null, }, @@ -194,10 +197,10 @@ const VARIANTS: Record< tables: { sys_user: [{ id: 'usr_1', email: 'ada@example.com', ai_access: true }], sys_member: [{ id: 'mem_1', userId: 'usr_1', organizationId: 'org_1', role: 'admin' }], - sys_user_position: [{ id: 'upo_1', userId: 'usr_1', position: 'contributor' }], + sys_user_position: [{ id: 'upo_1', userId: 'usr_1', position: 'contributor', organizationId: 'org_1' }], sys_position: [{ id: 'pos_1', name: 'contributor', active: true }], sys_position_permission_set: [{ positionId: 'pos_1', permissionSetId: 'pst_2' }], - sys_user_permission_set: [{ id: 'ups_1', userId: 'usr_1', permissionSetId: 'pst_1' }], + sys_user_permission_set: [{ id: 'ups_1', userId: 'usr_1', permissionSetId: 'pst_1', organizationId: 'org_1' }], sys_permission_set: [ { id: 'pst_1', @@ -269,10 +272,11 @@ const VARIANTS: Record< }, // [ADR-0131 D5] The `single` promotion's grant, owned by the Default - // Organization: the only variant in which that organization's row is read, - // so the union needs it — without it `sys_organization.id` would be absent - // from the declaration and the guard would stop judging the delete that - // moves the anchor off this grant. + // Organization and resolved IN it, with no other grant: the isolated reading + // of that organization's row (the happy path above reads it too, beside + // everything else), so the union needs it — without it `sys_organization.id` + // would be absent from the declaration and the guard would stop judging the + // delete that moves the anchor off this grant. 'default-organization-owned grant': { org: 'org_1', tables: { diff --git a/packages/core/src/security/authz-store-unavailable.test.ts b/packages/core/src/security/authz-store-unavailable.test.ts index 817064db4b4..ab52901b942 100644 --- a/packages/core/src/security/authz-store-unavailable.test.ts +++ b/packages/core/src/security/authz-store-unavailable.test.ts @@ -36,6 +36,9 @@ import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit. const USER = 'u_admin'; const SESSION = { getSession: async () => ({ user: { id: USER } }) }; +/** The organization {@link qlHealthy}'s grant belongs to, and the session acting in it. */ +const ORG = 'org_a'; +const SESSION_IN_ORG = { getSession: async () => ({ user: { id: USER }, session: { activeOrganizationId: ORG } }) }; /** The permission store, unreachable — every read throws, as a driver outage does. */ const qlDown = () => ({ find: async () => { throw new Error('permission store unreachable'); } }); @@ -44,11 +47,12 @@ const qlEmpty = () => ({ find: async () => [] }); /** * A store that actually grants something, so "resolves" is read against a real * grant: the grant row, and the set it names in the security catalog bound to - * the engine (ADR-0131 D3/D4). + * the engine (ADR-0131 D3/D4). The grant belongs to {@link ORG} and applies only + * there, so it is resolved with {@link SESSION_IN_ORG}. */ const qlHealthy = () => bindStaticSecurityCatalog({ find: async (object: string) => { - if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps', permission_set: 'pkg_admin' }]; + if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps', permission_set: 'pkg_admin', organization_id: ORG }]; if (object === 'sys_permission_set') return [{ id: 'ps', name: 'pkg_admin' }]; return []; }, @@ -83,7 +87,7 @@ describe('[#13279] resolveAuthzContext — an outage is loud, an empty store is }); it('⭐ CONTROL: a healthy store still resolves the capability it grants', async () => { - const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: qlHealthy(), headers: {}, ...SESSION }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: qlHealthy(), headers: {}, ...SESSION_IN_ORG }); expect(ctx.userId).toBe(USER); expect(ctx.systemPermissions).toContain('studio.access'); }); @@ -472,9 +476,13 @@ describe('[#13279 option A] ⭐ THE UNPROVISIONED DIRECTION — a never-provisio }); const empty = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: qlEmpty(), headers: {}, ...SESSION }); expect(JSON.stringify(unprovisioned)).toBe(JSON.stringify(empty)); - // CONTROL against a vacuous comparison: a HEALTHY store differs from both. - const healthy = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: qlHealthy(), headers: {}, ...SESSION }); - expect(JSON.stringify(healthy)).not.toBe(JSON.stringify(empty)); + // CONTROL against a vacuous comparison: a HEALTHY store differs from an + // empty one — compared in the organization its grant belongs to, so the + // difference is the grant and not the session. + const healthy = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: qlHealthy(), headers: {}, ...SESSION_IN_ORG }); + const emptyInOrg = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: qlEmpty(), headers: {}, ...SESSION_IN_ORG }); + expect(JSON.stringify(healthy)).not.toBe(JSON.stringify(emptyInOrg)); + expect(healthy.systemPermissions).toContain('studio.access'); }); it('⭐ `resolveUserAuthzGrants` inherits the quiet direction too', async () => { diff --git a/packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json b/packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json index 0b1b68539f3..58ea87c34e7 100644 --- a/packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json +++ b/packages/core/src/security/resolve-authz-context.batch-equivalence.golden.json @@ -385,6 +385,14 @@ "limit": 100, "isSystem": true }, + { + "object": "sys_organization", + "where": { + "slug": "default" + }, + "limit": 1, + "isSystem": true + }, { "object": "sys_metadata_activation", "where": { diff --git a/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts b/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts index ad03dc227ac..e00e84e994a 100644 --- a/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts +++ b/packages/core/src/security/resolve-authz-context.batch-equivalence.test.ts @@ -69,6 +69,24 @@ * convert to (`convertDeactivatedCatalogRows`, the upgrade ceremony's), so the * deactivated `retired` position and `dead_set` set still grant nothing. Every * fixture now resolves in two legs. + * + * ⚠️ A fourth recorded move (the organization-less grant readers retire): a + * stored grant row applies only in its own organization, and an + * organization-less row applies NOWHERE — the ADR-0131 D10 ownership ceremony + * attributes every such row, so it is invalid state rather than a "global" + * grant. The fixtures' organization-less grant rows that exercised something + * else are re-stamped with the active organization (`org_a`), and the + * platform-admin grant with the Default Organization (`org_default`, the + * `single` posture's anchor, ADR-0131 D5); every `grants` envelope is + * byte-identical to the capture. `position-derived-grants` and + * `permission-set-derived-grants` each gain one organization-less row that must + * resolve to nothing, which the unchanged envelope pins. ONE recorded read + * moved: `permission-set-derived-grants` now issues the Default Organization + * read (`sys_organization {slug: 'default'}`, limit 1) that an + * organization-owned `admin_full_access` grant has always cost under `single`; + * it joins the activation-ledger read's wave, so the fixture still resolves in + * two legs. That read is in the golden because the code genuinely issues it — + * the organization-less row this fixture used to hold asked none. */ import { readFileSync } from 'node:fs'; @@ -258,7 +276,9 @@ describe('[#10825] batched resolveUserAuthzGrants — equivalence with the seque expect(wave1).toEqual( asMultiset(GOLDEN[name].queries) .map((s) => JSON.parse(s) as RecordedCall) - .filter((c) => c.object !== 'sys_metadata_activation') + // Wave 2: the activation ledger and the Default Organization read, + // which are issued together after wave 1 (#10757 leg 4). + .filter((c) => c.object !== 'sys_metadata_activation' && c.object !== 'sys_organization') .map((c) => c.object) .sort(), ); diff --git a/packages/core/src/security/resolve-authz-context.default-organization-memo.test.ts b/packages/core/src/security/resolve-authz-context.default-organization-memo.test.ts index ada9f45c769..c86cce7483a 100644 --- a/packages/core/src/security/resolve-authz-context.default-organization-memo.test.ts +++ b/packages/core/src/security/resolve-authz-context.default-organization-memo.test.ts @@ -152,10 +152,14 @@ describe('[#24523] the Default Organization answer is read once per engine', () expect(await e.resolve()).toEqual({ reads: [BY_SLUG], standing: true }); }); - it('CONTROL — an organization-less grant never reads it, memo or not', async () => { + it('CONTROL — an organization-less grant never reads it, memo or not, and anchors nothing', async () => { const e = makeEngineQl(tablesOf('', [{ id: 'org_a', slug: 'default' }])); - // The grant's organization is blank: the legacy organization-less spelling. - expect(await e.resolve()).toEqual({ reads: [], standing: true }); + // The grant's organization is blank: the legacy organization-less spelling, + // which applies nowhere and anchors nothing — the D10 ownership ceremony + // attributes such a row to the Default Organization, so one still carrying + // none is invalid state. The arms above, on the same organization, are the + // positive half: the grant owned by `org_a` anchors. + expect(await e.resolve()).toEqual({ reads: [], standing: false }); expect(e.middlewares).toHaveLength(0); }); }); diff --git a/packages/core/src/security/resolve-authz-context.grant-set-by-name.test.ts b/packages/core/src/security/resolve-authz-context.grant-set-by-name.test.ts index c9ffc353324..519129ed1f4 100644 --- a/packages/core/src/security/resolve-authz-context.grant-set-by-name.test.ts +++ b/packages/core/src/security/resolve-authz-context.grant-set-by-name.test.ts @@ -15,7 +15,8 @@ * - the body is the catalog definition, whichever organization's row carries * the name — and a name only a row carries (no definition) confers nothing; * - a grant that names nothing confers nothing, platform standing included; - * - platform standing stands on an organization-less grant only; + * - platform standing stands on the Default Organization's grant only, and an + * organization-less grant confers nothing at all; * - deactivation: a ledger row switches a name off in every organization, and * a catalog row's `active` column is not read; * - position-bound sets are the ones the position's definition names, and the @@ -31,6 +32,9 @@ import { bindStaticSecurityCatalog } from './__tests__/security-catalog.testkit. const ORG_A = 'org_a'; const ORG_B = 'org_b'; +const DEFAULT_ORG = 'org_default'; +/** The organizations a platform-standing fixture names: the Default Organization and ORG_B. */ +const ORGANIZATIONS = { sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }, { id: ORG_B, slug: 'b' }] }; const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; const POSTURE_ENV = 'OS_TENANCY_POSTURE'; @@ -109,22 +113,32 @@ async function resolve( describe('[ADR-0131 D3/D4] a grant names its set; the catalog says what the set is', () => { it('the body is the definition, whichever organization\'s row carries the name', async () => { - for (const [grantOrg, tenantId] of [[ORG_A, ORG_A], [null, ORG_A], [ORG_B, ORG_B], [null, undefined]] as const) { + for (const [grantOrg, tenantId] of [[ORG_A, ORG_A], [ORG_B, ORG_B]] as const) { const { out } = await resolve([{ permission_set_id: 'ps_tools_a', permission_set: 'tools', organization_id: grantOrg }], tenantId); expect(out.permissions, `${grantOrg} in ${tenantId}`).toEqual(['tools']); expect(out.systemPermissions, `${grantOrg} in ${tenantId}`).toEqual(['cap_tools']); } }); + it('⛔ an ORGANIZATION-LESS grant of the same name confers nothing — in an organization or with none', async () => { + // The CONTROL is the arm above: the same name, owned by the active + // organization, resolves its body on the same catalog. + for (const tenantId of [ORG_A, ORG_B, undefined]) { + const { out } = await resolve([{ permission_set_id: 'ps_tools', permission_set: 'tools', organization_id: null }], tenantId); + expect(out.permissions, String(tenantId)).toEqual([]); + expect(out.systemPermissions, String(tenantId)).toEqual([]); + } + }); + it('a name only a ROW carries — no definition — confers nothing, in its organization or out of it', async () => { - for (const [grantOrg, tenantId] of [[ORG_B, ORG_B], [null, ORG_B], [null, undefined]] as const) { + for (const [grantOrg, tenantId] of [[ORG_B, ORG_B], [ORG_A, ORG_A]] as const) { const { out } = await resolve([{ permission_set_id: 'ps_row_only', permission_set: 'row_only', organization_id: grantOrg }], tenantId); expect(out.permissions, `${grantOrg} in ${tenantId}`).toEqual([]); } }); it('CONTROL — a definition with no row at all confers its body: no row carries no flag to read', async () => { - const { out } = await resolve([{ permission_set: 'no_row', organization_id: null }], ORG_A); + const { out } = await resolve([{ permission_set: 'no_row', organization_id: ORG_A }], ORG_A); expect(out.permissions).toEqual(['no_row']); expect(out.systemPermissions).toEqual(['cap_no_row']); }); @@ -144,24 +158,33 @@ describe('[ADR-0131 D3/D4] a grant names its set; the catalog says what the set }); }); -describe('[ADR-0068 D2] platform standing stands on an organization-less grant only', () => { - it('an organization-less grant named admin_full_access holds platform standing', async () => { - const { out } = await resolve([{ permission_set_id: 'ps_admin_b', permission_set: ADMIN_FULL_ACCESS, organization_id: null }], ORG_B); +describe('[ADR-0068 D2] platform standing stands on the Default Organization\'s grant only', () => { + it('the Default Organization\'s grant named admin_full_access holds platform standing, in another organization too', async () => { + const { out } = await resolve([{ permission_set_id: 'ps_admin_b', permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORG }], ORG_B, ORGANIZATIONS); expect(out.posture).toBe('PLATFORM_ADMIN'); expect(out.positions[0]).toBe('platform_admin'); }); + it('⛔ an ORGANIZATION-LESS grant named admin_full_access holds no platform standing, and not the set either', async () => { + for (const tenantId of [ORG_B, DEFAULT_ORG, undefined]) { + const { out } = await resolve([{ permission_set_id: 'ps_admin', permission_set: ADMIN_FULL_ACCESS, organization_id: null }], tenantId, ORGANIZATIONS); + expect(out.posture, String(tenantId)).not.toBe('PLATFORM_ADMIN'); + expect(out.positions, String(tenantId)).not.toContain('platform_admin'); + expect(out.permissions, String(tenantId)).toEqual([]); + } + }); + it('an ORGANIZATION’s admin_full_access grant carries the set, never platform standing', async () => { - const { out } = await resolve([{ permission_set_id: 'ps_admin_b', permission_set: ADMIN_FULL_ACCESS, organization_id: ORG_B }], ORG_B); + const { out } = await resolve([{ permission_set_id: 'ps_admin_b', permission_set: ADMIN_FULL_ACCESS, organization_id: ORG_B }], ORG_B, ORGANIZATIONS); expect(out.permissions).toContain(ADMIN_FULL_ACCESS); expect(out.posture).not.toBe('PLATFORM_ADMIN'); }); it('with no admin_full_access definition in the catalog, the grant confers nothing at all', async () => { const { out } = await resolve( - [{ permission_set_id: 'ps_admin', permission_set: ADMIN_FULL_ACCESS, organization_id: null }], - undefined, + [{ permission_set_id: 'ps_admin', permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORG }], undefined, + ORGANIZATIONS, { ...definitions, permissions: definitions.permissions.filter((d) => d.name !== ADMIN_FULL_ACCESS) }, ); expect(out.posture).not.toBe('PLATFORM_ADMIN'); @@ -170,9 +193,9 @@ describe('[ADR-0068 D2] platform standing stands on an organization-less grant o it('admin_full_access switched off in the activation ledger takes the standing away', async () => { const { out } = await resolve( - [{ permission_set_id: 'ps_admin', permission_set: ADMIN_FULL_ACCESS, organization_id: null }], + [{ permission_set_id: 'ps_admin', permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORG }], undefined, - { sys_metadata_activation: [off('permission', ADMIN_FULL_ACCESS)] }, + { ...ORGANIZATIONS, sys_metadata_activation: [off('permission', ADMIN_FULL_ACCESS)] }, ); expect(out.posture).not.toBe('PLATFORM_ADMIN'); expect(out.permissions).toEqual([]); @@ -181,7 +204,7 @@ describe('[ADR-0068 D2] platform standing stands on an organization-less grant o describe('[ADR-0049, ADR-0131 D3] the activation ledger switches a set off', () => { it('a ledger row switches the name off in every organization: no set, no capability', async () => { - for (const [grantOrg, tenantId] of [[ORG_A, ORG_A], [null, ORG_A], [null, undefined]] as const) { + for (const [grantOrg, tenantId] of [[ORG_A, ORG_A], [ORG_B, ORG_B]] as const) { const { out } = await resolve( [{ permission_set_id: 'ps_off_a', permission_set: 'switched', organization_id: grantOrg }], tenantId, @@ -218,8 +241,8 @@ describe('[ADR-0131 D4] a grant that names nothing confers nothing', () => { } }); - it('an unnamed UNSCOPED grant on the platform admin_full_access row: no platform standing', async () => { - const { out } = await resolve([{ permission_set_id: 'ps_admin', organization_id: null }], undefined); + it('an unnamed grant owned by the Default Organization, on the platform admin_full_access row: no platform standing', async () => { + const { out } = await resolve([{ permission_set_id: 'ps_admin', organization_id: DEFAULT_ORG }], undefined, ORGANIZATIONS); expect(out.posture).not.toBe('PLATFORM_ADMIN'); expect(out.positions).not.toContain('platform_admin'); expect(out.permissions).not.toContain(ADMIN_FULL_ACCESS); diff --git a/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts b/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts index 4f3403f19be..0cf13e63ff7 100644 --- a/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts +++ b/packages/core/src/security/resolve-authz-context.platform-admin-config.test.ts @@ -14,11 +14,14 @@ * * ⚠️ [#11663 L5] That last clause is L2's, and its WALLED half has since EXPIRED. * L2/L4 opened a time-boxed migration window; L5 closed it. Under a walled - * posture the legacy unscoped `admin_full_access` row is no longer an anchor and - * there is no deprecation pointer left to log — both symbols that carried it are - * gone from `@objectstack/core`. Under `single` the row still confers, exactly as - * it always did (Choice 4A, the zero-config path); its disposition is #11979's. - * The suites at the end of this file are the measurement of that boundary. + * posture the legacy grant-row anchor — the `admin_full_access` row first-user + * promotion mints — is no longer an anchor and there is no deprecation pointer + * left to log — both symbols that carried it are gone from `@objectstack/core`. + * Under `single` the row still confers (Choice 4A, the zero-config path). Since + * ADR-0131 D5 that row is OWNED BY THE DEFAULT ORGANIZATION; the + * organization-less row an earlier promotion wrote anchors nothing any more (the + * D10 ownership ceremony attributes it to the Default Organization). The suites + * at the end of this file are the measurement of that boundary. * * ⭐ The single most important test in this file is * "a session payload carrying a configured address over a sys_user row that @@ -336,27 +339,32 @@ describe('⭐ [#11663 L2 pin P1] the derivation reads the STORED row, never the }); }); +/** The Default Organization every grant-row fixture below owns its anchor by. */ +const DEFAULT_ORGANIZATION = 'org_default'; + /** - * A principal whose PLATFORM_ADMIN rests on the LEGACY unscoped - * `admin_full_access` grant row and nothing else — the shape - * `bootstrapPlatformAdmin` mints when it promotes the first human user. + * A principal whose PLATFORM_ADMIN rests on the LEGACY grant-row route (L5's + * name for it, as opposed to the config anchor) and nothing else: the + * `admin_full_access` row `bootstrapPlatformAdmin` mints when it promotes the + * first human user, owned by the Default Organization (ADR-0131 D5). * Hoisted out of the `#11663 L2 / P5` suite so the `#13667` posture suite below * drives the identical fixture rather than a second copy of it. */ const legacyTables = () => ({ sys_user: [{ id: 'usr_1', email: 'legacy@corp.example', email_verified: true }], + sys_organization: [{ id: DEFAULT_ORGANIZATION, slug: 'default' }], sys_member: [], sys_user_position: [], sys_position: [], sys_position_permission_set: [], sys_user_permission_set: [ - { id: 'ups_1', user_id: 'usr_1', permission_set_id: 'pst_1', permission_set: ADMIN_FULL_ACCESS, organization_id: null }, + { id: 'ups_1', user_id: 'usr_1', permission_set_id: 'pst_1', permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORGANIZATION }, ], sys_permission_set: [{ id: 'pst_1', name: ADMIN_FULL_ACCESS, active: true }], }); describe('[#11663 L5] the legacy grant row is an anchor under `single`, and NOWHERE else', () => { - it('an unscoped admin_full_access grant still confers PLATFORM_ADMIN with no config at all', async () => { + it('the Default Organization\'s admin_full_access grant still confers PLATFORM_ADMIN with no config at all', async () => { // ⭐ The zero-config path, and the reason this card is walled-only. The // harness clears both tenancy inputs, so this rig resolves `single` — what a // deployment that configured no tenancy at all looks like. Choice 4A rules @@ -417,9 +425,12 @@ describe('[#11663 L5] the legacy grant row is an anchor under `single`, and NOWH // ROW, which is ADR-0131 C3's on the v18 line and #11979's under `single` — // ⛔ not this card's. If a later leg widens to the row, this pin is the one // that should be re-authored deliberately rather than deleted in passing. + // Resolved IN the organization that owns the row: an organization-owned + // grant applies in its own organization, and with no anchor that is the + // only place it applies. declare(undefined); requestPosture('isolated'); - const grants = await resolveUserAuthzGrants(makeQl(legacyTables()), 'usr_1', { nowMs: NOW }); + const grants = await resolveUserAuthzGrants(makeQl(legacyTables()), 'usr_1', { nowMs: NOW, tenantId: DEFAULT_ORGANIZATION }); expect(grants.permissions).toContain(ADMIN_FULL_ACCESS); expect(grants.posture).toBe('MEMBER'); expect(grants.positions).not.toContain('platform_admin'); @@ -453,11 +464,11 @@ describe('[#11663 L2] the sys_user read stays CONDITIONAL on config', () => { // ─────────────────────────────────────────────────────────────────────────── /** - * [#11663 L5] The legacy unscoped anchor is POSTURE-KEYED — the request side + * [#11663 L5] The legacy grant-row anchor is POSTURE-KEYED — the request side * matching the boot side, on the standing itself rather than on a log line. * * `bootstrapPlatformAdmin` has always been posture-keyed: under `single` a - * pre-existing unscoped `admin_full_access` holder is `already_have_admin` and + * pre-existing Default Organization `admin_full_access` holder is `already_have_admin` and * the boot exits silently, because under Choice 4A that row IS that rig's * anchor — first-user promotion mints it and is ruled correct and unchanged. * Only under a walled posture is the same row the LEGACY anchor, and L4 stopped @@ -537,22 +548,31 @@ describe('[#11663 L5] the legacy anchor confers only on the rigs Choice 4A keeps } }); - it('adds NO read: the recorded query multiset is identical under both answers of the gate', async () => { + it('adds NO read of its own: the gate\'s two answers differ by the Default Organization read alone', async () => { // The in-place claim at the call site — the posture gate "asks the - // ENVIRONMENT, so it issues no query and the pinned batch-equivalence query - // multiset cannot move whichever way it answers" — re-MEASURED rather than - // quoted. #13667 owed this measurement for a gate on a LOG line; L5 owes it - // again because the gate moved onto the derivation itself, where a stray - // engine read would be a per-request cost on every walled rig. - const reads: Record = {}; + // ENVIRONMENT, so it issues no query" — re-MEASURED rather than quoted. + // #13667 owed this measurement for a gate on a LOG line; L5 owes it again + // because the gate moved onto the derivation itself, where a stray engine + // read would be a per-request cost on every walled rig. The one read the + // two answers do not share is the anchor's own question — which + // organization is the Default Organization — and `single` asks it because + // its anchor is that organization's grant, while a walled rig has no row + // anchor to ask it for (the D5 suite below pins the walled half). Resolved + // IN the organization that owns the grant, so the grant applies under both + // answers and every other read — the activation ledger's question included + // — is asked about the same names. + const reads: Record> = {}; for (const p of ['single', 'isolated'] as const) { declare(undefined); requestPosture(p); const ql = makeQl(legacyTables()); - await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW }); + await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW, tenantId: DEFAULT_ORGANIZATION }); reads[p] = ql.calls.map((c) => ({ object: c.object, where: c.where })); } - expect(reads.single).toEqual(reads.isolated); + const notOrganization = (r: { object: string }) => r.object !== 'sys_organization'; + expect(reads.single.filter(notOrganization)).toEqual(reads.isolated.filter(notOrganization)); + expect(reads.single.filter((r) => !notOrganization(r))).toEqual([{ object: 'sys_organization', where: { slug: 'default' } }]); + expect(reads.isolated.filter((r) => !notOrganization(r))).toEqual([]); expect(reads.single.length).toBeGreaterThan(0); // the fixture really did resolve }); @@ -662,7 +682,8 @@ describe('[#11663 L5] standing across postures — three arms invariant, the leg * row — one derivation site, the posture its parameter. Before this, the anchor * read the organization-less row only, so a promoted rig whose grant D10's * ceremony (or the promotion itself) had given the Default Organization resolved - * a zero-admin deployment. + * a zero-admin deployment. That organization-less row is no anchor now: the + * ceremony attributes it, so one still carrying no organization is invalid state. */ describe('[ADR-0131 D5] under `single` the Default Organization\'s admin_full_access grant is the anchor', () => { const ownedBy = (organizationId: string, organizations: Array>) => ({ @@ -679,8 +700,8 @@ describe('[ADR-0131 D5] under `single` the Default Organization\'s admin_full_ac const grants = await resolveUserAuthzGrants(makeQl(ownedBy('org_default', DEFAULT_ONLY)), 'usr_1', { nowMs: NOW, tenantId }); expect(grants.posture, String(tenantId)).toBe('PLATFORM_ADMIN'); expect(grants.positions[0]).toBe('platform_admin'); - // The anchoring grant applies wherever the principal acts, as the - // organization-less row it replaces did: the set comes with the standing. + // The anchoring grant applies wherever the principal acts, and with no + // organization active: the set comes with the standing. expect(grants.permissions).toContain(ADMIN_FULL_ACCESS); } }); @@ -714,10 +735,24 @@ describe('[ADR-0131 D5] under `single` the Default Organization\'s admin_full_ac expect(ql.calls.map((c) => c.object)).not.toContain('sys_organization'); }); - it('the organization-less row a pre-D5 promotion wrote still anchors, and asks no organization read', async () => { - const ql = makeQl({ ...legacyTables(), sys_organization: DEFAULT_ONLY }); - const grants = await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW }); - expect(grants.posture).toBe('PLATFORM_ADMIN'); - expect(ql.calls.map((c) => c.object)).not.toContain('sys_organization'); + it('⛔ the organization-less row a pre-D5 promotion wrote anchors NOTHING, confers nothing, and asks no organization read', async () => { + // The D10 ownership ceremony attributes that row to the Default Organization + // before an upgraded database boots, so a row still carrying no organization + // is invalid state, not an anchor. The CONTROL is the ⭐ arm above: the same + // grant owned by the Default Organization confers, on the same fixture. + for (const tenantId of [undefined, 'org_default']) { + const ql = makeQl({ + ...legacyTables(), + sys_organization: DEFAULT_ONLY, + sys_user_permission_set: [ + { id: 'ups_1', user_id: 'usr_1', permission_set_id: 'pst_1', permission_set: ADMIN_FULL_ACCESS, organization_id: null }, + ], + }); + const grants = await resolveUserAuthzGrants(ql, 'usr_1', { nowMs: NOW, tenantId }); + expect(grants.posture, String(tenantId)).toBe('MEMBER'); + expect(grants.positions, String(tenantId)).not.toContain('platform_admin'); + expect(grants.permissions, String(tenantId)).not.toContain(ADMIN_FULL_ACCESS); + expect(ql.calls.map((c) => c.object), String(tenantId)).not.toContain('sys_organization'); + } }); }); diff --git a/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts b/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts index 0f7bec4412c..450e6783d25 100644 --- a/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts +++ b/packages/core/src/security/resolve-authz-context.request-grants-memo.test.ts @@ -85,6 +85,11 @@ function makeTables(): Record { { id: 'u_member', email: 'member@x.com', ai_access: 1 }, { id: 'u_outsider', email: 'outsider@x.com' }, ], + sys_organization: [ + { id: 'org_default', slug: 'default' }, + { id: 'org_a', slug: 'org-a' }, + { id: 'org_b', slug: 'org-b' }, + ], sys_member: [ { user_id: 'u_owner', organization_id: 'org_a', role: 'owner' }, { user_id: 'u_admin', organization_id: 'org_a', role: 'admin' }, @@ -94,7 +99,7 @@ function makeTables(): Record { ], sys_user_position: [ { user_id: 'u_member', position: 'auditor', organization_id: 'org_a' }, - { user_id: 'u_member', position: 'temp_role', organization_id: null, valid_until: iso(T0 + DAY) }, + { user_id: 'u_member', position: 'temp_role', organization_id: 'org_a', valid_until: iso(T0 + DAY) }, { user_id: 'u_outsider', position: 'auditor', organization_id: 'org_a' }, ], sys_position: [ @@ -110,7 +115,8 @@ function makeTables(): Record { { position_id: 'p_everyone', permission_set_id: 'ps_base' }, ], sys_user_permission_set: [ - { user_id: 'u_padmin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, + // The `single` posture's platform-standing anchor: owned by the Default Organization. + { user_id: 'u_padmin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_default' }, { user_id: 'u_member', permission_set_id: 'ps_tools', permission_set: 'org_tools', organization_id: 'org_a' }, ], sys_permission_set: [ @@ -284,7 +290,7 @@ interface CallerClass { const CALLER_CLASSES: CallerClass[] = [ { - name: 'platform administrator — unscoped admin_full_access grant (single posture)', + name: 'platform administrator — the Default Organization\'s admin_full_access grant (single posture)', fixture: sessionOf('u_padmin', 'padmin@x.com', null), expect: (ctx) => { expect(ctx.posture).toBe('PLATFORM_ADMIN'); diff --git a/packages/core/src/security/resolve-authz-context.test.ts b/packages/core/src/security/resolve-authz-context.test.ts index 344f1ba51a8..ea843e0cf86 100644 --- a/packages/core/src/security/resolve-authz-context.test.ts +++ b/packages/core/src/security/resolve-authz-context.test.ts @@ -82,10 +82,10 @@ describe('resolveAuthzContext — single source of truth', () => { const ql = makeQl({ sys_user: [{ id: 'u1', email: 'ada@x.com' }], sys_member: [], - sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }], + sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: 'o1' }], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); expect(ctx.positions).toContain('contributor'); }); @@ -104,29 +104,44 @@ describe('resolveAuthzContext — single source of truth', () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], - sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }], + sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: 'o1' }], sys_user_permission_set: [], sys_position: [{ id: 'r1', name: 'contributor' }], sys_position_permission_set: [{ position_id: 'r1', permission_set_id: 'ps1' }], sys_permission_set: [{ id: 'ps1', name: 'contributor_ps', system_permissions: ['cap_x'] }], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); expect(ctx.permissions).toContain('contributor_ps'); expect(ctx.systemPermissions).toContain('cap_x'); }); - it('derives platform_admin from an UNSCOPED admin_full_access user grant', async () => { + it('derives platform_admin from the Default Organization\'s admin_full_access user grant', async () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], + sys_organization: [{ id: 'org_default', slug: 'default' }], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: null }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: 'org_default' }], sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], }); const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); expect(ctx.positions).toContain('platform_admin'); }); + it('does NOT derive platform_admin from an ORGANIZATION-LESS admin_full_access grant — it anchors nothing', async () => { + const ql = makeQl({ + sys_user: [{ id: 'u1' }], + sys_member: [], + sys_organization: [{ id: 'org_default', slug: 'default' }], + sys_user_position: [], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: null }], + sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], + }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); + expect(ctx.positions).not.toContain('platform_admin'); + expect(ctx.permissions).not.toContain('admin_full_access'); + }); + it('does NOT derive platform_admin from an ORG-scoped admin_full_access grant', async () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], @@ -716,12 +731,12 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { sys_user: [{ id: 'u1' }], sys_member: [], sys_user_position: [ - { user_id: 'u1', position: 'approver', organization_id: null, valid_until: PAST }, - { user_id: 'u1', position: 'contributor', organization_id: null }, + { user_id: 'u1', position: 'approver', organization_id: 'o1', valid_until: PAST }, + { user_id: 'u1', position: 'contributor', organization_id: 'o1' }, ], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW }); expect(ctx.positions).not.toContain('approver'); expect(ctx.positions).toContain('contributor'); // null bounds = unbounded, unchanged }); @@ -730,10 +745,10 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], - sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: null, valid_from: FUTURE }], + sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: 'o1', valid_from: FUTURE }], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW }); expect(ctx.positions).not.toContain('approver'); }); @@ -742,13 +757,13 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { sys_user: [{ id: 'u1' }], sys_member: [], sys_user_position: [ - { user_id: 'u1', position: 'stand_in', organization_id: null, valid_from: PAST, valid_until: FUTURE }, + { user_id: 'u1', position: 'stand_in', organization_id: 'o1', valid_from: PAST, valid_until: FUTURE }, // Boundary: valid_until exactly NOW → inactive AT the bound (half-open). - { user_id: 'u1', position: 'boundary', organization_id: null, valid_until: '2026-07-10T12:00:00Z' }, + { user_id: 'u1', position: 'boundary', organization_id: 'o1', valid_until: '2026-07-10T12:00:00Z' }, ], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW }); expect(ctx.positions).toContain('stand_in'); expect(ctx.positions).not.toContain('boundary'); }); @@ -757,9 +772,10 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], + sys_organization: [{ id: 'org_default', slug: 'default' }], sys_user_position: [], sys_user_permission_set: [ - { user_id: 'u1', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: null, valid_until: PAST }, + { user_id: 'u1', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: 'org_default', valid_until: PAST }, ], sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], }); @@ -772,10 +788,10 @@ describe('grant validity windows (ADR-0091 D1/D2)', () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], - sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: null, valid_until: 'not-a-date' }], + sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: 'o1', valid_until: 'not-a-date' }], sys_user_permission_set: [], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1'), nowMs: NOW }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }), nowMs: NOW }); expect(ctx.positions).not.toContain('approver'); }); @@ -953,7 +969,7 @@ describe('audience anchors in the resolver (ADR-0090 D5)', () => { it('every authenticated principal implicitly holds `everyone` (additive, no cliff)', async () => { const ql = makeQl({ sys_member: [{ user_id: 'u1', role: 'member', organization_id: 'o1' }], - sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }], + sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: 'o1' }], }); const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); // holding an explicit position must NOT cost the baseline anchor @@ -980,13 +996,14 @@ describe('audience anchors in the resolver (ADR-0090 D5)', () => { describe('resolveAuthzContext — posture ladder (ADR-0095 D2/D3)', () => { // Each fixture returns the ql tables + the session getter for one principal. const FIXTURES: Record = { - // Unscoped admin_full_access grant → the platform-admin capability. + // The Default Organization's admin_full_access grant → the platform-admin capability. platform_admin: { ql: makeQl({ sys_user: [{ id: 'pa' }], sys_member: [], + sys_organization: [{ id: 'org_default', slug: 'default' }], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'pa', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: null }], + sys_user_permission_set: [{ user_id: 'pa', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: 'org_default' }], sys_permission_set: [{ id: 'psA', name: 'admin_full_access' }], }), getSession: session('pa'), @@ -1052,15 +1069,16 @@ describe('resolveAuthzContext — posture ladder (ADR-0095 D2/D3)', () => { }); it('platform-admin grant wins over a co-held org-admin grant (capability, not role)', async () => { - // A principal who is BOTH an org admin (role) AND holds the unscoped - // platform grant resolves PLATFORM_ADMIN — derivation reads the capability, - // so the higher rung wins regardless of the better-auth role. + // A principal who is BOTH an org admin (role) AND holds the Default + // Organization's platform grant resolves PLATFORM_ADMIN — derivation reads + // the capability, so the higher rung wins regardless of the better-auth role. const ql = makeQl({ sys_user: [{ id: 'both' }], sys_member: [{ user_id: 'both', role: 'admin', organization_id: 'o1' }], + sys_organization: [{ id: 'org_default', slug: 'default' }, { id: 'o1', slug: 'o1' }], sys_user_position: [], sys_user_permission_set: [ - { user_id: 'both', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: null }, + { user_id: 'both', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: 'org_default' }, { user_id: 'both', permission_set_id: 'psO', permission_set: 'organization_admin', organization_id: 'o1' }, ], sys_permission_set: [ @@ -1107,14 +1125,16 @@ describe('resolveUserAuthzGrants — userId-driven authz for non-HTTP surfaces ( const tables = { sys_user: [{ id: 'u1', email: 'ada@x.com' }], sys_member: [], - sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }], - sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', permission_set: 'contributor_ps', organization_id: null }], + sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: 'o1' }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', permission_set: 'contributor_ps', organization_id: 'o1' }], sys_position: [{ id: 'r1', name: 'contributor' }], sys_position_permission_set: [{ position_id: 'r1', permission_set_id: 'ps1' }], sys_permission_set: [{ id: 'ps1', name: 'contributor_ps', system_permissions: ['cap_x'] }], }; - const viaHttp = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: makeQl(tables), headers: H(), getSession: session('u1') }); - const viaUser = await resolveUserAuthzGrants(makeQl(tables), 'u1'); + const viaHttp = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql: makeQl(tables), headers: H(), getSession: session('u1', { org: 'o1' }) }); + const viaUser = await resolveUserAuthzGrants(makeQl(tables), 'u1', { tenantId: 'o1' }); + // Not vacuous: both surfaces actually resolved the grants. + expect(viaUser.permissions).toContain('contributor_ps'); expect([...viaUser.positions].sort()).toEqual([...viaHttp.positions].sort()); expect([...viaUser.permissions].sort()).toEqual([...viaHttp.permissions].sort()); expect([...viaUser.systemPermissions].sort()).toEqual([...viaHttp.systemPermissions].sort()); @@ -1126,10 +1146,10 @@ describe('resolveUserAuthzGrants — userId-driven authz for non-HTTP surfaces ( sys_user: [{ id: 'u1' }], sys_member: [], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', permission_set: 'sales_ps', organization_id: null }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', permission_set: 'sales_ps', organization_id: 'o1' }], sys_permission_set: [{ id: 'ps1', name: 'sales_ps' }], }); - const grants = await resolveUserAuthzGrants(ql, 'u1', { seedPermissions: ['api:scope'] }); + const grants = await resolveUserAuthzGrants(ql, 'u1', { tenantId: 'o1', seedPermissions: ['api:scope'] }); expect(grants.permissions[0]).toBe('api:scope'); expect(grants.permissions).toContain('sales_ps'); }); @@ -1161,10 +1181,10 @@ describe('resolveUserAuthzGrants — userId-driven authz for non-HTTP surfaces ( sys_user: [{ id: 'u1' }], sys_member: [], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'expired_ps', organization_id: null, valid_until: past }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'expired_ps', organization_id: 'o1', valid_until: past }], sys_permission_set: [{ id: 'psA', name: 'expired_ps' }], }); - const grants = await resolveUserAuthzGrants(ql, 'u1'); + const grants = await resolveUserAuthzGrants(ql, 'u1', { tenantId: 'o1' }); expect(grants.permissions).not.toContain('expired_ps'); }); }); @@ -1356,7 +1376,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { const withActive = (v: unknown) => ({ sys_user: [{ id: 'u1' }], sys_member: [], - sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }], + sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: 'o1' }], sys_user_permission_set: [], sys_position: [{ id: 'r1', name: 'contributor' }], sys_position_permission_set: [{ position_id: 'r1', permission_set_id: 'ps1' }], @@ -1372,7 +1392,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { apiKeyLifecycle: undefined, ql: makeQl(withActive(false)), headers: H(), - getSession: session('u1'), + getSession: session('u1', { org: 'o1' }), }); expect(ctx.permissions).not.toContain('contributor_ps'); expect(ctx.systemPermissions).not.toContain('cap_x'); @@ -1387,7 +1407,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { apiKeyLifecycle: undefined, ql: makeQl(withActive(false)), headers: H(), - getSession: session('u1'), + getSession: session('u1', { org: 'o1' }), }); expect(ctx.positions).not.toContain('contributor'); // The audience anchor is untouched — it is not the deactivated row. @@ -1400,7 +1420,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { apiKeyLifecycle: undefined, ql: makeQl(withActive(true)), headers: H(), - getSession: session('u1'), + getSession: session('u1', { org: 'o1' }), }); expect(ctx.positions).toContain('contributor'); expect(ctx.permissions).toContain('contributor_ps'); @@ -1413,7 +1433,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { apiKeyLifecycle: undefined, ql: makeQl(withActive(undefined)), headers: H(), - getSession: session('u1'), + getSession: session('u1', { org: 'o1' }), }); expect(ctx.positions).toContain('contributor'); expect(ctx.permissions).toContain('contributor_ps'); @@ -1425,7 +1445,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { apiKeyLifecycle: undefined, ql: makeQl(withActive(0)), headers: H(), - getSession: session('u1'), + getSession: session('u1', { org: 'o1' }), }); expect(off.permissions).not.toContain('contributor_ps'); const on = await resolveAuthzContext({ @@ -1433,7 +1453,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { apiKeyLifecycle: undefined, ql: makeQl(withActive(1)), headers: H(), - getSession: session('u1'), + getSession: session('u1', { org: 'o1' }), }); expect(on.permissions).toContain('contributor_ps'); }); @@ -1457,8 +1477,8 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { sys_user: [{ id: 'u1' }], sys_member: [], sys_user_position: [ - { user_id: 'u1', position: 'contributor', organization_id: null }, - { user_id: 'u1', position: 'reviewer', organization_id: null }, + { user_id: 'u1', position: 'contributor', organization_id: 'o1' }, + { user_id: 'u1', position: 'reviewer', organization_id: 'o1' }, ], sys_user_permission_set: [], sys_position: [ @@ -1475,7 +1495,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { { id: 'ps2', name: 'reviewer_ps' }, ], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); expect(ctx.permissions).not.toContain('contributor_ps'); expect(ctx.permissions).toContain('reviewer_ps'); expect(ctx.positions).not.toContain('contributor'); @@ -1489,7 +1509,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { sys_user: [{ id: 'u1' }], sys_member: [], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', permission_set: 'crm_full', organization_id: null }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', permission_set: 'crm_full', organization_id: 'o1' }], sys_permission_set: [{ id: 'ps1', name: 'crm_full', @@ -1498,7 +1518,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { }], sys_metadata_activation: [off('permission', 'crm_full')], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); expect(ctx.permissions).not.toContain('crm_full'); expect(ctx.systemPermissions).not.toContain('cap_x'); expect(ctx.tabPermissions?.crm).toBeUndefined(); @@ -1508,8 +1528,9 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], + sys_organization: [{ id: 'org_default', slug: 'default' }], sys_user_position: [], - sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: null }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psA', permission_set: 'admin_full_access', organization_id: 'org_default' }], sys_permission_set: [{ id: 'psA', name: 'admin_full_access', @@ -1533,8 +1554,8 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { sys_member: [], sys_user_position: [], sys_user_permission_set: [ - { user_id: 'u1', permission_set_id: 'ps1', permission_set: 'crm_full', organization_id: null }, - { user_id: 'u1', permission_set_id: 'ps2', permission_set: 'crm_read', organization_id: null }, + { user_id: 'u1', permission_set_id: 'ps1', permission_set: 'crm_full', organization_id: 'o1' }, + { user_id: 'u1', permission_set_id: 'ps2', permission_set: 'crm_read', organization_id: 'o1' }, ], sys_permission_set: [ { id: 'ps1', name: 'crm_full' }, @@ -1542,7 +1563,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { ], sys_metadata_activation: [off('permission', 'crm_full'), off('permission', 'crm_read', true)], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); expect(ctx.permissions).not.toContain('crm_full'); expect(ctx.permissions).toContain('crm_read'); }); @@ -1553,20 +1574,20 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], - sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: null }], - sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', permission_set: 'crm_full', organization_id: null }], + sys_user_position: [{ user_id: 'u1', position: 'contributor', organization_id: 'o1' }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'ps1', permission_set: 'crm_full', organization_id: 'o1' }], sys_position: [{ id: 'r1', name: 'contributor' }], sys_position_permission_set: [{ position_id: 'r1', permission_set_id: 'ps1' }], sys_permission_set: [{ id: 'ps1', name: 'crm_full' }], sys_metadata_activation: [off('position', 'contributor')], }); - const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1') }); + const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, ql, headers: H(), getSession: session('u1', { org: 'o1' }) }); expect(ctx.permissions).toContain('crm_full'); expect(ctx.positions).not.toContain('contributor'); }); it('resolveUserAuthzGrants enforces it too — the non-HTTP surfaces share the seam', async () => { - const grants = await resolveUserAuthzGrants(makeQl(withActive(false)), 'u1'); + const grants = await resolveUserAuthzGrants(makeQl(withActive(false)), 'u1', { tenantId: 'o1' }); expect(grants.permissions).not.toContain('contributor_ps'); expect(grants.positions).not.toContain('contributor'); }); @@ -1586,13 +1607,13 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { return find(object, opts); }, }); - const grants = await resolveUserAuthzGrants(ql, 'u1'); + const grants = await resolveUserAuthzGrants(ql, 'u1', { tenantId: 'o1' }); expect(reads).not.toContain('sys_metadata_activation'); // No ledger in the composition: nothing is switched off. expect(grants.permissions).toContain('contributor_ps'); // CONTROL — the same engine with the ledger registered reads it, and honours it. const registered = Object.assign(makeQl(tables), { registry: { getObject: (name: string) => ({ name }) } }); - expect((await resolveUserAuthzGrants(registered, 'u1')).permissions).not.toContain('contributor_ps'); + expect((await resolveUserAuthzGrants(registered, 'u1', { tenantId: 'o1' })).permissions).not.toContain('contributor_ps'); }); it('[ADR-0131 D3] a catalog ROW\'s `active: false` is not read — the ledger is the switch', async () => { @@ -1607,7 +1628,7 @@ describe('[#8613] the `active` flag on the grant catalogues (ADR-0049)', () => { sys_permission_set: [{ id: 'ps1', name: 'contributor_ps', active: false, system_permissions: ['cap_x'] }], }), headers: H(), - getSession: session('u1'), + getSession: session('u1', { org: 'o1' }), }); expect(ctx.positions).toContain('contributor'); expect(ctx.permissions).toContain('contributor_ps'); @@ -1964,11 +1985,13 @@ describe('[#20580] vetOrganizationClaim — the organization a claim resolves in }); /** - * [#20515] A resolution with NO active organization applies only the GLOBAL - * grants (`organization_id` null). A grant scoped to an organization applies - * only while that organization is the active tenant — §4 (`sys_user_position`) - * and §6 (`sys_user_permission_set`) ask the one predicate, so they cannot - * disagree. + * [#20515] A grant applies only while its organization is the active tenant — + * §4 (`sys_user_position`) and §6 (`sys_user_permission_set`) ask the one + * predicate, so they cannot disagree. A resolution with NO active organization + * applies no stored grant row, and a row with no organization (`null`) applies + * NOWHERE: the ownership ceremony (ADR-0131 D10) attributes every such row to + * an organization and makes the column NOT NULL, so it is invalid state, not a + * "global" grant. * * The population this was measured on: a member removed from an organization * whose session still names it. The session arm drops the claim (#15409 ruling @@ -1980,24 +2003,24 @@ describe('[#20580] vetOrganizationClaim — the organization a claim resolves in * `DELETE /packages/:id`) is pinned in `packages/runtime` * (`packages-orgless-grants-capability-gate.test.ts`). */ -describe('[#20515] with no active organization, only global grants apply', () => { +describe('[#20515] with no active organization, no stored grant applies', () => { const ALPHA = 'org_alpha'; const BETA = 'org_beta'; - /** One set and one position per scope: global, alpha, beta. */ + /** One set and one position per scope: organization-less, alpha, beta. */ const grantRows = (userId: string) => ({ sys_user_position: [ - { user_id: userId, position: 'global_position', organization_id: null }, + { user_id: userId, position: 'orgless_position', organization_id: null }, { user_id: userId, position: 'alpha_position', organization_id: ALPHA }, { user_id: userId, position: 'beta_position', organization_id: BETA }, ], sys_user_permission_set: [ - { user_id: userId, permission_set_id: 'ps_global', permission_set: 'global_set', organization_id: null }, + { user_id: userId, permission_set_id: 'ps_orgless', permission_set: 'orgless_set', organization_id: null }, { user_id: userId, permission_set_id: 'ps_alpha', permission_set: 'alpha_set', organization_id: ALPHA }, { user_id: userId, permission_set_id: 'ps_beta', permission_set: 'beta_set', organization_id: BETA }, ], sys_permission_set: [ - { id: 'ps_global', name: 'global_set', system_permissions: ['global_cap'] }, + { id: 'ps_orgless', name: 'orgless_set', system_permissions: ['orgless_cap'] }, { id: 'ps_alpha', name: 'alpha_set', system_permissions: ['manage_metadata'] }, { id: 'ps_beta', name: 'beta_set', system_permissions: ['beta_cap'] }, ], @@ -2010,27 +2033,27 @@ describe('[#20515] with no active organization, only global grants apply', () => systemPermissions: [...g.systemPermissions].sort(), }); - it('no tenant: §4 and §6 both keep the global rows and NOTHING scoped to an organization', async () => { + it('no tenant: §4 and §6 both keep NOTHING — neither the organization-less rows nor any organization\'s', async () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], ...grantRows('u1') }); const grants = await resolveUserAuthzGrants(ql, 'u1', {}); expect(applied(grants)).toEqual({ - positions: ['global_position'], - permissions: ['global_set'], - systemPermissions: ['global_cap'], + positions: [], + permissions: [], + systemPermissions: [], }); }); - it('a tenant: §4 and §6 both keep the global rows plus THAT organization\'s, never another\'s', async () => { + it('a tenant: §4 and §6 both keep THAT organization\'s rows only — never another\'s, never the organization-less ones', async () => { const ql = makeQl({ sys_user: [{ id: 'u1' }], sys_member: [], ...grantRows('u1') }); expect(applied(await resolveUserAuthzGrants(ql, 'u1', { tenantId: ALPHA }))).toEqual({ - positions: ['alpha_position', 'global_position'], - permissions: ['alpha_set', 'global_set'], - systemPermissions: ['global_cap', 'manage_metadata'], + positions: ['alpha_position'], + permissions: ['alpha_set'], + systemPermissions: ['manage_metadata'], }); expect(applied(await resolveUserAuthzGrants(ql, 'u1', { tenantId: BETA }))).toEqual({ - positions: ['beta_position', 'global_position'], - permissions: ['beta_set', 'global_set'], - systemPermissions: ['beta_cap', 'global_cap'], + positions: ['beta_position'], + permissions: ['beta_set'], + systemPermissions: ['beta_cap'], }); }); @@ -2100,22 +2123,22 @@ describe('[#20515] with no active organization, only global grants apply', () => * `u_gone` has no membership left anywhere; both still hold the operator- * authored `manage_metadata` set granted SCOPED to `org_alpha`, which the * removal did not revoke. `u_member` is the control: a current `org_alpha` - * member holding the same grant. `u_global_ex` is removed the same way but - * holds the set GLOBALLY — a global grant is untouched by this card. + * member holding the same grant. `u_orgless_ex` is removed the same way but + * holds the set on an ORGANIZATION-LESS row — which confers nothing anywhere. */ const tables = () => ({ - sys_user: ['u_ex', 'u_gone', 'u_member', 'u_global_ex'].map((id) => ({ id, email: `${id}@x.com` })), + sys_user: ['u_ex', 'u_gone', 'u_member', 'u_orgless_ex'].map((id) => ({ id, email: `${id}@x.com` })), sys_member: [ { user_id: 'u_ex', organization_id: BETA, role: 'member' }, { user_id: 'u_member', organization_id: ALPHA, role: 'member' }, - { user_id: 'u_global_ex', organization_id: BETA, role: 'member' }, + { user_id: 'u_orgless_ex', organization_id: BETA, role: 'member' }, ], sys_user_position: [], sys_user_permission_set: [ { user_id: 'u_ex', permission_set_id: 'ps_meta', permission_set: 'alpha_metadata_editors', organization_id: ALPHA }, { user_id: 'u_gone', permission_set_id: 'ps_meta', permission_set: 'alpha_metadata_editors', organization_id: ALPHA }, { user_id: 'u_member', permission_set_id: 'ps_meta', permission_set: 'alpha_metadata_editors', organization_id: ALPHA }, - { user_id: 'u_global_ex', permission_set_id: 'ps_meta', permission_set: 'alpha_metadata_editors', organization_id: null }, + { user_id: 'u_orgless_ex', permission_set_id: 'ps_meta', permission_set: 'alpha_metadata_editors', organization_id: null }, ], sys_permission_set: [{ id: 'ps_meta', name: 'alpha_metadata_editors', system_permissions: ['manage_metadata'] }], }); @@ -2153,18 +2176,20 @@ describe('[#20515] with no active organization, only global grants apply', () => expect(ctx.permissions).toContain('alpha_metadata_editors'); }); - it('a GLOBAL grant is unchanged: the removed member still holds it with no active organization', async () => { + it('an ORGANIZATION-LESS grant confers nothing: the removed member does not hold it with no active organization', async () => { const ctx = await resolveAuthzContext({ userAuthGates: undefined, apiKeyLifecycle: undefined, - ql: makeQl(tables()), headers: H(), getSession: namingAlpha('u_global_ex'), tenancyPosture: 'isolated', + ql: makeQl(tables()), headers: H(), getSession: namingAlpha('u_orgless_ex'), tenancyPosture: 'isolated', }); + expect(ctx.userId).toBe('u_orgless_ex'); expect(ctx.tenantId).toBeUndefined(); - expect(ctx.systemPermissions).toContain('manage_metadata'); + expect(ctx.systemPermissions).not.toContain('manage_metadata'); + expect(ctx.permissions).not.toContain('alpha_metadata_editors'); }); }); - describe('platform-admin standing is unchanged — it was only ever derived from an UNSCOPED grant', () => { + describe('platform-admin standing is anchored by the Default Organization\'s grant — never an organization-less one', () => { const POSTURE_ENV = ['OS_TENANCY_POSTURE', 'OS_MULTI_ORG_ENABLED', 'OS_PLATFORM_OWNER_EMAIL'] as const; const saved: Record = {}; beforeEach(() => { @@ -2177,17 +2202,23 @@ describe('[#20515] with no active organization, only global grants apply', () => } }); - /** The row `bootstrapPlatformAdmin` mints: `admin_full_access`, organization null. */ + const DEFAULT_ORG = 'org_default'; + /** + * The row `bootstrapPlatformAdmin` mints is `admin_full_access` owned by the + * Default Organization; `null` is the organization-less row an earlier + * release's promotion wrote, which the ownership ceremony attributes. + */ const adminTables = (organizationId: string | null) => ({ sys_user: [{ id: 'u_admin', email: 'admin@x.com' }], + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }, { id: ALPHA, slug: 'alpha' }], sys_member: [{ user_id: 'u_admin', organization_id: ALPHA, role: 'owner' }], sys_user_position: [], sys_user_permission_set: [{ user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: organizationId }], sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_metadata'] }], }); - it('the bootstrap-shaped UNSCOPED grant: PLATFORM_ADMIN with no tenant and with one', async () => { - const ql = makeQl(adminTables(null)); + it('the bootstrap-shaped Default Organization grant: PLATFORM_ADMIN with no tenant and with one', async () => { + const ql = makeQl(adminTables(DEFAULT_ORG)); expect(await hasPlatformAdminStanding(ql, 'u_admin')).toBe(true); const orgless = await resolveUserAuthzGrants(ql, 'u_admin', {}); expect(orgless.posture).toBe('PLATFORM_ADMIN'); @@ -2195,6 +2226,17 @@ describe('[#20515] with no active organization, only global grants apply', () => expect((await resolveUserAuthzGrants(ql, 'u_admin', { tenantId: ALPHA })).posture).toBe('PLATFORM_ADMIN'); }); + it('an ORGANIZATION-LESS admin_full_access grant anchors nothing and confers nothing, with no tenant and with one', async () => { + const ql = makeQl(adminTables(null)); + expect(await hasPlatformAdminStanding(ql, 'u_admin')).toBe(false); + for (const opts of [{}, { tenantId: ALPHA }, { tenantId: DEFAULT_ORG }]) { + const grants = await resolveUserAuthzGrants(ql, 'u_admin', opts); + expect(grants.posture, JSON.stringify(opts)).not.toBe('PLATFORM_ADMIN'); + expect(grants.permissions, JSON.stringify(opts)).not.toContain('admin_full_access'); + expect(grants.systemPermissions, JSON.stringify(opts)).not.toContain('manage_metadata'); + } + }); + it('an ORG-scoped admin_full_access grant confers no platform standing, with or without that tenant', async () => { const ql = makeQl(adminTables(ALPHA)); expect(await hasPlatformAdminStanding(ql, 'u_admin')).toBe(false); diff --git a/packages/core/src/security/resolve-user-grants-cache.test.ts b/packages/core/src/security/resolve-user-grants-cache.test.ts index 1f3138c933d..4e5c34febbf 100644 --- a/packages/core/src/security/resolve-user-grants-cache.test.ts +++ b/packages/core/src/security/resolve-user-grants-cache.test.ts @@ -132,14 +132,18 @@ type SeamQl = ReturnType; /** Fixed clock. Injected everywhere, so no pin sleeps and no pin races. */ const T = Date.UTC(2026, 5, 1); -/** A principal whose PLATFORM_ADMIN standing hangs on one revocable row. */ +/** + * A principal whose PLATFORM_ADMIN standing hangs on one revocable row: the + * Default Organization's `admin_full_access` grant, the `single` posture's anchor. + */ function adminTables(): Record { return { sys_user: [{ id: 'u1', email: 'u1@x.com' }], + sys_organization: [{ id: 'org_default', slug: 'default' }, { id: 'org_a', slug: 'org-a' }], sys_member: [{ user_id: 'u1', organization_id: 'org_a', role: 'member' }], sys_user_position: [], sys_user_permission_set: [ - { user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, + { user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_default' }, ], sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access' }], sys_position: [], @@ -147,6 +151,23 @@ function adminTables(): Record { }; } +/** + * A principal holding one ordinary `org_a` grant and no `admin_full_access`. + * Pin 7 measures THIS module's footprint, so its principal must not reach the + * Default Organization read: that answer has its own per-engine memo (#24523), + * which wires its own write observer onto a seamful engine and serves the + * second resolution's `sys_organization` read — not the grants cache's doing. + */ +function memberTables(): Record { + return { + ...adminTables(), + sys_user_permission_set: [ + { user_id: 'u1', permission_set_id: 'ps_tools', permission_set: 'org_tools', organization_id: 'org_a' }, + ], + sys_permission_set: [{ id: 'ps_tools', name: 'org_tools', system_permissions: ['export_reports'] }], + }; +} + const OPTS: ResolveUserAuthzGrantsOptions = { tenantId: 'org_a', nowMs: T }; const TTL_ENV = 'OS_AUTHZ_GRANTS_CACHE_TTL_MS'; @@ -184,8 +205,10 @@ async function resolveExpectingZeroReads(ql: SeamQl, userId: string, opts: Resol describe('pin 7 — TTL 0 (the shipped default) is a real off path', () => { it('default config: resolves twice with the SAME query multiset, and wires NOTHING onto the engine', async () => { - const ql = makeSeamQl(adminTables()); + const ql = makeSeamQl(memberTables()); const first = await resolveUserAuthzGrants(ql, 'u1', OPTS); + // Not vacuous: the grant really resolved. + expect(first.permissions).toContain('org_tools'); const afterFirst = ql.calls.length; const second = await resolveUserAuthzGrants(ql, 'u1', OPTS); @@ -203,7 +226,7 @@ describe('pin 7 — TTL 0 (the shipped default) is a real off path', () => { it('explicit OS_AUTHZ_GRANTS_CACHE_TTL_MS=0 takes the same off path', async () => { process.env[TTL_ENV] = '0'; - const ql = makeSeamQl(adminTables()); + const ql = makeSeamQl(memberTables()); await resolveUserAuthzGrants(ql, 'u1', OPTS); const afterFirst = ql.calls.length; await resolveUserAuthzGrants(ql, 'u1', OPTS); @@ -287,7 +310,7 @@ describe('pin 2 — read-after-write on the writing node (invalidation, not TTL) expect(before.permissions).not.toContain('admin_full_access'); await ql.insert('sys_user_permission_set', { - user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null, + user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_default', }); const after = await resolveUserAuthzGrants(ql, 'u1', OPTS); @@ -550,7 +573,7 @@ describe('pin 9 — two nodes over one database', () => { // ── [#20515] the no-tenant rule holds with the cache ON ───────────────────── describe('[#20515] with the cache on, a no-tenant resolution never serves an organization\'s grants', () => { - /** One grant scoped to `org_a`, one global. */ + /** One grant scoped to `org_a`, one organization-less (which applies nowhere). */ function scopedTables(): Record { return { sys_user: [{ id: 'u1', email: 'u1@x.com' }], @@ -558,11 +581,11 @@ describe('[#20515] with the cache on, a no-tenant resolution never serves an org sys_user_position: [], sys_user_permission_set: [ { user_id: 'u1', permission_set_id: 'ps_org', permission_set: 'org_a_editors', organization_id: 'org_a' }, - { user_id: 'u1', permission_set_id: 'ps_global', permission_set: 'everyone_readers', organization_id: null }, + { user_id: 'u1', permission_set_id: 'ps_orgless', permission_set: 'orgless_readers', organization_id: null }, ], sys_permission_set: [ { id: 'ps_org', name: 'org_a_editors', system_permissions: ['manage_metadata'] }, - { id: 'ps_global', name: 'everyone_readers' }, + { id: 'ps_orgless', name: 'orgless_readers' }, ], sys_position: [], sys_position_permission_set: [], @@ -575,8 +598,11 @@ describe('[#20515] with the cache on, a no-tenant resolution never serves an org const inOrg = await resolveUserAuthzGrants(ql, 'u1', { tenantId: 'org_a', nowMs: T }); expect(inOrg.systemPermissions).toContain('manage_metadata'); + // The organization-less row applies in no organization, the active one included. + expect(inOrg.permissions).toEqual(['org_a_editors']); const orgless = await resolveUserAuthzGrants(ql, 'u1', { nowMs: T }); - expect(orgless.permissions).toEqual(['everyone_readers']); + // With no tenant, no stored grant applies — the organization-less row neither. + expect(orgless.permissions).toEqual([]); expect(orgless.systemPermissions).not.toContain('manage_metadata'); // Both entries are now cached, each under its own tenant — and each hit diff --git a/packages/plugins/plugin-auth/src/admin-has-permission-endpoint.test.ts b/packages/plugins/plugin-auth/src/admin-has-permission-endpoint.test.ts index a6bb07affcc..5fc1512fa36 100644 --- a/packages/plugins/plugin-auth/src/admin-has-permission-endpoint.test.ts +++ b/packages/plugins/plugin-auth/src/admin-has-permission-endpoint.test.ts @@ -24,8 +24,8 @@ // // ── Why the admin is granted, never scalared ──────────────────────────────── // -// ⛔ The subject is made a platform admin the ADR-0068 D2 way — an unscoped -// `admin_full_access` grant — and the fixture ASSERTS the legacy scalar is +// ⛔ The subject is made a platform admin the ADR-0068 D2 way — the Default +// Organization's `admin_full_access` grant — and the fixture ASSERTS the legacy scalar is // absent. A `role = 'admin'` fixture would be answered `true` by the UNSHADED // vendor too: it passes with or without this card's change and measures // nothing (the `admin-impersonate-endpoint.test.ts` discipline). @@ -126,14 +126,16 @@ async function stage() { const users = (engine.tables.get('sys_user') ?? []) as any[]; const adminId = String(users.find((r) => r.email === 'admin.11900@example.com')!.id); - // The ADR-0068 D2 grant — an ORG-LESS `admin_full_access` link. ⛔ NOT the - // legacy scalar (see header). + // The ADR-0068 D2 grant — an `admin_full_access` link owned by the Default + // Organization, the only owner whose grant anchors platform standing. ⛔ NOT + // the legacy scalar (see header). + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }); await engine.insert('sys_permission_set', { id: PS_ADMIN, name: ADMIN_FULL_ACCESS }); await engine.insert('sys_user_permission_set', { user_id: adminId, permission_set_id: PS_ADMIN, permission_set: 'admin_full_access', - organization_id: null, + organization_id: 'org_default', }); const bearerFor = async (email: string) => { diff --git a/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts b/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts index b61e8e9a5a1..1b5286d5ce2 100644 --- a/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts +++ b/packages/plugins/plugin-auth/src/admin-impersonate-endpoint.test.ts @@ -115,9 +115,20 @@ const userIdFor = (engine: any, email: string): string => { return String(row.id); }; +/** The Default Organization's id — the row slugged `default`, staged if absent. */ +const defaultOrganizationIdOf = async (engine: any): Promise => { + const existing = (engine.tables.get('sys_organization') ?? []).find((r: any) => r.slug === 'default'); + if (existing) return String(existing.id); + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }); + return 'org_default'; +}; + /** * Grant platform admin the ADR-0068 D2 way — a `sys_user_permission_set` row - * pointing at `admin_full_access` with `organization_id = null`. + * pointing at `admin_full_access`, owned by the Default Organization (the + * `sys_organization` row slugged `default`, staged here when the engine holds + * none). Only that organization's grant anchors platform standing; an + * organization-less one anchors nothing. * * ⛔ Deliberately NOT `row.role = 'admin'`. Writing that scalar is what the * 2026-08-18 ruling permanently vetoed, and a fixture that used it would be @@ -132,7 +143,7 @@ const grantPlatformAdmin = async (engine: any, userId: string) => { user_id: userId, permission_set_id: PS_ADMIN, permission_set: 'admin_full_access', - organization_id: null, + organization_id: await defaultOrganizationIdOf(engine), }); }; diff --git a/packages/plugins/plugin-auth/src/auth-manager.test.ts b/packages/plugins/plugin-auth/src/auth-manager.test.ts index ed1d0550614..8a3fd7b1687 100644 --- a/packages/plugins/plugin-auth/src/auth-manager.test.ts +++ b/packages/plugins/plugin-auth/src/auth-manager.test.ts @@ -3642,9 +3642,10 @@ describe('AuthManager', () => { }); describe('customSession – derived identity and positions array', () => { - // dataEngine stub: `adminLinks` controls whether the user resolves as a + // dataEngine stub: `platformAdmin` controls whether the user resolves as a // platform admin (a sys_user_permission_set row pointing at the - // admin_full_access permission set with no organization scope). + // admin_full_access permission set, owned by the Default Organization — + // the `sys_organization` row slugged `default` the stub serves). const makeDataEngine = (opts: { platformAdmin: boolean }) => bindTestSecurityCatalog({ // Two parameters because the seam has two: `IDataEngine.find(objectName, // query?, options?)`, and every production read reaching this double goes @@ -3654,12 +3655,15 @@ describe('AuthManager', () => { find: vi.fn(async (object: string, _query?: any) => { if (object === 'sys_user_permission_set') { return opts.platformAdmin - ? [{ user_id: 'u-1', permission_set_id: 'ps-admin', permission_set: 'admin_full_access', organization_id: null }] + ? [{ user_id: 'u-1', permission_set_id: 'ps-admin', permission_set: 'admin_full_access', organization_id: 'org_default' }] : []; } if (object === 'sys_permission_set') { return [{ id: 'ps-admin', name: 'admin_full_access' }]; } + if (object === 'sys_organization') { + return [{ id: 'org_default', slug: 'default' }]; + } return []; }), findOne: vi.fn(), @@ -3750,15 +3754,16 @@ describe('AuthManager', () => { const inner = engine.find; engine.find = vi.fn(async (object: string, q?: any) => { if (object === 'sys_user_position') { - return [{ user_id: 'u-1', position: 'demo_reviewer', organization_id: null }]; + return [{ user_id: 'u-1', position: 'demo_reviewer', organization_id: 'o1' }]; } return inner(object, q); }) as any; + // The assignment applies in its own organization, so the session acts in it. const callback = await getSessionCallback(engine); const result = await callback({ user: { id: 'u-1', email: 'a@b.com', role: 'manager' }, - session: {}, + session: { activeOrganizationId: 'o1' }, }); expect(result.user.positions).toContain('demo_reviewer'); // Still not the scalar — the two axes do not blend. @@ -4997,26 +5002,33 @@ describe('isPlatformAdminUserId – the /sso/register criterion is platform-admi const SECRET = 'test-secret-at-least-32-chars-long'; /** - * Read-only engine stub. `platformAdmin` controls the ADR-0068 org-less - * `admin_full_access` link; `members` are `sys_member` rows, which this + * Read-only engine stub. `platformAdmin` controls the ADR-0068 + * `admin_full_access` link, owned by the Default Organization (`org-default`, + * slugged `default`) unless `grantOrganization` names another owner — `null` + * for an organization-less row; `members` are `sys_member` rows, which this * criterion must now ignore entirely. `find` honours the `where` the judge * passes, so any org-scoping is the product's, not the fixture's. */ - const makeEngine = (opts: { members?: any[]; platformAdmin?: boolean; throws?: boolean; orgScopedGrant?: boolean } = {}) => bindTestSecurityCatalog({ + const makeEngine = (opts: { members?: any[]; platformAdmin?: boolean; throws?: boolean; grantOrganization?: string | null } = {}) => bindTestSecurityCatalog({ find: vi.fn(async (object: string, query?: any) => { if (opts.throws) throw new Error('db down'); + const byWhere = (rows: any[]) => { + const where = query?.where ?? {}; + return rows.filter((row) => + Object.entries(where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return row[k] === v; }), + ); + }; if (object === 'sys_user_permission_set') { + const organization_id = opts.grantOrganization === undefined ? 'org-default' : opts.grantOrganization; return opts.platformAdmin - ? [{ user_id: 'u-1', permission_set_id: 'ps-admin', permission_set: 'admin_full_access', organization_id: opts.orgScopedGrant ? 'org-1' : null }] + ? [{ user_id: 'u-1', permission_set_id: 'ps-admin', permission_set: 'admin_full_access', organization_id }] : []; } if (object === 'sys_permission_set') return [{ id: 'ps-admin', name: 'admin_full_access' }]; - if (object === 'sys_member') { - const where = query?.where ?? {}; - return (opts.members ?? []).filter((row) => - Object.entries(where).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); return row[k] === v; }), - ); + if (object === 'sys_organization') { + return byWhere([{ id: 'org-default', slug: 'default' }, { id: 'org-1', slug: 'org-1' }]); } + if (object === 'sys_member') return byWhere(opts.members ?? []); return []; }), findOne: vi.fn(), @@ -5067,7 +5079,7 @@ describe('isPlatformAdminUserId – the /sso/register criterion is platform-admi // -- (2) the other direction: a real platform admin is still admitted ------ describe('the ADR-0068 platform admin is still ADMITTED', () => { - it('admits an org-less admin_full_access grant even when the membership is a plain member', async () => { + it('admits the Default Organization\'s admin_full_access grant even when the membership is a plain member', async () => { expect(await judge(makeEngine({ platformAdmin: true, members: [memberRow('member')] }))).toBe(true); }); @@ -5082,10 +5094,18 @@ describe('isPlatformAdminUserId – the /sso/register criterion is platform-admi expect(await judge(makeEngine({ members: [memberRow('member')] }))).toBe(false); }); - it('refuses when the admin_full_access grant is ORG-SCOPED, not platform-wide', async () => { - // `organization_id != null` is an org-scoped assignment — ADR-0068 D2 - // reads only the org-less link as platform admin. - expect(await judge(makeEngine({ platformAdmin: true, orgScopedGrant: true }))).toBe(false); + it('refuses when the admin_full_access grant is owned by ANOTHER organization, not the Default one', async () => { + // Any organization's grant other than the Default Organization's is an + // org-scoped assignment — it confers the set there, never platform standing. + expect(await judge(makeEngine({ platformAdmin: true, grantOrganization: 'org-1' }))).toBe(false); + }); + + it('refuses an ORGANIZATION-LESS admin_full_access grant — it anchors nothing', async () => { + // Invalid state since the ownership ceremony made the column NOT NULL; the + // reading that took it as the platform-wide link retired. The Default + // Organization's grant on the same engine is the control (admitted above). + expect(await judge(makeEngine({ platformAdmin: true, grantOrganization: null }))).toBe(false); + expect(await judge(makeEngine({ platformAdmin: true }))).toBe(true); }); it('refuses when the engine read throws (fail CLOSED)', async () => { diff --git a/packages/plugins/plugin-auth/src/organization-add-member.test.ts b/packages/plugins/plugin-auth/src/organization-add-member.test.ts index 8888a0238d5..5929f16bf00 100644 --- a/packages/plugins/plugin-auth/src/organization-add-member.test.ts +++ b/packages/plugins/plugin-auth/src/organization-add-member.test.ts @@ -23,7 +23,7 @@ * would pass against a handler that refuses everyone. * * The platform admin here is REAL: `sys_permission_set`(`admin_full_access`) - * + an org-less `sys_user_permission_set` link — the exact rows the + * + a `sys_user_permission_set` link owned by the Default Organization — the exact rows the * customSession override derives `positions[]`/`isPlatformAdmin` from. No * `role = 'admin'` scalar is patched anywhere (re-synthesizing it is * permanently vetoed, maintainer ruling 2026-08-18). @@ -44,6 +44,7 @@ const BASE = 'http://localhost:3000'; const BASE_PATH = '/api/v1/auth'; const ORG_ACME = 'org_acme'; const ORG_BETA = 'org_beta'; +const ORG_DEFAULT = 'org_default'; const PASSWORD = 'S3cure!Passw0rd-9941'; /** @@ -243,16 +244,20 @@ describe('#9941 POST /organization/add-member — mounted, gated, and it clears orgOwner = await signUp(manager, engine, 'org.owner@example.com'); target = await signUp(manager, engine, 'existing.user@example.com'); - // The REAL ADR-0068 platform-admin signal: admin_full_access held with - // organization_id = null — the rows customSession derives positions[] - // from. NOT the legacy role scalar. + // The REAL ADR-0068 platform-admin signal: admin_full_access held by the + // Default Organization's grant — the rows customSession derives + // positions[] from. NOT the legacy role scalar, and not an + // organization-less row (that anchors nothing). The Default Organization + // is staged AFTER the sign-ups, so it is a third organization the admin is + // not a member of, and nothing above saw it. + await engine.insert('sys_organization', { id: ORG_DEFAULT, name: 'Default Organization', slug: 'default' }); await engine.insert('sys_permission_set', { id: 'ps_admin', name: 'admin_full_access' }); await engine.insert('sys_user_permission_set', { id: 'ups_admin', user_id: admin.userId, permission_set_id: 'ps_admin', permission_set: 'admin_full_access', - organization_id: null, + organization_id: ORG_DEFAULT, }); // Org-scoped actors on Acme: a plain member and an org OWNER — the owner diff --git a/packages/plugins/plugin-auth/src/platform-admin-standing.consolidation.test.ts b/packages/plugins/plugin-auth/src/platform-admin-standing.consolidation.test.ts index 9d204e003c0..dae7031b345 100644 --- a/packages/plugins/plugin-auth/src/platform-admin-standing.consolidation.test.ts +++ b/packages/plugins/plugin-auth/src/platform-admin-standing.consolidation.test.ts @@ -211,14 +211,23 @@ interface StandingShape { deactivatedSet?: boolean; /** ADR-0091: the grant carries a `valid_until` already in the past. */ expiredGrant?: boolean; - /** ADR-0068 D2 says PLATFORM standing is the ORG-LESS link; this scopes it. */ - orgScopedGrant?: boolean; + /** + * Who owns the grant. PLATFORM standing is anchored only by the Default + * Organization's grant (the default here); `'other'` scopes it to another + * organization, `'none'` writes the retired organization-less row. The + * Default Organization is staged in every shape, so neither near-miss is + * refused merely for want of one. + */ + grantOwner?: 'default' | 'other' | 'none'; } +const ORG_DEFAULT = 'org_default'; + const seedPlatformAdmin = async (engine: any, userId: string, shape: StandingShape = {}) => { for (let i = 0; i < (shape.decoySets ?? 0); i += 1) { await engine.insert('sys_permission_set', { id: `ps_decoy_${i}`, name: `decoy_set_${i}` }); } + await engine.insert('sys_organization', { id: ORG_DEFAULT, name: 'Default Organization', slug: 'default' }); await engine.insert('sys_permission_set', { id: PS_ADMIN, name: ADMIN_FULL_ACCESS }); if (shape.deactivatedSet) { await engine.insert('sys_metadata_activation', { @@ -232,7 +241,7 @@ const seedPlatformAdmin = async (engine: any, userId: string, shape: StandingSha user_id: userId, permission_set_id: PS_ADMIN, permission_set: 'admin_full_access', - organization_id: shape.orgScopedGrant ? 'org_pin' : null, + organization_id: { default: ORG_DEFAULT, other: 'org_pin', none: null }[shape.grantOwner ?? 'default'], ...(shape.expiredGrant ? { valid_until: new Date(Date.now() - 60_000).toISOString() } : {}), }); }; @@ -335,7 +344,7 @@ afterAll(scrypt.restore); // ─────────────────────────────────────────────────────────────────────────── // PIN 1 — the genuine ADR-0068 D2 platform admin is admitted at BOTH gates. // ─────────────────────────────────────────────────────────────────────────── -describe.concurrent('a genuine platform admin (ADR-0068 D2 org-less admin_full_access) is ADMITTED', () => { +describe.concurrent('a genuine platform admin (ADR-0068 D2 admin_full_access, owned by the Default Organization) is ADMITTED', () => { it('/sso/register admits (and refuses the standing-less control on the same engine)', async () => { const { manager, callerBearer, targetBearer } = await arrange((e, id) => seedPlatformAdmin(e, id)); const v = await ssoVerdict(manager, callerBearer); @@ -365,7 +374,8 @@ describe.concurrent('an org owner / org admin / TENANT_ADMIN principal is NOT ad ['org owner (sys_member.role = owner)', (e, id) => seedOrgAdmin(e, id, 'owner')], ['org admin (sys_member.role = admin)', (e, id) => seedOrgAdmin(e, id, 'admin')], ['TENANT_ADMIN posture (organization_admin capability grant)', seedTenantAdmin], - ['an ORG-SCOPED admin_full_access grant', (e, id) => seedPlatformAdmin(e, id, { orgScopedGrant: true })], + ['an ORG-SCOPED admin_full_access grant', (e, id) => seedPlatformAdmin(e, id, { grantOwner: 'other' })], + ['an ORGANIZATION-LESS admin_full_access grant', (e, id) => seedPlatformAdmin(e, id, { grantOwner: 'none' })], ]; for (const [label, seed] of nearMisses) { diff --git a/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts b/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts index a10b7828435..6cd394d089f 100644 --- a/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts +++ b/packages/plugins/plugin-auth/src/session-grants-resolved-once.test.ts @@ -195,10 +195,12 @@ const arrange = async (config: Record = {}) => { await engine.insert('sys_user_position', { user_id: memberId, position: 'reviewer', organization_id: ORG }); await engine.insert('sys_user_position', { user_id: leaverId, position: 'reviewer', organization_id: ORG }); - // The platform operator: an UNSCOPED admin_full_access user grant (the - // single-posture anchor; no tenancy posture is configured here). + // The platform operator: an admin_full_access user grant owned by the Default + // Organization (the single-posture anchor; no tenancy posture is configured + // here). The Default Organization is a third organization nobody is a member of. + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }); await engine.insert('sys_permission_set', { id: 'ps_admin', name: 'admin_full_access' }); - await engine.insert('sys_user_permission_set', { user_id: operatorId, permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }); + await engine.insert('sys_user_permission_set', { user_id: operatorId, permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_default' }); const bearers = { owner: bearerFrom(await signIn(manager, 'owner@example.com')), diff --git a/packages/plugins/plugin-auth/src/session-platform-admin-rung-agreement.test.ts b/packages/plugins/plugin-auth/src/session-platform-admin-rung-agreement.test.ts index 0ef8353ddc7..29a352bd317 100644 --- a/packages/plugins/plugin-auth/src/session-platform-admin-rung-agreement.test.ts +++ b/packages/plugins/plugin-auth/src/session-platform-admin-rung-agreement.test.ts @@ -29,7 +29,8 @@ // capability grant. // // ⚠️ POPULATION OF THIS PIN, stated because a pin proves only what it covers: -// it covers the D4-spelled-built-in-name shape and a genuine-grant control. It +// it covers the D4-spelled-built-in-name shape, a genuine-grant control and the +// retired organization-less grant (which anchors nothing). It // does NOT cover the ADR-0091 validity window, the ADR-0049 catalogue flag, or // the catalogue-page shapes — those are `platform-admin-standing.consolidation. // test.ts`'s population, and that suite passing is NOT evidence about this one. @@ -100,17 +101,23 @@ const sessionFor = async (manager: AuthManager, bearer: string) => { /** * `shape: 'name-only'` — the escalation: a plain org member plus a D4 row - * SPELLING the built-in name, and no capability grant behind it. - * `shape: 'genuine'` — the control: a real unscoped `admin_full_access` grant. + * SPELLING the built-in name (in the member's own organization, where it + * applies), and no capability grant behind it. + * `shape: 'genuine'` — the control: a real `admin_full_access` grant owned by + * the Default Organization (`ORG`, slugged `default`) — the only grant that + * anchors platform standing. + * `shape: 'org-less'` — the retired reading: the same grant with NO + * organization, which anchors nothing. */ -const arrange = async (shape: 'name-only' | 'genuine') => { +type Shape = 'name-only' | 'genuine' | 'org-less'; +const arrange = async (shape: Shape) => { const engine = createMemoryEngine(); const manager = makeManager(engine); await signUp(manager, 'subject@example.com', 'Subject'); const userId = userIdFor(engine, 'subject@example.com'); - await engine.insert('sys_organization', { id: ORG, name: 'Rung Org', slug: 'rung-org' }); + await engine.insert('sys_organization', { id: ORG, name: 'Rung Org', slug: 'default' }); await engine.insert('sys_member', { organization_id: ORG, user_id: userId, role: 'member' }); if (shape === 'name-only') { @@ -120,7 +127,7 @@ const arrange = async (shape: 'name-only' | 'genuine') => { await engine.insert('sys_user_position', { user_id: userId, position: 'platform_admin', - organization_id: null, + organization_id: ORG, }); } else { await engine.insert('sys_permission_set', { id: PS_ADMIN, name: ADMIN_FULL_ACCESS }); @@ -128,7 +135,7 @@ const arrange = async (shape: 'name-only' | 'genuine') => { user_id: userId, permission_set_id: PS_ADMIN, permission_set: 'admin_full_access', - organization_id: null, + organization_id: shape === 'genuine' ? ORG : null, }); } @@ -137,7 +144,7 @@ const arrange = async (shape: 'name-only' | 'genuine') => { }; /** All three answers, read off one arranged principal. */ -const verdicts = async (shape: 'name-only' | 'genuine') => { +const verdicts = async (shape: Shape) => { const { engine, manager, userId, bearer } = await arrange(shape); const session = await sessionFor(manager, bearer); return { @@ -183,7 +190,7 @@ describe('a `sys_user_position` row spelling `platform_admin` confers NO platfor }); }); -describe('CONTROL — a genuine unscoped admin_full_access grant still admits', () => { +describe('CONTROL — a genuine Default Organization admin_full_access grant still admits', () => { it('all three answers are TRUE, and they agree with each other', async () => { const v = await verdicts('genuine'); expect( @@ -198,6 +205,24 @@ describe('CONTROL — a genuine unscoped admin_full_access grant still admits', }); }); +describe('an ORGANIZATION-LESS admin_full_access grant confers NO platform standing', () => { + it('all three answers are FALSE — and the Default Organization grant on the same rig is TRUE', async () => { + const v = await verdicts('org-less'); + expect( + { alias: v.alias, gate: v.gate, rung: v.rung }, + `positions=${JSON.stringify(v.positions)}`, + ).toEqual({ alias: false, gate: false, rung: false }); + expect(v.positions).not.toContain('platform_admin'); + + const control = await verdicts('genuine'); + expect({ alias: control.alias, gate: control.gate, rung: control.rung }).toEqual({ + alias: true, + gate: true, + rung: true, + }); + }); +}); + describe('the two shapes are INDISTINGUISHABLE by name and separable only by the rung', () => { it('both carry `platform_admin` in positions[]; only the granted one has standing', async () => { const escalation = await verdicts('name-only'); diff --git a/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts b/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts index e9f222794d2..b74b593418a 100644 --- a/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts +++ b/packages/plugins/plugin-auth/src/session-positions-security-axis.test.ts @@ -155,14 +155,15 @@ const arrange = async (opts: { assignPosition?: boolean } = {}) => { await engine.insert('sys_member', { organization_id: ORG, user_id: bystanderId, role: 'member' }); // The security-layer position, and the ADR-0057 D4 assignment row that is its - // source of truth. `organization_id: null` = a global assignment, so the - // fixture does not also depend on the session carrying an active org. + // source of truth. The assignment is owned by `ORG` — a grant applies only in + // its own organization — and the holder's session acts in `ORG` (stamped + // from the membership above at sign-in). await engine.insert('sys_position', { id: 'pos_reviewer', name: POSITION, label: 'Demo Reviewer' }); if (opts.assignPosition !== false) { await engine.insert('sys_user_position', { user_id: holderId, position: POSITION, - organization_id: null, + organization_id: ORG, }); } diff --git a/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts b/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts index 46b4de6031e..a38d8535d0c 100644 --- a/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts +++ b/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts @@ -38,7 +38,7 @@ * * The platform admin is made one the way a real deployment does it — a * `sys_user_permission_set` row pointing at the `admin_full_access` - * `sys_permission_set`, with `organization_id = null` — and the case ASSERTS + * `sys_permission_set`, owned by the Default Organization — and the case ASSERTS * that the legacy `sys_user.role` scalar is NOT `'admin'`. Without that second * assertion the suite could pass while riding the retired D2 channel, which is * precisely the channel this family is closing; the pin would then survive the @@ -187,8 +187,8 @@ async function bootEngine(): Promise { engine.registry.registerObject(sysPosition as never, '@objectstack/plugin-auth'); engine.registry.registerObject(sysUserPosition as never, '@objectstack/plugin-auth'); await engine.syncSchemas(); - // [ADR-0131 D3/D4] The platform's `admin_full_access` definition, which an - // org-less grant of it resolves through (no security plugin binds one here). + // [ADR-0131 D3/D4] The platform's `admin_full_access` definition, which a + // grant of it resolves through (no security plugin binds one here). return bindTestSecurityCatalog(engine, { permissions: [{ name: ADMIN_FULL_ACCESS, ...ADMIN_FULL_ACCESS_CAPABILITIES }] }); } @@ -249,11 +249,22 @@ async function createOrg( return id; } -/** Grant platform admin the ADR-0068 D2 way: an ORG-LESS `admin_full_access` link. */ +/** + * Grant platform admin the ADR-0068 D2 way: an `admin_full_access` link owned by + * the Default Organization — the only grant that anchors platform standing (an + * organization-less one anchors nothing). The Default Organization is written + * through the engine, so the resolver's Default Organization answer sees it. + */ async function grantPlatformAdmin(engine: ObjectQL, userId: string): Promise { - // [C8 stage 8b] The legacy organization-less catalogue row and grant, landed - // beneath the engine: under `single` the engine refuses an organization-less - // platform insert while the install holds no organization (ADR-0131 D9). + await engine.insert( + 'sys_organization', + { id: 'org_default', name: 'Default Organization', slug: 'default' }, + SYSTEM as never, + ); + // [C8 stage 8b] The legacy organization-less catalogue row, and the grant, + // landed beneath the engine as before: under `single` the engine refuses an + // organization-less platform insert while the install holds no organization + // (ADR-0131 D9). const residue = (object: string, row: Record) => (engine as any).getDriver(object).create(object, row); await residue('sys_permission_set', { id: 'ps_admin_full_access', name: ADMIN_FULL_ACCESS }); @@ -262,7 +273,7 @@ async function grantPlatformAdmin(engine: ObjectQL, userId: string): Promise }`. Leg B, + * only when leg A found nobody, is the ordered, bounded scan of every grant of + * the set, applying the same predicate client-side. + * + * An ORGANIZATION-LESS row (`null`, or the `''` shape both SQL families store) + * is never a holder: the 18.0 ownership ceremony (ADR-0131 D10) attributes every + * such row to an organization before an upgraded database boots, and a fresh + * install never writes one. This card's earlier revision pinned the opposite — + * leg A asking a second time for the `null` row, and leg B still seeing the + * `''`-shaped one; both cases below are rewritten to the rule that replaced it. + * Where no Default Organization can be named — under a wall, or with no + * organization to name — the scan asks the grant table NOTHING. * * ## Why the row ORDER is permuted rather than a second driver package * @@ -87,6 +91,8 @@ const SYSTEM_CTX = { isSystem: true }; const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; /** The permission-set id the fixture pre-seeds, so grant rows can point at it. */ const ADMIN_PS_ID = 'ps_admin_full'; +/** [ADR-0131 D5] The Default Organization {@link boot} creates — the anchoring grant's owner. */ +const DEFAULT_ORG = 'org_default'; const engines: ObjectQL[] = []; @@ -212,25 +218,26 @@ async function seedUser( /** * The card's population: a tenant whose `admin_full_access` rows are dominated - * by ORGANIZATION-scoped grants, plus the one unscoped human holder that IS its - * platform admin. + * by OTHER organizations' grants, plus the one Default Organization grant whose + * human holder IS its platform admin. * - * The unscoped grant row's id collates AFTER every org-scoped one, so the real + * The anchoring grant row's id collates AFTER every other one, so the real * driver's own unordered window is exactly the window the card describes: full - * of rows that fail `!organization_id`, with the row that decides outside it. + * of rows that fail the anchor predicate, with the row that decides outside it. * * `usr_orgadmin_001` is the OLDEST authenticable human — an org admin imported * before the founder signed up, which is what a tenant with sixty org admins * actually looks like. So when the guard fails, the promotion does not merely * duplicate the founder's grant: it hands platform admin to an ORG admin. * - * @param orgGrants how many organization-scoped grants of the same set exist - * @param unscopedOrganizationId the value the founder's unscoped grant stores + * @param orgGrants how many other organizations' grants of the same set exist + * @param founderOrganizationId the value the founder's grant stores — the + * Default Organization (the anchor), or an organization-less legacy shape */ async function seedTenant( ql: any, orgGrants: number, - unscopedOrganizationId: null | '' = null, + founderOrganizationId: typeof DEFAULT_ORG | null | '' = DEFAULT_ORG, ): Promise { await ql.insert( 'sys_permission_set', @@ -241,17 +248,18 @@ async function seedTenant( // The founder — this deployment's platform admin, and the row the guard has // to find. Inserted FIRST so the insertion-order family puts it in row 1. await seedUser(ql, 'usr_founder', 'founder@tenant.example', '2026-01-01T00:00:00.000Z', true); - // [C8 stage 8b] The unscoped grant is legacy residue, landed straight through - // the driver: under `single` the engine stamps an organization-less platform - // insert with the Default Organization (ADR-0131 D9), which is where 8b-0a - // already puts a new promotion's grant. An unranked row sorts first in the - // insertion-order family, as it did when it was inserted first. + // [C8 stage 8b] Landed straight through the driver, so the value stored is + // exactly `founderOrganizationId`: under `single` the engine would stamp an + // organization-less platform insert with the Default Organization (ADR-0131 + // D9), and the organization-less shapes are what the negative case needs. + // An unranked row sorts first in the insertion-order family, as it did when + // it was inserted first. const real: any = ENGINE_OF.get(ql) ?? ql; await real.getDriver('sys_user_permission_set').create('sys_user_permission_set', { id: 'ups_zzz_founder', user_id: 'usr_founder', permission_set: 'admin_full_access', - organization_id: unscopedOrganizationId, + organization_id: founderOrganizationId, }); for (let i = 1; i <= orgGrants; i++) { @@ -281,11 +289,11 @@ async function findRows(engine: ObjectQL, object: string, where: any = {}): Prom return Array.isArray(rows) ? rows : []; } -/** Every `admin_full_access` grant row that no organization scopes. */ -async function unscopedGrants(engine: ObjectQL): Promise { +/** Every `admin_full_access` grant row the Default Organization owns — the anchoring ones. */ +async function anchorGrants(engine: ObjectQL): Promise { const links = await findRows(engine, 'sys_user_permission_set', { permission_set: 'admin_full_access' }); expect(links.length, 'ANTI-VACUITY: the fixture must have written grant rows').toBeGreaterThan(0); - return links.filter((r) => !r.organization_id); + return links.filter((r) => r.organization_id === DEFAULT_ORG); } function collectingLogger() { @@ -304,12 +312,95 @@ function collectingLogger() { }; } +/** + * A synthetic grant population — the one case whose subject is a row COUNT + * larger than any fixture worth storing. Every row is ORGANIZATION-scoped, + * which is the shape that fills the window in the first place, and the + * generator serves `offset`/`limit` so the paging is exercised for real. + */ +function makeSyntheticQl( + grantCount: number, + organizations: any[] = [{ id: DEFAULT_ORG, slug: 'default' }], +) { + const permissionSets: any[] = []; + const inserted: any[] = []; + /** The `where` of every read of the grant table, in order. */ + const grantReads: Record[] = []; + return { + inserted, + grantReads, + async find(object: string, q: any) { + const bound = (rows: any[]) => + typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; + const where = q?.where ?? {}; + if (object === 'sys_permission_set') { + return bound( + permissionSets.filter((r) => + Object.entries(where).every(([k, v]) => { + // Refuse loudly rather than reading a combinator as a field + // name — a matcher that answers `false` for `$or` reports a + // row it never understood as absent. + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return r[k] === v; + }), + ), + ); + } + for (const k of Object.keys(where)) { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + } + if (object === 'sys_user_permission_set') { + grantReads.push(where); + // Every synthetic row is another organization's, so the narrow leg + // (the Default Organization's grants) legitimately answers with + // nothing. + if ('organization_id' in where && where.organization_id === DEFAULT_ORG) return []; + if ('organization_id' in where) throw new Error(`fake driver: unexpected organization_id ${String(where.organization_id)}`); + const offset = q?.offset ?? 0; + const limit = q?.limit ?? 100; + const out: any[] = []; + for (let i = offset; i < Math.min(offset + limit, grantCount); i++) { + out.push({ + id: `ups_${String(i).padStart(6, '0')}`, + user_id: `usr_${String(i).padStart(6, '0')}`, + permission_set: 'admin_full_access', + organization_id: `org_${i}`, + }); + } + return out; + } + if (object === 'sys_user') { + if (Object.keys(where).length > 0) return []; + const offset = q?.offset ?? 0; + return offset === 0 + ? [{ id: 'usr_promotable', email: 'p@demo.example', created_at: '2026-01-01T00:00:00.000Z' }] + : []; + } + if (object === 'sys_account') return [{ id: 'acc_p', user_id: 'usr_promotable' }]; + // [ADR-0131 D5] The Default Organization, which owns the promotion's grant. + if (object === 'sys_organization') { + return bound(organizations.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v))); + } + return []; + }, + async insert(object: string, data: any) { + if (object === 'sys_permission_set') permissionSets.push({ ...data }); + inserted.push({ object, data }); + return { id: data.id }; + }, + async update(_object: string, data: any, options?: any) { + assertEngineUpdateDispatch(data, options); + return 0; + }, + }; +} + describe('#16861 — an existing platform admin is found, not sampled for', () => { // ───────────────────────────────────────────────────────────────────────── // ANTI-VACUITY: the truncated window really is driver-shaped // ───────────────────────────────────────────────────────────────────────── - it('ANTI-VACUITY: the old unordered 50-row read really does hide the unscoped grant', async () => { + it('ANTI-VACUITY: the old unordered 50-row read really does hide the anchoring grant', async () => { // Without this the 60-row case could be green because the fixture happens // to be shaped some other way, rather than because the guard was repaired. const engine = await boot(); @@ -328,9 +419,9 @@ describe('#16861 — an existing platform admin is found, not sampled for', () = expect(window50).toHaveLength(50); expect(window50.some((r: any) => r.id === 'ups_zzz_founder')).toBe(false); // …and every row it DID return fails the predicate that decides. - expect(window50.every((r: any) => !!r.organization_id)).toBe(true); + expect(window50.every((r: any) => !!r.organization_id && r.organization_id !== DEFAULT_ORG)).toBe(true); // The row that decides exists all the same. - expect((await unscopedGrants(engine)).map((r) => r.id)).toEqual(['ups_zzz_founder']); + expect((await anchorGrants(engine)).map((r) => r.id)).toEqual(['ups_zzz_founder']); }); // ───────────────────────────────────────────────────────────────────────── @@ -353,8 +444,8 @@ describe('#16861 — an existing platform admin is found, not sampled for', () = expect(report.adminPromoted).toBe(false); expect(report.reason).toBe('already_have_admin'); - // ⭐ The harm, stated as the card states it: no SECOND unscoped grant. - expect((await unscopedGrants(engine)).map((r) => r.id)).toEqual(['ups_zzz_founder']); + // ⭐ The harm, stated as the card states it: no SECOND anchoring grant. + expect((await anchorGrants(engine)).map((r) => r.id)).toEqual(['ups_zzz_founder']); // …and nothing was re-owned, because nothing was promoted. expect(info.join('\n')).not.toContain('promoted to platform admin'); }); @@ -363,25 +454,38 @@ describe('#16861 — an existing platform admin is found, not sampled for', () = }); // ───────────────────────────────────────────────────────────────────────── - // 2. The leg the `where` narrowing alone could not have covered + // 2. An organization-less row is never a holder, in either stored shape // ───────────────────────────────────────────────────────────────────────── - it("a legacy holder storing organization_id '' is still seen — the fix only tightens", async () => { - // Measured on both SQL families through ObjectQL and these real objects: - // `organization_id: ''` is storable, reads back as `''`, is UNSCOPED to - // `!organization_id`, and is NOT returned by `where: { organization_id: - // null }`. So the ordered bounded scan — not the narrowed read — is what - // answers here, and dropping it would have RELAXED this guard. - const engine = await boot(); - const ql = withNaturalOrder(engine, 'AS_RETURNED'); - await seedTenant(ql, 60, ''); - - const { logger } = collectingLogger(); - const report = await bootstrapPlatformAdmin(ql, defaultPermissionSets, { logger }); - - expect(report.reason).toBe('already_have_admin'); - expect((await unscopedGrants(engine)).map((r) => r.id)).toEqual(['ups_zzz_founder']); - }); + for (const [shape, organizationLess] of [['null', null], ["''", '']] as const) { + it(`an organization-less grant (organization_id ${shape}) is never a holder — the promotion proceeds`, async () => { + // What this proves: the scan reads the founder's row and REFUSES it — leg + // B examines every one of the 61 rows, the organization-less one among + // them — and the deployment is promoted exactly as one with no grant at + // all would be. ⛔ An organization-less `admin_full_access` row anchors + // nothing (ADR-0131 D5/D10): the 18.0 ceremony attributes it to an + // organization before an upgraded database boots. The CONTROL is the + // card's cell above — the same fixture with the founder's grant owned by + // the Default Organization answers `already_have_admin`. + const engine = await boot(); + const ql = withNaturalOrder(engine, 'AS_RETURNED'); + await seedTenant(ql, 60, organizationLess); + + const { logger } = collectingLogger(); + const report = await bootstrapPlatformAdmin(ql, defaultPermissionSets, { logger }); + + expect(report.adminGrantRowsExamined).toBe(61); + expect(report.adminPromoted).toBe(true); + // The age rule answers, as on a fresh install: the oldest authenticable + // human, who is not the founder (the fixture's seconds wrap at 60, so + // that is `usr_orgadmin_060`, created at second :00). + expect(report.adminUserId).toBe('usr_orgadmin_060'); + expect((await anchorGrants(engine)).map((r) => r.user_id)).toEqual(['usr_orgadmin_060']); + // The organization-less row itself is left exactly as it was. + const [legacy] = await findRows(engine, 'sys_user_permission_set', { id: 'ups_zzz_founder' }); + expect(legacy?.organization_id).toBe(organizationLess); + }); + } // ───────────────────────────────────────────────────────────────────────── // 3. The guard says how many rows it examined @@ -397,7 +501,7 @@ describe('#16861 — an existing platform admin is found, not sampled for', () = expect(report.reason).toBe('already_have_admin'); // The narrow leg answers this fixture on its own, so the number is the - // unscoped population — not "up to 50, whichever the driver produced first". + // anchoring population — not "up to 50, whichever the driver produced first". expect(report.adminGrantRowsExamined).toBe(1); }); @@ -417,7 +521,7 @@ describe('#16861 — an existing platform admin is found, not sampled for', () = // 4. The system row still never counts — the fix did not over-tighten // ───────────────────────────────────────────────────────────────────────── - it('an unscoped grant held by usr_system does NOT block promotion', async () => { + it('an anchoring grant held by usr_system does NOT block promotion', async () => { // Self-healing on restart: a DB where the seed identity was wrongly // promoted must not block every real admin forever. The scan sees the row // and the predicate still refuses it. @@ -430,7 +534,7 @@ describe('#16861 — an existing platform admin is found, not sampled for', () = ); await ql.insert( 'sys_user_permission_set', - { id: 'ups_system', user_id: 'usr_system', permission_set: 'admin_full_access', organization_id: null }, + { id: 'ups_system', user_id: 'usr_system', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG }, { context: SYSTEM_CTX }, ); await seedUser(ql, 'usr_real', 'real@tenant.example', '2026-03-01T00:00:00.000Z', true); @@ -447,79 +551,6 @@ describe('#16861 — an existing platform admin is found, not sampled for', () = // ───────────────────────────────────────────────────────────────────────── describe('the grant scan is bounded but never silent', () => { - /** - * A synthetic grant population — the one case whose subject is a row COUNT - * larger than any fixture worth storing. Every row is ORGANIZATION-scoped, - * which is the shape that fills the window in the first place, and the - * generator serves `offset`/`limit` so the paging is exercised for real. - */ - function makeSyntheticQl(grantCount: number) { - const permissionSets: any[] = []; - const inserted: any[] = []; - return { - inserted, - async find(object: string, q: any) { - const bound = (rows: any[]) => - typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; - const where = q?.where ?? {}; - if (object === 'sys_permission_set') { - return bound( - permissionSets.filter((r) => - Object.entries(where).every(([k, v]) => { - // Refuse loudly rather than reading a combinator as a field - // name — a matcher that answers `false` for `$or` reports a - // row it never understood as absent. - if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); - return r[k] === v; - }), - ), - ); - } - for (const k of Object.keys(where)) { - if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); - } - if (object === 'sys_user_permission_set') { - // Every synthetic row is another organization's, so the narrow leg - // (`organization_id: null`, and the Default Organization's) - // legitimately answers with nothing. - if ('organization_id' in where && (where.organization_id === null || where.organization_id === 'org_default')) return []; - const offset = q?.offset ?? 0; - const limit = q?.limit ?? 100; - const out: any[] = []; - for (let i = offset; i < Math.min(offset + limit, grantCount); i++) { - out.push({ - id: `ups_${String(i).padStart(6, '0')}`, - user_id: `usr_${String(i).padStart(6, '0')}`, - permission_set: 'admin_full_access', - organization_id: `org_${i}`, - }); - } - return out; - } - if (object === 'sys_user') { - if (Object.keys(where).length > 0) return []; - const offset = q?.offset ?? 0; - return offset === 0 - ? [{ id: 'usr_promotable', email: 'p@demo.example', created_at: '2026-01-01T00:00:00.000Z' }] - : []; - } - if (object === 'sys_account') return [{ id: 'acc_p', user_id: 'usr_promotable' }]; - // [ADR-0131 D5] The Default Organization, which owns the promotion's grant. - if (object === 'sys_organization') return [{ id: 'org_default', slug: 'default' }]; - return []; - }, - async insert(object: string, data: any) { - if (object === 'sys_permission_set') permissionSets.push({ ...data }); - inserted.push({ object, data }); - return { id: data.id }; - }, - async update(_object: string, data: any, options?: any) { - assertEngineUpdateDispatch(data, options); - return 0; - }, - }; - } - it('warns, naming the ceiling and the number examined, when the scan stops short', async () => { const ql = makeSyntheticQl( PLATFORM_ADMIN_GRANT_SCAN_CEILING + PLATFORM_ADMIN_GRANT_PAGE_SIZE, @@ -554,4 +585,56 @@ describe('#16861 — an existing platform admin is found, not sampled for', () = expect(warn.join('\n')).not.toContain('stopped at its ceiling'); }); }); + + // ───────────────────────────────────────────────────────────────────────── + // 6. What leg A asks — and that nothing is asked when no owner can be named + // ───────────────────────────────────────────────────────────────────────── + + describe('the scan asks the Default Organization, once — and nothing when none can be named', () => { + it("leg A is ONE read, for the Default Organization's grants of the set; no organization-less read is made", async () => { + const ql = makeSyntheticQl(10); + const report = await bootstrapPlatformAdmin(ql as any, defaultPermissionSets, { logger: collectingLogger().logger }); + + // Everyone is another organization's grant: the scan saw all ten and promoted. + expect(report.adminPromoted).toBe(true); + expect(report.adminGrantRowsExamined).toBe(10); + const narrow = ql.grantReads.filter((where) => 'organization_id' in where); + expect(narrow).toEqual([{ permission_set: 'admin_full_access', organization_id: DEFAULT_ORG }]); + // …and it is the first question asked; leg B's pages follow it. + expect(ql.grantReads[0]).toEqual({ permission_set: 'admin_full_access', organization_id: DEFAULT_ORG }); + expect(ql.grantReads.length).toBeGreaterThan(1); + }); + + it('no nameable Default Organization (several organizations, none `default`) ⇒ zero grant reads, nobody found', async () => { + const ql = makeSyntheticQl(10, [{ id: 'org_a', slug: 'a' }, { id: 'org_b', slug: 'b' }]); + const report = await bootstrapPlatformAdmin(ql as any, defaultPermissionSets, { logger: collectingLogger().logger }); + + expect(ql.grantReads).toEqual([]); + expect(report.adminGrantRowsExamined).toBe(0); + // Nobody found, and no grant is written without an owner. + expect(report.adminPromoted).toBe(false); + expect(report.reason).toBe('default_organization_unresolved'); + expect(ql.inserted.filter((i) => i.object === 'sys_user_permission_set')).toEqual([]); + }); + + describe('under a wall', () => { + const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; + afterEach(() => { + if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE; + else process.env.OS_TENANCY_POSTURE = OLD_POSTURE; + }); + + it('a walled posture ⇒ zero grant reads, whatever the Default Organization', async () => { + process.env.OS_TENANCY_POSTURE = 'isolated'; + // The Default Organization exists here — the wall alone is why nothing is asked. + const ql = makeSyntheticQl(10); + const report = await bootstrapPlatformAdmin(ql as any, defaultPermissionSets, { logger: collectingLogger().logger }); + + expect(ql.grantReads).toEqual([]); + expect(report.adminPromoted).toBe(false); + expect(report.reason).toBe('walled_owner_email_undeclared'); + expect(ql.inserted.filter((i) => i.object === 'sys_user_permission_set')).toEqual([]); + }); + }); + }); }); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts index 7e8bad5acdf..d7544ae83ce 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-walled-owner.test.ts @@ -70,9 +70,13 @@ function makeQl(seed: { users?: any[]; grants?: any[]; sets?: any[]; accounts?: ['sys_organization', [{ id: 'org_default', slug: 'default' }]], ]); const rowsOf = (object: string) => tables.get(object) ?? []; + /** Every read, by object — so a case can say which tables a pass asked. */ + const reads: string[] = []; return { tables, + reads, async find(object: string, q: any) { + reads.push(object); const where = q?.where ?? {}; return rowsOf(object).filter((r) => Object.entries(where).every(([k, v]) => { @@ -388,7 +392,7 @@ describe('walled posture — the legacy grant is no longer an anchor (#11663 L5) expect(sinkWarns).toEqual([]); // Nothing is revoked and nothing new is minted: the one legacy row stays // exactly where it is. ⛔ Retiring the ANCHOR is not deleting the ROW — - // that row's ownership is ADR-0131 C3's, on the v18 line. + // that row's ownership is ADR-0131 D10's ceremony's. expect(ql.grants()).toHaveLength(1); expect(r1.reason).toBe('walled_config_derived'); expect(r2.reason).toBe('walled_config_derived'); @@ -407,28 +411,38 @@ describe('walled posture — the legacy grant is no longer an anchor (#11663 L5) expect(log.error).toHaveBeenCalledTimes(1); const line = String(log.error.mock.calls[0][0]); expect(line).toContain('OS_PLATFORM_OWNER_EMAIL'); - // …and it names the row and its holder, so the operator can tell WHOSE - // standing just ended rather than only that nobody is declared. - expect(line).toContain('u_legacy'); - expect(line).toContain('admin_full_access'); + // The line no longer names the row or its holder: under a wall no grant + // anchors standing, so the holder scan asks NOTHING — not one read of the + // grant table — and the line is the general one, byte-for-byte what a rig + // holding no such row is told (the CONTROL below). + expect(ql.reads).not.toContain('sys_user_permission_set'); + expect(line).not.toContain('u_legacy'); + expect(line).toBe(await undeclaredLineWithoutLegacyRow()); // The row is untouched, and no deprecation pointer came back through the // module sink either. expect(ql.grants()).toHaveLength(1); expect(sinkWarns).toEqual([]); }); - it('the holder clause is CONDITIONAL — a walled rig with no legacy row says only the general line', async () => { - // The control for the arm above: the extra sentence must come from the row - // actually being there, not from the message having grown unconditionally. - process.env.OS_TENANCY_POSTURE = 'isolated'; + /** The fail-closed line a walled rig holding NO legacy row is told. */ + async function undeclaredLineWithoutLegacyRow(): Promise { const log = logger(); const ql = makeQl({ users: [user('u_stranger', 'stranger@evil.example', '2026-08-23T01:00:00Z')], }); const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: log }); expect(r.reason).toBe('walled_owner_email_undeclared'); - const line = String(log.error.mock.calls[0][0]); + expect(log.error).toHaveBeenCalledTimes(1); + return String(log.error.mock.calls[0][0]); + } + + it('CONTROL: a walled rig with no legacy row says the same general line, naming the variable', async () => { + // The control for the arm above: the line it is compared to is a real + // fail-closed line naming the remedy, not an empty string both sides share. + process.env.OS_TENANCY_POSTURE = 'isolated'; + const line = await undeclaredLineWithoutLegacyRow(); expect(line).toContain('OS_PLATFORM_OWNER_EMAIL'); + expect(line).toContain('ZERO config-derived platform administrators'); expect(line).not.toContain('legacy unscoped'); }); @@ -540,10 +554,12 @@ describe('single posture — "first user is owner" is ruled reasonable and UNCHA }); it('an existing human admin short-circuits as already_have_admin — the single-posture no-op-forever shape', async () => { + // [ADR-0131 D5] The anchoring grant is the Default Organization's — the + // only grant the holder scan reads. const ql = makeQl({ sets: [{ id: 'ps_admin', name: 'admin_full_access', active: true }], users: [user('u_admin', 'admin@corp.example', '2026-08-23T01:00:00Z')], - grants: [{ id: 'ups_1', user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }], + grants: [{ id: 'ups_1', user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_default' }], }); const r = await bootstrapPlatformAdmin(ql as any, [adminFullAccess()], { logger: logger() }); expect(r.adminPromoted).toBe(false); diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts b/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts index 5e5035ce74c..6bba91c296d 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership-seed-settle-rerun.test.ts @@ -307,11 +307,17 @@ describe('seed-ownership claim — the one-shot pass and the seed it races', () user_id: ADMIN, permission_set_id: 'ps_admin_full_access', permission_set: 'admin_full_access', - organization_id: null, + // [ADR-0131 D5] Owned by the Default Organization — the only grant the + // holder scan reads (an organization-less one is never a holder). + organization_id: 'org_default', }); rig.tables.sys_permission_set.push({ id: 'ps_admin_full_access', name: 'admin_full_access' }); await rig.fire('kernel:ready'); + // The promotion DID short-circuit: no second grant was minted. Without this + // a pass that ignored the existing grant and promoted the same (only) human + // would satisfy the claim assertion below for the wrong reason. + expect(rig.tables.sys_user_permission_set.map((r) => r.id)).toEqual(['ups_existing']); rig.landSeedRows('crm_contract', seedRow('con_late')); rig.settlement.settleOne(); await rig.fire('app:seeded', { appId: 'com.example.crm', overBudget: true }); diff --git a/packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts b/packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts index 0db87c56aab..f48bdf0a27f 100644 --- a/packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts +++ b/packages/plugins/plugin-security/src/controlled-by-parent-chain-middle-master.test.ts @@ -133,6 +133,9 @@ const SETS = [MEMBER_DEFAULT, MANAGER_SET, ROOT_READER_SET, MIDDLE_READER_SET, Q /** The execution context `resolveAuthzContext` hands the middleware for an organization member holding the sets. */ const memberOf = (userId: string, ...sets: string[]) => ({ userId, + // The organization the request runs in — and so the one a delegator's stored + // grants are resolved in (`resolveDelegatorContext`). + tenantId: ORG, positions: ['org_member'], permissions: [...sets, 'member_default'], posture: 'MEMBER', @@ -143,7 +146,7 @@ const MIDDLE_READER_CALLER = memberOf(MIDDLE_READER, MIDDLE_READER_SET.name); const LOCKED_CALLER = memberOf(LOCKED, MANAGER_SET.name, QUOTE_OPEN_ONLY_SET.name); const PLAIN_CALLER = memberOf(PLAIN, PLAIN_SET.name); /** Holds the manager's grants and NO `org_member`, so no floor binds it. */ -const AGENT_UNBOUND_CALLER = { userId: AGENT_UNBOUND, positions: [], permissions: [MANAGER_SET.name], posture: 'MEMBER' }; +const AGENT_UNBOUND_CALLER = { userId: AGENT_UNBOUND, tenantId: ORG, positions: [], permissions: [MANAGER_SET.name], posture: 'MEMBER' }; const onBehalfOf = (caller: object, delegator: string) => ({ ...caller, onBehalfOf: { userId: delegator } }); type Answer = { kind: 'admitted' } | { kind: 'refused'; code?: string; status?: number; message: string }; @@ -225,21 +228,24 @@ beforeAll(async () => { // The two delegators exist as users, so the on-behalf-of link resolves rather // than failing closed. Each holds the manager's set through a stored position - // row; only DELEGATOR_MEMBER is an organization member, so only it carries the floor. + // row owned by ORG — the organization the delegated request runs in, the only + // one a grant row applies in (ADR-0131 D10: an organization-less row applies + // nowhere); only DELEGATOR_MEMBER is an organization member, so only it + // carries the floor. await engine.insert('sys_user', [ { id: DELEGATOR_UNBOUND, name: 'Delegator, not a member', email: 'delegator-unbound@cbc.test' }, { id: DELEGATOR_MEMBER, name: 'Delegator, a member', email: 'delegator-member@cbc.test' }, ], SYS); await engine.insert('sys_organization', [{ id: ORG, name: 'CBC', slug: 'cbc' }], SYS); await engine.insert('sys_member', [{ id: 'mem_delegator', user_id: DELEGATOR_MEMBER, organization_id: ORG, role: 'member' }], SYS); - // [C8 stage 8b] Organization-less position rows are landed straight through the - // driver, as the residue an older release left: under `single` the engine now - // stamps such a system insert with the one organization (ADR-0131 D9), which - // would tie DELEGATOR_UNBOUND's position to an organization it is not a member of. + // Landed straight through the driver, so each row stores exactly the + // organization named here. DELEGATOR_UNBOUND's row is ORG's without a + // membership: under `single` the session's organization is kept without one, + // and no `sys_member` row means no `org_member` floor. const positions = (engine as any).getDriver('sys_user_position'); for (const row of [ - { id: 'up_unbound', user_id: DELEGATOR_UNBOUND, position: MANAGER_SET.name }, - { id: 'up_member', user_id: DELEGATOR_MEMBER, position: MANAGER_SET.name }, + { id: 'up_unbound', user_id: DELEGATOR_UNBOUND, position: MANAGER_SET.name, organization_id: ORG }, + { id: 'up_member', user_id: DELEGATOR_MEMBER, position: MANAGER_SET.name, organization_id: ORG }, ]) { await positions.create('sys_user_position', row); } diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts index 22493f8a1e3..5a76dc67d38 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts @@ -20,12 +20,13 @@ * * ## The rule the read now follows — the runtime's own * - * `resolveAuthzContext` (step 4, `@objectstack/core`) keeps a holding when its - * `organization_id` is empty OR equals the active organization, and drops it - * when it names a DIFFERENT one. The gate agrees: a holding stamped for another - * organization never counts; an organization-less holding counts in every - * organization, exactly as it grants in every organization. An - * organization-less caller (`single` posture) drops nothing, as before. + * `resolveAuthzContext` (step 4, `@objectstack/core`) keeps a holding only + * when its `organization_id` equals the active organization. The gate agrees: a + * holding stamped for another organization never counts; an organization-less + * holding counts nowhere, exactly as it grants nowhere (the 18.0 ownership + * ceremony, ADR-0131 D10, attributes every such row before an upgraded database + * boots, so one is invalid state); and a caller whose context carries no + * organization holds nothing to delegate. */ import { describe, it, expect, afterEach } from 'vitest'; @@ -209,12 +210,16 @@ describe('DelegatedAdminGate — rule 4 counts only holdings the caller\'s organ await expect(selfDelegate(gateOn(engine), ORG_A)).resolves.toBeUndefined(); }); - it('an organization-less holding counts in org A — the runtime grants it in every organization', async () => { + it('an organization-less holding counts nowhere — not in org A, where the runtime grants it nothing', async () => { const engine = await bootEngine(); await seedOrganization(engine, ORG_B, '0'); await seedOrganization(engine, ORG_A, 'a'); await hold(engine, undefined, [{ id: 'up_none', user_id: USER }]); + await expectRefused(selfDelegate(gateOn(engine), ORG_A), /you do not currently hold 'field_lead'/); + await expectRefused(selfDelegate(gateOn(engine), ORG_B), /you do not currently hold 'field_lead'/); + // Control: the same user's holding OWNED by org A makes the same request stand. + await hold(engine, ORG_A, [{ id: 'up_a', user_id: USER }]); await expect(selfDelegate(gateOn(engine), ORG_A)).resolves.toBeUndefined(); }); @@ -245,11 +250,14 @@ describe('DelegatedAdminGate — rule 4 counts only holdings the caller\'s organ await expect(selfDelegate(gateOn(engine), ORG_A, a.salesEast)).resolves.toBeUndefined(); }); - it('a `single`-posture caller (no organization in context) keeps counting every holding', async () => { + it('a caller whose context carries no organization counts no holding — owned or organization-less', async () => { const engine = await bootEngine(); await seedOrganization(engine, ORG_B, '0'); await hold(engine, ORG_B, [{ id: 'up_b', user_id: USER }]); + await hold(engine, undefined, [{ id: 'up_none', user_id: USER }]); - await expect(selfDelegate(gateOn(engine), undefined)).resolves.toBeUndefined(); + await expectRefused(selfDelegate(gateOn(engine), undefined), /you do not currently hold 'field_lead'/); + // Control: the same holding counts once the request runs in the organization that owns it. + await expect(selfDelegate(gateOn(engine), ORG_B)).resolves.toBeUndefined(); }); }); diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts index 15377fb65f8..3d1b0080e21 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts @@ -426,7 +426,15 @@ const iso = (ms: number) => new Date(ms).toISOString(); * plain_pos (NOT delegatable) ← u_boss holds directly * spare_pos (delegatable) ← u_boss does NOT hold * approver ← u_relay holds via delegation + * + * Every holding is owned by {@link DELEGATION_ORG} and every delegation runs in + * it: a holding takes effect only in its own organization (`resolveAuthzContext` + * step 4, `grantAppliesInTenant`), so an organization-less holding, or a context + * carrying no organization, holds nothing to delegate — pinned at the end of + * the describe below. */ +const DELEGATION_ORG = 'org_1'; + function makeDelegationHarness(nowMs = T0) { const tables: Record = { sys_position: [ @@ -445,10 +453,10 @@ function makeDelegationHarness(nowMs = T0) { { id: 'b_admin', position_id: 'p_admin', permission_set_id: 's_sub' }, ], sys_user_position: [ - { id: 'h1', user_id: 'u_boss', position: 'approver' }, - { id: 'h2', user_id: 'u_boss', position: 'plain_pos' }, - { id: 'h3', user_id: 'u_boss', position: 'admin_pos' }, - { id: 'h4', user_id: 'u_relay', position: 'approver', delegated_from: 'u_boss', valid_until: iso(nowMs + 20 * DAY) }, + { id: 'h1', user_id: 'u_boss', position: 'approver', organization_id: DELEGATION_ORG }, + { id: 'h2', user_id: 'u_boss', position: 'plain_pos', organization_id: DELEGATION_ORG }, + { id: 'h3', user_id: 'u_boss', position: 'admin_pos', organization_id: DELEGATION_ORG }, + { id: 'h4', user_id: 'u_relay', position: 'approver', delegated_from: 'u_boss', valid_until: iso(nowMs + 20 * DAY), organization_id: DELEGATION_ORG }, ], sys_user: [{ id: 'u_boss' }, { id: 'u_relay' }, { id: 'u_deleg' }], }; @@ -474,8 +482,12 @@ function makeDelegationHarness(nowMs = T0) { resolveSets: async () => [{ name: 'member_default', objects: {} }], now: () => nowMs, }); - const delegate = (userId: string, row: any) => - gate.assert({ object: 'sys_user_position', operation: 'insert', data: row, context: { userId, positions: [] } }); + /** `organizationId` null = a context carrying no organization. */ + const delegate = (userId: string, row: any, organizationId: string | null = DELEGATION_ORG) => + gate.assert({ + object: 'sys_user_position', operation: 'insert', data: row, + context: { userId, positions: [], ...(organizationId ? { organizationId } : {}) }, + }); return { gate, ql, tables, delegate }; } @@ -567,6 +579,20 @@ describe('DelegatedAdminGate — self-service delegation of duty (ADR-0091 D3)', await expect(d.delegate('u_boss', { user_id: 'u_deleg', position: 'everyone', delegated_from: 'u_boss', valid_until: iso(T0 + 5 * DAY), reason: 'x' })) .rejects.toThrow(/audience anchor/); }); + + it('an organization-less holding counts nowhere, and a context with no organization holds nothing', async () => { + const d = makeDelegationHarness(); + const row = () => ({ user_id: 'u_deleg', position: 'approver', delegated_from: 'u_boss', valid_until: iso(T0 + 5 * DAY), reason: 'x' }); + // Control: the holding owned by the organization the request runs in counts. + await expect(d.delegate('u_boss', row())).resolves.toBeUndefined(); + // The same request from a context that carries no organization holds nothing. + await expect(d.delegate('u_boss', row(), null)).rejects.toThrow(/do not currently hold/); + // A holding with no organization — invalid state since the 18.0 ownership + // ceremony — counts in no organization. + d.tables.sys_user_position.find((r: any) => r.id === 'h1').organization_id = null; + await expect(d.delegate('u_boss', row())).rejects.toThrow(/do not currently hold/); + await expect(d.delegate('u_boss', row(), null)).rejects.toThrow(/do not currently hold/); + }); }); // ── [cloud#830 follow-up] Self-delegation anchor containment ──────────────── @@ -588,6 +614,9 @@ describe('DelegatedAdminGate — self-service delegation of duty (ADR-0091 D3)', // ├── east (bu_east) ← u_boss's own approver anchor // │ └── east_sales (bu_es) // └── west (bu_west) +// +// Every row is owned by DELEGATION_ORG and every delegation runs in it (see +// makeDelegationHarness). function makeAnchoredDelegationHarness(nowMs = T0) { const tables: Record = { sys_business_unit: [ @@ -604,6 +633,9 @@ function makeAnchoredDelegationHarness(nowMs = T0) { sys_business_unit_member: [{ id: 'm_boss', user_id: 'u_boss', business_unit_id: 'bu_es' }], sys_user: [{ id: 'u_boss' }, { id: 'u_deleg' }], }; + for (const object of ['sys_business_unit', 'sys_user_position', 'sys_business_unit_member']) { + for (const r of tables[object]) r.organization_id = DELEGATION_ORG; + } const matches = (row: any, where: any): boolean => Object.entries(where ?? {}).every(([k, v]) => { if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); @@ -627,7 +659,10 @@ function makeAnchoredDelegationHarness(nowMs = T0) { now: () => nowMs, }); const delegate = (userId: string, row: any) => - gate.assert({ object: 'sys_user_position', operation: 'insert', data: row, context: { userId, positions: [] } }); + gate.assert({ + object: 'sys_user_position', operation: 'insert', data: row, + context: { userId, positions: [], organizationId: DELEGATION_ORG }, + }); const base = (extra: any) => ({ user_id: 'u_deleg', position: 'approver', delegated_from: 'u_boss', valid_until: iso(nowMs + 5 * DAY), reason: 'coverage', ...extra, @@ -669,7 +704,7 @@ describe('DelegatedAdminGate — self-delegation anchor containment (cloud#830 f await expect(d.delegate('u_boss', d.base({ business_unit_id: 'bu_west' }))) .rejects.toThrow(/outside your own effective anchor/); // …and a would-be west holder cannot reach east either (no east holding to source it). - d.tables.sys_user_position.push({ id: 'hb', user_id: 'u_deleg', position: 'approver', business_unit_id: 'bu_west' }); + d.tables.sys_user_position.push({ id: 'hb', user_id: 'u_deleg', position: 'approver', business_unit_id: 'bu_west', organization_id: DELEGATION_ORG }); await expect(d.delegate('u_deleg', { user_id: 'u_boss', position: 'approver', delegated_from: 'u_deleg', valid_until: iso(T0 + 5 * DAY), reason: 'x', business_unit_id: 'bu_east' })) .rejects.toThrow(/outside your own effective anchor/); }); @@ -698,7 +733,7 @@ describe('DelegatedAdminGate — self-delegation anchor containment (cloud#830 f const d = makeAnchoredDelegationHarness(); // u_relay holds approver only via delegation, anchored east. d.tables.sys_user.push({ id: 'u_relay' }); - d.tables.sys_user_position.push({ id: 'hd', user_id: 'u_relay', position: 'approver', business_unit_id: 'bu_east', delegated_from: 'u_boss', valid_until: iso(T0 + 20 * DAY) }); + d.tables.sys_user_position.push({ id: 'hd', user_id: 'u_relay', position: 'approver', business_unit_id: 'bu_east', delegated_from: 'u_boss', valid_until: iso(T0 + 20 * DAY), organization_id: DELEGATION_ORG }); await expect(d.delegate('u_relay', { user_id: 'u_deleg', position: 'approver', delegated_from: 'u_relay', valid_until: iso(T0 + 5 * DAY), reason: 'x', business_unit_id: 'bu_es' })) .rejects.toThrow(/only via delegation/); }); diff --git a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts index 5d2ce28d622..a1fc8b712f8 100644 --- a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts +++ b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts @@ -72,6 +72,14 @@ const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_defa const BOARD = 'qa_ag_board'; const MEMBER = 'usr_ag_member'; +/** + * The organization the rig runs in. A grant applies only in its own + * organization, so the member's grant rows are this organization's and every + * asker acts in it: the explained member is resolved in the asker's + * organization (#20515), and under `single` the claim stands without a + * membership row. + */ +const ORG = 'org_ag'; /** * The member's grant: every verb on the board; a READ policy that admits only @@ -98,17 +106,17 @@ const USER_ADMIN_SET = PermissionSetSchema.parse({ /** `manage_users` alone: no data grant at all (it is not a data-read grant). */ const USERS_ONLY_SET = PermissionSetSchema.parse({ name: 'qa_ag_users_only', objects: {}, systemPermissions: ['manage_users'] }); -const SELF = { userId: MEMBER, positions: [] as string[], permissions: [MEMBER_SET.name] }; -const ADMIN = { userId: 'usr_ag_admin', positions: [] as string[], permissions: [USER_ADMIN_SET.name] }; +const SELF = { userId: MEMBER, tenantId: ORG, positions: [] as string[], permissions: [MEMBER_SET.name] }; +const ADMIN = { userId: 'usr_ag_admin', tenantId: ORG, positions: [] as string[], permissions: [USER_ADMIN_SET.name] }; /** A user administrator held to the member's read policy. */ -const BLIND_ADMIN = { userId: 'usr_ag_blind', positions: [] as string[], permissions: [MEMBER_SET.name, USERS_ONLY_SET.name] }; +const BLIND_ADMIN = { userId: 'usr_ag_blind', tenantId: ORG, positions: [] as string[], permissions: [MEMBER_SET.name, USERS_ONLY_SET.name] }; /** A delegated administrator (D12, no `manage_users`) held to the member's read policy. */ -const DELEGATE = { userId: 'usr_ag_delegate', positions: [] as string[], permissions: [MEMBER_SET.name] }; -const SYSTEM = { isSystem: true }; +const DELEGATE = { userId: 'usr_ag_delegate', tenantId: ORG, positions: [] as string[], permissions: [MEMBER_SET.name] }; +const SYSTEM = { isSystem: true, tenantId: ORG }; /** [#22851] A user administrator with no read grant on the object: its own read of every row is REFUSED. */ -const REFUSED_ADMIN = { userId: 'usr_ag_refused_admin', positions: [] as string[], permissions: [USERS_ONLY_SET.name] }; +const REFUSED_ADMIN = { userId: 'usr_ag_refused_admin', tenantId: ORG, positions: [] as string[], permissions: [USERS_ONLY_SET.name] }; /** [#22851] A delegated administrator (D12) with no read grant on the object: its own read is refused too. */ -const REFUSED_DELEGATE = { userId: 'usr_ag_refused_delegate', positions: [] as string[], permissions: [] as string[] }; +const REFUSED_DELEGATE = { userId: 'usr_ag_refused_delegate', tenantId: ORG, positions: [] as string[], permissions: [] as string[] }; /** * [#22851] Edits, deletes and transfers boards, and may not read them: the * principal whose self-explanation keeps the write path's notion under ruling @@ -119,7 +127,7 @@ const EDIT_ONLY_SET = PermissionSetSchema.parse({ objects: { [BOARD]: { allowEdit: true, allowDelete: true, allowTransfer: true } }, }); const EDIT_ONLY_USER = 'usr_ag_edit_only'; -const EDIT_ONLY = { userId: EDIT_ONLY_USER, positions: [] as string[], permissions: [EDIT_ONLY_SET.name] }; +const EDIT_ONLY = { userId: EDIT_ONLY_USER, tenantId: ORG, positions: [] as string[], permissions: [EDIT_ONLY_SET.name] }; /** Per write verb, a board the edit-only principal's own door writes, so a door that lands changes no other case's row. */ const editOnlyRowOf = (verb: ExplainOperation) => `b_edit_only_${verb}`; @@ -169,10 +177,10 @@ beforeAll(async () => { for (const id of [MEMBER, ADMIN.userId, BLIND_ADMIN.userId, DELEGATE.userId, REFUSED_ADMIN.userId, REFUSED_DELEGATE.userId, EDIT_ONLY_USER]) { await e.insert('sys_user', { id, name: id, email: `${id}@example.test` }, SYS); } - // [C8 stage 8b] The grant-side rows below carry no organization: under `single` - // the engine stamps or refuses such a platform insert (ADR-0131 D9), and this rig - // holds no organization, so they are landed straight through the driver, as the - // residue an older release left. The explainer reads them as before. + // [C8 stage 8b] The catalogue rows below carry no organization: under `single` + // the engine stamps or refuses such a platform insert (ADR-0131 D9), so the + // grant-side rows are landed straight through the driver. The user grants are + // owned by the rig's organization: an organization-less grant applies nowhere. const residue = (o: string, row: Record) => (engine as any).getDriver(o).create(o, row); // The explained member's grant, as the explainer resolves it: a grant row // pointing at a catalogue row; what the set confers is declared in metadata. @@ -181,14 +189,14 @@ beforeAll(async () => { active: true, object_permissions: JSON.stringify({}), system_permissions: JSON.stringify([]), }); await residue('sys_user_permission_set', { - id: 'ups_ag_member', user_id: MEMBER, permission_set: MEMBER_SET.name, + id: 'ups_ag_member', user_id: MEMBER, permission_set: MEMBER_SET.name, organization_id: ORG, }); await residue('sys_permission_set', { id: 'ps_ag_edit_only', name: EDIT_ONLY_SET.name, label: EDIT_ONLY_SET.name, organization_id: null, managed_by: 'admin', active: true, object_permissions: JSON.stringify({}), system_permissions: JSON.stringify([]), }); await residue('sys_user_permission_set', { - id: 'ups_ag_edit_only', user_id: EDIT_ONLY_USER, permission_set: EDIT_ONLY_SET.name, + id: 'ups_ag_edit_only', user_id: EDIT_ONLY_USER, permission_set: EDIT_ONLY_SET.name, organization_id: ORG, }); await e.insert(BOARD, [ { id: HIDDEN, name: 'hidden', owner_id: 'usr_ag_owner' }, diff --git a/packages/plugins/plugin-security/src/explain-engine.test.ts b/packages/plugins/plugin-security/src/explain-engine.test.ts index 87d23d8cb9c..4753e82ea83 100644 --- a/packages/plugins/plugin-security/src/explain-engine.test.ts +++ b/packages/plugins/plugin-security/src/explain-engine.test.ts @@ -444,7 +444,7 @@ describe('explainAccess — record-grained (C2 / ADR-0095)', () => { expect(d.record).toMatchObject({ recordId: 'r1', visible: true }); }); - it('derives PLATFORM_ADMIN posture from the unscoped-grant flag', async () => { + it('derives PLATFORM_ADMIN posture from the platform-grant flag', async () => { // [#15981] Was "from the platform_admin position". The NAME is no longer // that evidence: `positions[]` is the security axis and carries ADR-0057 D4 // `sys_user_position` names from an `apiEnabled` table, so a tenant can mint @@ -801,13 +801,14 @@ describe('posture derivation aligns with enforcement (label-drift elimination)', }); it('a merely-SCOPED admin_full_access grant no longer over-labels as PLATFORM_ADMIN', async () => { - // Name present in `permissions` but NOT held as an unscoped grant and NOT the - // projected platform_admin position → MEMBER, matching enforcement (which - // requires hasPlatformAdminGrant = unscoped admin_full_access user grant). + // Name present in `permissions` but NOT held as the anchoring grant and NOT + // the projected platform_admin position → MEMBER, matching enforcement (which + // requires hasPlatformAdminGrant = the Default Organization's + // admin_full_access user grant). expect(await postureOf({ userId: 'a1', tenantId: 'org1', positions: ['everyone'], permissions: ['admin_full_access'] })).toBe('MEMBER'); }); - it('the unscoped-grant flag (hasPlatformAdminGrant) DOES yield PLATFORM_ADMIN', async () => { + it('the platform-grant flag (hasPlatformAdminGrant) DOES yield PLATFORM_ADMIN', async () => { expect(await postureOf({ userId: 'a1', tenantId: 'org1', positions: ['everyone'], permissions: ['admin_full_access'], hasPlatformAdminGrant: true })).toBe('PLATFORM_ADMIN'); }); @@ -815,7 +816,7 @@ describe('posture derivation aligns with enforcement (label-drift elimination)', // This assertion is INVERTED from what it pinned before, deliberately. It // used to read "the PROJECTED platform_admin built-in position still yields // PLATFORM_ADMIN", whose premise was that the name is only ever projected - // from the unscoped `admin_full_access` grant. Ruling A made `positions[]` + // from the anchoring `admin_full_access` grant. Ruling A made `positions[]` // the security axis, so it now also carries ADR-0057 D4 // `sys_user_position` names — and that table is `apiEnabled` with // unconstrained values, so the name can be MINTED by a tenant. The genuine @@ -897,19 +898,25 @@ function makeGrantQl(tables: Rows) { const NOW = Date.parse('2026-07-10T12:00:00Z'); describe('buildContextForUser', () => { + // A grant applies only in its own organization (an organization-less row + // applies nowhere), so the fixtures below own their grant rows by ORG and + // explain the user IN it, unless a case says otherwise. + const ORG = 'org1'; const ql = makeGrantQl({ - sys_user_position: [{ user_id: 'u2', position: 'hr_specialist' }], - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'ps1', permission_set: 'payroll_reader' }], + sys_user_position: [{ user_id: 'u2', organization_id: ORG, position: 'hr_specialist' }], + sys_user_permission_set: [{ user_id: 'u2', organization_id: ORG, permission_set_id: 'ps1', permission_set: 'payroll_reader' }], sys_permission_set: [{ id: 'ps1', name: 'payroll_reader' }], }); - it('derives hasPlatformAdminGrant from an UNSCOPED admin_full_access user grant (matches resolveAuthzContext)', async () => { - const qlUnscoped = makeGrantQl({ - // organization_id absent → unscoped - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', permission_set: 'admin_full_access' }], + it('derives hasPlatformAdminGrant from the DEFAULT ORGANIZATION\'s admin_full_access user grant (matches resolveAuthzContext)', async () => { + const qlAnchored = makeGrantQl({ + sys_organization: [{ id: 'org_default', slug: 'default' }], + sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', permission_set: 'admin_full_access', organization_id: 'org_default' }], sys_permission_set: [{ id: 'psAdmin', name: 'admin_full_access' }], }); - const ctx = await buildContextForUser(qlUnscoped, 'u2'); + // Explained with NO active organization: the anchoring grant applies + // whatever the tenant, because platform standing is the deployment's. + const ctx = await buildContextForUser(qlAnchored, 'u2'); expect(ctx.hasPlatformAdminGrant).toBe(true); expect(ctx.permissions).toContain('admin_full_access'); // The resolver PROJECTS the built-in position from the same grant (ADR-0068 @@ -918,6 +925,22 @@ describe('buildContextForUser', () => { expect(ctx.posture).toBe('PLATFORM_ADMIN'); }); + it('an organization-LESS admin_full_access user grant anchors nothing and confers nothing, in no organization or in one', async () => { + const qlOrphan = makeGrantQl({ + sys_organization: [{ id: 'org_default', slug: 'default' }], + // organization_id absent → the row belongs to no organization + sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', permission_set: 'admin_full_access' }], + sys_permission_set: [{ id: 'psAdmin', name: 'admin_full_access' }], + }); + for (const tenantId of [undefined, 'org_default']) { + const ctx = await buildContextForUser(qlOrphan, 'u2', NOW, tenantId); + expect(ctx.hasPlatformAdminGrant, String(tenantId)).toBe(false); + expect(ctx.permissions, String(tenantId)).not.toContain('admin_full_access'); + expect(ctx.positions, String(tenantId)).not.toContain('platform_admin'); + expect(ctx.posture, String(tenantId)).toBe('MEMBER'); + } + }); + it('[#11974 / #11663 L4, P8] CONFIG-derived standing reaches the panel: a declared+verified admin with ZERO grant rows explains as PLATFORM_ADMIN', async () => { // Under walled postures the bootstrap mints no grant row any more, so this // is the ONLY shape a fresh walled deployment's administrator has. Explain @@ -965,7 +988,7 @@ describe('buildContextForUser', () => { }); it('reconstructs positions + direct grants + the everyone anchor', async () => { - const ctx = await buildContextForUser(ql, 'u2'); + const ctx = await buildContextForUser(ql, 'u2', NOW, ORG); expect(ctx).toEqual({ userId: 'u2', positions: ['hr_specialist', 'everyone'], @@ -990,11 +1013,11 @@ describe('buildContextForUser', () => { it('surfaces delegation provenance for a position held via a delegated_from row (ADR-0091 D3)', async () => { const qlDelegated = makeGrantQl({ sys_user_position: [ - { user_id: 'u2', position: 'hr_specialist' }, - { user_id: 'u2', position: 'approver', delegated_from: 'u_boss', valid_until: '2026-07-20T00:00:00Z' }, + { user_id: 'u2', organization_id: ORG, position: 'hr_specialist' }, + { user_id: 'u2', organization_id: ORG, position: 'approver', delegated_from: 'u_boss', valid_until: '2026-07-20T00:00:00Z' }, ], }); - const ctx = await buildContextForUser(qlDelegated, 'u2', NOW); + const ctx = await buildContextForUser(qlDelegated, 'u2', NOW, ORG); expect(ctx.positions).toEqual(['hr_specialist', 'approver', 'everyone']); expect(ctx.delegatedPositions).toEqual([ { name: 'approver', from: 'u_boss', until: '2026-07-20T00:00:00Z' }, @@ -1004,15 +1027,15 @@ describe('buildContextForUser', () => { it('filters grants outside their validity window and reports them as expired (ADR-0091 D2)', async () => { const qlWindowed = makeGrantQl({ sys_user_position: [ - { user_id: 'u2', position: 'hr_specialist' }, - { user_id: 'u2', position: 'payroll_approver', valid_until: '2026-07-01T00:00:00Z' }, + { user_id: 'u2', organization_id: ORG, position: 'hr_specialist' }, + { user_id: 'u2', organization_id: ORG, position: 'payroll_approver', valid_until: '2026-07-01T00:00:00Z' }, // Pending (future valid_from) is filtered but NOT reported as expired. - { user_id: 'u2', position: 'auditor', valid_from: '2026-08-01T00:00:00Z' }, + { user_id: 'u2', organization_id: ORG, position: 'auditor', valid_from: '2026-08-01T00:00:00Z' }, ], sys_user_permission_set: [ - { user_id: 'u2', permission_set_id: 'ps1', permission_set: 'payroll_reader' }, + { user_id: 'u2', organization_id: ORG, permission_set_id: 'ps1', permission_set: 'payroll_reader' }, { - user_id: 'u2', permission_set_id: 'ps2', permission_set: 'quarter_close_admin', + user_id: 'u2', organization_id: ORG, permission_set_id: 'ps2', permission_set: 'quarter_close_admin', valid_until: '2026-06-01T00:00:00Z', }, ], @@ -1021,7 +1044,7 @@ describe('buildContextForUser', () => { { id: 'ps2', name: 'quarter_close_admin' }, ], }); - const ctx = await buildContextForUser(qlWindowed, 'u2', NOW); + const ctx = await buildContextForUser(qlWindowed, 'u2', NOW, ORG); expect(ctx.positions).toEqual(['hr_specialist', 'everyone']); expect(ctx.permissions).toEqual(['payroll_reader']); expect(ctx.droppedGrants).toEqual([ @@ -1037,15 +1060,15 @@ describe('buildContextForUser', () => { it('reports a directly-granted permission set whose catalogue row is deactivated (ADR-0049 / #8714)', async () => { const qlDeactivated = makeGrantQl({ sys_user_permission_set: [ - { user_id: 'u2', permission_set_id: 'ps1', permission_set: 'payroll_reader' }, - { user_id: 'u2', permission_set_id: 'psOff', permission_set: 'crm_full' }, + { user_id: 'u2', organization_id: ORG, permission_set_id: 'ps1', permission_set: 'payroll_reader' }, + { user_id: 'u2', organization_id: ORG, permission_set_id: 'psOff', permission_set: 'crm_full' }, ], sys_permission_set: [ { id: 'ps1', name: 'payroll_reader' }, { id: 'psOff', name: 'crm_full', active: false }, ], }); - const ctx = await buildContextForUser(qlDeactivated, 'u2', NOW); + const ctx = await buildContextForUser(qlDeactivated, 'u2', NOW, ORG); // The resolver's fail-closed dropping is untouched… expect(ctx.permissions).toEqual(['payroll_reader']); // …and the dropped row is REPORTED, with the deactivated reason. @@ -1057,8 +1080,8 @@ describe('buildContextForUser', () => { it('reports a held position whose sys_position row is deactivated — including the 0/1 storage shape (ADR-0049 / #8714)', async () => { const qlDeactivated = makeGrantQl({ sys_user_position: [ - { user_id: 'u2', position: 'hr_specialist' }, - { user_id: 'u2', position: 'field_auditor' }, + { user_id: 'u2', organization_id: ORG, position: 'hr_specialist' }, + { user_id: 'u2', organization_id: ORG, position: 'field_auditor' }, ], // 0 is what the primary driver hands back for a stored false — the // shared isRowActive predicate reads it as deactivated (#8613). @@ -1067,7 +1090,7 @@ describe('buildContextForUser', () => { { id: 'pos_fa', name: 'field_auditor', active: 0 }, ], }); - const ctx = await buildContextForUser(qlDeactivated, 'u2', NOW); + const ctx = await buildContextForUser(qlDeactivated, 'u2', NOW, ORG); expect(ctx.positions).toEqual(['hr_specialist', 'everyone']); expect(ctx.droppedGrants).toEqual([ { kind: 'position', name: 'field_auditor', state: 'deactivated' }, @@ -1077,11 +1100,11 @@ describe('buildContextForUser', () => { it('a row both expired AND deactivated reports expired — one reason per row, resolver drop order (#8714)', async () => { const qlBoth = makeGrantQl({ sys_user_permission_set: [ - { user_id: 'u2', permission_set_id: 'psOff', permission_set: 'crm_full', valid_until: '2026-06-01T00:00:00Z' }, + { user_id: 'u2', organization_id: ORG, permission_set_id: 'psOff', permission_set: 'crm_full', valid_until: '2026-06-01T00:00:00Z' }, ], sys_permission_set: [{ id: 'psOff', name: 'crm_full', active: false }], }); - const ctx = await buildContextForUser(qlBoth, 'u2', NOW); + const ctx = await buildContextForUser(qlBoth, 'u2', NOW, ORG); expect(ctx.droppedGrants).toEqual([ { kind: 'permission_set', name: 'crm_full', state: 'expired', until: '2026-06-01T00:00:00Z' }, ]); @@ -1093,16 +1116,16 @@ describe('buildContextForUser', () => { // and the explainer must side with what the resolver enforces. it('a set and a position the LEDGER switched off are reported deactivated, whatever their rows say', async () => { const ql = makeGrantQl({ - sys_user_position: [{ user_id: 'u2', position: 'field_auditor' }], + sys_user_position: [{ user_id: 'u2', organization_id: ORG, position: 'field_auditor' }], sys_position: [{ id: 'pos_fa', name: 'field_auditor', active: true }], - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psOff', permission_set: 'crm_full' }], + sys_user_permission_set: [{ user_id: 'u2', organization_id: ORG, permission_set_id: 'psOff', permission_set: 'crm_full' }], sys_permission_set: [{ id: 'psOff', name: 'crm_full', active: true }], sys_metadata_activation: [ { metadata_type: 'position', name: 'field_auditor', active: false }, { metadata_type: 'permission', name: 'crm_full', active: 0 }, ], }); - const ctx = await buildContextForUser(ql, 'u2', NOW); + const ctx = await buildContextForUser(ql, 'u2', NOW, ORG); // Enforcement dropped both… expect(ctx.positions).toEqual(['everyone']); expect(ctx.permissions).toEqual([]); @@ -1115,13 +1138,13 @@ describe('buildContextForUser', () => { it("a catalog ROW's `active: false` with no ledger row is in effect, and explain reports nothing dropped", async () => { const ql = makeGrantQl({ - sys_user_position: [{ user_id: 'u2', position: 'field_auditor' }], + sys_user_position: [{ user_id: 'u2', organization_id: ORG, position: 'field_auditor' }], sys_position: [{ id: 'pos_fa', name: 'field_auditor', active: false }], - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psOff', permission_set: 'crm_full' }], + sys_user_permission_set: [{ user_id: 'u2', organization_id: ORG, permission_set_id: 'psOff', permission_set: 'crm_full' }], sys_permission_set: [{ id: 'psOff', name: 'crm_full', active: false }], sys_metadata_activation: [], }); - const ctx = await buildContextForUser(ql, 'u2', NOW); + const ctx = await buildContextForUser(ql, 'u2', NOW, ORG); expect(ctx.positions).toEqual(['field_auditor', 'everyone']); expect(ctx.permissions).toEqual(['crm_full']); expect(ctx.droppedGrants).toEqual([]); @@ -1132,23 +1155,23 @@ describe('buildContextForUser', () => { // neither name; the ledger switches one of them off all the same. const ql = makeGrantQl({ sys_user_permission_set: [ - { user_id: 'u2', permission_set_id: 'ps_gone', permission_set: 'gone_set', valid_until: '2026-06-01T00:00:00Z' }, - { user_id: 'u2', permission_set_id: 'ps_gone2', permission_set: 'gone_off' }, + { user_id: 'u2', organization_id: ORG, permission_set_id: 'ps_gone', permission_set: 'gone_set', valid_until: '2026-06-01T00:00:00Z' }, + { user_id: 'u2', organization_id: ORG, permission_set_id: 'ps_gone2', permission_set: 'gone_off' }, ], sys_metadata_activation: [{ metadata_type: 'permission', name: 'gone_off', active: false }], }); - const ctx = await buildContextForUser(ql, 'u2', NOW); + const ctx = await buildContextForUser(ql, 'u2', NOW, ORG); expect(ctx.droppedGrants).toEqual([]); }); it('an absent `active` column keeps granting and is NOT reported — absent is ACTIVE (ADR-0049)', async () => { const qlAbsent = makeGrantQl({ - sys_user_position: [{ user_id: 'u2', position: 'hr_specialist' }], + sys_user_position: [{ user_id: 'u2', organization_id: ORG, position: 'hr_specialist' }], sys_position: [{ id: 'pos_hr', name: 'hr_specialist' }], - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'ps1', permission_set: 'payroll_reader' }], + sys_user_permission_set: [{ user_id: 'u2', organization_id: ORG, permission_set_id: 'ps1', permission_set: 'payroll_reader' }], sys_permission_set: [{ id: 'ps1', name: 'payroll_reader' }], }); - const ctx = await buildContextForUser(qlAbsent, 'u2', NOW); + const ctx = await buildContextForUser(qlAbsent, 'u2', NOW, ORG); expect(ctx.positions).toEqual(['hr_specialist', 'everyone']); expect(ctx.permissions).toEqual(['payroll_reader']); expect(ctx.droppedGrants).toEqual([]); @@ -1194,13 +1217,16 @@ describe('buildContextForUser ↔ resolveUserAuthzGrants parity (#6352)', () => // (ADR-0057 D4), a permission set the POSITION carries // (`sys_position_permission_set`), and the cloud ADR-0024 `ai_seat` synthesis. name: 'org role + position-bound permission set + ai_seat', + // A grant applies only in its own organization, so the grant rows are + // org1's and both sides resolve in org1. + tenantId: 'org1', tables: { sys_user: [{ id: 'u2', email: 'u2@example.com', ai_access: true }], sys_member: [{ user_id: 'u2', organization_id: 'org1', role: 'admin' }], - sys_user_position: [{ user_id: 'u2', position: 'hr_specialist' }], + sys_user_position: [{ user_id: 'u2', organization_id: 'org1', position: 'hr_specialist' }], sys_position: [{ id: 'pos_hr', name: 'hr_specialist' }], sys_position_permission_set: [{ position_id: 'pos_hr', permission_set_id: 'ps_hr_tools' }], - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'ps1', permission_set: 'payroll_reader' }], + sys_user_permission_set: [{ user_id: 'u2', organization_id: 'org1', permission_set_id: 'ps1', permission_set: 'payroll_reader' }], sys_permission_set: [ { id: 'ps1', name: 'payroll_reader' }, { id: 'ps_hr_tools', name: 'hr_tools', system_permissions: ['manage_users'] }, @@ -1236,10 +1262,13 @@ describe('buildContextForUser ↔ resolveUserAuthzGrants parity (#6352)', () => }, }, { - // [ADR-0068 D2] The platform_admin derivation, both polarities. - name: 'unscoped admin_full_access derives platform_admin', + // [ADR-0068 D2] The platform_admin derivation, both polarities. The + // anchor is the Default Organization's grant, and it applies with no + // active organization. + name: 'the Default Organization\'s admin_full_access derives platform_admin', tables: { - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', permission_set: 'admin_full_access' }], + sys_organization: [{ id: 'org_default', slug: 'default' }], + sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', permission_set: 'admin_full_access', organization_id: 'org_default' }], sys_permission_set: [{ id: 'psAdmin', name: 'admin_full_access' }], }, expected: { @@ -1251,6 +1280,24 @@ describe('buildContextForUser ↔ resolveUserAuthzGrants parity (#6352)', () => hasPlatformAdminGrant: true, }, }, + { + // ...and an organization-less grant of the same set anchors nothing and + // applies nowhere: it confers neither the rung nor the set. + name: 'organization-less admin_full_access derives nothing', + tables: { + sys_organization: [{ id: 'org_default', slug: 'default' }], + sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', permission_set: 'admin_full_access' }], + sys_permission_set: [{ id: 'psAdmin', name: 'admin_full_access' }], + }, + expected: { + positions: ['everyone'], + permissions: [], + systemPermissions: [], + accessible_org_ids: [], + posture: 'MEMBER', + hasPlatformAdminGrant: false, + }, + }, { name: 'org-scoped admin_full_access does NOT derive platform_admin', // [#20515] Resolved IN org1, where the scoped set applies — with no active @@ -1292,18 +1339,21 @@ describe('buildContextForUser ↔ resolveUserAuthzGrants parity (#6352)', () => }, { // [ADR-0091 D2] Validity windows drop rows on BOTH sides, including the - // platform_admin derivation: an EXPIRED unscoped admin_full_access grant - // must not confer the rung. Only the explain-side annotation survives. + // platform_admin derivation: an EXPIRED admin_full_access grant of the + // Default Organization (org1 here) must not confer the rung. Only the + // explain-side annotation survives. name: 'ADR-0091 windows: expired admin grant confers nothing', + tenantId: 'org1', tables: { + sys_organization: [{ id: 'org1', slug: 'default' }], sys_user_position: [ - { user_id: 'u2', position: 'hr_specialist' }, - { user_id: 'u2', position: 'payroll_approver', valid_until: '2026-07-01T00:00:00Z' }, - { user_id: 'u2', position: 'auditor', valid_from: '2026-08-01T00:00:00Z' }, + { user_id: 'u2', organization_id: 'org1', position: 'hr_specialist' }, + { user_id: 'u2', organization_id: 'org1', position: 'payroll_approver', valid_until: '2026-07-01T00:00:00Z' }, + { user_id: 'u2', organization_id: 'org1', position: 'auditor', valid_from: '2026-08-01T00:00:00Z' }, ], sys_user_permission_set: [ - { user_id: 'u2', permission_set_id: 'ps1', permission_set: 'payroll_reader' }, - { user_id: 'u2', permission_set_id: 'psAdmin', permission_set: 'admin_full_access', valid_until: '2026-06-01T00:00:00Z' }, + { user_id: 'u2', organization_id: 'org1', permission_set_id: 'ps1', permission_set: 'payroll_reader' }, + { user_id: 'u2', organization_id: 'org1', permission_set_id: 'psAdmin', permission_set: 'admin_full_access', valid_until: '2026-06-01T00:00:00Z' }, ], sys_permission_set: [ { id: 'ps1', name: 'payroll_reader' }, @@ -1349,28 +1399,31 @@ describe('buildContextForUser ↔ resolveUserAuthzGrants parity (#6352)', () => it('the explain-only surface is ADDITIVE — it annotates rows, it never changes the verdict', async () => { const tables: Rows = { sys_user_position: [ - { user_id: 'u2', position: 'hr_specialist' }, - { user_id: 'u2', position: 'approver', delegated_from: 'u_boss', valid_until: '2026-07-20T00:00:00Z' }, - { user_id: 'u2', position: 'payroll_approver', valid_until: '2026-07-01T00:00:00Z' }, + { user_id: 'u2', organization_id: 'org1', position: 'hr_specialist' }, + { user_id: 'u2', organization_id: 'org1', position: 'approver', delegated_from: 'u_boss', valid_until: '2026-07-20T00:00:00Z' }, + { user_id: 'u2', organization_id: 'org1', position: 'payroll_approver', valid_until: '2026-07-01T00:00:00Z' }, // [commit 42b05af89] a held position whose catalogue row is switched off - { user_id: 'u2', position: 'field_auditor' }, + { user_id: 'u2', organization_id: 'org1', position: 'field_auditor' }, ], sys_position: [{ id: 'pos_fa', name: 'field_auditor', active: false }], sys_user_permission_set: [ { - user_id: 'u2', permission_set_id: 'ps2', permission_set: 'quarter_close_admin', + user_id: 'u2', organization_id: 'org1', permission_set_id: 'ps2', permission_set: 'quarter_close_admin', valid_until: '2026-06-01T00:00:00Z', }, // [commit 42b05af89] a held direct grant whose SET's catalogue row is switched off - { user_id: 'u2', permission_set_id: 'psOff', permission_set: 'crm_full' }, + { user_id: 'u2', organization_id: 'org1', permission_set_id: 'psOff', permission_set: 'crm_full' }, ], sys_permission_set: [ { id: 'ps2', name: 'quarter_close_admin' }, { id: 'psOff', name: 'crm_full', active: false }, ], }; - const grants = await resolveUserAuthzGrants(makeGrantQl(tables), 'u2', { nowMs: NOW }); - const ctx = await buildContextForUser(makeGrantQl(tables), 'u2', NOW); + // Both sides resolve in org1, the organization the grant rows are owned by. + const grants = await resolveUserAuthzGrants(makeGrantQl(tables), 'u2', { nowMs: NOW, tenantId: 'org1' }); + const ctx = await buildContextForUser(makeGrantQl(tables), 'u2', NOW, 'org1'); + // Non-vacuity: the live grant the comparison below is about does resolve. + expect(ctx.positions).toContain('hr_specialist'); // The annotations are non-empty… expect(ctx.delegatedPositions).toEqual([ @@ -1528,8 +1581,11 @@ describe('buildContextForUser bypasses the #11971 grants cache (ruled bypass lis const saved = process.env[TTL_ENV]; process.env[TTL_ENV] = '3600000'; // one hour — nothing here may pass by expiry try { + // The Default Organization's grant: it anchors platform standing and + // applies with no active organization, so the cached answer is the rung. const tables: Rows = { - sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', permission_set: 'admin_full_access' }], + sys_organization: [{ id: 'org_default', slug: 'default' }], + sys_user_permission_set: [{ user_id: 'u2', permission_set_id: 'psAdmin', permission_set: 'admin_full_access', organization_id: 'org_default' }], sys_permission_set: [{ id: 'psAdmin', name: 'admin_full_access' }], }; const ql = makeSeamGrantQl(tables); @@ -1537,6 +1593,7 @@ describe('buildContextForUser bypasses the #11971 grants cache (ruled bypass lis // Populate the cache through the SAME resolver every request uses. const cached = await resolveUserAuthzGrants(ql, 'u2', { nowMs: NOW }); expect(cached.permissions).toContain('admin_full_access'); + expect(cached.posture).toBe('PLATFORM_ADMIN'); // Revoke WITHOUT an engine write: no seam reports it, so nothing // retires the entry and the cached path keeps the old answer… @@ -1835,8 +1892,9 @@ describe('[#18253] explain refuses an object that does not exist', () => { // ─── [#20515] the explainer resolves IN an organization, as enforcement does ── // -// The resolver applies an organization-scoped grant only while that -// organization is the active tenant; with none, only global grants. The +// The resolver applies a grant only while its organization is the active +// tenant; with none, no stored grant applies, and an organization-less row +// applies nowhere. The // explainer used to reach every organization's grants by passing NO tenant — // the very resolution that let a member removed from an organization keep that // organization's `manage_metadata`. There is no "every organization" option to @@ -1850,38 +1908,40 @@ describe('[#20515] buildContextForUser and resolveDelegatorContext resolve in an sys_user_permission_set: [ { user_id: 'u_x', permission_set_id: 'ps_alpha', permission_set: 'alpha_metadata_editors', organization_id: ALPHA }, { user_id: 'u_x', permission_set_id: 'ps_beta', permission_set: 'beta_readers', organization_id: BETA }, - { user_id: 'u_x', permission_set_id: 'ps_global', permission_set: 'global_readers' }, + // Organization-less: invalid state since the 18.0 ownership ceremony, and + // it confers nothing in any resolution. + { user_id: 'u_x', permission_set_id: 'ps_orphan', permission_set: 'orphan_readers' }, ], sys_permission_set: [ { id: 'ps_alpha', name: 'alpha_metadata_editors', system_permissions: ['manage_metadata'] }, { id: 'ps_beta', name: 'beta_readers' }, - { id: 'ps_global', name: 'global_readers' }, + { id: 'ps_orphan', name: 'orphan_readers' }, ], }); - it('with no organization it explains what enforcement answers with none: the global grants only', async () => { + it('with no organization it explains what enforcement answers with none: no stored grant at all', async () => { const ctx = await buildContextForUser(makeGrantQl(tables()), 'u_x', NOW); - expect([...ctx.permissions].sort()).toEqual(['global_readers']); + expect([...ctx.permissions].sort()).toEqual([]); expect(ctx.systemPermissions).not.toContain('manage_metadata'); // …and it is the enforcement resolver's own answer for that state. const enforced = await resolveUserAuthzGrants(makeGrantQl(tables()), 'u_x', { nowMs: NOW }); expect(ctx.permissions).toEqual(enforced.permissions); }); - it('in an organization it explains that organization\'s grants beside the global ones, never another\'s', async () => { + it('in an organization it explains that organization\'s grants alone, never another\'s nor the organization-less one', async () => { const inAlpha = await buildContextForUser(makeGrantQl(tables()), 'u_x', NOW, ALPHA); - expect([...inAlpha.permissions].sort()).toEqual(['alpha_metadata_editors', 'global_readers']); + expect([...inAlpha.permissions].sort()).toEqual(['alpha_metadata_editors']); expect(inAlpha.systemPermissions).toContain('manage_metadata'); const inBeta = await buildContextForUser(makeGrantQl(tables()), 'u_x', NOW, BETA); - expect([...inBeta.permissions].sort()).toEqual(['beta_readers', 'global_readers']); + expect([...inBeta.permissions].sort()).toEqual(['beta_readers']); expect(inBeta.systemPermissions).not.toContain('manage_metadata'); }); /** * The delegator leg is an ENFORCEMENT input (the D10 intersection), not only * an explanation: `SecurityPlugin` resolves it through this function on every - * on-behalf-of request. Resolved with no tenant it would hold only the - * delegator's global grants — never what the delegator holds in the + * on-behalf-of request. Resolved with no tenant it would hold none of the + * delegator's stored grants — never what the delegator holds in the * organization the request runs in. */ describe('resolveDelegatorContext: the delegator is resolved in the live principal\'s organization', () => { @@ -1905,15 +1965,15 @@ describe('[#20515] buildContextForUser and resolveDelegatorContext resolve in an ); expect(res.kind).toBe('resolved'); const ctx = (res as { kind: 'resolved'; context: any }).context; - expect([...ctx.permissions].sort()).toEqual(['alpha_metadata_editors', 'global_readers']); + expect([...ctx.permissions].sort()).toEqual(['alpha_metadata_editors']); expect(ctx.tenantId).toBe(ALPHA); }); - it('live principal with no organization → the delegator\'s global grants only', async () => { + it('live principal with no organization → none of the delegator\'s stored grants', async () => { const res = await resolveDelegatorContext(delegatorQl(), { userId: 'agent_1', onBehalfOf: { userId: 'u_x' } }, NOW); expect(res.kind).toBe('resolved'); const ctx = (res as { kind: 'resolved'; context: any }).context; - expect([...ctx.permissions].sort()).toEqual(['global_readers']); + expect([...ctx.permissions].sort()).toEqual([]); expect(ctx.systemPermissions).not.toContain('manage_metadata'); }); }); diff --git a/packages/plugins/plugin-security/src/explain-positions-name-authority.test.ts b/packages/plugins/plugin-security/src/explain-positions-name-authority.test.ts index 505f5628a4f..2277b202566 100644 --- a/packages/plugins/plugin-security/src/explain-positions-name-authority.test.ts +++ b/packages/plugins/plugin-security/src/explain-positions-name-authority.test.ts @@ -122,13 +122,22 @@ function makeAuthzQl(tables: Record>>) { }, tables); } +/** + * Both shapes' grant rows are owned by `HOME_ORG`, which is also the Default + * Organization: a grant applies only in its own organization (an + * organization-less row confers nothing), and platform standing is anchored + * only by the Default Organization's `admin_full_access` grant. The principal + * is resolved in `HOME_ORG` ({@link buildInHome}), so the name-only position + * row applies and reaches the array the panel prints. + */ function authzTables(shape: 'name-only' | 'genuine') { return { + sys_organization: [{ id: HOME_ORG, slug: 'default' }], sys_user: [{ id: USER, email: 'subject@example.com', email_verified: true }], sys_member: [{ organization_id: HOME_ORG, user_id: USER, role: 'member' }], sys_user_position: shape === 'name-only' - ? [{ user_id: USER, position: BUILTIN_IDENTITY_PLATFORM_ADMIN, organization_id: null }] + ? [{ user_id: USER, position: BUILTIN_IDENTITY_PLATFORM_ADMIN, organization_id: HOME_ORG }] : [], sys_position: shape === 'name-only' @@ -137,16 +146,19 @@ function authzTables(shape: 'name-only' | 'genuine') { sys_position_permission_set: [], sys_user_permission_set: shape === 'genuine' - ? [{ user_id: USER, permission_set_id: PS_ADMIN, permission_set: ADMIN_FULL_ACCESS, organization_id: null }] + ? [{ user_id: USER, permission_set_id: PS_ADMIN, permission_set: ADMIN_FULL_ACCESS, organization_id: HOME_ORG }] : [], sys_permission_set: [{ id: PS_ADMIN, name: ADMIN_FULL_ACCESS, active: true }], }; } +/** The real principal builder, resolving the user in `HOME_ORG`. */ +const buildInHome = (ql: unknown) => buildContextForUser(ql as any, USER, Date.now(), HOME_ORG); + describe('[#15981] the SHIPPING explain path was already gated — a regression guard, not a repair', () => { it('a D4 row spelling the built-in name is REPORTED as MEMBER, and the rung agrees', async () => { const ql = makeAuthzQl(authzTables('name-only')); - const context = await buildContextForUser(ql as any, USER); + const context = await buildInHome(ql); // The premise: the name really is on the array the panel prints. expect(context.positions, JSON.stringify(context.positions)).toContain( @@ -159,9 +171,9 @@ describe('[#15981] the SHIPPING explain path was already gated — a regression expect(await postureOf(context)).toBe('MEMBER'); }); - it('CONTROL — a genuine unscoped admin_full_access grant is still reported as PLATFORM_ADMIN', async () => { + it('CONTROL — a genuine Default Organization admin_full_access grant is still reported as PLATFORM_ADMIN', async () => { const ql = makeAuthzQl(authzTables('genuine')); - const context = await buildContextForUser(ql as any, USER); + const context = await buildInHome(ql); expect(context.positions).toContain(BUILTIN_IDENTITY_PLATFORM_ADMIN); expect(context.hasPlatformAdminGrant).toBe(true); @@ -169,11 +181,23 @@ describe('[#15981] the SHIPPING explain path was already gated — a regression expect(await postureOf(context)).toBe('PLATFORM_ADMIN'); }); + it('the same admin_full_access grant with NO organization anchors nothing, and is reported as MEMBER', async () => { + const tables = authzTables('genuine'); + tables.sys_user_permission_set = tables.sys_user_permission_set.map((r) => ({ ...r, organization_id: null })) as never; + const ql = makeAuthzQl(tables); + const context = await buildInHome(ql); + + expect(context.positions).not.toContain(BUILTIN_IDENTITY_PLATFORM_ADMIN); + expect(context.hasPlatformAdminGrant).toBe(false); + expect(await hasPlatformAdminStanding(ql as any, USER)).toBe(false); + expect(await postureOf(context)).toBe('MEMBER'); + }); + it('the two shapes are INDISTINGUISHABLE by name and separable only by the rung', async () => { const nameOnly = makeAuthzQl(authzTables('name-only')); const genuine = makeAuthzQl(authzTables('genuine')); - const a = await buildContextForUser(nameOnly as any, USER); - const b = await buildContextForUser(genuine as any, USER); + const a = await buildInHome(nameOnly); + const b = await buildInHome(genuine); expect(a.positions).toContain(BUILTIN_IDENTITY_PLATFORM_ADMIN); expect(b.positions).toContain(BUILTIN_IDENTITY_PLATFORM_ADMIN); @@ -200,7 +224,7 @@ describe('[#15981] the FALLBACK branch — reachable on a posture-less context, }); }); - it('the unscoped-grant flag alone STILL yields PLATFORM_ADMIN — the evidence that survives', async () => { + it('the platform-grant flag alone STILL yields PLATFORM_ADMIN — the evidence that survives', async () => { // `buildContextForUser` sets this from `grants.posture === 'PLATFORM_ADMIN'`, // so the fallback's remaining input is the rung itself. expect( diff --git a/packages/plugins/plugin-security/src/explain-principal-person.test.ts b/packages/plugins/plugin-security/src/explain-principal-person.test.ts index 05d0f396ca3..ec632f1a1be 100644 --- a/packages/plugins/plugin-security/src/explain-principal-person.test.ts +++ b/packages/plugins/plugin-security/src/explain-principal-person.test.ts @@ -68,16 +68,23 @@ const GONE = 'usr_pp_gone'; const LIN = 'usr_pp_lin'; const DELEGATED_POSITION = 'qa_pp_approver'; const DELEGATED_UNTIL = '2099-01-01T00:00:00.000Z'; - -const DIRECTORY_ADMIN = { userId: 'usr_pp_directory_admin', positions: [] as string[], permissions: [DIRECTORY_SET.name] }; -const MASKED_ADMIN = { userId: 'usr_pp_masked_admin', positions: [] as string[], permissions: [MASKED_DIRECTORY_SET.name] }; -const BLIND_ADMIN = { userId: 'usr_pp_blind_admin', positions: [] as string[], permissions: [USERS_ONLY_SET.name] }; -const SYSTEM = { isSystem: true }; +/** + * The organization the rig runs in. A grant applies only in its own + * organization, so Grace's and Lin's grant rows are this organization's and + * every asker acts in it; under `single` the claim stands without a + * membership row. + */ +const ORG = 'org_pp'; + +const DIRECTORY_ADMIN = { userId: 'usr_pp_directory_admin', tenantId: ORG, positions: [] as string[], permissions: [DIRECTORY_SET.name] }; +const MASKED_ADMIN = { userId: 'usr_pp_masked_admin', tenantId: ORG, positions: [] as string[], permissions: [MASKED_DIRECTORY_SET.name] }; +const BLIND_ADMIN = { userId: 'usr_pp_blind_admin', tenantId: ORG, positions: [] as string[], permissions: [USERS_ONLY_SET.name] }; +const SYSTEM = { isSystem: true, tenantId: ORG }; /** Ada explaining herself, holding the directory. */ -const ADA_SELF = { userId: ADA, positions: [] as string[], permissions: [DIRECTORY_SET.name] }; +const ADA_SELF = { userId: ADA, tenantId: ORG, positions: [] as string[], permissions: [DIRECTORY_SET.name] }; /** An agent holding the directory, acting on behalf of `delegator` (ADR-0090 D10). */ const agentFor = (delegator: string) => ({ - userId: AGENT, positions: [] as string[], permissions: [DIRECTORY_SET.name], + userId: AGENT, tenantId: ORG, positions: [] as string[], permissions: [DIRECTORY_SET.name], principalKind: 'agent', onBehalfOf: { userId: delegator }, }); /** The same agent without the directory: its own leg holds the baseline alone. */ @@ -129,22 +136,23 @@ beforeAll(async () => { ], SYS); // Grace holds the directory too, so an agent acting for her keeps it under // the D10 intersection; the plain user holds nothing beyond the baseline. - // [C8 stage 8b] The three grant-side rows below carry no organization: under + // [C8 stage 8b] The catalogue row below carries no organization: under // `single` the engine stamps or refuses such a platform insert (ADR-0131 D9), - // and this rig holds no organization, so they are landed straight through the - // driver, as the residue an older release left. The explainer reads them as before. + // so the three grant-side rows are landed straight through the driver. The two + // user grants are owned by the rig's organization: an organization-less grant + // applies nowhere. const residue = (o: string, row: Record) => (engine as any).getDriver(o).create(o, row); await residue('sys_permission_set', { id: 'ps_pp_directory', name: DIRECTORY_SET.name, label: DIRECTORY_SET.name, organization_id: null, managed_by: 'admin', active: true, object_permissions: JSON.stringify({}), system_permissions: JSON.stringify([]), }); await residue('sys_user_permission_set', { - id: 'ups_pp_grace', user_id: GRACE, permission_set: DIRECTORY_SET.name, + id: 'ups_pp_grace', user_id: GRACE, permission_set: DIRECTORY_SET.name, organization_id: ORG, }); // A position Lin holds because Grace delegated it, until a date far ahead. await residue('sys_user_position', { id: 'up_pp_lin_delegated', user_id: LIN, position: DELEGATED_POSITION, - delegated_from: GRACE, valid_until: DELEGATED_UNTIL, + delegated_from: GRACE, valid_until: DELEGATED_UNTIL, organization_id: ORG, }); await e.insert(BOARD, [{ id: 'b_pp_1', name: 'one' }], SYS); diff --git a/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts b/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts index 13633aaf183..3e7218d9aeb 100644 --- a/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts +++ b/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts @@ -13,7 +13,7 @@ * Each reader takes its fail-closed direction: * * - **explain** reports nothing about it — it names no set; - * - **the platform-admin bootstrap** reads an unscoped one as an administrator + * - **the platform-admin bootstrap** reads a Default Organization one as an administrator * who may already exist, and withholds the promotion — without naming it as * the holder; * - **the organization-administrator reconcile** reads grants by name alone: @@ -204,7 +204,7 @@ describe('[ADR-0131 D4] a grant that names nothing — the platform-admin bootst expect(await findExistingPlatformAdmin(engine, defaultPermissionSets)).toBe('usr_admin'); }); - it('unnamed, the promotion is WITHHELD — no second unscoped grant is minted for the oldest account', async () => { + it('unnamed, the promotion is WITHHELD — no second anchoring grant is minted for the oldest account', async () => { const engine = await standingAdminUnnamed(); await unname(engine, { id: 'g_admin' }); // An existing database: the platform-bucket rows an earlier release seeded. @@ -247,7 +247,7 @@ describe('[ADR-0131 D4] a grant that names nothing — the platform-admin bootst const [message] = withheld; expect(message).toBe( - '[security] platform-admin promotion WITHHELD: an unscoped grant exists whose permission-set name ' + '[security] platform-admin promotion WITHHELD: a Default Organization grant exists whose permission-set name ' + '(sys_user_permission_set.permission_set) was never written — a grant from a release before that ' + 'column existed. Which set it holds can no longer be read, and it may be the platform administrator\'s, ' + 'so this pass will not mint a second one. ' + REWRITE_SENTENCE, diff --git a/packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts b/packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts index fcf26d47df8..31340c48bb0 100644 --- a/packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts +++ b/packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts @@ -403,7 +403,9 @@ async function bootBaseline(declared: boolean) { for (const schema of declarers) await knex(schema.name).insert(fixtureRowsFor(schema)); // What an agent's delegator resolves from (ADR-0090 D10: the delegator leg is // rebuilt from the store): the memberships, and the grants the viewer and the - // two administrators hold. + // two administrators hold — each owned by `org_x`, the organization every + // agent's request runs in and so the one its delegator's grants are resolved + // in (an organization-less grant applies nowhere, ADR-0131 D10). const member = objects.get('sys_member')!; await knex('sys_member').insert([ ['u_x', 'org_x', 'member'], ['u_x2', 'org_x', 'member'], ['a_x', 'org_x', 'admin'], ['u_y', 'org_y', 'member'], @@ -411,7 +413,7 @@ async function bootBaseline(declared: boolean) { await knex('sys_user_permission_set').insert([ { id: 'ups:u_x2', user_id: 'u_x2', permission_set: 'viewer_readonly', organization_id: 'org_x' }, { id: 'ups:a_x', user_id: 'a_x', permission_set: 'organization_admin', organization_id: 'org_x' }, - { id: 'ups:p_admin', user_id: 'p_admin', permission_set: 'admin_full_access', organization_id: null }, + { id: 'ups:p_admin', user_id: 'p_admin', permission_set: 'admin_full_access', organization_id: 'org_x' }, ]); const served = async (object: string, persona: Persona): Promise => { diff --git a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts index b2eb89e8a98..586caade527 100644 --- a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts +++ b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts @@ -29,12 +29,15 @@ * ## The rule pinned here * * A row scoped to an organization applies only while that organization is - * active; an organization-less row applies everywhere — the rule - * `resolveUserAuthzGrants` already applies to grant rows. So the negative - * pins below are paired with preservation pins: the global grants an - * organization-less principal really holds (a global position folded onto a - * global same-named set, a global user grant) keep resolving, and an - * organization's own set still reaches its own members while it is active. + * active — the rule `resolveUserAuthzGrants` applies to grant rows + * (`grantAppliesInTenant`). An organization-less grant row applies nowhere: + * the 18.0 ownership ceremony (ADR-0131 D10) attributes every such row before + * an upgraded database boots, so one is invalid state, not a "global" grant. + * So the negative pins below are paired with preservation pins: the grants a + * principal holds in its own organization (a position folded onto the + * catalog's same-named set, a user grant) resolve while that organization is + * active and confer nothing with none active, and a legacy organization-less + * grant row confers nothing either way. * * ## The rig * @@ -68,8 +71,10 @@ const ORG_OTHER = 'org_other'; const ORG_HOME = 'org_home'; /** A member of `ORG_HOME` only. */ const USER_HOME = 'usr_home_member'; -/** A member of `ORG_HOME` only, holding two GLOBAL grants. */ +/** A member of `ORG_HOME` only, holding two grants owned by `ORG_HOME`. */ const USER_GLOBAL = 'usr_home_global'; +/** A member of `ORG_HOME` only, holding the same two grants as organization-less legacy rows. */ +const USER_LEGACY = 'usr_home_legacy'; /** A member of `ORG_OTHER` — the control that the authored set resolves at all. */ const USER_OTHER = 'usr_other_member'; @@ -96,11 +101,10 @@ afterEach(async () => { }); /** - * [C8 stage 8b] An organization-less GLOBAL row is legacy residue: under - * `single` the engine stamps or refuses an organization-less platform insert - * (ADR-0131 D9), so a row with `organization_id` NULL is landed straight - * through the driver, as an older release left it. The readers this file pins - * still read it until C8's NULL-reader stages (18.x). + * [C8 stage 8b] An organization-less row is legacy residue: under `single` the + * engine stamps or refuses an organization-less platform insert (ADR-0131 D9), + * so a row with `organization_id` NULL is landed straight through the driver, + * as an older release left it. The grant readers no longer honour one. */ function write(e: any, object: string, row: Record) { return row.organization_id === null @@ -145,24 +149,25 @@ async function boot(): Promise { const e = engine as any; await e.insert('sys_organization', { id: ORG_OTHER, name: ORG_OTHER, slug: 'other' }, SYS); await e.insert('sys_organization', { id: ORG_HOME, name: ORG_HOME, slug: 'home' }, SYS); - for (const id of [USER_HOME, USER_GLOBAL, USER_OTHER]) { + for (const id of [USER_HOME, USER_GLOBAL, USER_LEGACY, USER_OTHER]) { await e.insert('sys_user', { id, name: id, email: `${id}@example.test` }, SYS); } await e.insert('sys_member', { id: 'mem_home', user_id: USER_HOME, organization_id: ORG_HOME, role: 'member' }, SYS); await e.insert('sys_member', { id: 'mem_global', user_id: USER_GLOBAL, organization_id: ORG_HOME, role: 'member' }, SYS); + await e.insert('sys_member', { id: 'mem_legacy', user_id: USER_LEGACY, organization_id: ORG_HOME, role: 'member' }, SYS); await e.insert('sys_member', { id: 'mem_other', user_id: USER_OTHER, organization_id: ORG_OTHER, role: 'member' }, SYS); // ORG_OTHER authors sets named after what every member holds as a position. await insertSet(e, 'ps_other_org_member', 'org_member', ORG_OTHER, ['manage_metadata', 'probe.other_org_member']); await insertSet(e, 'ps_other_everyone', 'everyone', ORG_OTHER, ['manage_metadata', 'probe.other_everyone']); - // USER_GLOBAL's GLOBAL grants — what an organization-less principal really holds: - // (1) a global position assignment, folded onto a global same-named set; + // USER_GLOBAL's grants, owned by ORG_HOME: + // (1) a position assignment, folded onto a global same-named set; await write(e, 'sys_user_position', { - id: 'up_global_ops_lead', user_id: USER_GLOBAL, position: 'ops_lead', organization_id: null, + id: 'up_global_ops_lead', user_id: USER_GLOBAL, position: 'ops_lead', organization_id: ORG_HOME, }); await insertSet(e, 'ps_global_ops_lead', 'ops_lead', null, ['probe.global_ops_lead']); - // (2) a global user grant to a global set, beside ORG_OTHER's same-named + // (2) a user grant to a global set, beside ORG_OTHER's same-named // copy. The copy's id SORTS FIRST (`ps_0_…` before `ps_1_…`) and the // SQL driver's read comes back ordered by id, so an unscoped read meets // the copy first. With the ids the other way round this pin stays green @@ -172,6 +177,15 @@ async function boot(): Promise { await insertSet(e, 'ps_1_global_platform_ops', 'platform_ops', null, ['probe.global_platform_ops']); await write(e, 'sys_user_permission_set', { id: 'ups_global_platform_ops', user_id: USER_GLOBAL, + permission_set: 'platform_ops', organization_id: ORG_HOME, + }); + // USER_LEGACY holds the same two as organization-less rows — the shape an + // older release left, which confers nothing anywhere. + await write(e, 'sys_user_position', { + id: 'up_legacy_ops_lead', user_id: USER_LEGACY, position: 'ops_lead', organization_id: null, + }); + await write(e, 'sys_user_permission_set', { + id: 'ups_legacy_platform_ops', user_id: USER_LEGACY, permission_set: 'platform_ops', organization_id: null, }); // [ADR-0131 D3/D4] The two GLOBAL sets are declared in the catalog, which is @@ -295,29 +309,42 @@ describe('[#20555] with no active organization, another organization\'s set name expect(orgless.ledger).toBeNull(); }, 120_000); - it('a GLOBAL grant resolves the global row it names, not another organization\'s same-named copy', async () => { + it('an owned grant confers nothing with no organization active — neither the set it names nor another organization\'s copy', async () => { const r = await rig(); const orgless = await resolve(r, USER_GLOBAL); - expect(orgless.permissions).toContain('platform_ops'); - expect(orgless.byName.get('platform_ops')?.systemPermissions).toEqual(['probe.global_platform_ops']); + expect(orgless.permissions).not.toContain('platform_ops'); + expect(orgless.byName.get('platform_ops')).toBeUndefined(); + // Control: with its owner active the same grant resolves the global set it + // names, not ORG_OTHER's same-named copy (whose id sorts first). + const home = await resolve(r, USER_GLOBAL, ORG_HOME); + expect(home.permissions).toContain('platform_ops'); + expect(home.byName.get('platform_ops')?.systemPermissions).toEqual(['probe.global_platform_ops']); }, 120_000); it('…and the same-named copy\'s systemPermissions do not reach that principal', async () => { const r = await rig(); const orgless = await resolve(r, USER_GLOBAL); expect([...orgless.capabilities].filter((c) => c.startsWith('probe.other_'))).toEqual([]); + // Nor with the grant's owner active, where the grant does confer (the + // control that keeps the leg above from passing for want of a grant). + const home = await resolve(r, USER_GLOBAL, ORG_HOME); + expect(home.capabilities.has('probe.global_platform_ops')).toBe(true); + expect([...home.capabilities].filter((c) => c.startsWith('probe.other_'))).toEqual([]); }, 120_000); }); describe('[#20555] what the rule keeps', () => { - it('a global position folded onto a global same-named set still resolves with no active organization', async () => { + it('a position folded onto a global same-named set resolves while its owner is active, and not with none active', async () => { const r = await rig(); + const home = await resolve(r, USER_GLOBAL, ORG_HOME); + expect(home.positions).toContain('ops_lead'); + expect(home.byName.get('ops_lead')?.systemPermissions).toEqual(['probe.global_ops_lead']); const orgless = await resolve(r, USER_GLOBAL); - expect(orgless.positions).toContain('ops_lead'); - expect(orgless.byName.get('ops_lead')?.systemPermissions).toEqual(['probe.global_ops_lead']); + expect(orgless.positions).not.toContain('ops_lead'); + expect(orgless.byName.get('ops_lead')).toBeUndefined(); }, 120_000); - it('the global grants resolve identically with the principal\'s own organization active', async () => { + it('the owned grants resolve with the principal\'s own organization active', async () => { const r = await rig(); const home = await resolve(r, USER_GLOBAL, ORG_HOME); expect(home.byName.get('ops_lead')?.systemPermissions).toEqual(['probe.global_ops_lead']); @@ -325,6 +352,18 @@ describe('[#20555] what the rule keeps', () => { expect(home.setNames).not.toContain('org_member'); }, 120_000); + it('an organization-less grant row confers nothing — with no organization active, or with the holder\'s own', async () => { + const r = await rig(); + for (const organizationId of [undefined, ORG_HOME]) { + const legacy = await resolve(r, USER_LEGACY, organizationId); + expect(legacy.positions).not.toContain('ops_lead'); + expect(legacy.permissions).not.toContain('platform_ops'); + expect(legacy.byName.get('ops_lead')).toBeUndefined(); + expect(legacy.byName.get('platform_ops')).toBeUndefined(); + expect([...legacy.capabilities].filter((c) => c.startsWith('probe.'))).toEqual([]); + } + }, 120_000); + // [ADR-0131 D3/D4, #15196 Q3 = A] REVERSED: the plugin no longer reads // `sys_permission_set` rows at all, so a set an organization authored as a // row with no catalog definition resolves for nobody — its own members, diff --git a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts index cef87436f6a..06bf5c2f0dd 100644 --- a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts +++ b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts @@ -91,6 +91,14 @@ const SYS = { isSystem: true } as const; const ADMIN = { userId: 'u_admin', positions: [], permissions: ['qa_admin'] }; const ORG_ADMIN_LIKE = { userId: 'u_org_admin', positions: [], permissions: ['qa_org_admin_like'] }; const PLAIN_MEMBER = { userId: 'u_member', positions: [], permissions: [] }; +/** + * The organization the grant-resolving legs below run in. A grant row takes + * effect only in the organization that owns it (`grantAppliesInTenant`), so a + * leg that reads what an assignment grants writes it from inside `org_a` and + * resolves it there. + */ +const GRANT_ORG = 'org_a'; +const ADMIN_IN_GRANT_ORG = { ...ADMIN, tenantId: GRANT_ORG }; /** A position record id — the spelling the measured authors wrote where the position's NAME belongs. */ const AUDITOR_POSITION_ID = 'position_mtur8hz5jp7ptm3r'; @@ -201,9 +209,9 @@ async function addMember(h: Harness, userId: string, organizationId: string): Pr { context: { isSystem: true, tenantId: organizationId } } as any); } -/** What an account reads of `qa_inquiry`, through the real resolver and the real middleware. */ +/** What an account reads of `qa_inquiry` in {@link GRANT_ORG}, through the real resolver and the real middleware. */ async function rowsReadBy(h: Harness, userId: string): Promise { - const g = await resolveUserAuthzGrants(h.engine, userId); + const g = await resolveUserAuthzGrants(h.engine, userId, { tenantId: GRANT_ORG }); try { const rows = await h.engine.find('qa_inquiry', { context: { userId, positions: g.positions, permissions: g.permissions }, @@ -244,8 +252,9 @@ async function assignmentsOf(h: Harness, userId: string): Promise { describe('a position spelled as a record id is refused, and the account is provably fine', () => { it('refuses 400 VALIDATION_FAILED, reference_not_found at position, naming the value and the remedy', async () => { const h = await boot(); + await addMember(h, 'u_holder', GRANT_ORG); const err = await refusalOf(() => h.engine.insert( - 'sys_user_position', { user_id: 'u_holder', position: AUDITOR_POSITION_ID }, { context: ADMIN } as any, + 'sys_user_position', { user_id: 'u_holder', position: AUDITOR_POSITION_ID }, { context: ADMIN_IN_GRANT_ORG } as any, )); const env = envelopeOf(err); expect(env.code).toBe('VALIDATION_FAILED'); @@ -268,7 +277,7 @@ describe('a position spelled as a record id is refused, and the account is prova // distributes reads rows at once, so the account, the set and the object // are fine — the refused edge was the whole difference. await h.engine.insert( - 'sys_user_permission_set', { user_id: 'u_holder', permission_set: 'qa_auditor_set' }, { context: ADMIN } as any, + 'sys_user_permission_set', { user_id: 'u_holder', permission_set: 'qa_auditor_set' }, { context: ADMIN_IN_GRANT_ORG } as any, ); expect(await rowsReadBy(h, 'u_holder')).toBe(3); }); @@ -301,19 +310,21 @@ describe('the accepted half — a catalog NAME, active or deactivated', () => { it('a catalog name is accepted and resolves: the holder reads rows', async () => { const h = await boot(); + await addMember(h, 'u_named', GRANT_ORG); const created = await h.engine.insert( - 'sys_user_position', { user_id: 'u_named', position: 'qa_auditor' }, { context: ADMIN } as any, + 'sys_user_position', { user_id: 'u_named', position: 'qa_auditor' }, { context: ADMIN_IN_GRANT_ORG } as any, ); - expect(created).toMatchObject({ user_id: 'u_named', position: 'qa_auditor' }); + expect(created).toMatchObject({ user_id: 'u_named', position: 'qa_auditor', organization_id: GRANT_ORG }); expect(await rowsReadBy(h, 'u_named')).toBe(3); }); it('ADR-0049 shape E: a DEACTIVATED position is still a position — accepted, stored, and grants nothing', async () => { const h = await boot(); + await addMember(h, 'u_retired', GRANT_ORG); const created = await h.engine.insert( - 'sys_user_position', { user_id: 'u_retired', position: 'qa_retired' }, { context: ADMIN } as any, + 'sys_user_position', { user_id: 'u_retired', position: 'qa_retired' }, { context: ADMIN_IN_GRANT_ORG } as any, ); - expect(created).toMatchObject({ position: 'qa_retired' }); + expect(created).toMatchObject({ position: 'qa_retired', organization_id: GRANT_ORG }); expect(await assignmentsOf(h, 'u_retired')).toHaveLength(1); // It stops granting (the resolver drops the deactivated name) — it is not refused. expect(await rowsReadBy(h, 'u_retired')).toBe(0); diff --git a/packages/plugins/plugin-security/src/security-plugin.test.ts b/packages/plugins/plugin-security/src/security-plugin.test.ts index 6476c519dbc..2ab67120d06 100644 --- a/packages/plugins/plugin-security/src/security-plugin.test.ts +++ b/packages/plugins/plugin-security/src/security-plugin.test.ts @@ -3481,6 +3481,13 @@ describe('explainAccessForCaller (ADR-0090 D6/D12)', () => { describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { const AGENT = 'user_agent'; const DELEGATOR = 'user_delegator'; + /** + * The organization every request below runs in, and the owner of every + * delegator holding: a holding takes effect only in its own organization + * (`grantAppliesInTenant`), so an organization-less one would leave the + * delegator holding nothing and every deny below passing for want of a grant. + */ + const D10_ORG = 'org-1'; // task carries every field the RLS/FLS policies below target, so no policy is // dropped as a fail-closed "missing field" deny. @@ -3510,7 +3517,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { if (opts.delegatorPositions !== null) { tables.sys_user.push({ id: DELEGATOR, email: 'delegator@x' }); for (const p of opts.delegatorPositions) { - tables.sys_user_position.push({ user_id: DELEGATOR, position: p }); + tables.sys_user_position.push({ user_id: DELEGATOR, position: p, organization_id: D10_ORG }); } } const matches = (row: any, where: any): boolean => @@ -3576,7 +3583,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { }; const agentCtx = (extra?: Record) => ({ - userId: AGENT, tenantId: 'org-1', positions: ['agent_set'], permissions: [], + userId: AGENT, tenantId: D10_ORG, positions: ['agent_set'], permissions: [], onBehalfOf: { userId: DELEGATOR, principalKind: 'human' }, principalKind: 'agent', ...(extra ?? {}), }); @@ -3643,7 +3650,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { agentPositions: ['agent_set'], delegatorPositions: ['del_set'], }); // Same context, but WITHOUT onBehalfOf. - const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, context: { userId: AGENT, tenantId: 'org-1', positions: ['agent_set'], permissions: [] } }; + const opCtx: any = { object: 'task', operation: 'find', ast: { where: undefined }, context: { userId: AGENT, tenantId: D10_ORG, positions: ['agent_set'], permissions: [] } }; await h.run(opCtx); expect(opCtx.ast.where).toEqual({ owner_id: AGENT }); }); @@ -3854,7 +3861,7 @@ describe('SecurityPlugin — ADR-0090 D10 agent intersection', () => { sets: [silentCeiling('agent_set'), viewAllDelegator('del_set')], agentPositions: ['agent_set'], delegatorPositions: ['del_set'], }); - const humanCtx = { userId: DELEGATOR, tenantId: 'org-1', positions: ['del_set'], permissions: [] }; + const humanCtx = { userId: DELEGATOR, tenantId: D10_ORG, positions: ['del_set'], permissions: [] }; expect(await h.security().describeDelegationNarrowing('task', humanCtx)).toEqual({ narrowed: false }); }); diff --git a/packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts b/packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts index 6fe5ddba4e1..50fa25612ff 100644 --- a/packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts +++ b/packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts @@ -15,8 +15,9 @@ * judged at all — neither as the payload was sent nor on the stored row. On an * ordinary tenant object such a row drops out of every reader's scope; on the * organization-scoped grant tables (`sys_user_position`, - * `sys_user_permission_set`) an organization-less row is read as a grant that - * applies in every organization (`@objectstack/core`, `grantAppliesInTenant`). + * `sys_user_permission_set`) an organization-less row is invalid state — a + * grant that applies nowhere (`@objectstack/core`, `grantAppliesInTenant`), so + * emptying the column silently revokes it from its own organization. * * The wall now judges the stored organization on every non-system update * path: an empty organization, sent or written by a hook, is refused with the From 4ba0dc9d43899a6a190a5af98ccdab021b6623bf Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:06:55 +0000 Subject: [PATCH 37/44] docs(core): the admin-standing surface names the Default Organization's grant as the only anchor Claude-Session: https://claude.ai/code/session_01QzqbxJk3oD6sqe47Q84ET8 Co-authored-by: Claude --- packages/core/src/security/admin-standing-surface.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/core/src/security/admin-standing-surface.ts b/packages/core/src/security/admin-standing-surface.ts index 5e6f4f4d78a..44ce39080f5 100644 --- a/packages/core/src/security/admin-standing-surface.ts +++ b/packages/core/src/security/admin-standing-surface.ts @@ -99,8 +99,8 @@ export interface AdminStandingTable { * authenticates a principal and seeds `permissions` with the key's scopes, and * confers no administrator standing of its own — `hasPlatformAdminGrant` is set * from the catalog's `admin_full_access` set reached through the anchoring - * `sys_user_permission_set` grant (§6b — the Default Organization's or an - * organization-less one, on a NON-WALLED posture only since #11663 L5) or from the deployment config matched against the caller's own + * `sys_user_permission_set` grant (§6b — the Default Organization's, on a + * NON-WALLED posture only since #11663 L5) or from the deployment config matched against the caller's own * STORED `sys_user` row (§6b-config, on every posture), never from a scope * string and never from the caller-seedable `grants.email`. */ @@ -128,7 +128,7 @@ export const ADMIN_STANDING_SURFACE: Readonly reason: 'The grant that makes a user a platform admin UNDER A NON-WALLED POSTURE: an in-window ' + '(ADR-0091) grant of `admin_full_access` owned by the Default Organization (ADR-0131 D5, the ' - + 'row the first-user promotion writes) or by no organization (the row it wrote before) — §6, ' + + 'row the first-user promotion writes) — §6, ' + '`grantAnchorsPlatformStanding`. Re-pointing it, scoping it to another organization or moving ' + 'it out of its window revokes the standing while leaving the row in place. ⚠️ Under a ' + 'WALLED posture (`group`/`isolated`) it confers NOTHING: the walled half ' From 72a7c4b10a09084c0b3455b7194e68a7119e1e06 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:20:24 +0000 Subject: [PATCH 38/44] wip(test): re-stamp the downstream grant fixtures (rest and in-progress packages) Claude-Session: https://claude.ai/code/session_01QzqbxJk3oD6sqe47Q84ET8 Co-authored-by: Claude --- .../src/cloud-connection-plugin.test.ts | 11 ++- ...oud-connection-runtime-version.pin.test.ts | 7 +- .../src/connection-credential-store.test.ts | 9 +- .../src/error-envelope.conformance.test.ts | 5 +- .../src/install-local-principal.fixtures.ts | 48 +++++++++-- ...stall-local-sample-data-not-loaded.test.ts | 4 +- .../approval-positions-name-authority.test.ts | 52 ++++++++++-- ...ent-user-endpoints-position-grants.test.ts | 18 ++-- .../src/server-timing-e2e.test.ts | 14 +-- ...ring-rule-positions-name-authority.test.ts | 85 +++++++++++++------ .../src/direct-mount-auth-gate.pin.test.ts | 9 +- ...cctx-authz-input-seam-reachability.test.ts | 10 ++- ...ernal-datasource-routes-auth-guard.test.ts | 10 ++- .../src/meta-type-read-capability.test.ts | 5 +- .../src/meta-type-write-capability.test.ts | 5 +- ...ge-door-execctx-fault-reachability.test.ts | 7 +- .../remote-tables-twin.equivalence.test.ts | 5 +- ...ugin-objectql-provider-three-state.test.ts | 9 +- .../src/rest-exec-ctx-principal-kind.test.ts | 11 ++- packages/rest/src/rest-server-timing.test.ts | 9 +- ...tion-gate-positions-name-authority.test.ts | 17 ++-- .../meta-read-org-scope-parity.test.ts | 18 ++-- ...ges-orgless-grants-capability-gate.test.ts | 78 ++++++++++++++--- ...s-read-delete-response-conformance.test.ts | 3 +- .../src/domains/packages-single-door.test.ts | 13 ++- ...ges-uninstall-refuse-before-mutate.test.ts | 22 +++-- .../packages-vetted-org-source.test.ts | 24 ++++-- 27 files changed, 371 insertions(+), 137 deletions(-) diff --git a/packages/cloud-connection/src/cloud-connection-plugin.test.ts b/packages/cloud-connection/src/cloud-connection-plugin.test.ts index 28a58b77e7e..12745233deb 100644 --- a/packages/cloud-connection/src/cloud-connection-plugin.test.ts +++ b/packages/cloud-connection/src/cloud-connection-plugin.test.ts @@ -56,19 +56,24 @@ function makeC(url: string, body?: any) { }; } +/** The organization the sessions act in, and the one {@link setupAccessEngine}'s grant is owned by. */ +const ORG = 'o1'; + const sessionAuth = (userId?: string) => ({ - api: { getSession: async () => (userId ? { user: { id: userId } } : null) }, + api: { getSession: async () => (userId ? { user: { id: userId }, session: { activeOrganizationId: ORG } } : null) }, }); /** * [#24148] A data engine under which `userId` holds `setup.access` — what * `/status` serves its full answer behind. Real permission-set rows, read by - * the shared resolver through `find`. + * the shared resolver through `find`. The grant is owned by {@link ORG}, the + * organization the session acts in: a grant applies only in its own + * organization. */ const setupAccessEngine = (userId: string) => { const rows: Record = { sys_user: [{ id: userId, email: `${userId}@example.test` }], - sys_user_permission_set: [{ id: 'ups_1', user_id: userId, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }], + sys_user_permission_set: [{ id: 'ups_1', user_id: userId, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: ORG }], sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }], }; return bindCatalogFromTables({ find: async (object: string) => rows[object] ?? [] }, rows); diff --git a/packages/cloud-connection/src/cloud-connection-runtime-version.pin.test.ts b/packages/cloud-connection/src/cloud-connection-runtime-version.pin.test.ts index 4a18234c408..86f0ff8617a 100644 --- a/packages/cloud-connection/src/cloud-connection-runtime-version.pin.test.ts +++ b/packages/cloud-connection/src/cloud-connection-runtime-version.pin.test.ts @@ -35,10 +35,11 @@ const CC = '/api/v1/cloud-connection'; const ORIGIN = 'https://env.example.test'; const CLOUD = 'http://cloud.test'; -/** Holds `setup.access`, which the two bind doors demand. */ +/** Holds `setup.access`, which the two bind doors demand — in {@link ORG}, the organization the session acts in. */ const ADMIN = 'u_admin'; +const ORG = 'o1'; const GRANTS: Record = { - sys_user_permission_set: [{ id: 'ups_1', user_id: ADMIN, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }], + sys_user_permission_set: [{ id: 'ups_1', user_id: ADMIN, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: ORG }], sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }], }; @@ -95,7 +96,7 @@ async function mount(mode: Mode, discovery: Discovery) { post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), }; const hooks = new Map(); - const auth = { api: { getSession: async () => ({ user: { id: ADMIN }, session: { id: 'sess_admin' } }) } }; + const auth = { api: { getSession: async () => ({ user: { id: ADMIN }, session: { id: 'sess_admin', activeOrganizationId: ORG } }) } }; const objectql = bindCatalogFromTables({ find: async (object: string) => GRANTS[object] ?? [] }, GRANTS); const protocol = discovery === 'real' ? discoveryProducer() diff --git a/packages/cloud-connection/src/connection-credential-store.test.ts b/packages/cloud-connection/src/connection-credential-store.test.ts index a0630254f87..f1222f99ba3 100644 --- a/packages/cloud-connection/src/connection-credential-store.test.ts +++ b/packages/cloud-connection/src/connection-credential-store.test.ts @@ -55,15 +55,18 @@ function makeRawApp() { post: (p: string, h: Handler) => routes.set(`POST ${p}`, h), }; } -const sessionAuth = (userId?: string) => ({ api: { getSession: async () => (userId ? { user: { id: userId } } : null) } }); +/** The organization the sessions act in, and the one {@link ADMIN_GRANTS}' grant is owned by. */ +const ORG = 'o1'; +const sessionAuth = (userId?: string) => ({ api: { getSession: async () => (userId ? { user: { id: userId }, session: { activeOrganizationId: ORG } } : null) } }); /** * [#24148] The suites below sign in as `admin`, who must hold `setup.access` to * reach the connection-changing doors (their subject is the credential, not the * permission — that is `cloud-connection-setup-access.pin.test.ts`). Real - * permission-set rows, read by the shared resolver through `find`. + * permission-set rows, read by the shared resolver through `find`; the grant is + * owned by {@link ORG}, the organization the session acts in. */ const ADMIN_GRANTS: Record = { - sys_user_permission_set: [{ id: 'ups_1', user_id: 'admin', permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }], + sys_user_permission_set: [{ id: 'ups_1', user_id: 'admin', permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: ORG }], sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }], }; /** diff --git a/packages/cloud-connection/src/error-envelope.conformance.test.ts b/packages/cloud-connection/src/error-envelope.conformance.test.ts index 598f58c02ec..b3f1d610ece 100644 --- a/packages/cloud-connection/src/error-envelope.conformance.test.ts +++ b/packages/cloud-connection/src/error-envelope.conformance.test.ts @@ -189,12 +189,13 @@ async function mountCloudConnection(opts: { const hooks = new Map(); const auth = { api: { - getSession: async () => (opts.userId ? { user: { id: opts.userId }, session: {} } : null), + // The session acts in `o1`, the organization the grant below is owned by. + getSession: async () => (opts.userId ? { user: { id: opts.userId }, session: { activeOrganizationId: 'o1' } } : null), }, }; const grantRows: Record = opts.userId && opts.setupAccess !== false ? { - sys_user_permission_set: [{ id: 'ups_1', user_id: opts.userId, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: null }], + sys_user_permission_set: [{ id: 'ups_1', user_id: opts.userId, permission_set_id: 'ps_1', permission_set: 'setup_admin', organization_id: 'o1' }], sys_permission_set: [{ id: 'ps_1', name: 'setup_admin', system_permissions: ['setup.access'] }], } : {}; diff --git a/packages/cloud-connection/src/install-local-principal.fixtures.ts b/packages/cloud-connection/src/install-local-principal.fixtures.ts index f182e893ee8..b0fca0c4bcd 100644 --- a/packages/cloud-connection/src/install-local-principal.fixtures.ts +++ b/packages/cloud-connection/src/install-local-principal.fixtures.ts @@ -47,11 +47,26 @@ export const INSTALLER_USER_ID = 'admin'; */ export const INSTALLER_SET = { name: 'admin_full_access', systemPermissions: ['manage_metadata', 'studio.access', 'setup.access'] }; +/** + * [ADR-0131 D5] The Default Organization the installer's grant is owned by — + * the organization `resolveAuthzContext` names by `slug = 'default'`. Only the + * `admin_full_access` grant that organization owns anchors platform standing, + * and an anchoring grant applies whatever organization the request acts in + * (none included), so these fixtures stay independent of the active tenant. + */ +export const INSTALLER_ORGANIZATION = { id: 'org_default', slug: 'default' }; + /** * The `sys_*` rows that make `userId` a holder of `manage_metadata`, shaped the - * way `resolveAuthzContext` reads them (an UNSCOPED `sys_user_permission_set` - * grant pointing at a `sys_permission_set` whose `system_permissions` carry the - * capability — the shipped `admin_full_access` shape). + * way `resolveAuthzContext` reads them (a `sys_user_permission_set` grant owned + * by {@link INSTALLER_ORGANIZATION}, pointing at a `sys_permission_set` whose + * `system_permissions` carry the capability — the shipped `admin_full_access` + * shape the first-user promotion writes). An organization-less grant would + * apply nowhere and anchor nothing. + * + * `sys_organization` is deliberately NOT among them: suites wire their own + * organizations. {@link answerDefaultOrganizationRead} supplies the Default + * Organization only where the suite's engine names none. */ export function installerGrantRows(userId: string = INSTALLER_USER_ID): Record { return { @@ -62,7 +77,7 @@ export function installerGrantRows(userId: string = INSTALLER_USER_ID): Record } | undefined)?.where; + if (!where || Object.keys(where).length !== 1 || where.slug !== 'default') return undefined; + const rows = innerRows && (innerRows as { value?: unknown }).value !== undefined ? (innerRows as { value: unknown }).value : innerRows; + if (Array.isArray(rows) && rows.length > 0) return undefined; + return [{ ...INSTALLER_ORGANIZATION }]; +} + /** The `auth` service shape the plugin resolves a session through. */ export function installerAuthService(userId: string = INSTALLER_USER_ID) { return { api: { getSession: async () => ({ user: { id: userId }, session: {} }) } }; @@ -81,8 +112,10 @@ export function installerAuthService(userId: string = INSTALLER_USER_ID) { /** * Wrap an existing `objectql` fake so the authorization tables answer from - * {@link installerGrantRows} and EVERY other object falls through to whatever - * the suite already wired. + * {@link installerGrantRows}, the resolver's Default Organization read answers + * {@link INSTALLER_ORGANIZATION} when the suite names none + * ({@link answerDefaultOrganizationRead}), and EVERY other object falls through + * to whatever the suite already wired. * * Wrapping rather than replacing is the point: these suites' engines carry * behaviour their own assertions depend on (seed lookups, ledger probes, @@ -100,7 +133,8 @@ export function withInstallerGrants>( ...engine, find: async (object: string, options?: unknown) => { if (Object.prototype.hasOwnProperty.call(rows, object)) return rows[object]; - return inner ? inner(object, options) : []; + const answered = inner ? await inner(object, options) : []; + return answerDefaultOrganizationRead(object, options, answered) ?? answered; }, } as T; // What the security plugin binds to a real engine: the catalog the diff --git a/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts b/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts index 8d88fcffcc8..ee2173713a0 100644 --- a/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-sample-data-not-loaded.test.ts @@ -49,7 +49,7 @@ import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; import '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { LocalManifestSource, type InstalledManifestEntry } from './local-manifest-source.js'; -import { installerGrantRows, INSTALLER_USER_ID, INSTALLER_SET } from './install-local-principal.fixtures.js'; +import { answerDefaultOrganizationRead, installerGrantRows, INSTALLER_USER_ID, INSTALLER_SET } from './install-local-principal.fixtures.js'; import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; type Handler = (c: any) => Promise; @@ -138,7 +138,7 @@ async function restartWith( async find(object: string, query?: any): Promise { const granted = installerGrantRows(); if (Object.prototype.hasOwnProperty.call(granted, object)) return granted[object]!; - if (object === 'sys_organization') return []; + if (object === 'sys_organization') return answerDefaultOrganizationRead(object, query, []) ?? []; const where: Record = query?.where ?? {}; const rows = known(object).filter((row) => Object.entries(where).every(([k, v]) => { // Scalar equality only; anything else is refused, never guessed. diff --git a/packages/plugins/plugin-approvals/src/approval-positions-name-authority.test.ts b/packages/plugins/plugin-approvals/src/approval-positions-name-authority.test.ts index 2ece3a5c3f4..b4e96340ee6 100644 --- a/packages/plugins/plugin-approvals/src/approval-positions-name-authority.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-positions-name-authority.test.ts @@ -18,8 +18,8 @@ * values are unconstrained, so a tenant can mint an ADR-0057 D4 row spelling * that built-in name, `resolveUserAuthzGrants` §4 pushes it into * `grants.positions`, and the third arm answered `true` while `grants.posture` - * — derived from the unscoped `admin_full_access` grant and nothing else — - * stayed `MEMBER`. + * — derived from the anchoring `admin_full_access` grant (the Default + * Organization's) and nothing else — stayed `MEMBER`. * * ⭐ WHAT THE ESCALATION BUYS, driven rather than argued: the platform arm of * `isOverrideActor` deliberately CROSSES THE TENANT WALL (the tenant-admin arm @@ -33,8 +33,10 @@ * (the console's participant-visibility read, `visibleRequestIds`), for a * NON-slate-holding actor in a DIFFERENT organization from the request, in * three shapes — a D4 row spelling the built-in name with no capability grant - * (`name-only`), a genuine unscoped `admin_full_access` grant (`genuine`), and - * a plain member (`plain`, the floor that proves the arms are not all passing + * (`name-only`), a genuine Default Organization `admin_full_access` grant + * (`genuine`), the same grant carrying NO organization (`orgless`, which + * anchors nothing — a grant applies only in its own organization), and a + * plain member (`plain`, the floor that proves the arms are not all passing * for some unrelated reason). The `name-only` and `genuine` contexts are built * by inserting rows and resolving them through the REAL * `resolveUserAuthzGrants`. @@ -150,10 +152,22 @@ function makeAuthzQl(tables: Record>>) { }, tables); } -type Shape = 'name-only' | 'genuine' | 'plain'; +type Shape = 'name-only' | 'genuine' | 'orgless' | 'plain'; +/** + * Every grant row is owned by `ATTACKER_ORG`, which is also the Default + * Organization: a grant applies only in its own organization (an + * organization-less row confers nothing), and platform standing is anchored + * only by the Default Organization's `admin_full_access` grant. The principal + * is resolved in `ATTACKER_ORG`, so the name-only position row applies — and + * the request under test still belongs to the OTHER organization. + */ function authzTables(shape: Shape) { return { + sys_organization: [ + { id: ATTACKER_ORG, slug: 'default' }, + { id: VICTIM_ORG, slug: 'victim' }, + ], sys_user: [{ id: ATTACKER, email: 'attacker@example.com', email_verified: true }], sys_member: [{ organization_id: ATTACKER_ORG, user_id: ATTACKER, role: 'member' }], sys_user_position: @@ -161,7 +175,7 @@ function authzTables(shape: Shape) { ? [ // Exactly what a tenant admin can write through the `apiEnabled` // `sys_user_position` surface: a row whose NAME is the built-in. - { user_id: ATTACKER, position: BUILTIN_IDENTITY_PLATFORM_ADMIN, organization_id: null }, + { user_id: ATTACKER, position: BUILTIN_IDENTITY_PLATFORM_ADMIN, organization_id: ATTACKER_ORG }, ] : [], // An ACTIVE catalogue row, so ADR-0049's deactivated-position filter is not @@ -172,8 +186,11 @@ function authzTables(shape: Shape) { : [], sys_position_permission_set: [], sys_user_permission_set: - shape === 'genuine' - ? [{ user_id: ATTACKER, permission_set_id: PS_ADMIN, permission_set: ADMIN_FULL_ACCESS, organization_id: null }] + shape === 'genuine' || shape === 'orgless' + ? [{ + user_id: ATTACKER, permission_set_id: PS_ADMIN, permission_set: ADMIN_FULL_ACCESS, + organization_id: shape === 'genuine' ? ATTACKER_ORG : null, + }] : [], sys_permission_set: [{ id: PS_ADMIN, name: ADMIN_FULL_ACCESS, active: true }], }; @@ -335,7 +352,7 @@ describe('[#15981] a D4 `sys_user_position` row spelling `platform_admin` confer }); }); -describe('[#15981] CONTROL — a genuine unscoped admin_full_access grant still overrides', () => { +describe('[#15981] CONTROL — a genuine Default Organization admin_full_access grant still overrides', () => { let engine: ReturnType; let svc: ApprovalService; let n = 0; @@ -404,4 +421,21 @@ describe('[#15981] CONTROL — a genuine unscoped admin_full_access grant still expect(escalation.rung).toBe(false); expect(genuine.rung).toBe(true); }); + + it('the same admin_full_access grant with NO organization anchors nothing, and decides nothing', async () => { + const orgless = await resolve('orgless'); + const req = await openVictimRequest(); + + expect(orgless.context.positions).not.toContain(BUILTIN_IDENTITY_PLATFORM_ADMIN); + expect(orgless.context.permissions).not.toContain(ADMIN_FULL_ACCESS); + expect(orgless.grants.posture).not.toBe('PLATFORM_ADMIN'); + expect(orgless.rung).toBe(false); + + await expect( + svc.decideNode(req.id, { decision: 'approve', actorId: ATTACKER }, orgless.context), + ).rejects.toThrow(/^FORBIDDEN/); + const [row] = await engine.find('sys_approval_request', { where: { id: req.id } }); + const actions = (await svc.listActions(req.id, SYS)).filter((a: any) => a.actor_id === ATTACKER); + expect({ status: row?.status, decided: actions.length > 0 }).toEqual({ status: 'pending', decided: false }); + }); }); diff --git a/packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts b/packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts index 3f7b5da4f06..df0ab4aee27 100644 --- a/packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts +++ b/packages/plugins/plugin-hono-server/src/current-user-endpoints-position-grants.test.ts @@ -19,7 +19,8 @@ // halves: the issue's own repro (a position-bound set surfaces), and the // semantics that come free with the delegation and would have to be re-written // by hand otherwise — the implicit `everyone` anchor (ADR-0090 D5), null-org = -// global, active-org matching, and the ADR-0091 validity window. +// nowhere (a grant applies only in its own organization), active-org matching, +// and the ADR-0091 validity window. // // Every negative case here carries a co-present VALID grant and asserts it // surfaced. A negative asserted on its own would pass in the pre-fix world for @@ -259,17 +260,24 @@ describe('position-bound permission sets reach /auth/me/permissions (#6334)', () expect(body.positions).toContain('org_member'); }); - it('treats a null-org position row as global (resolves under any active org)', async () => { + it('drops a null-org position row (an organization-less grant applies nowhere), keeping the active-org one', async () => { const app = mount({ tables: baseTables({ - sys_user_position: [{ id: 'up1', user_id: USER, position: 'ops', organization_id: null }], + sys_user_position: [ + { id: 'up1', user_id: USER, position: 'ops', organization_id: null }, + { id: 'up2', user_id: USER, position: 'auditor', organization_id: ACTIVE_ORG }, + ], }), }); const body = await permissionsOf(app); - expect(body.positions).toContain('ops'); - expect(body.systemPermissions).toContain('showcase.export_data'); + // The co-present valid grant is asserted so the negative below cannot + // pass merely because nothing resolved. + expect(body.positions).toContain('auditor'); + expect(body.systemPermissions).toContain('showcase.audit_read'); + expect(body.positions).not.toContain('ops'); + expect(body.systemPermissions).not.toContain('showcase.export_data'); }); it('drops a position row scoped to another organization, keeping the active-org one', async () => { diff --git a/packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts b/packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts index 96c69a3fea0..d2b8d73f6a6 100644 --- a/packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts +++ b/packages/plugins/plugin-hono-server/src/server-timing-e2e.test.ts @@ -20,16 +20,18 @@ import type { PluginContext } from '@objectstack/core'; import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit'; /** - * Fake data engine: an UNSCOPED `admin_full_access` grant for `admin1` (the - * seeded platform admin) vs. a `member_default` grant for `member1`, plus the - * `widget` rows the data route returns. Every other read resolves empty. + * Fake data engine: an `admin_full_access` grant owned by the Default + * Organization for `admin1` (the seeded platform admin — the only grant that + * anchors platform standing) vs. a `member_default` grant for `member1`, plus + * the Default Organization row and the `widget` rows the data route returns. + * Every other read resolves empty. */ const makeQl = () => bindTestSecurityCatalog({ find: async (object: string, opts: any) => { const where = opts?.where ?? {}; if (object === 'sys_user_permission_set') { - if (where.user_id === 'admin1') return [{ permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }]; - if (where.user_id === 'member1') return [{ permission_set_id: 'ps_member', permission_set: 'member_default', organization_id: null }]; + if (where.user_id === 'admin1') return [{ permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_default' }]; + if (where.user_id === 'member1') return [{ permission_set_id: 'ps_member', permission_set: 'member_default', organization_id: 'org_default' }]; return []; } if (object === 'sys_permission_set') { @@ -41,6 +43,8 @@ const makeQl = () => bindTestSecurityCatalog({ ids.includes('ps_member') || names.includes('member_default') ? { id: 'ps_member', name: 'member_default' } : null, ].filter(Boolean); } + // The Default Organization — by slug, or as the only organization. + if (object === 'sys_organization') return [{ id: 'org_default', slug: 'default' }]; if (object === 'widget') return [{ id: '1', name: 'w' }]; // sys_member, sys_user — nothing to contribute. return []; diff --git a/packages/plugins/plugin-sharing/src/sharing-rule-positions-name-authority.test.ts b/packages/plugins/plugin-sharing/src/sharing-rule-positions-name-authority.test.ts index fe3ca93f7eb..0f119ab1b33 100644 --- a/packages/plugins/plugin-sharing/src/sharing-rule-positions-name-authority.test.ts +++ b/packages/plugins/plugin-sharing/src/sharing-rule-positions-name-authority.test.ts @@ -16,16 +16,17 @@ * built-in name. `resolveUserAuthzGrants` §4 pushes the row's `position` * straight into `grants.positions`, and `resolveAuthzContext` copies that array * onto `ExecutionContext.positions` verbatim. The capability rung - * (`grants.posture`, §6d) is derived from the unscoped `admin_full_access` - * grant and nothing else, so it stays `MEMBER` — the two answers genuinely + * (`grants.posture`, §6d) is derived from the anchoring `admin_full_access` + * grant (the Default Organization's) and nothing else, so it stays `MEMBER` — the two answers genuinely * disagree, and the name-read took the wrong one. * * ⭐ WHAT THE ESCALATION BUYS, driven rather than argued (see the arms below): * `manage_sharing` is an ORG-scoped capability (ADR-0111 D6) that an ordinary - * tenant admin may grant. (Since #20515 a grant scoped to an organization no - * longer applies to a resolution with no organization at all, so the arms below - * hold it through a GLOBAL grant — the one way an org-less caller still holds - * it.) Holding it with no organization resolved is refused + * tenant admin may grant. (A grant applies only in its own organization, and + * an organization-less grant row confers nothing, so the arms below resolve + * the caller's grants IN `HOME_ORG` and then present them on a context that + * carries no organization — the org-less shape the gate under test exists to + * refuse.) Holding it with no organization on the context is refused * by `assertResolvableAdminScope` precisely because an unscoped answer "would * expose every tenant's rules". The D4 name-read was the bypass: it satisfied * that gate, `adminOrgScope` then returned the UNFILTERED `where`, and @@ -34,10 +35,12 @@ * * ## POPULATION OF THIS PIN — stated because a pin proves only what it covers * - * Covers: an ORG-LESS caller holding `manage_sharing`, in two shapes — a D4 + * Covers: an ORG-LESS caller holding `manage_sharing`, in three shapes — a D4 * row spelling the built-in name with no capability grant behind it - * (`name-only`), and a genuine unscoped `admin_full_access` grant - * (`genuine`, the control that proves the fix did not simply deny everyone). + * (`name-only`), a genuine Default Organization `admin_full_access` grant + * (`genuine`, the control that proves the fix did not simply deny everyone), + * and that same grant carrying NO organization (`orgless`, which anchors + * nothing). * Both shapes are built by inserting rows and resolving them through the REAL * `resolveUserAuthzGrants`, so the `positions` / `posture` disagreement under * test is produced by the shipping resolver rather than hand-asserted. @@ -96,29 +99,43 @@ const sharingAdminSet = { active: true, }; -function authzTables(shape: 'name-only' | 'genuine') { +type Shape = 'name-only' | 'genuine' | 'orgless'; + +/** + * Every grant row is owned by `HOME_ORG`, which is also the Default + * Organization: a grant applies only in its own organization, and platform + * standing is anchored only by the Default Organization's `admin_full_access` + * grant. + */ +function authzTables(shape: Shape) { const userPositions = shape === 'name-only' ? [ // Exactly what a tenant admin can write through the `apiEnabled` // `sys_user_position` surface: a row whose NAME is the built-in. - { user_id: USER, position: BUILTIN_IDENTITY_PLATFORM_ADMIN, organization_id: null }, + { user_id: USER, position: BUILTIN_IDENTITY_PLATFORM_ADMIN, organization_id: HOME_ORG }, ] : []; const userSets: Array> = [ - // The `manage_sharing` capability both shapes hold — the precondition, not - // the axis under test. GLOBAL (no organization): this caller resolves with - // NO organization, and an organization-less resolution applies only global - // grants (#20515), so an org-scoped grant here would simply not be held and - // every arm below would be vacuous. It is still `sharing_admin`, never - // `admin_full_access`, so it cannot be mistaken for the grant that confers - // standing. - { user_id: USER, permission_set_id: PS_SHARING, permission_set: 'sharing_admin', organization_id: null }, + // The `manage_sharing` capability every shape holds — the precondition, not + // the axis under test. Owned by `HOME_ORG`, the organization the grants + // are resolved in ({@link resolve}): an organization-less row would confer + // nothing, and every arm below would be vacuous. It is still + // `sharing_admin`, never `admin_full_access`, so it cannot be mistaken for + // the grant that confers standing. + { user_id: USER, permission_set_id: PS_SHARING, permission_set: 'sharing_admin', organization_id: HOME_ORG }, ]; - if (shape === 'genuine') { - userSets.push({ user_id: USER, permission_set_id: PS_ADMIN, permission_set: ADMIN_FULL_ACCESS, organization_id: null }); + if (shape === 'genuine' || shape === 'orgless') { + userSets.push({ + user_id: USER, permission_set_id: PS_ADMIN, permission_set: ADMIN_FULL_ACCESS, + organization_id: shape === 'genuine' ? HOME_ORG : null, + }); } return { + sys_organization: [ + { id: HOME_ORG, slug: 'default' }, + { id: OTHER_ORG, slug: 'victim' }, + ], sys_user: [{ id: USER, email: 'subject@example.com', email_verified: true }], sys_member: [{ organization_id: HOME_ORG, user_id: USER, role: 'member' }], sys_user_position: userPositions, @@ -142,12 +159,14 @@ function authzTables(shape: 'name-only' | 'genuine') { * context the transports would build and the rung, so every assertion below is * about resolver output rather than a hand-written array. * - * The caller is deliberately ORG-LESS (`tenantId` omitted): that is the shape - * `assertResolvableAdminScope` exists to refuse. + * The grants are resolved IN `HOME_ORG` (the only organization a grant row + * applies in), and the context handed to the service is deliberately ORG-LESS + * (`tenantId` omitted): that is the shape `assertResolvableAdminScope` exists + * to refuse. */ -async function resolve(shape: 'name-only' | 'genuine') { +async function resolve(shape: Shape) { const ql = makeAuthzQl(authzTables(shape)); - const grants = await resolveUserAuthzGrants(ql as any, USER); + const grants = await resolveUserAuthzGrants(ql as any, USER, { tenantId: HOME_ORG }); const context = { userId: USER, positions: grants.positions, @@ -254,7 +273,7 @@ describe('[#15981] a D4 `sys_user_position` row spelling `platform_admin` confer }); }); -describe('[#15981] CONTROL — a genuine unscoped admin_full_access grant still admits', () => { +describe('[#15981] CONTROL — a genuine Default Organization admin_full_access grant still admits', () => { it('all three answers are TRUE and agree, and the cross-tenant read is served', async () => { const { context, rung } = await resolve('genuine'); const engine = makeSharingEngine(); @@ -279,6 +298,20 @@ describe('[#15981] CONTROL — a genuine unscoped admin_full_access grant still expect(escalation.rung).toBe(false); expect(genuine.rung).toBe(true); }); + + it('the same admin_full_access grant with NO organization anchors nothing, and is refused', async () => { + const { context, grants, rung } = await resolve('orgless'); + const engine = makeSharingEngine(); + const service = makeService(engine); + + expect(context.positions).not.toContain(BUILTIN_IDENTITY_PLATFORM_ADMIN); + expect(grants.posture).not.toBe('PLATFORM_ADMIN'); + expect(rung).toBe(false); + // The capability is still held, so the refusal is about platform authority. + expect(context.systemPermissions).toContain('manage_sharing'); + await expect(service.listRules({}, context)).rejects.toThrow(/PERMISSION_DENIED/); + expect(engine.finds).toEqual([]); + }); }); describe('[#15981] the OTHER spelling of platform authority is untouched', () => { diff --git a/packages/rest/src/direct-mount-auth-gate.pin.test.ts b/packages/rest/src/direct-mount-auth-gate.pin.test.ts index be153743174..9e4c603a886 100644 --- a/packages/rest/src/direct-mount-auth-gate.pin.test.ts +++ b/packages/rest/src/direct-mount-auth-gate.pin.test.ts @@ -122,6 +122,9 @@ async function bootDirectMounts() { package: { list: async () => [], publish: vi.fn(async () => ({ success: true })) }, }; + const SET = 'ps_gate_pin'; + const ORG = 'o1'; + /** Session reads per request for the outage token; reset by `call`. */ const outage = { reads: 0 }; const auth = { @@ -133,17 +136,17 @@ async function bootDirectMounts() { // The identity read is the request's first; REST's gate re-read is its second. if (token === 'sess-outage' && ++outage.reads > 1) throw new Error('session backend unavailable'); const authGate = SESSIONS[token]; - return { user: { id: USER, ...(authGate ? { authGate } : {}) }, session: { userId: USER } }; + // The caller acts in `o1`, the organization its grant is stamped with. + return { user: { id: USER, ...(authGate ? { authGate } : {}) }, session: { userId: USER, activeOrganizationId: ORG } }; }, }, }; - const SET = 'ps_gate_pin'; const engine = { find: async (object: string, query: any) => { if (object === 'sys_user_permission_set') { return query?.where?.user_id === USER - ? [{ id: 'ups_1', user_id: USER, permission_set_id: SET, permission_set: 'gate_pin_caller', organization_id: null }] + ? [{ id: 'ups_1', user_id: USER, permission_set_id: SET, permission_set: 'gate_pin_caller', organization_id: ORG }] : []; } if (object === 'sys_permission_set') { diff --git a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts index c01f5b4d1fa..1b8b7a66ab3 100644 --- a/packages/rest/src/execctx-authz-input-seam-reachability.test.ts +++ b/packages/rest/src/execctx-authz-input-seam-reachability.test.ts @@ -436,7 +436,9 @@ function matchesWhere(row: any, where: any): boolean { * `sys_api_key`, memberships in `sys_member`, capabilities through * `sys_user_permission_set` → `sys_permission_set`. Every read filters * through the one refusing matcher above, and the caller's `limit` bound is - * held BY PRESENCE (check:objectql-double-limit). + * held BY PRESENCE (check:objectql-double-limit). Every grant is owned by + * `org_A`, the organization the stamped keys act in — a grant applies only in + * its own organization. */ function qlWith(opts: { memberships: Array<{ user_id: string; organization_id: string }> }) { const tables: Record = { @@ -453,9 +455,9 @@ function qlWith(opts: { memberships: Array<{ user_id: string; organization_id: s { id: 'u_gated', email: 'u_gated@example.com' }, ], sys_user_permission_set: [ - { user_id: 'u_member', permission_set_id: 'ps_pkg', permission_set: 'pkg_admin' }, - { user_id: 'u_exmember', permission_set_id: 'ps_pkg', permission_set: 'pkg_admin' }, - { user_id: 'u_orgless', permission_set_id: 'ps_pkg', permission_set: 'pkg_admin' }, + { user_id: 'u_member', permission_set_id: 'ps_pkg', permission_set: 'pkg_admin', organization_id: 'org_A' }, + { user_id: 'u_exmember', permission_set_id: 'ps_pkg', permission_set: 'pkg_admin', organization_id: 'org_A' }, + { user_id: 'u_orgless', permission_set_id: 'ps_pkg', permission_set: 'pkg_admin', organization_id: 'org_A' }, ], sys_permission_set: [ { id: 'ps_pkg', name: 'pkg_admin', system_permissions: ['manage_metadata', 'studio.access'] }, diff --git a/packages/rest/src/external-datasource-routes-auth-guard.test.ts b/packages/rest/src/external-datasource-routes-auth-guard.test.ts index 208d9705821..f5f120a9fa9 100644 --- a/packages/rest/src/external-datasource-routes-auth-guard.test.ts +++ b/packages/rest/src/external-datasource-routes-auth-guard.test.ts @@ -208,7 +208,7 @@ async function bootFederation( api: { getSession: async ({ headers }: { headers: Headers }) => headers?.get?.('authorization') === `Bearer ${SESSION}` - ? { user: { id: 'u_federation' }, session: { userId: 'u_federation' } } + ? { user: { id: 'u_federation' }, session: { userId: 'u_federation', activeOrganizationId: 'o1' } } : null, }, }; @@ -229,6 +229,10 @@ async function bootFederation( * `manage_platform_settings` among six other capabilities, so a gate keyed on * platform-admin posture rather than on the named capability would pass here * unnoticed — the same reason the twin-equivalence fixture builds its own. + * + * The grant is stamped with `o1`, and both credentials act in `o1` (the + * session's active organization, the key's stamped one): a grant applies + * only in its own organization. */ const grants = opts.grants ?? FULL_GRANT; const GRANT_SET_ID = 'ps_federation_caller'; @@ -236,12 +240,12 @@ async function bootFederation( find: async (object: string, query: any) => { if (object === 'sys_api_key') { return query?.where?.key === hashApiKey(API_KEY) - ? [{ id: 'key_1', key: hashApiKey(API_KEY), user_id: 'u_federation', revoked: false }] + ? [{ id: 'key_1', key: hashApiKey(API_KEY), user_id: 'u_federation', revoked: false, active_organization_id: 'o1' }] : []; } if (object === 'sys_user_permission_set') { return query?.where?.user_id === 'u_federation' && grants.length > 0 - ? [{ id: 'ups_1', user_id: 'u_federation', permission_set_id: GRANT_SET_ID, permission_set: 'federation_caller', organization_id: null }] + ? [{ id: 'ups_1', user_id: 'u_federation', permission_set_id: GRANT_SET_ID, permission_set: 'federation_caller', organization_id: 'o1' }] : []; } if (object === 'sys_permission_set') { diff --git a/packages/rest/src/meta-type-read-capability.test.ts b/packages/rest/src/meta-type-read-capability.test.ts index d47059d0eac..775df59ac09 100644 --- a/packages/rest/src/meta-type-read-capability.test.ts +++ b/packages/rest/src/meta-type-read-capability.test.ts @@ -347,7 +347,7 @@ const SETS: Record = { */ const GRANT_TABLES: Record = { sys_user_permission_set: Object.values(PRINCIPALS).flatMap((p) => p.sets.map((setId) => ({ - id: `ups_${p.userId}_${setId}`, user_id: p.userId, permission_set_id: setId, permission_set: setId, organization_id: null, + id: `ups_${p.userId}_${setId}`, user_id: p.userId, permission_set_id: setId, permission_set: setId, organization_id: 'o1', }))), sys_permission_set: Object.entries(SETS).map(([id, caps]) => ({ id, name: id, system_permissions: JSON.stringify(caps), object_permissions: '{}', @@ -381,7 +381,8 @@ const authService = { api: { getSession: async ({ headers }: { headers: Headers }) => { const principal = PRINCIPALS[headers?.get?.('authorization') ?? '']; - return principal ? { user: { id: principal.userId } } : null; + // Every principal acts in `o1`, the organization its grants are stamped with. + return principal ? { user: { id: principal.userId }, session: { userId: principal.userId, activeOrganizationId: 'o1' } } : null; }, }, }; diff --git a/packages/rest/src/meta-type-write-capability.test.ts b/packages/rest/src/meta-type-write-capability.test.ts index fcaa11fa676..83954202d88 100644 --- a/packages/rest/src/meta-type-write-capability.test.ts +++ b/packages/rest/src/meta-type-write-capability.test.ts @@ -308,7 +308,7 @@ const SETS: Record = { const GRANT_TABLES: Record = { sys_user_permission_set: Object.values(PRINCIPALS).flatMap((p) => p.sets.map((setId) => ({ - id: `ups_${p.userId}_${setId}`, user_id: p.userId, permission_set_id: setId, permission_set: setId, organization_id: null, + id: `ups_${p.userId}_${setId}`, user_id: p.userId, permission_set_id: setId, permission_set: setId, organization_id: 'o1', }))), sys_permission_set: Object.entries(SETS).map(([id, caps]) => ({ id, name: id, system_permissions: JSON.stringify(caps), object_permissions: '{}', @@ -336,7 +336,8 @@ const authService = { api: { getSession: async ({ headers }: { headers: Headers }) => { const principal = PRINCIPALS[headers?.get?.('authorization') ?? '']; - return principal ? { user: { id: principal.userId } } : null; + // Every principal acts in `o1`, the organization its grants are stamped with. + return principal ? { user: { id: principal.userId }, session: { userId: principal.userId, activeOrganizationId: 'o1' } } : null; }, }, }; diff --git a/packages/rest/src/package-door-execctx-fault-reachability.test.ts b/packages/rest/src/package-door-execctx-fault-reachability.test.ts index 881ba6839f6..3acd3bfb736 100644 --- a/packages/rest/src/package-door-execctx-fault-reachability.test.ts +++ b/packages/rest/src/package-door-execctx-fault-reachability.test.ts @@ -250,17 +250,18 @@ const settle = async (p: Promise): Promise<'fulfilled' | 'rejected'> => // Healthy production wiring — the baseline every fault is injected into. // --------------------------------------------------------------------------- -/** A better-auth-shaped service that resolves a real session. */ -const AUTH_OK = async () => ({ api: { getSession: async () => ({ user: { id: 'u_admin' } }) } }); +/** A better-auth-shaped service that resolves a real session, acting in `o1`. */ +const AUTH_OK = async () => ({ api: { getSession: async () => ({ user: { id: 'u_admin' }, session: { userId: 'u_admin', activeOrganizationId: 'o1' } }) } }); /** * A permission store that actually grants the package capabilities, through * the SHIPPED aggregation (`sys_user_permission_set` → `sys_permission_set` * → `system_permissions`) rather than by handing the door a ready-made context. + * The grant is owned by `o1`, the session's active organization. */ const qlHealthy = () => bindTestSecurityCatalog({ find: async (object: string) => { - if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps_pkg', permission_set: 'pkg_admin' }]; + if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps_pkg', permission_set: 'pkg_admin', organization_id: 'o1' }]; if (object === 'sys_permission_set') return [{ id: 'ps_pkg', name: 'pkg_admin' }]; return []; }, diff --git a/packages/rest/src/remote-tables-twin.equivalence.test.ts b/packages/rest/src/remote-tables-twin.equivalence.test.ts index 601189ca01f..7b67858c4f6 100644 --- a/packages/rest/src/remote-tables-twin.equivalence.test.ts +++ b/packages/rest/src/remote-tables-twin.equivalence.test.ts @@ -139,7 +139,8 @@ const authService = { api: { getSession: async ({ headers }: { headers: Headers }) => { const id = USERS[headers?.get?.('authorization') ?? '']; - return id ? { user: { id } } : null; + // Every caller acts in `o1`, the organization its grant is stamped with. + return id ? { user: { id }, session: { userId: id, activeOrganizationId: 'o1' } } : null; }, }, }; @@ -195,7 +196,7 @@ const makeQl = () => bindTestSecurityCatalog({ return setId ? [{ id: `ups_${where.user_id}`, user_id: where.user_id, permission_set_id: setId, - permission_set: SETS[setId].name, organization_id: null, + permission_set: SETS[setId].name, organization_id: 'o1', }] : []; } diff --git a/packages/rest/src/rest-api-plugin-objectql-provider-three-state.test.ts b/packages/rest/src/rest-api-plugin-objectql-provider-three-state.test.ts index cafc1489668..b97be792b1d 100644 --- a/packages/rest/src/rest-api-plugin-objectql-provider-three-state.test.ts +++ b/packages/rest/src/rest-api-plugin-objectql-provider-three-state.test.ts @@ -249,18 +249,19 @@ describe('[#13904] the shipped objectQLProvider, driven against a real ObjectKer // transport seam #13910 pinned, read at the package door. // --------------------------------------------------------------------------- -/** A better-auth-shaped service that resolves a real session. */ -const AUTH_OK = async () => ({ api: { getSession: async () => ({ user: { id: 'u_admin' } }) } }); +/** A better-auth-shaped service that resolves a real session, acting in `o1`. */ +const AUTH_OK = async () => ({ api: { getSession: async () => ({ user: { id: 'u_admin' }, session: { userId: 'u_admin', activeOrganizationId: 'o1' } }) } }); /** * An engine that actually grants the package capabilities through the SHIPPED * aggregation (`sys_user_permission_set` → `sys_permission_set` → * `system_permissions`) — the healthy fixture - * `package-door-execctx-fault-reachability.test.ts` established. + * `package-door-execctx-fault-reachability.test.ts` established. The grant is + * owned by `o1`, the session's active organization. */ const engineGranting = () => bindTestSecurityCatalog({ find: async (object: string) => { - if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps_pkg', permission_set: 'pkg_admin' }]; + if (object === 'sys_user_permission_set') return [{ permission_set_id: 'ps_pkg', permission_set: 'pkg_admin', organization_id: 'o1' }]; if (object === 'sys_permission_set') { return [{ id: 'ps_pkg', name: 'pkg_admin', system_permissions: ['manage_metadata', 'studio.access'] }]; } diff --git a/packages/rest/src/rest-exec-ctx-principal-kind.test.ts b/packages/rest/src/rest-exec-ctx-principal-kind.test.ts index f73b191a80b..8f3eea540ad 100644 --- a/packages/rest/src/rest-exec-ctx-principal-kind.test.ts +++ b/packages/rest/src/rest-exec-ctx-principal-kind.test.ts @@ -43,8 +43,9 @@ const RAW_API_KEY = 'osk_6071_rest_face'; /** * A fake data engine holding what a real deployment holds: one `sys_api_key` - * row for `member1`, a `member_default` grant for `member1` and an UNSCOPED - * `admin_full_access` grant for `admin1` (→ PLATFORM_ADMIN). Everything else + * row for `member1`, a `member_default` grant for `member1` and an + * `admin_full_access` grant for `admin1` owned by the Default Organization (the + * grant that anchors platform standing → PLATFORM_ADMIN). Everything else * resolves empty. */ const makeQl = () => bindTestSecurityCatalog({ @@ -55,9 +56,11 @@ const makeQl = () => bindTestSecurityCatalog({ return Object.entries(where).every(([k, v]) => (row as any)[k] === v) ? [row] : []; } if (object === 'sys_user') return [{ id: where.id, email: `${where.id}@example.com` }]; + // The Default Organization, by its slug. + if (object === 'sys_organization') return [{ id: 'org_default', slug: 'default' }]; if (object === 'sys_user_permission_set') { - if (where.user_id === 'admin1') return [{ permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }]; - if (where.user_id === 'member1') return [{ permission_set_id: 'ps_member', permission_set: 'member_default', organization_id: null }]; + if (where.user_id === 'admin1') return [{ permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_default' }]; + if (where.user_id === 'member1') return [{ permission_set_id: 'ps_member', permission_set: 'member_default', organization_id: 'org_default' }]; return []; } if (object === 'sys_permission_set') { diff --git a/packages/rest/src/rest-server-timing.test.ts b/packages/rest/src/rest-server-timing.test.ts index e5d020bfe84..d4d16568405 100644 --- a/packages/rest/src/rest-server-timing.test.ts +++ b/packages/rest/src/rest-server-timing.test.ts @@ -24,7 +24,8 @@ const makeServer = () => ({ /** * A fake data engine that returns just enough rows for `resolveAuthzContext` to - * derive a posture: an UNSCOPED `admin_full_access` grant for `admin1` (→ + * derive a posture: an `admin_full_access` grant for `admin1` owned by the + * Default Organization (the only grant that anchors platform standing → * PLATFORM_ADMIN) and a plain `member_default` grant for `member1` (→ MEMBER). * Every other object read resolves empty. */ @@ -32,9 +33,11 @@ const makeQl = () => bindTestSecurityCatalog({ find: async (object: string, opts: any) => { const where = opts?.where ?? {}; if (object === 'sys_user') return [{ id: where.id, email: `${where.id}@example.com` }]; + // The Default Organization, by its slug. + if (object === 'sys_organization') return [{ id: 'org_default', slug: 'default' }]; if (object === 'sys_user_permission_set') { - if (where.user_id === 'admin1') return [{ permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }]; - if (where.user_id === 'member1') return [{ permission_set_id: 'ps_member', permission_set: 'member_default', organization_id: null }]; + if (where.user_id === 'admin1') return [{ permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_default' }]; + if (where.user_id === 'member1') return [{ permission_set_id: 'ps_member', permission_set: 'member_default', organization_id: 'org_default' }]; return []; } if (object === 'sys_permission_set') { diff --git a/packages/runtime/src/domains/activation-gate-positions-name-authority.test.ts b/packages/runtime/src/domains/activation-gate-positions-name-authority.test.ts index 6ca5b702e41..a87e79bab2a 100644 --- a/packages/runtime/src/domains/activation-gate-positions-name-authority.test.ts +++ b/packages/runtime/src/domains/activation-gate-positions-name-authority.test.ts @@ -32,8 +32,9 @@ // automation toggle door, for a tenant org admin who already holds // `manage_metadata` — so the tier-above gate passes and THIS gate is the only // thing left. Three shapes: a D4 row spelling the built-in name with no -// capability grant (`name-only`), a genuine unscoped `admin_full_access` grant -// (`genuine`), and a plain tenant admin (`plain`, the floor). All three +// capability grant (`name-only`), a genuine `admin_full_access` grant owned by +// the Default Organization (`genuine`), and a plain tenant admin (`plain`, the +// floor). All three // contexts are produced by inserting rows and resolving them through the REAL // `resolveUserAuthzGrants`, so `positions` / `posture` come from the shipping // resolver rather than being hand-asserted. @@ -56,6 +57,8 @@ const FLOW = 'vendor_lead_router'; const DEFINITION = { name: FLOW, label: 'Vendor Lead Router', type: 'autolaunched', nodes: [], edges: [] }; const TENANT_ORG = 'org_northwind'; +/** The Default Organization: the owner of the one `admin_full_access` grant that anchors platform standing. */ +const DEFAULT_ORG = 'org_default'; const ACTOR = 'usr_tenant_admin'; const PS_ADMIN = 'ps_admin_full_access'; const PS_METADATA = 'ps_metadata_author'; @@ -116,17 +119,19 @@ function authzTables(shape: Shape) { { user_id: ACTOR, permission_set_id: PS_METADATA, permission_set: 'metadata_author', organization_id: TENANT_ORG }, ]; if (shape === 'genuine') { - userSets.push({ user_id: ACTOR, permission_set_id: PS_ADMIN, permission_set: ADMIN_FULL_ACCESS, organization_id: null }); + userSets.push({ user_id: ACTOR, permission_set_id: PS_ADMIN, permission_set: ADMIN_FULL_ACCESS, organization_id: DEFAULT_ORG }); } return { sys_user: [{ id: ACTOR, email: 'tenant-admin@example.com', email_verified: true }], sys_member: [{ organization_id: TENANT_ORG, user_id: ACTOR, role: 'owner' }], + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }, { id: TENANT_ORG, slug: 'northwind' }], sys_user_position: shape === 'name-only' ? [ // Exactly what a tenant admin can write through the - // `apiEnabled` `sys_user_position` surface. - { user_id: ACTOR, position: BUILTIN_IDENTITY_PLATFORM_ADMIN, organization_id: null }, + // `apiEnabled` `sys_user_position` surface, in their own + // organization — the one the resolution runs in. + { user_id: ACTOR, position: BUILTIN_IDENTITY_PLATFORM_ADMIN, organization_id: TENANT_ORG }, ] : [], // An ACTIVE catalogue row, so ADR-0049's deactivated-position filter is @@ -240,7 +245,7 @@ for (const posture of ['group', 'isolated'] as const) { expect(hName.toggleFlow).not.toHaveBeenCalled(); }); - it('CONTROL — a genuine unscoped admin_full_access grant still flips the switch', async () => { + it('CONTROL — a genuine admin_full_access grant owned by the Default Organization still flips the switch', async () => { const { context, rung } = await resolve('genuine'); const h = boot(posture); const ec = (context as any).executionContext; diff --git a/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts b/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts index ec6d5c731cd..9a16db15522 100644 --- a/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts +++ b/packages/runtime/src/domains/meta-read-org-scope-parity.test.ts @@ -137,8 +137,8 @@ function protocolDouble() { * `u_exmember`'s session is stamped `org_alpha` while its only current * `sys_member` row is `org_beta` — the session outlived the membership that * backed it. `u_member` is a current `org_alpha` member. Both hold ONE shared - * permission set, so RBAC cannot separate any two arms: only the organization - * claim can. + * permission set (bound to the built-in `everyone` position), so RBAC cannot + * separate any two arms: only the organization claim can. */ function makeQl() { const tables: Record = { @@ -153,13 +153,21 @@ function makeQl() { { id: 'u_exmember', email: 'u_exmember@example.com' }, { id: 'u_admin', email: 'u_admin@example.com' }, ], + // The Default Organization: it owns the operator's `admin_full_access` + // grant, the one grant that anchors platform standing. + sys_organization: [{ id: 'org_default', slug: 'default' }], sys_user_permission_set: [ - { user_id: 'u_member', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, - { user_id: 'u_exmember', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, // [ADR-0131 D12 items 7–8] The platform operator: under the wall // the one caller the `/meta` write doors admit. - { user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, + { user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_default' }, ], + // The ONE shared set reaches every caller through the built-in + // `everyone` position, so it holds in whichever organization a caller + // acts in and with none — the ex-member's dropped claim included. A + // user grant applies only in its own organization, so it could not be + // shared across the arms. + sys_position: [{ id: 'pos_everyone', name: 'everyone' }], + sys_position_permission_set: [{ id: 'pps_everyone_shared', position_id: 'pos_everyone', permission_set_id: 'ps_shared' }], sys_permission_set: [ { id: 'ps_shared', name: 'shared_access', system_permissions: ['manage_metadata', 'studio.access'] }, { id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }, diff --git a/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts b/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts index a1830e86a35..72ed3166a67 100644 --- a/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts +++ b/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts @@ -22,9 +22,11 @@ * organization's copy of `org_member` fed its bindings to anyone still an * `org_member` somewhere. * - * The rule now: with no active organization only the global grants apply, and - * a grant scoped to an organization applies only while that organization is the - * active tenant (`resolveUserAuthzGrants`, `@objectstack/core`). + * The rule now: a grant applies only while its organization is the active + * tenant, so with no active organization no stored grant applies, and a grant + * that names no organization applies nowhere (`resolveUserAuthzGrants`, + * `@objectstack/core`). Platform standing is anchored only by the + * `admin_full_access` grant the Default Organization owns. * * ## The rig * @@ -50,6 +52,8 @@ import { bindCatalogFromTables } from '../security/security-catalog.testkit.js'; const PKG = 'com.acme.crm'; const ALPHA = 'org_alpha'; const BETA = 'org_beta'; +/** The Default Organization: its `admin_full_access` grant anchors platform standing. */ +const DEFAULT_ORG = 'org_default'; const SESSION_HEADER = 'x-test-session'; /** Equality plus `$in`, and a loud refusal of every other shape. */ @@ -74,9 +78,12 @@ function matchesWhere(row: any, where: any): boolean { * `org_beta`; `u_gone` has no membership left anywhere. Both still hold the * `org_alpha`-scoped `manage_metadata` set. `u_member` is the control: a * current `org_alpha` member holding the same grant. `u_global` was removed the - * same way but holds the set GLOBALLY. `u_admin` holds `admin_full_access` - * unscoped — the row the platform-admin bootstrap mints. `u_orgless` signed in, - * never selected an organization and holds no grant at all. + * same way but holds the set with NO organization — a row the ownership + * ceremony never leaves behind, so it confers nothing. `u_admin` holds + * `admin_full_access` owned by the Default Organization — the row the + * platform-admin bootstrap mints. `u_oldadmin` holds `admin_full_access` with no + * organization, the row an earlier release's bootstrap wrote. `u_orgless` + * signed in, never selected an organization and holds no grant at all. */ const TABLES: Record = { sys_api_key: [], @@ -87,16 +94,20 @@ const TABLES: Record = { { user_id: 'u_admin', organization_id: ALPHA, role: 'owner' }, { user_id: 'u_posex', organization_id: BETA, role: 'member' }, { user_id: 'u_posmember', organization_id: ALPHA, role: 'member' }, + { user_id: 'u_oldadmin', organization_id: ALPHA, role: 'member' }, ], - sys_user: ['u_member', 'u_exmember', 'u_gone', 'u_global', 'u_admin', 'u_posex', 'u_posmember', 'u_orgless'] + sys_user: ['u_member', 'u_exmember', 'u_gone', 'u_global', 'u_admin', 'u_posex', 'u_posmember', 'u_orgless', 'u_oldadmin'] .map((id) => ({ id, email: `${id}@example.com` })), sys_user_permission_set: [ { user_id: 'u_member', permission_set_id: 'ps_meta', permission_set: 'alpha_metadata_editors', organization_id: ALPHA }, { user_id: 'u_exmember', permission_set_id: 'ps_meta', permission_set: 'alpha_metadata_editors', organization_id: ALPHA }, { user_id: 'u_gone', permission_set_id: 'ps_meta', permission_set: 'alpha_metadata_editors', organization_id: ALPHA }, { user_id: 'u_global', permission_set_id: 'ps_meta', permission_set: 'alpha_metadata_editors', organization_id: null }, - { user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, + { user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG }, + { user_id: 'u_oldadmin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, ], + // The Default Organization, which owns the platform-admin bootstrap's grant. + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }], sys_permission_set: [ { id: 'ps_meta', name: 'alpha_metadata_editors', system_permissions: ['manage_metadata', 'studio.access'] }, { id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }, @@ -115,16 +126,24 @@ const TABLES: Record = { ], }; -type Who = 'member' | 'exmember' | 'gone' | 'global' | 'admin' | 'admin_orgless' | 'posex' | 'posmember' | 'orgless'; +type Who = 'member' | 'exmember' | 'gone' | 'global' | 'global_beta' | 'admin' | 'admin_orgless' + | 'oldadmin' | 'oldadmin_orgless' | 'posex' | 'posmember' | 'orgless'; -/** Every session names `org_alpha` except `admin_orgless` and `orgless`, which name no organization. */ +/** + * Every session names `org_alpha` except `global_beta` (`org_beta`, where its + * holder IS a member), and `admin_orgless`, `oldadmin_orgless` and `orgless`, + * which name no organization. + */ const SESSIONS: Record = { member: { id: 'ses_member', token: 'tok_member', userId: 'u_member', activeOrganizationId: ALPHA }, exmember: { id: 'ses_exmember', token: 'tok_exmember', userId: 'u_exmember', activeOrganizationId: ALPHA }, gone: { id: 'ses_gone', token: 'tok_gone', userId: 'u_gone', activeOrganizationId: ALPHA }, global: { id: 'ses_global', token: 'tok_global', userId: 'u_global', activeOrganizationId: ALPHA }, + global_beta: { id: 'ses_global_beta', token: 'tok_global_beta', userId: 'u_global', activeOrganizationId: BETA }, admin: { id: 'ses_admin', token: 'tok_admin', userId: 'u_admin', activeOrganizationId: ALPHA }, admin_orgless: { id: 'ses_admin2', token: 'tok_admin2', userId: 'u_admin' }, + oldadmin: { id: 'ses_oldadmin', token: 'tok_oldadmin', userId: 'u_oldadmin', activeOrganizationId: ALPHA }, + oldadmin_orgless: { id: 'ses_oldadmin2', token: 'tok_oldadmin2', userId: 'u_oldadmin' }, posex: { id: 'ses_posex', token: 'tok_posex', userId: 'u_posex', activeOrganizationId: ALPHA }, posmember: { id: 'ses_posmember', token: 'tok_posmember', userId: 'u_posmember', activeOrganizationId: ALPHA }, orgless: { id: 'ses_orgless', token: 'tok_orgless', userId: 'u_orgless' }, @@ -261,6 +280,15 @@ async function refusedByTheWallNotTheCapabilityGate(answer: { status: number; co expect(message).not.toContain('requires the `manage_metadata` capability'); } +/** The capability gate's own refusal: it names the capability, and the walled refusal is never reached. */ +function refusedByTheCapabilityGate(answer: { status: number; code: unknown; httpStatus?: unknown; body: any }): void { + expect({ status: answer.status, code: answer.code, httpStatus: answer.httpStatus }) + .toEqual({ status: 403, code: 'PERMISSION_DENIED', httpStatus: 403 }); + const message = String(answer.body?.error?.message ?? ''); + expect(message).toContain('requires the `manage_metadata` capability'); + expect(message).not.toContain("'isolated' tenancy posture"); +} + describe('[#20515] what the rule leaves unchanged', () => { it('CONTROL · a current member with the same grant and org_alpha active passes the capability gate: DELETE /packages/:id reaches the walled refusal, and nothing is deleted', async () => { const r = rig(); @@ -286,11 +314,21 @@ describe('[#20515] what the rule leaves unchanged', () => { expect(r.deleteRequests).toEqual([]); }); - it('a GLOBAL grant still passes the capability gate for the removed member — the walled refusal answers next, below the operator rung', async () => { + // The CONTROL above holds the same set scoped to org_alpha and passes the + // capability gate, so this refusal is the organization-less row's, not the set's. + it('an ORGANIZATION-LESS grant confers nothing: the removed member holding it is refused by the capability gate on both doors', async () => { + const r = rig(); + refusedByTheCapabilityGate(await r.call('DELETE', 'global', `/packages/${PKG}`)); + expect(r.deleteRequests).toEqual([]); + refusedByTheCapabilityGate(await r.call('PATCH', 'global', `/packages/${PKG}/disable`)); + expect(switchedOff(r)).toBe(false); + }); + + it('an organization-less grant confers nothing in an organization its holder IS a member of, either: org_beta active, both doors refused by the capability gate', async () => { const r = rig(); - await refusedByTheWallNotTheCapabilityGate(await r.call('DELETE', 'global', `/packages/${PKG}`)); + refusedByTheCapabilityGate(await r.call('DELETE', 'global_beta', `/packages/${PKG}`)); expect(r.deleteRequests).toEqual([]); - await refusedByTheWallNotTheCapabilityGate(await r.call('PATCH', 'global', `/packages/${PKG}/disable`)); + refusedByTheCapabilityGate(await r.call('PATCH', 'global_beta', `/packages/${PKG}/disable`)); expect(switchedOff(r)).toBe(false); }); @@ -303,13 +341,25 @@ describe('[#20515] what the rule leaves unchanged', () => { expect(switchedOff(d)).toBe(true); }); - it('platform-admin standing (unscoped admin_full_access) passes with org_alpha active, and with no organization at all', async () => { + it('platform-admin standing (the Default Organization\'s admin_full_access) passes with org_alpha active, and with no organization at all', async () => { expect((await rig().call('DELETE', 'admin', `/packages/${PKG}`)).status).toBe(200); expect((await rig().call('PATCH', 'admin', `/packages/${PKG}/disable`)).status).toBe(200); expect((await rig().call('DELETE', 'admin_orgless', `/packages/${PKG}`)).status).toBe(200); expect((await rig().call('PATCH', 'admin_orgless', `/packages/${PKG}/disable`)).status).toBe(200); }); + // The operator test above is the positive control: the same set, owned by the + // Default Organization, passes both doors with and without an organization. + it('an ORGANIZATION-LESS admin_full_access grant anchors nothing: its holder is refused by the capability gate with org_alpha active, and with no organization at all', async () => { + for (const who of ['oldadmin', 'oldadmin_orgless'] as const) { + const r = rig(); + refusedByTheCapabilityGate(await r.call('DELETE', who, `/packages/${PKG}`)); + refusedByTheCapabilityGate(await r.call('PATCH', who, `/packages/${PKG}/disable`)); + expect(r.deleteRequests).toEqual([]); + expect(switchedOff(r)).toBe(false); + } + }); + it('a caller who never selected an organization and holds no grant is refused 403 on both doors, as before', async () => { const r = rig(); const del = await r.call('DELETE', 'orgless', `/packages/${PKG}`); diff --git a/packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts b/packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts index 49f8c0415ae..015da15ef82 100644 --- a/packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts +++ b/packages/runtime/src/domains/packages-read-delete-response-conformance.test.ts @@ -123,7 +123,8 @@ const ORG = 'org_acme'; const TABLES: Record = { sys_member: [{ user_id: 'u_admin', organization_id: ORG, role: 'member' }], sys_user: [{ id: 'u_admin', email: 'u_admin@example.com' }], - sys_user_permission_set: [{ user_id: 'u_admin', permission_set_id: 'ps_pkg', permission_set: 'pkg_admin' }], + // Owned by the organization the session names: a grant applies only there. + sys_user_permission_set: [{ user_id: 'u_admin', permission_set_id: 'ps_pkg', permission_set: 'pkg_admin', organization_id: ORG }], sys_permission_set: [ { id: 'ps_pkg', name: 'pkg_admin', system_permissions: ['manage_metadata', 'studio.access'] }, ], diff --git a/packages/runtime/src/domains/packages-single-door.test.ts b/packages/runtime/src/domains/packages-single-door.test.ts index 59e16958635..b03af40a423 100644 --- a/packages/runtime/src/domains/packages-single-door.test.ts +++ b/packages/runtime/src/domains/packages-single-door.test.ts @@ -106,10 +106,17 @@ function matchesWhere(row: any, where: any): boolean { return true; } -/** The permission store the shared authz resolver reads, in its shipped shapes. */ +/** The organization the caller acts in, and the one that owns their grant. */ +const ORG = 'org_1'; + +/** + * The permission store the shared authz resolver reads, in its shipped shapes. + * A grant applies only in its own organization, so the caller's session names + * the organization that owns it (kept under `single` without a membership row). + */ const TABLES: Record = { sys_user: [{ id: 'u_admin', email: 'u_admin@example.com' }], - sys_user_permission_set: [{ user_id: 'u_admin', permission_set_id: 'ps_pkg', permission_set: 'pkg_admin' }], + sys_user_permission_set: [{ user_id: 'u_admin', permission_set_id: 'ps_pkg', permission_set: 'pkg_admin', organization_id: ORG }], sys_permission_set: [ { id: 'ps_pkg', name: 'pkg_admin', system_permissions: ['manage_metadata', 'studio.access'] }, ], @@ -138,7 +145,7 @@ function kernelWith(registry: SchemaRegistry): any { return typeof q?.limit === 'number' ? rows.slice(0, q.limit) : rows; }, }, TABLES); - const auth = { api: { getSession: async () => ({ user: { id: 'u_admin' } }) } }; + const auth = { api: { getSession: async () => ({ user: { id: 'u_admin' }, session: { activeOrganizationId: ORG } }) } }; const services: Record = { objectql: ql, auth }; return { getState: () => 'running', diff --git a/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts b/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts index ca9170f4958..088d95dc532 100644 --- a/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts +++ b/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts @@ -41,9 +41,9 @@ * Three populations below the operator rung: a current member, a member * removed from the organization whose session still names it (the resolver * drops the claim), and a caller who never selected an organization. All three - * are refused the same way. The platform operator (an unscoped - * `admin_full_access` grant) uninstalls, with or without an active - * organization, naming none. + * are refused the same way. The platform operator (an `admin_full_access` + * grant owned by the Default Organization) uninstalls, with or without an + * active organization, naming none. */ import { mkdtempSync, rmSync } from 'node:fs'; @@ -57,6 +57,8 @@ import { bindCatalogFromTables } from '../security/security-catalog.testkit.js'; const PKG = 'com.acme.crm'; const ALPHA = 'org_alpha'; const BETA = 'org_beta'; +/** The Default Organization, which owns the operator's grant. */ +const DEFAULT_ORG = 'org_default'; const SESSION_HEADER = 'x-test-session'; // ── Identity: sessions and the permission store, in the shipped shapes ──────── @@ -96,12 +98,18 @@ const TABLES: Record = { { id: 'u_orgless', email: 'u_orgless@example.com' }, { id: 'u_admin', email: 'u_admin@example.com' }, ], + // The Default Organization: it owns the operator's `admin_full_access` + // grant, the one grant that anchors platform standing. + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }], sys_user_permission_set: [ - { user_id: 'u_member', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, - { user_id: 'u_exmember', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, - { user_id: 'u_orgless', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, - { user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, + { user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG }, ], + // The ONE shared set reaches every caller through the built-in `everyone` + // position, so it holds in whichever organization a caller acts in and with + // none. A user grant applies only in its own organization, so it could not + // be shared by a caller whose claim is dropped or who never selected one. + sys_position: [{ id: 'pos_everyone', name: 'everyone' }], + sys_position_permission_set: [{ id: 'pps_everyone_shared', position_id: 'pos_everyone', permission_set_id: 'ps_shared' }], sys_permission_set: [ { id: 'ps_shared', name: 'shared_access', system_permissions: ['manage_metadata', 'studio.access'] }, { id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }, diff --git a/packages/runtime/src/domains/packages-vetted-org-source.test.ts b/packages/runtime/src/domains/packages-vetted-org-source.test.ts index f4233ba31ad..2de803904b9 100644 --- a/packages/runtime/src/domains/packages-vetted-org-source.test.ts +++ b/packages/runtime/src/domains/packages-vetted-org-source.test.ts @@ -57,8 +57,9 @@ * [ADR-0131 D12 items 7–8] Under this rig's `isolated` posture every package * WRITE is the platform operator's, so the three members are refused at the * write doors before any protocol call, and reach no partition at all; the - * read doors serve them the env-wide partition. The operator (an unscoped - * `admin_full_access` grant, its session naming `org_alpha`) is the admitted + * read doors serve them the env-wide partition. The operator (an + * `admin_full_access` grant owned by the Default Organization, its session + * naming `org_alpha`) is the admitted * writer, and it reaches the env-wide partition too. */ @@ -75,6 +76,8 @@ const PREFIX = '/api/v1'; const PKG = 'com.acme.crm'; const ALPHA = 'org_alpha'; const BETA = 'org_beta'; +/** The Default Organization, which owns the operator's grant. */ +const DEFAULT_ORG = 'org_default'; /** The env-wide partition's key: what a request carrying no organization reaches. */ const ENV_WIDE = 'env_wide'; const SESSION_HEADER = 'x-test-session'; @@ -178,7 +181,8 @@ function protocolDouble() { /** * `u_exmember`'s stale session names `org_alpha` while their only current * `sys_member` row is `org_beta`. `u_member` is a current `org_alpha` member. - * Both hold ONE shared permission set carrying both package capabilities. + * Both hold ONE shared permission set carrying both package capabilities, + * bound to the built-in `everyone` position. */ function makeQl() { const tables: Record = { @@ -193,11 +197,19 @@ function makeQl() { { id: 'u_exmember', email: 'u_exmember@example.com' }, { id: 'u_admin', email: 'u_admin@example.com' }, ], + // The Default Organization: it owns the operator's `admin_full_access` + // grant, the one grant that anchors platform standing. + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }], sys_user_permission_set: [ - { user_id: 'u_member', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, - { user_id: 'u_exmember', permission_set_id: 'ps_shared', permission_set: 'shared_access' }, - { user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, + { user_id: 'u_admin', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG }, ], + // The ONE shared set reaches every caller through the built-in + // `everyone` position, so it holds in whichever organization a caller + // acts in and with none — the ex-member's dropped claim included. A + // user grant applies only in its own organization, so it could not be + // shared across the arms. + sys_position: [{ id: 'pos_everyone', name: 'everyone' }], + sys_position_permission_set: [{ id: 'pps_everyone_shared', position_id: 'pos_everyone', permission_set_id: 'ps_shared' }], sys_permission_set: [ { id: 'ps_shared', name: 'shared_access', system_permissions: ['manage_metadata', 'studio.access'] }, { id: 'ps_admin', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }, From 3166df2db24e3f4173b5ffde6b94ea2a30eceb8d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:39:26 +0000 Subject: [PATCH 39/44] fix(runtime): the run-lifecycle refusal names the Default Organization's admin_full_access grant as the platform-operator anchor Claude-Session: https://claude.ai/code/session_01QzqbxJk3oD6sqe47Q84ET8 Co-authored-by: Claude --- ...ult-organization-statements.golden.test.ts | 21 +++++---- ...tomation-run-lifecycle-deny-remedy.test.ts | 9 ++-- packages/runtime/src/domains/automation.ts | 2 +- .../domains/catalog-activation-door.test.ts | 11 +++-- ...ges-orgless-grants-capability-gate.test.ts | 4 ++ ...ges-uninstall-refuse-before-mutate.test.ts | 4 ++ ...ecution-context.oauth-organization.test.ts | 47 +++++++++++++------ .../resolve-execution-context.test.ts | 46 +++++++++++++----- ...runas-grant-resolution.integration.test.ts | 24 ++++++---- ...user-default-row-scope.integration.test.ts | 20 ++++---- 10 files changed, 127 insertions(+), 61 deletions(-) diff --git a/packages/runtime/src/default-organization-statements.golden.test.ts b/packages/runtime/src/default-organization-statements.golden.test.ts index 5709d7a1190..d83e2f33962 100644 --- a/packages/runtime/src/default-organization-statements.golden.test.ts +++ b/packages/runtime/src/default-organization-statements.golden.test.ts @@ -50,6 +50,8 @@ const DATA_READ = '/data/pin_note?$top=1'; /** * The organization-less administrator's warm counts on this composition, * measured on `origin/main` 5a871e4e41: `get-session` 8, the data read 10. + * An organization-less grant no longer anchors standing, so that administrator + * is gone; the counts are the bar the organization-owned one is held to. * The organization-owned administrator paid 9 / 11 there (slug `default`) and * 10 / 12 (the sole organization slugged otherwise). */ @@ -144,7 +146,7 @@ afterAll(async () => { else process.env.NODE_ENV = prevNodeEnv; }, 60_000); -describe('[#24523] statements per warm request: an organization-owned admin grant costs what an organization-less one does', () => { +describe('[#24523] statements per warm request: an organization-owned admin grant costs what an organization-less one did', () => { it('the fixture: the promoted administrator\'s grant is owned by the Default Organization, slugged `default`', async () => { const [organization] = await find('sys_organization'); expect(organization.slug).toBe('default'); @@ -169,20 +171,21 @@ describe('[#24523] statements per warm request: an organization-owned admin gran expect((await standing()).standing).toBe(true); }); - it('CONTROL — the organization-less grant: the same counts, on either slug', async () => { + it('CONTROL — the organization-less grant anchors nothing: on either slug, no standing, and no sys_organization statement is issued for it', async () => { const grant = await adminGrant(); const [organization] = await find('sys_organization'); await engine.update('sys_user_permission_set', { id: grant.id, organization_id: null }, { context: SYS }); - const sole = await warm(); + const sole = { reads: await warm(), standing: await standing() }; await engine.update('sys_organization', { id: organization.id, slug: 'default' }, { context: SYS }); - const slugged = await warm(); + const slugged = { reads: await warm(), standing: await standing() }; await engine.update('sys_user_permission_set', { id: grant.id, organization_id: organization.id }, { context: SYS }); - for (const counts of [sole, slugged]) { - expect(counts).toEqual({ - [GET_SESSION]: { count: WARM[GET_SESSION], organization: 0 }, - [DATA_READ]: { count: WARM[DATA_READ], organization: 0 }, - }); + for (const { reads, standing: s } of [sole, slugged]) { + expect(s).toEqual({ standing: false, organizationReads: 0 }); + expect(reads[GET_SESSION].organization).toBe(0); + expect(reads[DATA_READ].organization).toBe(0); } + // The positive leg: the same row, owned by the Default Organization again, anchors. + expect((await standing()).standing).toBe(true); }); }); diff --git a/packages/runtime/src/domains/automation-run-lifecycle-deny-remedy.test.ts b/packages/runtime/src/domains/automation-run-lifecycle-deny-remedy.test.ts index 28545421823..b0dbcb57122 100644 --- a/packages/runtime/src/domains/automation-run-lifecycle-deny-remedy.test.ts +++ b/packages/runtime/src/domains/automation-run-lifecycle-deny-remedy.test.ts @@ -9,7 +9,7 @@ * how that standing is obtained. Which routes to it exist is decided in ONE * place, `resolveUserAuthzGrants` in `@objectstack/core`, and it answers per * REQUESTED tenancy posture: the declared administrator list - * (`OS_PLATFORM_OWNER_EMAIL`) on every posture, the unscoped + * (`OS_PLATFORM_OWNER_EMAIL`) on every posture, the Default Organization's * `admin_full_access` grant under `single` only. The sentence used to name the * grant everywhere, so on a walled deployment it sent the operator to do a * thing that then failed silently at the permission layer. @@ -128,12 +128,15 @@ const REMEDIES: readonly Remedy[] = [ }), }, { - label: `the unscoped ${ADMIN_FULL_ACCESS} grant`, + label: `the Default Organization's ${ADMIN_FULL_ACCESS} grant`, named: (message) => message.includes(ADMIN_FULL_ACCESS), arrange: () => { const tables = userOnly('usr_granted', 'legacy@corp.example'); + // The grant that anchors platform standing is the one the Default + // Organization owns; an organization-less one anchors nothing. + tables.sys_organization = [{ id: 'org_default', slug: 'default' }]; tables.sys_user_permission_set = [ - { id: 'ups_1', user_id: 'usr_granted', permission_set_id: 'pst_1', permission_set: ADMIN_FULL_ACCESS, organization_id: null }, + { id: 'ups_1', user_id: 'usr_granted', permission_set_id: 'pst_1', permission_set: ADMIN_FULL_ACCESS, organization_id: 'org_default' }, ]; tables.sys_permission_set = [{ id: 'pst_1', name: ADMIN_FULL_ACCESS, active: true }]; return { userId: 'usr_granted', email: 'legacy@corp.example', tables, declared: undefined }; diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 84d4f8b34e4..a46ab02c13f 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -739,7 +739,7 @@ function runLifecycleStandingSentence(): string { + `that standing comes only from ${RUN_LIFECYCLE_DECLARED_ADMINISTRATORS}.`; } return `It requires platform-operator standing, which under this deployment's '${posture}' tenancy posture ` - + `comes from an unscoped \`${ADMIN_FULL_ACCESS}\` grant (ADR-0068 D2) or from ` + + `comes from the Default Organization's \`${ADMIN_FULL_ACCESS}\` grant (ADR-0068 D2, ADR-0131 D5) or from ` + `${RUN_LIFECYCLE_DECLARED_ADMINISTRATORS}.`; } diff --git a/packages/runtime/src/domains/catalog-activation-door.test.ts b/packages/runtime/src/domains/catalog-activation-door.test.ts index f04444a9eac..04bf5653bfe 100644 --- a/packages/runtime/src/domains/catalog-activation-door.test.ts +++ b/packages/runtime/src/domains/catalog-activation-door.test.ts @@ -26,6 +26,8 @@ import { bindTestSecurityCatalog, type TestSecurityCatalog } from '../security/s const LEDGER = 'sys_metadata_activation'; const USER = 'u_holder'; +/** The organization the holder's grants belong to, and the one the EFFECT is resolved in. */ +const HOLDER_ORG = 'org_holder'; const POSITION = 'sales_rep'; const SET = 'sales_access'; const DIRECT_SET = 'report_reader'; @@ -67,11 +69,11 @@ function makeEngine(opts: EngineOptions = {}) { sys_user: [{ id: USER }], sys_member: [], sys_user_position: [ - { user_id: USER, position: POSITION, organization_id: null }, - { user_id: USER, position: ENV_POSITION, organization_id: null }, + { user_id: USER, position: POSITION, organization_id: HOLDER_ORG }, + { user_id: USER, position: ENV_POSITION, organization_id: HOLDER_ORG }, ], sys_user_permission_set: [ - { user_id: USER, permission_set_id: 'ps_direct', permission_set: DIRECT_SET, organization_id: null }, + { user_id: USER, permission_set_id: 'ps_direct', permission_set: DIRECT_SET, organization_id: HOLDER_ORG }, ], [LEDGER]: [], }; @@ -185,7 +187,8 @@ const messageOf = (r: any): string => String(r?.response?.body?.error?.message ? const flip = (h: Harness, ctx: HttpProtocolContext, path: string, body: unknown = { enabled: false }) => h.dispatcher.handleSecurity(path, 'POST', body, {}, ctx); -const grantsOf = (h: Harness) => resolveUserAuthzGrants(h.engine, USER, {}); +// A grant applies only in its own organization, so the effect is read there. +const grantsOf = (h: Harness) => resolveUserAuthzGrants(h.engine, USER, { tenantId: HOLDER_ORG }); describe('the catalog activation door — authority', () => { for (const [type, name] of [['position', POSITION], ['permission', SET]] as const) { diff --git a/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts b/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts index 72ed3166a67..b34827231d0 100644 --- a/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts +++ b/packages/runtime/src/domains/packages-orgless-grants-capability-gate.test.ts @@ -154,6 +154,10 @@ function rig() { (registry as any).logLevel = 'silent'; registry.installPackage({ id: PKG, namespace: 'crm', name: 'CRM', version: '1.0.0', type: 'app', scope: 'project' } as any, 'code'); registry.registerObject({ name: 'crm_lead', fields: { title: { type: 'text' } } } as any, PKG, 'crm', 'own'); + // The organization object the platform registers: the resolver reads the + // Default Organization (the owner of the anchoring admin grant) only from a + // registry that holds it. + registry.registerObject({ name: 'sys_organization', fields: { slug: { type: 'text' } } } as any, 'com.objectstack.platform', 'sys', 'own'); const deleteRequests: any[] = []; const protocol = { diff --git a/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts b/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts index 088d95dc532..2a0a480548e 100644 --- a/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts +++ b/packages/runtime/src/domains/packages-uninstall-refuse-before-mutate.test.ts @@ -160,6 +160,10 @@ function rig(opts: { persistedHalf?: boolean } = {}): Rig { (registry as any).logLevel = 'silent'; registry.installPackage({ id: PKG, namespace: 'crm', name: 'CRM', version: '1.0.0', type: 'app', scope: 'project' } as any, 'code'); registry.registerObject({ name: 'crm_lead', fields: { title: { type: 'text' } } } as any, PKG, 'crm', 'own'); + // The organization object the platform registers: the resolver reads the + // Default Organization (the owner of the anchoring admin grant) only from a + // registry that holds it. + registry.registerObject({ name: 'sys_organization', fields: { slug: { type: 'text' } } } as any, 'com.objectstack.platform', 'sys', 'own'); const objectName = registry.getAllObjects().map((o: any) => o.name).find((n: string) => n.endsWith('crm_lead'))!; const rows = ['object:crm_lead', 'view:crm_lead_list']; diff --git a/packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts b/packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts index 3ccce48aa9d..9f77b5472a7 100644 --- a/packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.oauth-organization.test.ts @@ -23,8 +23,8 @@ * session's `activeOrganizationId`, so the session arm of * `resolveAuthzContext` vets it on EVERY request with the rule it applies to a * browser session (`vetOrganizationClaim`): kept while a current membership - * backs it under a walled posture, dropped (no organization, global grants - * only) once none does. ⛔ No union of the user's organizations, no widened + * backs it under a walled posture, dropped (no organization, so no stored + * grant applies) once none does. ⛔ No union of the user's organizations, no widened * global grant, no third branch in core. * * ## The rig @@ -51,6 +51,8 @@ const ALPHA = 'org_alpha'; const BETA = 'org_beta'; /** An organization no fixture user belongs to. */ const GAMMA = 'org_gamma'; +/** The Default Organization, which owns the platform-admin row. */ +const DEFAULT_ORG = 'org_default'; const CLIENT = 'agent-client-1'; const LEAD = 'crm_lead'; const CONTACT = 'crm_contact'; @@ -68,11 +70,14 @@ const SETS: PermissionSet[] = [ * `crm_contact_reader` in org_beta; a member of both — the "another * organization" control. * - `u_pat` is an org_alpha member holding no grant. - * - `u_admin` holds `admin_full_access` GLOBALLY (the platform-admin row). + * - `u_admin` holds `admin_full_access` owned by the Default Organization (the + * platform-admin row), which applies whatever organization is active. * - `u_ex` held `sales_rep` in org_alpha and was removed from it (no * org_alpha `sys_member` row; still a member of org_beta). They also hold - * `crm_lead_global` globally, so what is left after the claim is dropped is - * visible: a read the wall empties and a write it refuses. + * `crm_lead_global` in org_beta, so what is left after the claim is dropped + * is visible: nothing, since no organization is active. And they hold + * `crm_contact_reader` with NO organization — a row that applies nowhere, + * not even in org_beta. */ function tables(): Record { return { @@ -91,9 +96,12 @@ function tables(): Record { ], sys_user_permission_set: [ { user_id: 'u_mia', permission_set: 'crm_contact_reader', organization_id: BETA }, - { user_id: 'u_admin', permission_set: 'admin_full_access', organization_id: null }, - { user_id: 'u_ex', permission_set: 'crm_lead_global', organization_id: null }, + { user_id: 'u_admin', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG }, + { user_id: 'u_ex', permission_set: 'crm_lead_global', organization_id: BETA }, + { user_id: 'u_ex', permission_set: 'crm_contact_reader', organization_id: null }, ], + // The Default Organization: its `admin_full_access` grant anchors platform standing. + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }], sys_permission_set: SETS.map((s) => ({ id: `ps_${s.name}`, name: s.name, object_permissions: JSON.stringify(s.objects ?? {}) })), sys_position: [{ id: 'pos_sales_rep', name: 'sales_rep', organization_id: null }], sys_position_permission_set: [{ position_id: 'pos_sales_rep', permission_set_id: 'ps_crm_sales_user' }], @@ -273,26 +281,35 @@ describe('an OAuth token acts in the organization its consent bound — isolated await expect(r.run(ctx, 'find', LEAD)).rejects.toThrow(D10_DENIAL); }); - it('CONTROL — a revoked membership: the claim is dropped, so only global grants apply — the read comes back empty and the tenant write is refused loudly', async () => { + it('CONTROL — a revoked membership: the claim is dropped, so no stored grant applies — the read and the write are both refused', async () => { const ctx = await r.resolve(READ_TOKEN('u_ex', ALPHA)); expect(ctx.tenantId).toBeUndefined(); expect(droppedClaimLines()).toHaveLength(1); expect(droppedClaimLines()[0]).toContain(`principal=u_ex organization=${ALPHA}`); - // The global grant admits the read; the wall has no organization to read in. - const read = await r.run(ctx, 'find', LEAD); - expect(conjuncts(read.ast.where)).toEqual([RLS_DENY_FILTER, RLS_DENY_FILTER]); - await expect(r.run(ctx, 'insert', LEAD)).rejects.toThrow(/has no active organization/); + // The org_beta grant counts only with org_beta active, and none is. + await expect(r.run(ctx, 'find', LEAD)).rejects.toThrow(D10_DENIAL); + await expect(r.run(ctx, 'insert', LEAD)).rejects.toThrow(D10_DENIAL); + }); + + it('CONTROL — the same former member bound to org_beta: the org_beta grant admits the read; the ORGANIZATION-LESS grant admits nothing', async () => { + const ctx = await r.resolve(READ_TOKEN('u_ex', BETA)); + expect(ctx.tenantId).toBe(BETA); + expect(droppedClaimLines()).toEqual([]); + // The positive leg: the organization-scoped grant counts in its organization. + expect(conjuncts((await r.run(ctx, 'find', LEAD)).ast.where)).toEqual([{ organization_id: BETA }, { organization_id: BETA }]); + // An organization-less row applies nowhere, even where its holder is a member. + await expect(r.run(ctx, 'find', CONTACT)).rejects.toThrow(D10_DENIAL); }); - it('CONTROL — a platform admin\'s global grant is unchanged: it admits the delegated read bound or unbound; only the organization the wall reads in moves', async () => { + it('CONTROL — a platform admin\'s Default Organization grant is unchanged: it admits the delegated read bound or unbound; only the organization the wall reads in moves', async () => { const bound = await r.resolve(READ_TOKEN('u_admin', ALPHA)); expect(bound.tenantId).toBe(ALPHA); expect(bound.posture).toBe('PLATFORM_ADMIN'); expect(conjuncts((await r.run(bound, 'find', LEAD)).ast.where)).toEqual([{ organization_id: ALPHA }, { organization_id: ALPHA }]); // Unbound is what every token resolved before this change: still admitted - // by the global grant, and — a business object under `isolated` — walled + // by the anchoring grant, and — a business object under `isolated` — walled // to nothing with no organization to read in (ADR-0095 W2), as before. const unbound = await r.resolve(READ_TOKEN('u_admin')); expect(unbound.tenantId).toBeUndefined(); @@ -300,7 +317,7 @@ describe('an OAuth token acts in the organization its consent bound — isolated expect(conjuncts((await r.run(unbound, 'find', LEAD)).ast.where)).toEqual([RLS_DENY_FILTER, RLS_DENY_FILTER]); }); - it('CONTROL — a token minted before consents bound an organization (no claim) keeps today\'s behaviour: global grants only', async () => { + it('CONTROL — a token minted before consents bound an organization (no claim) acts in no organization: no stored grant applies', async () => { const ctx = await r.resolve(READ_TOKEN('u_mia')); expect(ctx.tenantId).toBeUndefined(); await expect(r.run(ctx, 'find', LEAD)).rejects.toThrow(D10_DENIAL); diff --git a/packages/runtime/src/security/resolve-execution-context.test.ts b/packages/runtime/src/security/resolve-execution-context.test.ts index a5d64d8aa4c..ec2e8ce80d0 100644 --- a/packages/runtime/src/security/resolve-execution-context.test.ts +++ b/packages/runtime/src/security/resolve-execution-context.test.ts @@ -304,15 +304,19 @@ describe('resolveExecutionContext — localization (timezone + locale)', () => { // --------------------------------------------------------------------------- -// ADR-0066 — platform-scoped (null-org) permission-set grants are GLOBAL and -// must resolve even when the caller has an active org. Regression for the gap -// where `organization_id = tenantId` dropped a platform admin's null-org -// admin_full_access grant (and its systemPermissions) the moment they owned an -// org — which then locked them out of a requiredPermissions-gated object. +// ADR-0066 — the platform-scoped permission-set grant must resolve even when the +// caller has an active org. Regression for the gap where `organization_id = +// tenantId` dropped a platform admin's admin_full_access grant (and its +// systemPermissions) the moment they owned an org — which then locked them out +// of a requiredPermissions-gated object. The platform-scoped grant is the one +// the Default Organization owns: it anchors platform standing whatever the +// active org. An organization-less grant is not platform-scoped — it applies +// nowhere and anchors nothing. // --------------------------------------------------------------------------- -describe('resolveExecutionContext — platform-scoped (null-org) grants (ADR-0066)', () => { +describe('resolveExecutionContext — the platform-scoped (Default Organization) grant (ADR-0066)', () => { const RAW = 'osk_admin'; - function makeAuthQl(extraGrants = []) { + /** @param adminGrantOrg the organization that owns u1's admin_full_access grant. */ + function makeAuthQl(extraGrants = [], adminGrantOrg: string | null = 'org_default') { const tables = { // [#8287] Re-spelled from `organization_id`: these fixtures use the key as a // VEHICLE to authenticate u1 into orgA, and `active_organization_id` is the @@ -321,8 +325,9 @@ describe('resolveExecutionContext — platform-scoped (null-org) grants (ADR-006 // out), so this keeps them meaningful rather than merely green. sys_api_key: [{ id: 'k1', key: hashApiKey(RAW), revoked: false, user_id: 'u1', active_organization_id: 'orgA', expires_at: FUTURE }], sys_member: [{ user_id: 'u1', organization_id: 'orgA', role: 'owner' }], + sys_organization: [{ id: 'org_default', slug: 'default' }, { id: 'orgA', slug: 'org-a' }], sys_user_permission_set: [ - { id: 'ups_global', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }, + { id: 'ups_global', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: adminGrantOrg }, ...extraGrants, ], sys_permission_set: [ @@ -352,20 +357,32 @@ describe('resolveExecutionContext — platform-scoped (null-org) grants (ADR-006 } const opts = (ql) => ({ getService: async () => undefined, getQl: async () => ql, request: { headers: { 'x-api-key': RAW } } }); - it('resolves a null-org admin grant + its systemPermissions even with an active org', async () => { + it('resolves the Default Organization\'s admin grant + its systemPermissions even with another org active', async () => { const ctx = await resolveExecutionContext(opts(makeAuthQl())); expect(ctx.tenantId).toBe('orgA'); expect(ctx.permissions).toContain('admin_full_access'); expect(ctx.systemPermissions).toContain('manage_platform_settings'); // [#2947] the derived posture rung is now CARRIED on the ctx (previously - // dropped at this entry). An unscoped admin_full_access grant → PLATFORM_ADMIN. + // dropped at this entry). The Default Organization's admin_full_access + // grant → PLATFORM_ADMIN. expect(ctx.posture).toBe('PLATFORM_ADMIN'); }); + // The test above is the CONTROL: the same grant, owned by the Default + // Organization, resolves in full with orgA active. + it('an ORGANIZATION-LESS admin grant resolves nothing: no set, no capability, no platform standing', async () => { + const ctx = await resolveExecutionContext(opts(makeAuthQl([], null))); + expect(ctx.tenantId).toBe('orgA'); + expect(ctx.permissions).not.toContain('admin_full_access'); + expect(ctx.systemPermissions).not.toContain('manage_platform_settings'); + expect(ctx.systemPermissions).not.toContain('manage_users'); + expect(ctx.posture).not.toBe('PLATFORM_ADMIN'); + }); + it('still drops a grant scoped to a DIFFERENT org', async () => { const ql = makeAuthQl([{ id: 'ups_otherorg', user_id: 'u1', permission_set_id: 'ps_other', permission_set: 'other_org_set', organization_id: 'orgB' }]); const ctx = await resolveExecutionContext(opts(ql)); - expect(ctx.permissions).toContain('admin_full_access'); // global grant kept + expect(ctx.permissions).toContain('admin_full_access'); // the Default Organization's grant kept expect(ctx.permissions).not.toContain('other_org_set'); // foreign-org grant dropped expect(ctx.systemPermissions).not.toContain('should_not_appear'); }); @@ -501,11 +518,14 @@ describe('resolveExecutionContext — ADR-0090 D10 agent principal (OAuth on /mc // ── [ADR-0090 D10 #2] action-capability delegation ─────────────────────── // A ql that resolves user `u1` to a set carrying system capabilities, so we - // can see whether the agent inherits the user's ACTION authority. + // can see whether the agent inherits the user's ACTION authority. The grant is + // the Default Organization's, so it applies whatever organization (or none) + // the agent resolves into. const capQl = () => { const tables: Record = { sys_member: [{ user_id: 'u1', organization_id: 'orgA', role: 'owner' }], - sys_user_permission_set: [{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: null }], + sys_organization: [{ id: 'org_default', slug: 'default' }], + sys_user_permission_set: [{ id: 'ups1', user_id: 'u1', permission_set_id: 'ps_admin', permission_set: 'admin_full_access', organization_id: 'org_default' }], sys_permission_set: [{ id: 'ps_admin', name: 'admin_full_access', system_permissions: '["manage_users","manage_platform_settings"]', object_permissions: '{}' }], sys_position: [], sys_position_permission_set: [], sys_user_position: [], }; diff --git a/packages/services/service-automation/src/runas-grant-resolution.integration.test.ts b/packages/services/service-automation/src/runas-grant-resolution.integration.test.ts index 6104851a557..72470785420 100644 --- a/packages/services/service-automation/src/runas-grant-resolution.integration.test.ts +++ b/packages/services/service-automation/src/runas-grant-resolution.integration.test.ts @@ -62,12 +62,19 @@ function updateFlow(name: string, runAs: 'system' | 'user') { }; } -/** Authz tables granting user 'u1' the unscoped `ehr_all` set + the `approver` position. */ +/** + * The organization the run acts in, and the one u1's grants are owned by: a + * grant row applies only in its own organization (an organization-less row + * confers nothing), so the run names it. + */ +const ORG = 'org_1'; + +/** Authz tables granting user 'u1' the `ehr_all` set + the `approver` position, both in {@link ORG}. */ const AUTHZ_TABLES: Record = { sys_user: [{ id: 'u1', email: 'u1@x.com' }], - sys_member: [], - sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: null }], - sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psE', permission_set: 'ehr_all', organization_id: null }], + sys_member: [{ user_id: 'u1', organization_id: ORG, role: 'member' }], + sys_user_position: [{ user_id: 'u1', position: 'approver', organization_id: ORG }], + sys_user_permission_set: [{ user_id: 'u1', permission_set_id: 'psE', permission_set: 'ehr_all', organization_id: ORG }], sys_permission_set: [{ id: 'psE', name: 'ehr_all', system_permissions: ['cap_ehr'] }], sys_position: [], sys_position_permission_set: [], @@ -96,8 +103,9 @@ describe("AutomationServicePlugin bridges the runAs:'user' grant resolver (#3356 const automation = kernel.getService('automation'); automation.registerFlow('usr', updateFlow('usr', 'user') as never); - // The record-change hook shape: ONLY a userId — no positions/permissions. - const res = await automation.execute('usr', { userId: 'u1', params: { noteId: 'n1' } }); + // The record-change hook shape: ONLY a userId (and the organization the + // write ran in) — no positions/permissions. + const res = await automation.execute('usr', { userId: 'u1', tenantId: ORG, params: { noteId: 'n1' } } as never); expect(res.success, `run failed: ${JSON.stringify(res)}`).toBe(true); const update = crud.find((c) => c.op === 'update' && c.obj === 'runas_thing'); @@ -111,8 +119,8 @@ describe("AutomationServicePlugin bridges the runAs:'user' grant resolver (#3356 update!.ctx.permissions, "the triggering user's permission set was not propagated — runAs:'user' ran with the bare member fallback (#3356)", ).toContain('ehr_all'); - // The peer list rides the same bridge. No organization here, so it is the - // user alone — what an organization-less request resolves to as well. + // The peer list rides the same bridge: the members of the organization the + // run acts in, u1 alone here. expect(update!.ctx.org_user_ids, 'the bridge dropped the resolver\'s peer list').toEqual(['u1']); await kernel.shutdown(); diff --git a/packages/services/service-automation/src/sys-user-default-row-scope.integration.test.ts b/packages/services/service-automation/src/sys-user-default-row-scope.integration.test.ts index 9b54bb93f69..d839953aa83 100644 --- a/packages/services/service-automation/src/sys-user-default-row-scope.integration.test.ts +++ b/packages/services/service-automation/src/sys-user-default-row-scope.integration.test.ts @@ -20,8 +20,11 @@ * The default applies only where the caller's sets carry no row policy for * `sys_user`. Every human holds the platform baseline (`member_default`), * which carries both literals, so this deployment composes no baseline and - * grants one authored `'*'` read set with no row policy of its own — the - * ruling's no-baseline principal. Explain names the object default, so the + * binds one authored `'*'` read set with no row policy of its own to the + * `everyone` anchor — the ruling's no-baseline principal. The anchor is the + * one channel that holds in every resolution, an organisation or none: a + * stored grant row applies only in its own organisation, and an + * organisation-less one applies nowhere. Explain names the object default, so the * rows read are the declaration's, not a set's. * * The REAL stack, as its two twins beside it compose it: ObjectKernel, @@ -148,12 +151,13 @@ describe("sys_user's one `||` default keeps the reader's own row on every user-c for (const [id, userId, organizationId] of memberships) { await ql.insert('sys_member', { id, user_id: userId, organization_id: organizationId, role: 'member' }, { context: SYS }); } - // The one grant: a global (organisation-less) assignment of the `'*'` reader, - // written straight into the table — a system write here must name one of - // the two organisations, and a global grant names none. - await (driver as unknown as { knex: (t: string) => { insert(r: object): Promise } }) - .knex('sys_user_permission_set') - .insert({ id: 'ups_me', user_id: 'usr_me', permission_set: STAR_READER.name, organization_id: null }); + // The one grant: the `'*'` reader bound to the `everyone` anchor, declared + // in the registry's bare slot (where an environment definition answers + // first). A stored `sys_user_permission_set` row would apply only in its own + // organisation, so it could not reach the "acting in no organisation" arms + // below; the anchor reaches every authenticated resolution. + (ql as unknown as { registry: { registerItem(type: string, item: Record, key: string): void } }) + .registry.registerItem('position', { name: 'everyone', label: 'Everyone', permissionSets: [STAR_READER.name] }, 'name'); hookSeen = []; readContexts = []; From f96aa7dae532c3c9672459786d572756135ec7f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:59:59 +0000 Subject: [PATCH 40/44] wip(test): re-stamp the dogfood and remaining downstream grant fixtures Claude-Session: https://claude.ai/code/session_01QzqbxJk3oD6sqe47Q84ET8 Co-authored-by: Claude --- .../cloud-connection-auth-gate.pin.test.ts | 9 ++- .../cloud-connection-setup-access.pin.test.ts | 18 +++-- .../src/install-local-principal.fixtures.ts | 74 ++++++++++++++--- ...-local-api-key-account-posture.pin.test.ts | 9 ++- ...stall-local-capability-enumeration.test.ts | 23 +++++- ...tplace-install-local-install-modes.test.ts | 7 +- ...etplace-install-local-list-posture.test.ts | 22 +++++- ...e-install-local-listing-not-loaded.test.ts | 15 +++- ...etplace-install-local-posture-gate.test.ts | 7 +- .../marketplace-install-local-purge.test.ts | 9 ++- ...tplace-install-local-seed-replayer.test.ts | 8 +- ...ce-install-local-tenancy-admission.test.ts | 16 ++-- ...lace-install-local-tenancy-posture.test.ts | 7 +- ...audit-log-audit-capability.dogfood.test.ts | 5 +- ...-write-refusal-not-visible.dogfood.test.ts | 32 +++++--- ...ite-unreadable-not-matched.dogfood.test.ts | 32 +++++--- ...aring-rule-org-less-caller.dogfood.test.ts | 79 +++++++++++-------- ...or-unreadable-is-not-found.dogfood.test.ts | 32 +++++--- ...routes-api-key-account-posture.pin.test.ts | 8 +- .../admin-routes-auth-gate.pin.test.ts | 3 +- ...n-routes-tenancy-posture-admission.test.ts | 62 +++++++++++---- .../src/__tests__/entitled-caller.fixture.ts | 34 ++++++-- ...settings-admission-tenancy-posture.test.ts | 14 ++-- 23 files changed, 389 insertions(+), 136 deletions(-) diff --git a/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts b/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts index 2f824bc650a..a823e29401c 100644 --- a/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts +++ b/packages/cloud-connection/src/cloud-connection-auth-gate.pin.test.ts @@ -267,9 +267,14 @@ const APP = { }; const LEDGER_FILE = 'com.acme.gated.json'; -/** Every test user holds the authoring capability, so a refusal below is the gate's alone. */ +/** + * Every test user holds the authoring capability, so a refusal below is the gate's alone: + * the `admin_full_access` grant owned by the Default Organization, which applies with no + * active organization. + */ function grantRows(): Record { return { + sys_organization: [{ id: 'org_default', slug: 'default' }], sys_user: Object.keys(POSTURES).map((id) => ({ id, email: `${id}@acme.test` })), sys_member: [], sys_user_position: [], @@ -277,7 +282,7 @@ function grantRows(): Record { sys_metadata_activation: [], sys_position_permission_set: [], sys_user_permission_set: Object.keys(POSTURES).map((id, i) => ({ - id: `ups${i}`, user_id: id, permission_set_id: 'ps1', permission_set: 'admin_full_access', organization_id: null, + id: `ups${i}`, user_id: id, permission_set_id: 'ps1', permission_set: 'admin_full_access', organization_id: 'org_default', })), sys_permission_set: [{ id: 'ps1', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }], }; diff --git a/packages/cloud-connection/src/cloud-connection-setup-access.pin.test.ts b/packages/cloud-connection/src/cloud-connection-setup-access.pin.test.ts index f4f7a015b17..4095b296df8 100644 --- a/packages/cloud-connection/src/cloud-connection-setup-access.pin.test.ts +++ b/packages/cloud-connection/src/cloud-connection-setup-access.pin.test.ts @@ -38,20 +38,22 @@ import { bindCatalogFromTables } from './__tests__/security-catalog.testkit.js'; const CC = '/api/v1/cloud-connection'; const ORIGIN = 'https://env.example.test'; -/** Holds `setup.access` through an unscoped grant (the platform-admin shape). */ +/** Holds `setup.access` through the Default Organization's `admin_full_access` grant (the platform-admin shape: it applies with no active organization). */ const ADMIN = 'u_setup_admin'; /** Holds `setup.access` through a grant scoped to the organization its session is active in (the organization-admin shape). */ const ORG_ADMIN = 'u_org_admin'; -/** Signed in, holds a permission set, and the set carries no `setup.access`. */ +/** Signed in, holds a permission set in the organization its session is active in, and the set carries no `setup.access`. */ const MEMBER = 'u_member'; -/** Holds `setup.access`, but the ADR-0069 policy holds the session. */ +/** Holds `setup.access` (the platform-admin shape, as {@link ADMIN}), but the ADR-0069 policy holds the session. */ const HELD_ADMIN = 'u_held_admin'; const ORG = 'org_a'; +/** The Default Organization — the owner of the one `admin_full_access` grant that anchors platform standing. */ +const DEFAULT_ORG = 'org_default'; const SESSIONS: Record = { [ADMIN]: {}, [ORG_ADMIN]: { activeOrganizationId: ORG }, - [MEMBER]: {}, + [MEMBER]: { activeOrganizationId: ORG }, [HELD_ADMIN]: { authGate: { code: 'PASSWORD_EXPIRED', message: 'You must change your password before continuing.' } }, }; @@ -61,18 +63,20 @@ const ADMIN_KEY = 'osk_setup_admin_key_24148'; function grantRows(): Record { return { sys_user: Object.keys(SESSIONS).map((id) => ({ id, email: `${id}@example.test` })), + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }, { id: ORG, slug: 'org-a' }], sys_member: [{ id: 'mem_1', user_id: ORG_ADMIN, organization_id: ORG, role: 'admin' }], sys_user_position: [], sys_position: [], sys_metadata_activation: [], sys_position_permission_set: [], sys_user_permission_set: [ - { id: 'ups_admin', user_id: ADMIN, permission_set_id: 'ps_setup', permission_set: 'setup_admin', organization_id: null }, + { id: 'ups_admin', user_id: ADMIN, permission_set_id: 'ps_full', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG }, { id: 'ups_org_admin', user_id: ORG_ADMIN, permission_set_id: 'ps_setup', permission_set: 'setup_admin', organization_id: ORG }, - { id: 'ups_held', user_id: HELD_ADMIN, permission_set_id: 'ps_setup', permission_set: 'setup_admin', organization_id: null }, - { id: 'ups_member', user_id: MEMBER, permission_set_id: 'ps_member', permission_set: 'member_basic', organization_id: null }, + { id: 'ups_held', user_id: HELD_ADMIN, permission_set_id: 'ps_full', permission_set: 'admin_full_access', organization_id: DEFAULT_ORG }, + { id: 'ups_member', user_id: MEMBER, permission_set_id: 'ps_member', permission_set: 'member_basic', organization_id: ORG }, ], sys_permission_set: [ + { id: 'ps_full', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }, { id: 'ps_setup', name: 'setup_admin', system_permissions: ['setup.access'] }, { id: 'ps_member', name: 'member_basic', system_permissions: ['export_data'] }, ], diff --git a/packages/cloud-connection/src/install-local-principal.fixtures.ts b/packages/cloud-connection/src/install-local-principal.fixtures.ts index b0fca0c4bcd..e3a51a4c5d8 100644 --- a/packages/cloud-connection/src/install-local-principal.fixtures.ts +++ b/packages/cloud-connection/src/install-local-principal.fixtures.ts @@ -70,7 +70,9 @@ export const INSTALLER_ORGANIZATION = { id: 'org_default', slug: 'default' }; */ export function installerGrantRows(userId: string = INSTALLER_USER_ID): Record { return { - sys_user: [{ id: userId, email: `${userId}@objectstack.test` }], + // Verified, so a suite that declares the installer the platform owner + // ({@link declareInstallerPlatformOwner}) is read as one. + sys_user: [{ id: userId, email: installerEmail(userId), email_verified: true }], sys_member: [], sys_user_position: [], sys_position: [], @@ -91,20 +93,63 @@ export function installerGrantRows(userId: string = INSTALLER_USER_ID): Record } | undefined)?.where; if (!where || Object.keys(where).length !== 1 || where.slug !== 'default') return undefined; const rows = innerRows && (innerRows as { value?: unknown }).value !== undefined ? (innerRows as { value: unknown }).value : innerRows; - if (Array.isArray(rows) && rows.length > 0) return undefined; + if (Array.isArray(rows) && rows.some((row) => (row as { slug?: unknown } | null)?.slug === 'default')) return undefined; return [{ ...INSTALLER_ORGANIZATION }]; } +/** + * A suite's real registry, except that it holds `sys_organization`: the + * resolver reads no Default Organization out of a composition whose registry + * lacks the object, and every deployment the installer exists in registers it. + * Every other member answers from the suite's registry, bound to it. + */ +function registryHoldingOrganizations(registry: any): any { + if (!registry || typeof registry.getObject !== 'function') return registry; + return new Proxy(registry, { + get(target, prop) { + if (prop === 'getObject') { + return (name: string, ...rest: unknown[]) => + target.getObject(name, ...rest) ?? (name === 'sys_organization' ? { name: 'sys_organization', fields: {} } : undefined); + } + const value = Reflect.get(target, prop, target); + return typeof value === 'function' ? value.bind(target) : value; + }, + }); +} + +/** The installer's stored address. */ +export function installerEmail(userId: string = INSTALLER_USER_ID): string { + return `${userId}@objectstack.test`; +} + +/** + * [#11663 L5] Declare the installer the deployment's platform owner + * (`OS_PLATFORM_OWNER_EMAIL`) — the one platform administrator a WALLED posture + * (`group` / `isolated`) has: there no stored grant anchors standing, and with + * no active organization no grant applies, so a walled suite whose request + * carries no organization installs as the declared owner, exactly as a walled + * deployment's operator does. Returns the restore; call it in `afterEach`. + */ +export function declareInstallerPlatformOwner(userId: string = INSTALLER_USER_ID): () => void { + const previous = process.env.OS_PLATFORM_OWNER_EMAIL; + process.env.OS_PLATFORM_OWNER_EMAIL = installerEmail(userId); + return () => { + if (previous === undefined) delete process.env.OS_PLATFORM_OWNER_EMAIL; + else process.env.OS_PLATFORM_OWNER_EMAIL = previous; + }; +} + /** The `auth` service shape the plugin resolves a session through. */ export function installerAuthService(userId: string = INSTALLER_USER_ID) { return { api: { getSession: async () => ({ user: { id: userId }, session: {} }) } }; @@ -114,8 +159,9 @@ export function installerAuthService(userId: string = INSTALLER_USER_ID) { * Wrap an existing `objectql` fake so the authorization tables answer from * {@link installerGrantRows}, the resolver's Default Organization read answers * {@link INSTALLER_ORGANIZATION} when the suite names none - * ({@link answerDefaultOrganizationRead}), and EVERY other object falls through - * to whatever the suite already wired. + * ({@link answerDefaultOrganizationRead}; a suite registry is made to hold + * `sys_organization` so the read is issued), and EVERY other object falls + * through to whatever the suite already wired. * * Wrapping rather than replacing is the point: these suites' engines carry * behaviour their own assertions depend on (seed lookups, ledger probes, @@ -131,9 +177,19 @@ export function withInstallerGrants>( const inner = typeof engine?.find === 'function' ? engine.find.bind(engine) : undefined; const wrapped = { ...engine, + ...(engine?.registry ? { registry: registryHoldingOrganizations(engine.registry) } : {}), find: async (object: string, options?: unknown) => { if (Object.prototype.hasOwnProperty.call(rows, object)) return rows[object]; - const answered = inner ? await inner(object, options) : []; + let answered: unknown; + try { + answered = inner ? await inner(object, options) : []; + } catch (err) { + // An engine that registers no organization object refuses the read; + // the installer's Default Organization is still the one it names. + const supplied = answerDefaultOrganizationRead(object, options, []); + if (supplied) return supplied; + throw err; + } return answerDefaultOrganizationRead(object, options, answered) ?? answered; }, } as T; diff --git a/packages/cloud-connection/src/marketplace-install-local-api-key-account-posture.pin.test.ts b/packages/cloud-connection/src/marketplace-install-local-api-key-account-posture.pin.test.ts index b9cf74e512a..72dd977ab75 100644 --- a/packages/cloud-connection/src/marketplace-install-local-api-key-account-posture.pin.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-api-key-account-posture.pin.test.ts @@ -94,9 +94,14 @@ function matchesWhere(row: Record, where: unknown): boolean { return true; } -/** The key's owner holds the authoring capability, so a refusal below is the gate's alone. */ +/** + * The key's owner holds the authoring capability, so a refusal below is the gate's alone: + * the `admin_full_access` grant owned by the Default Organization, which applies with no + * active organization (a key carries none here). + */ function grantRows(): Record>> { return { + sys_organization: [{ id: 'org_default', slug: 'default' }], sys_api_key: [{ id: 'key_1', key: hashApiKey(KEY), user_id: OWNER, revoked: false, scopes: '[]' }], sys_user: [{ id: OWNER, email: 'owner@acme.test' }], sys_member: [], @@ -105,7 +110,7 @@ function grantRows(): Record>> { sys_metadata_activation: [], sys_position_permission_set: [], sys_user_permission_set: [ - { id: 'ups1', user_id: OWNER, permission_set_id: 'ps1', permission_set: 'admin_full_access', organization_id: null }, + { id: 'ups1', user_id: OWNER, permission_set_id: 'ps1', permission_set: 'admin_full_access', organization_id: 'org_default' }, ], sys_permission_set: [{ id: 'ps1', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access', 'setup.access'] }], }; diff --git a/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts b/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts index 3b5acae1079..27e82e2da0b 100644 --- a/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-capability-enumeration.test.ts @@ -108,6 +108,11 @@ function makeRawApp() { }; } +/** The Default Organization — the owner of the one `admin_full_access` grant that anchors platform standing. */ +const DEFAULT_ORG = 'org_default'; +/** The tenant organization the org-admin shape administers, and its session is active in. */ +const TENANT_ORG = 'org_acme'; + /** The three principal shapes the ruling names, as the resolver sees them. */ type Shape = 'header-only' | 'member' | 'org-admin' | 'capable'; @@ -117,6 +122,12 @@ type Shape = 'header-only' | 'member' | 'org-admin' | 'capable'; * + `sys_permission_set` rows rather than handing the plugin a pre-baked * capability list is what keeps this test honest about the resolution path: a * gate wired to a different aggregate would not see these. + * + * Every grant is owned by an organization: the capable shape's by the Default + * Organization (the platform-admin shape, which applies with no active + * organization), the org-admin shape's by {@link TENANT_ORG}, the organization + * its session is active in — so its capabilities are really held, and its + * refusal is the missing `manage_metadata`, not a grant that applies nowhere. */ function grantRows(shape: Shape): Record { const sets: Record = { @@ -129,6 +140,7 @@ function grantRows(shape: Shape): Record { }; const held = sets[shape]; return { + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }, { id: TENANT_ORG, slug: 'acme' }], sys_user: [{ id: `usr_${shape}`, email: `${shape}@acme.test` }], sys_member: [], sys_user_position: [], @@ -138,7 +150,8 @@ function grantRows(shape: Shape): Record { sys_user_permission_set: held.length ? [{ id: 'ups1', user_id: `usr_${shape}`, permission_set_id: 'ps1', - permission_set: shape === 'capable' ? 'admin_full_access' : 'organization_admin', organization_id: null, + permission_set: shape === 'capable' ? 'admin_full_access' : 'organization_admin', + organization_id: shape === 'capable' ? DEFAULT_ORG : TENANT_ORG, }] : [], sys_permission_set: held.length @@ -192,7 +205,13 @@ async function mount(shape: Shape, storageDir: string) { const services: Record = { manifest: { register }, - auth: { api: { getSession: async () => (sessionUser ? { user: sessionUser, session: {} } : null) } }, + auth: { + api: { + getSession: async () => (sessionUser + ? { user: sessionUser, session: shape === 'org-admin' ? { activeOrganizationId: TENANT_ORG } : {} } + : null), + }, + }, objectql: bindCatalogFromTables({ syncSchemas, find: async (object: string) => (object === 'widget' ? widgets.map((w) => ({ ...w })) : rows[object] ?? []), diff --git a/packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts b/packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts index 5b1c617a079..4ff094baf78 100644 --- a/packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts @@ -34,7 +34,7 @@ import { tmpdir } from 'node:os'; // top, never inside a clocked `it` (`scripts/check-test-source-alias.mjs`). import '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; -import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { declareInstallerPlatformOwner, installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; import { LocalManifestSource } from './local-manifest-source.js'; const ROUTE = '/api/v1/marketplace/install-local'; @@ -251,6 +251,11 @@ describe('install-local — the package must permit the mode (`installModes`)', }); describe('install-local — `unmanaged` under a walled REQUESTED posture', () => { + // The request carries no organization, so under a wall the installer is + // the deployment's declared platform owner. + let restoreOwner: () => void; + beforeEach(() => { restoreOwner = declareInstallerPlatformOwner(INSTALLER); }); + afterEach(() => restoreOwner()); // An UNRECOGNISED posture value is read as walled by the same function // (pinned in `package-unmanaged-install.test.ts`); at this door the // install-capability admission refuses such a boot first, so it is not a diff --git a/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts b/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts index 346bbb9fb50..7c2eecf47ba 100644 --- a/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-list-posture.test.ts @@ -85,6 +85,11 @@ function makeRawApp() { }; } +/** The Default Organization — the owner of the one `admin_full_access` grant that anchors platform standing. */ +const DEFAULT_ORG = 'org_default'; +/** The tenant organization the member shape administers, and its session is active in. */ +const TENANT_ORG = 'org_acme'; + /** The three caller shapes the ruling names, as the shared resolver sees them. */ type Shape = 'anonymous' | 'member' | 'operator'; @@ -96,6 +101,11 @@ type Shape = 'anonymous' | 'member' | 'operator'; * through the same `find` production calls is what keeps this file honest about * the resolution path: a gate rewired to some other aggregate would not see * these and would fail here rather than silently keep passing. + * + * Every grant is owned by an organization: the operator's by the Default + * Organization (the platform-admin shape, which applies with no active + * organization), the member's by {@link TENANT_ORG}, the organization its + * session is active in — so the narrowed row really holds its capabilities. */ function grantRows(shape: Shape): Record { // `organization_admin` deliberately withholds `manage_metadata` — the @@ -106,6 +116,7 @@ function grantRows(shape: Shape): Record { ? ['setup.access', 'manage_org_users'] : []; return { + sys_organization: [{ id: DEFAULT_ORG, slug: 'default' }, { id: TENANT_ORG, slug: 'acme' }], sys_user: [{ id: `usr_${shape}`, email: `${shape}@acme.test` }], sys_member: [], sys_user_position: [], @@ -115,7 +126,8 @@ function grantRows(shape: Shape): Record { sys_user_permission_set: held.length ? [{ id: 'ups1', user_id: `usr_${shape}`, permission_set_id: 'ps1', - permission_set: shape === 'operator' ? 'admin_full_access' : 'organization_admin', organization_id: null, + permission_set: shape === 'operator' ? 'admin_full_access' : 'organization_admin', + organization_id: shape === 'operator' ? DEFAULT_ORG : TENANT_ORG, }] : [], sys_permission_set: held.length @@ -164,7 +176,13 @@ async function mount(shape: Shape, storageDir: string) { const services: Record = { manifest: { register: vi.fn() }, - auth: { api: { getSession: async () => (sessionUser ? { user: sessionUser, session: {} } : null) } }, + auth: { + api: { + getSession: async () => (sessionUser + ? { user: sessionUser, session: shape === 'member' ? { activeOrganizationId: TENANT_ORG } : {} } + : null), + }, + }, objectql: bindCatalogFromTables({ syncSchemas: vi.fn(async () => undefined), find: async (object: string) => rows[object] ?? [] }, rows), metadata: {}, }; diff --git a/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts b/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts index 69aef94f2c5..3172e2efe87 100644 --- a/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-listing-not-loaded.test.ts @@ -43,7 +43,7 @@ import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; import '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; import { LocalManifestSource, type InstalledManifestEntry } from './local-manifest-source.js'; -import { installerGrantRows, INSTALLER_USER_ID, INSTALLER_SET } from './install-local-principal.fixtures.js'; +import { answerDefaultOrganizationRead, installerGrantRows, INSTALLER_USER_ID, INSTALLER_SET } from './install-local-principal.fixtures.js'; import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; type Handler = (c: any) => Promise; @@ -82,8 +82,12 @@ function ledgerEntry(manifest: { id: string; version: string }): InstalledManife }; } -/** A tenant administrator that does NOT hold `manage_metadata`: the narrowed caller. */ +/** + * A tenant administrator that does NOT hold `manage_metadata`: the narrowed caller. Its + * grant is owned by {@link MEMBER_ORG}, the organization its session is active in. + */ const MEMBER_ID = 'usr_member'; +const MEMBER_ORG = 'org_acme'; const MEMBER_GRANTS: Record = { sys_user: [{ id: MEMBER_ID, email: 'member@objectstack.test' }], sys_member: [], @@ -91,7 +95,7 @@ const MEMBER_GRANTS: Record = { sys_position: [], sys_metadata_activation: [], sys_position_permission_set: [], - sys_user_permission_set: [{ id: 'ups_member', user_id: MEMBER_ID, permission_set_id: 'ps_member', permission_set: 'organization_admin', organization_id: null }], + sys_user_permission_set: [{ id: 'ups_member', user_id: MEMBER_ID, permission_set_id: 'ps_member', permission_set: 'organization_admin', organization_id: MEMBER_ORG }], sys_permission_set: [{ id: 'ps_member', name: 'organization_admin', system_permissions: ['setup.access', 'manage_org_users'] }], }; @@ -119,6 +123,9 @@ async function restartWith(manifests: Array<{ id: string; version: string }>, di async find(object: string, query?: any): Promise { const granted = grants(); if (Object.prototype.hasOwnProperty.call(granted, object)) return granted[object]!; + // The resolver's Default Organization read: the operator's grant is owned by it. + const defaultOrganization = answerDefaultOrganizationRead(object, query, []); + if (defaultOrganization) return defaultOrganization; reads.push(object); if (!objects.has(object)) throw new Error(`Object '${object}' not found`); const where: Record = query?.where ?? {}; @@ -137,7 +144,7 @@ async function restartWith(manifests: Array<{ id: string; version: string }>, di const register = vi.fn((m: any) => { for (const o of m?.objects ?? []) objects.set(o.name, o); }); const services: Record = { manifest: { register }, - auth: { api: { getSession: async () => ({ user: { id: caller.as === 'operator' ? INSTALLER_USER_ID : MEMBER_ID }, session: {} }) } }, + auth: { api: { getSession: async () => ({ user: { id: caller.as === 'operator' ? INSTALLER_USER_ID : MEMBER_ID }, session: caller.as === 'operator' ? {} : { activeOrganizationId: MEMBER_ORG } }) } }, objectql: engine, metadata: { getObject: async (name: string) => objects.get(name) }, }; diff --git a/packages/cloud-connection/src/marketplace-install-local-posture-gate.test.ts b/packages/cloud-connection/src/marketplace-install-local-posture-gate.test.ts index e04011f204c..53bbb1d8214 100644 --- a/packages/cloud-connection/src/marketplace-install-local-posture-gate.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-posture-gate.test.ts @@ -53,7 +53,7 @@ import { tmpdir } from 'node:os'; // (the clocked-window rule, `scripts/check-test-source-alias.mjs`). import '@objectstack/runtime'; import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; -import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { declareInstallerPlatformOwner, installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; import { LocalManifestSource } from './local-manifest-source.js'; type Handler = (c: any) => Promise; @@ -135,12 +135,17 @@ const EXPECTED_IDS = ['material:field:code', 'material:index:external_id']; let dir: string; let posture: string | undefined; +let restoreOwner: () => void; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-d5e-')); posture = process.env.OS_TENANCY_POSTURE; + // The request carries no organization, and most of these run walled: the + // installer is the deployment's declared platform owner. + restoreOwner = declareInstallerPlatformOwner('usr_installer'); }); afterEach(() => { + restoreOwner(); rmSync(dir, { recursive: true, force: true }); if (posture === undefined) delete process.env.OS_TENANCY_POSTURE; else process.env.OS_TENANCY_POSTURE = posture; diff --git a/packages/cloud-connection/src/marketplace-install-local-purge.test.ts b/packages/cloud-connection/src/marketplace-install-local-purge.test.ts index 9b377eba365..5eb6d420357 100644 --- a/packages/cloud-connection/src/marketplace-install-local-purge.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-purge.test.ts @@ -543,9 +543,12 @@ describe('GET … listing — `withSampleData` is the caller\'s own scope, read reads.length = 0; opts.activeOrg = 'org_b'; expect(await listedSampleData(list)).toEqual({ status: 200, withSampleData: true, items: 1 }); - // B's answer came from B's rows, and from no other organization's. - expect(reads.length).toBeGreaterThan(0); - for (const r of reads) expect(r.query.where).toEqual({ organization_id: 'org_b' }); + // B's answer came from B's rows, and from no other organization's. (The + // resolver's Default Organization read — the installer's grant is owned + // by it — is an authorization read, not a row of the listing's.) + const rowReads = reads.filter((r) => r.object !== 'sys_organization'); + expect(rowReads.length).toBeGreaterThan(0); + for (const r of rowReads) expect(r.query.where).toEqual({ organization_id: 'org_b' }); // A keeps a user-authored row in a seeded object; it is not a seed row. opts.activeOrg = 'org_a'; diff --git a/packages/cloud-connection/src/marketplace-install-local-seed-replayer.test.ts b/packages/cloud-connection/src/marketplace-install-local-seed-replayer.test.ts index d96b5140e90..791e9bffc01 100644 --- a/packages/cloud-connection/src/marketplace-install-local-seed-replayer.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-seed-replayer.test.ts @@ -97,7 +97,7 @@ vi.mock('@objectstack/spec/data', async (importOriginal) => ({ })); import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; -import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { declareInstallerPlatformOwner, installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; import { LocalManifestSource } from './local-manifest-source.js'; type Handler = (c: any) => Promise; @@ -201,6 +201,7 @@ const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; const OLD_LEGACY = process.env.OS_MULTI_ORG_ENABLED; let dir: string; +let restoreOwner: () => void; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-replayer-')); seedResult = { summary: { totalInserted: 2, totalUpdated: 0, totalSkipped: 0 }, errors: [] }; @@ -208,11 +209,14 @@ beforeEach(() => { helpersPresent = true; helperRegisterOnce.mockClear(); helperMerge.mockClear(); - // The walled deployment the defect was measured on. + // The walled deployment the defect was measured on, and its installer: with + // no organization on the request, the deployment's declared platform owner. process.env.OS_TENANCY_POSTURE = 'isolated'; delete process.env.OS_MULTI_ORG_ENABLED; + restoreOwner = declareInstallerPlatformOwner(); }); afterEach(() => { + restoreOwner(); rmSync(dir, { recursive: true, force: true }); if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE; else process.env.OS_TENANCY_POSTURE = OLD_POSTURE; diff --git a/packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts b/packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts index 81b091239dc..89be8f9968a 100644 --- a/packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts @@ -122,8 +122,11 @@ function matchesWhere(row: any, where: any): boolean { /** * A permission store in the SHIPPED aggregation shapes: keys in `sys_api_key`, * memberships in `sys_member`, capabilities through `sys_user_permission_set` - * → `sys_permission_set`. All three principals hold `manage_metadata`, so a - * refusal below can only be the tenancy wall — never a missing capability. + * → `sys_permission_set`. All three principals hold `manage_metadata` through + * the Default Organization's `admin_full_access` grant — the platform-admin + * shape, which applies whichever organization the caller acts in and carries + * the platform-operator rung a walled install demands — so a refusal below can + * only be the tenancy wall — never a missing capability. * * `u_exmember`'s key still carries `active_organization_id: 'org_A'`; the row * that made that stamp true is simply gone from `sys_member`. That is the whole @@ -139,6 +142,7 @@ function permissionStore(opts: { syncSchemas: () => Promise }) { // `u_exmember` is deliberately ABSENT: the membership that backed the // stamp ended. `u_orgless` never had one. sys_member: [{ id: 'm1', user_id: 'u_member', organization_id: 'org_A' }], + sys_organization: [{ id: 'org_default', slug: 'default' }, { id: 'org_A', slug: 'org-a' }], sys_user: [ { id: 'u_member', email: 'u_member@acme.test' }, { id: 'u_exmember', email: 'u_exmember@acme.test' }, @@ -150,10 +154,10 @@ function permissionStore(opts: { syncSchemas: () => Promise }) { sys_metadata_activation: [], sys_position_permission_set: [], sys_user_permission_set: [ - { id: 'ups_member', user_id: 'u_member', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: null }, - { id: 'ups_exmember', user_id: 'u_exmember', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: null }, - { id: 'ups_orgless', user_id: 'u_orgless', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: null }, - { id: 'ups_session', user_id: 'u_session', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: null }, + { id: 'ups_member', user_id: 'u_member', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: 'org_default' }, + { id: 'ups_exmember', user_id: 'u_exmember', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: 'org_default' }, + { id: 'ups_orgless', user_id: 'u_orgless', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: 'org_default' }, + { id: 'ups_session', user_id: 'u_session', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: 'org_default' }, ], sys_permission_set: [ { id: 'ps_install', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access'] }, diff --git a/packages/cloud-connection/src/marketplace-install-local-tenancy-posture.test.ts b/packages/cloud-connection/src/marketplace-install-local-tenancy-posture.test.ts index 862acc8095f..b38fd7e8df3 100644 --- a/packages/cloud-connection/src/marketplace-install-local-tenancy-posture.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-tenancy-posture.test.ts @@ -66,7 +66,7 @@ vi.mock('@objectstack/spec/data', async (importOriginal) => ({ })); import { MarketplaceInstallLocalPlugin } from './marketplace-install-local-plugin.js'; -import { installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; +import { declareInstallerPlatformOwner, installerAuthService, withInstallerGrants } from './install-local-principal.fixtures.js'; import { LocalManifestSource } from './local-manifest-source.js'; type Handler = (c: any) => Promise; @@ -145,14 +145,19 @@ const OLD_POSTURE = process.env.OS_TENANCY_POSTURE; const OLD_LEGACY = process.env.OS_MULTI_ORG_ENABLED; let dir: string; +let restoreOwner: () => void; beforeEach(() => { dir = mkdtempSync(join(tmpdir(), 'mil-posture-')); seedResult = { summary: { totalInserted: 2, totalUpdated: 0, totalSkipped: 0 }, errors: [] }; loadCalls = []; delete process.env.OS_TENANCY_POSTURE; delete process.env.OS_MULTI_ORG_ENABLED; + // No organization on the request: under the walled postures below the + // installer is the deployment's declared platform owner. + restoreOwner = declareInstallerPlatformOwner(); }); afterEach(() => { + restoreOwner(); rmSync(dir, { recursive: true, force: true }); if (OLD_POSTURE === undefined) delete process.env.OS_TENANCY_POSTURE; else process.env.OS_TENANCY_POSTURE = OLD_POSTURE; diff --git a/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts b/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts index 8217f488c54..9cf8cf63fc4 100644 --- a/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts +++ b/packages/qa/dogfood/test/audit-log-audit-capability.dogfood.test.ts @@ -429,7 +429,10 @@ describe('[#21260] the ledger audit capability is bounded by its holder’s orga } const userA = await ql.findOne('sys_user', { where: { email: 'alc-org-a@verify.test' }, context: { ...SYS } }); expect(await catalogPermissionSet(ql, capOnly.name), 'fixture permission set served by the security catalog').toBeTruthy(); - await ql.insert('sys_user_permission_set', { user_id: userA.id, permission_set: capOnly.name }, { context: { ...SYS } }); + // The grant is owned by the first organization: a grant applies only in its + // own organization, and under the wall an organization-less system insert is + // refused (ADR-0131 D9). + await ql.insert('sys_user_permission_set', { user_id: userA.id, permission_set: capOnly.name, organization_id: org.a }, { context: { ...SYS } }); for (const who of ['a', 'b'] as const) { const res = await stack.apiAs(tok[who], 'POST', `/data/${OBJ}`, { name: `gone ${who}` }); expect(res.status).toBeLessThan(300); diff --git a/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts b/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts index d900b833f2e..14c5bfc7b2f 100644 --- a/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts +++ b/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts @@ -131,19 +131,33 @@ async function boot(inside: boolean, email: string): Promise { const ql = await stack.kernel.getServiceAsync('objectql'); const adminId = (await ql.findOne('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS }))?.id; const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; + // The grants are owned by the Default Organization, the organization both + // principals' sessions are active in: a grant applies only in its own + // organization, so an organization-less row would confer nothing on either. + const defaultOrg = await ql.findOne('sys_organization', { where: { slug: 'default' }, context: SYS }); + expect(defaultOrg?.id, 'the `single` boot names a Default Organization').toBeTruthy(); + const orgId = String(defaultOrg.id); for (const name of GRANTS) { const set = await catalogPermissionSet(ql, name); expect(set, `fixture permission set ${name} served by the security catalog`).toBeTruthy(); - // [C8 stage 8b] A GLOBAL grant, which the outside principal (no membership, no - // active organization) must still hold: under `single` the engine now stamps an - // organization-less system insert with the Default Organization (ADR-0131 D9), - // so the global row is landed beneath the engine, as the residue an older - // release left. The readers still apply it until C8's NULL-reader stages. - await ql.getDriver('sys_user_permission_set').create('sys_user_permission_set', { - id: `ups_${userId}_${set.name}`, user_id: userId, permission_set: set.name, organization_id: null, - }); + await ql.insert('sys_user_permission_set', { + id: `ups_${userId}_${set.name}`, user_id: userId, permission_set: set.name, organization_id: orgId, + }, { context: SYS }); + } + if (!inside) { + token = await leaveOrganization(stack, email); + // The OUTSIDE principal holds no `sys_member` row — so no `org_member` — + // while its session keeps the Default Organization as its active one: under + // `single` a session's organization claim is kept without a membership row + // (`vetOrganizationClaim`), which is the state a session that selected the + // organization before its user was removed is left in. That claim is what + // lets the grants above apply to a principal outside the floor's domain; + // with no active organization no stored grant applies at all. + const sessions = await ql.find('sys_session', { where: { user_id: userId }, limit: 20, context: SYS }); + for (const s of Array.isArray(sessions) ? sessions : sessions?.records ?? []) { + await ql.update('sys_session', { id: s.id, active_organization_id: orgId }, { context: SYS }); + } } - if (!inside) token = await leaveOrganization(stack, email); return { stack, rootDir, ql, adminId, token }; } diff --git a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts index 1c30c648be9..dd615c94370 100644 --- a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts +++ b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts @@ -154,19 +154,33 @@ async function boot(inside: boolean, email: string): Promise { const ql = await stack.kernel.getServiceAsync('objectql'); const adminId = (await ql.findOne('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS }))?.id; const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; + // The grants are owned by the Default Organization, the organization both + // principals' sessions are active in: a grant applies only in its own + // organization, so an organization-less row would confer nothing on either. + const defaultOrg = await ql.findOne('sys_organization', { where: { slug: 'default' }, context: SYS }); + expect(defaultOrg?.id, 'the `single` boot names a Default Organization').toBeTruthy(); + const orgId = String(defaultOrg.id); for (const name of GRANTS) { const set = await catalogPermissionSet(ql, name); expect(set, `fixture permission set ${name} served by the security catalog`).toBeTruthy(); - // [C8 stage 8b] A GLOBAL grant, which the outside principal (no membership, no - // active organization) must still hold: under `single` the engine now stamps an - // organization-less system insert with the Default Organization (ADR-0131 D9), - // so the global row is landed beneath the engine, as the residue an older - // release left. The readers still apply it until C8's NULL-reader stages. - await ql.getDriver('sys_user_permission_set').create('sys_user_permission_set', { - id: `ups_${userId}_${set.name}`, user_id: userId, permission_set: set.name, organization_id: null, - }); + await ql.insert('sys_user_permission_set', { + id: `ups_${userId}_${set.name}`, user_id: userId, permission_set: set.name, organization_id: orgId, + }, { context: SYS }); + } + if (!inside) { + token = await leaveOrganization(stack, email); + // The OUTSIDE principal holds no `sys_member` row — so no `org_member` — + // while its session keeps the Default Organization as its active one: under + // `single` a session's organization claim is kept without a membership row + // (`vetOrganizationClaim`), which is the state a session that selected the + // organization before its user was removed is left in. That claim is what + // lets the grants above apply to a principal outside the floor's domain; + // with no active organization no stored grant applies at all. + const sessions = await ql.find('sys_session', { where: { user_id: userId }, limit: 20, context: SYS }); + for (const s of Array.isArray(sessions) ? sessions : sessions?.records ?? []) { + await ql.update('sys_session', { id: s.id, active_organization_id: orgId }, { context: SYS }); + } } - if (!inside) token = await leaveOrganization(stack, email); return { stack, rootDir, ql, adminId, userId, token }; } diff --git a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts index 18fc74ea469..c5d9293fbe0 100644 --- a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts +++ b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts @@ -42,13 +42,20 @@ // was filed, `resolveUserAuthzGrants` kept an ORGANIZATION-scoped grant for a // caller with no active organization (`!(org && tenantId && org !== tenantId)` // read "no tenant" as "every organization"), so an org-scoped `manage_sharing` -// reached `adminOrgScope`. It no longer does: with no active organization only -// GLOBAL grants apply. So the two faces are pinned separately below — -// - the EXPOSED persona holds `manage_sharing` through a GLOBAL grant, the one -// way an org-less caller still holds it, and keeps pinning `adminOrgScope` -// (#8158's defence in depth: capability held, no organization to use it in); +// reached `adminOrgScope`. It no longer does: a grant applies only in its own +// organization, so with no active organization no stored grant applies — and a +// grant that carries NO organization applies nowhere. So the two faces are +// pinned separately below — +// - the EXPOSED persona holds `manage_sharing` through an ORGANIZATION-LESS +// grant, the spelling that once applied everywhere, and is now refused at +// the ADR-0111 D6 capability gate: the row confers nothing; // - the ORG-SCOPED persona holds the very grant #8158 was filed with, scoped -// to tenant A, and is now refused at the ADR-0111 D6 capability gate. +// to tenant A, and is refused at the same gate. +// Neither reaches `assertResolvableAdminScope` (#8158's defence in depth: +// capability held, no organization to use it in) any more: the one grant that +// still applies with no active organization is the Default Organization's +// `admin_full_access`, and its holder is a platform operator, which that guard +// lets through by design. // // ## Anti-vacuity // @@ -90,13 +97,13 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r // eslint-disable-next-line @typescript-eslint/no-explicit-any let ql: any; /** - * A platform operator with no active organization: `admin_full_access` held - * globally, and no membership. [ADR-0131 D3] The harness admin can no longer + * A platform operator with no active organization: the Default + * Organization's `admin_full_access` grant, and no membership. [ADR-0131 D3] The harness admin can no longer * play this part — every `single` boot makes them the Default Organization's * owner, so their session carries it. */ let platform: string; - /** The exposed persona: GLOBAL `manage_sharing`, no membership, no active org. */ + /** The exposed persona: `manage_sharing` through an organization-less grant, no membership, no active org. */ let orgLess: string; /** [#20515] The #8158 grant as filed — scoped to tenant A — with no membership and no active org. */ let orgScopedOrgLess: string; @@ -157,17 +164,16 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r orgBoundUserId = await uid(ORG_BOUND_EMAIL); orgScopedOrgLessUserId = await uid(ORG_SCOPED_ORG_LESS_EMAIL); - // [#20515] The exposed persona holds the set GLOBALLY (no organization): - // with no active organization only global grants apply, so this is the one - // spelling under which it still carries `manage_sharing` and still reaches - // `adminOrgScope`. - // [C8 stage 8b] A global grant is legacy residue in 18.0: the engine now stamps - // or refuses an organization-less system insert under `single` (ADR-0131 D9), - // so it is landed beneath the engine. The readers still apply it until C8's - // NULL-reader stages. - const globalGrant = (row: Record) => - ql.getDriver('sys_user_permission_set').create('sys_user_permission_set', { ...row, organization_id: null }); - await globalGrant({ id: 'ups_8158_org_less', user_id: orgLessUserId, permission_set: 'sharing_admin_8158' }); + // [#20515] The exposed persona holds the set with NO organization — the + // spelling an older release read as "applies everywhere", and so the one + // under which an org-less caller once carried `manage_sharing` into + // `adminOrgScope`. It is invalid state in 18.0: the engine stamps or refuses + // an organization-less system insert (ADR-0131 D9), so the row is landed + // beneath the engine, as the residue an older release left. It applies + // nowhere now. + await ql.getDriver('sys_user_permission_set').create('sys_user_permission_set', { + id: 'ups_8158_org_less', user_id: orgLessUserId, permission_set: 'sharing_admin_8158', organization_id: null, + }); // The #8158 grant as filed — scoped to tenant A — for the control (which // has tenant A active) and for the org-scoped persona (which has none). for (const userId of [orgBoundUserId, orgScopedOrgLessUserId]) { @@ -189,12 +195,19 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r id: 'mem_8158_bound', organization_id: ORG_A, user_id: orgBoundUserId, role: 'member', }, { context: SYS }); - // A platform operator outside the organization: `admin_full_access` held - // globally, the grant a platform administrator holds under `single`. + // A platform operator outside the organization: `admin_full_access` owned + // by the Default Organization, the one grant that anchors platform standing + // under `single` — and that anchors it whatever the active organization, + // none included. await stack.signUp(OPERATOR_EMAIL, PASSWORD); const adminSet = await catalogPermissionSet(ql, 'admin_full_access'); expect(adminSet, 'admin_full_access is served by the security catalog').toBeTruthy(); - await globalGrant({ id: 'ups_8158_operator', user_id: await uid(OPERATOR_EMAIL), permission_set: adminSet.name }); + const defaultOrg = await ql.findOne('sys_organization', { where: { slug: 'default' }, context: SYS }); + expect(defaultOrg?.id, 'the `single` boot names a Default Organization').toBeTruthy(); + await ql.insert('sys_user_permission_set', { + id: 'ups_8158_operator', user_id: await uid(OPERATOR_EMAIL), permission_set: adminSet.name, + organization_id: defaultOrg.id, + }, { context: SYS }); // Sign in AFTER the grants, so every session is minted by the same path // with the membership state above already in place. The org-less personas @@ -267,8 +280,9 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r // ── the measurement ────────────────────────────────────────────────── it('THE REPORTED CASE: listing is refused 403, not answered with every tenant’s rules', async () => { - // The exposed persona holds `manage_sharing` GLOBALLY (#20515 — see the - // header), so this is `adminOrgScope` refusing, as the next case proves. + // The exposed persona's `manage_sharing` grant carries no organization + // (#20515 — see the header), so it confers nothing: this is the capability + // gate refusing, as the next case proves. const res = await stack.apiAs(orgLess, 'GET', RULES); const payload = res.status === 200 ? ((await res.json()) as { data: RuleRow[] }).data.map((r) => `${r.name}@${r.organization_id}`) @@ -283,17 +297,18 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r ).toBe(403); }); - it('the refusal names the ORGANIZATION, which is also how we know the capability gate was cleared', async () => { + it('the refusal is the CAPABILITY gate’s: an organization-less grant confers no manage_sharing', async () => { // Both refusals on this surface are `PERMISSION_DENIED`; only the message // separates "you lack manage_sharing" from "you have it and no org to use - // it in". Reading the second proves the persona really did carry the - // capability — i.e. that this fixture measures the fall-open and not the - // older ADR-0111 D6 gate. + // it in". Reading the first proves the organization-less row conferred + // nothing — the persona never carried the capability as far as the + // active-organization guard. The org-BOUND control below holds the same set + // through a grant that carries an organization, and is served. const res = await stack.apiAs(orgLess, 'GET', RULES); const body = (await res.json()) as { code?: string; error?: string }; expect(body.code).toBe('PERMISSION_DENIED'); - expect(body.error ?? '').toMatch(/active organization/); - expect(body.error ?? '').not.toMatch(/requires the manage_sharing capability/); + expect(body.error ?? '').toMatch(/requires the manage_sharing capability/); + expect(body.error ?? '').not.toMatch(/active organization/); }); it('by-NAME GET of the OTHER tenant’s rule is refused', async () => { @@ -354,7 +369,7 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r }); it('an ORG-scoped manage_sharing grant with no active organization is refused 403 at the CAPABILITY gate', async () => { - // With no active organization only global grants apply, so the tenant-A + // With no active organization no stored grant applies, so the tenant-A // grant confers nothing here: the ADR-0111 D6 gate refuses before // `adminOrgScope` is ever asked — and no tenant's rule is read. const res = await stack.apiAs(orgScopedOrgLess, 'GET', RULES); diff --git a/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts b/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts index 69a8d1d01fe..305abcdaece 100644 --- a/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts +++ b/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts @@ -123,19 +123,33 @@ async function boot(inside: boolean, email: string): Promise { const ql = await stack.kernel.getServiceAsync('objectql'); const adminId = (await ql.findOne('sys_user', { where: { email: 'admin@objectos.ai' }, context: SYS }))?.id; const userId = (await ql.findOne('sys_user', { where: { email }, context: SYS }))?.id; + // The grants are owned by the Default Organization, the organization both + // principals' sessions are active in: a grant applies only in its own + // organization, so an organization-less row would confer nothing on either. + const defaultOrg = await ql.findOne('sys_organization', { where: { slug: 'default' }, context: SYS }); + expect(defaultOrg?.id, 'the `single` boot names a Default Organization').toBeTruthy(); + const orgId = String(defaultOrg.id); for (const name of GRANTS) { const set = await catalogPermissionSet(ql, name); expect(set, `fixture permission set ${name} served by the security catalog`).toBeTruthy(); - // [C8 stage 8b] A GLOBAL grant, which the outside principal (no membership, no - // active organization) must still hold: under `single` the engine now stamps an - // organization-less system insert with the Default Organization (ADR-0131 D9), - // so the global row is landed beneath the engine, as the residue an older - // release left. The readers still apply it until C8's NULL-reader stages. - await ql.getDriver('sys_user_permission_set').create('sys_user_permission_set', { - id: `ups_${userId}_${set.name}`, user_id: userId, permission_set: set.name, organization_id: null, - }); + await ql.insert('sys_user_permission_set', { + id: `ups_${userId}_${set.name}`, user_id: userId, permission_set: set.name, organization_id: orgId, + }, { context: SYS }); + } + if (!inside) { + token = await leaveOrganization(stack, email); + // The OUTSIDE principal holds no `sys_member` row — so no `org_member` — + // while its session keeps the Default Organization as its active one: under + // `single` a session's organization claim is kept without a membership row + // (`vetOrganizationClaim`), which is the state a session that selected the + // organization before its user was removed is left in. That claim is what + // lets the grants above apply to a principal outside the floor's domain; + // with no active organization no stored grant applies at all. + const sessions = await ql.find('sys_session', { where: { user_id: userId }, limit: 20, context: SYS }); + for (const s of Array.isArray(sessions) ? sessions : sessions?.records ?? []) { + await ql.update('sys_session', { id: s.id, active_organization_id: orgId }, { context: SYS }); + } } - if (!inside) token = await leaveOrganization(stack, email); return { stack, rootDir, ql, adminId, token }; } diff --git a/packages/services/service-datasource/src/__tests__/admin-routes-api-key-account-posture.pin.test.ts b/packages/services/service-datasource/src/__tests__/admin-routes-api-key-account-posture.pin.test.ts index d29dde83ac1..ed24baccc96 100644 --- a/packages/services/service-datasource/src/__tests__/admin-routes-api-key-account-posture.pin.test.ts +++ b/packages/services/service-datasource/src/__tests__/admin-routes-api-key-account-posture.pin.test.ts @@ -25,7 +25,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { hashApiKey } from '@objectstack/core'; import { HonoHttpServer } from '@objectstack/plugin-hono-server'; import { registerDatasourceAdminRoutes } from '../admin-routes.js'; -import { ENTITLED_USER, createSessionAuthService, createGrantsEngine } from './entitled-caller.fixture.js'; +import { ENTITLED_ORGANIZATION, ENTITLED_USER, createSessionAuthService, createGrantsEngine } from './entitled-caller.fixture.js'; const KEY = 'osk_datasource_account_posture_pin'; @@ -54,13 +54,15 @@ function mountFamily() { ...createSessionAuthService(), resolveUserAuthGates: async (userId: string) => postures[userId] ?? [], }; - // The fixture's grants engine, taught the one key the entitled operator owns. + // The fixture's grants engine, taught the one key the entitled operator owns — + // stamped with the organization the operator's grant belongs to, since a key + // resolves its owner's grants in the key's own organization. const engine: { find: (object: string, opts: any) => Promise } = createGrantsEngine(); const grantsFind = engine.find; const keyHash = hashApiKey(KEY); engine.find = async (object: string, opts: any) => ( object === 'sys_api_key' - ? (opts?.where?.key === keyHash ? [{ id: 'key_1', key: keyHash, user_id: ENTITLED_USER, revoked: false, scopes: '[]' }] : []) + ? (opts?.where?.key === keyHash ? [{ id: 'key_1', key: keyHash, user_id: ENTITLED_USER, active_organization_id: ENTITLED_ORGANIZATION, revoked: false, scopes: '[]' }] : []) : grantsFind(object, opts) ); const ctx = { diff --git a/packages/services/service-datasource/src/__tests__/admin-routes-auth-gate.pin.test.ts b/packages/services/service-datasource/src/__tests__/admin-routes-auth-gate.pin.test.ts index f4dfdd5b64a..d1dced50be0 100644 --- a/packages/services/service-datasource/src/__tests__/admin-routes-auth-gate.pin.test.ts +++ b/packages/services/service-datasource/src/__tests__/admin-routes-auth-gate.pin.test.ts @@ -46,6 +46,7 @@ import { UNENTITLED_USER, createSessionAuthService, createGrantsEngine, + sessionFor, } from './entitled-caller.fixture.js'; /** The gate postures plugin-auth's enrichment writes, one per policy. */ @@ -74,7 +75,7 @@ function createAuthService() { api: { getSession: async (input: { headers: Headers }) => { const user = held[input.headers?.get?.('authorization') ?? '']; - return user ? { user } : base.api.getSession(input); + return user ? sessionFor(user) : base.api.getSession(input); }, }, }; diff --git a/packages/services/service-datasource/src/__tests__/admin-routes-tenancy-posture-admission.test.ts b/packages/services/service-datasource/src/__tests__/admin-routes-tenancy-posture-admission.test.ts index 1b71dda3190..44fc49fe4c9 100644 --- a/packages/services/service-datasource/src/__tests__/admin-routes-tenancy-posture-admission.test.ts +++ b/packages/services/service-datasource/src/__tests__/admin-routes-tenancy-posture-admission.test.ts @@ -34,9 +34,15 @@ * get the capability refusal — all on the same wiring. * 2. **The RBAC grant is SYMMETRIC across all three key principals.** One * shared permission set carries `manage_platform_settings` for the member, - * the ex-member and the organization-less caller alike. If they held - * different capabilities, RBAC could be what separates the arms; with one - * shared grant, only the tenancy posture can be. + * the ex-member and the organization-less caller alike, each row owned by + * `org_alpha`. If they held different capabilities, RBAC could be what + * separates the arms; with one shared grant, only the tenancy posture can + * be. A stored grant applies only in its own organization, so the + * organization-less key — which resolves with NO active organization — + * holds nothing wherever the posture lets it through: it is ADMITTED + * (authenticated, no refusal line) and then answered the capability `403`, + * never the posture `401`. That `401`-versus-`403` split is how every arm + * below reads "did the posture refuse it". * 3. **A REAL `ObjectKernel` carries the wiring fact.** The classification * under measurement is the REGISTRY's — the branded "never registered" * rejection versus the unbranded "registered and failed to construct" one @@ -124,13 +130,15 @@ function makeQl() { { id: 'u_orgless', email: 'u_orgless@example.com' }, { id: 'u_plain', email: 'u_plain@example.com' }, ], - // The grant, SYMMETRIC across the three subject principals (note 2). - // `u_plain` is deliberately absent: it is the capability control, the one - // caller separated by RBAC rather than by posture. + // The grant, SYMMETRIC across the three subject principals (note 2), and + // owned by the organization the stamped keys name: an organization-less + // grant row applies in no organization, so it would confer nothing to + // anyone. `u_plain` is deliberately absent: it is the capability control, + // the one caller separated by RBAC rather than by posture. sys_user_permission_set: [ - { user_id: 'u_member', permission_set_id: SHARED_SET, permission_set: 'datasource_operator', organization_id: null }, - { user_id: 'u_exmember', permission_set_id: SHARED_SET, permission_set: 'datasource_operator', organization_id: null }, - { user_id: 'u_orgless', permission_set_id: SHARED_SET, permission_set: 'datasource_operator', organization_id: null }, + { user_id: 'u_member', permission_set_id: SHARED_SET, permission_set: 'datasource_operator', organization_id: ORG_ALPHA }, + { user_id: 'u_exmember', permission_set_id: SHARED_SET, permission_set: 'datasource_operator', organization_id: ORG_ALPHA }, + { user_id: 'u_orgless', permission_set_id: SHARED_SET, permission_set: 'datasource_operator', organization_id: ORG_ALPHA }, ], sys_permission_set: [ { @@ -487,10 +495,16 @@ describe('[#15350] §4 — ablation: with no kernel to read the posture from, th expect(h.store().filter((r) => r.name === 'pg')).toHaveLength(0); }); - it('the organization-less key is admitted again too', async () => { + it('the organization-less key is admitted again too — the capability 403, not the posture 401', async () => { + // Authenticated again (no `organization_required`), and then served what a + // principal with NO active organization holds: no stored grant applies + // outside its own organization, so the shared `org_alpha` grant confers + // nothing and the capability gate answers. The member control in this + // block proves the same grant does serve inside its organization. const h = mount(ABLATED); const res = await listOf(h, RAW_ORGLESS_KEY); - expect(res.status).toBe(200); + expect(res.status).toBe(403); + expect(res.body?.error?.code).toBe('PERMISSION_DENIED'); }); it('and NOTHING is said about any of it — no refusal line, because no refusal was decided', async () => { @@ -523,7 +537,9 @@ describe('[#15350] §5 — a `tenancy` service that was NEVER REGISTERED resolve it('and it runs NO posture-conditional refusal — behaviour is exactly what it was', async () => { const h = mount(UNREGISTERED); expect((await listOf(h, RAW_EXMEMBER_KEY)).status).toBe(200); - expect((await listOf(h, RAW_ORGLESS_KEY)).status).toBe(200); + // Admitted (the capability 403, not the posture 401): with no active + // organization its owner's organization-owned grant applies nowhere. + expect((await listOf(h, RAW_ORGLESS_KEY)).status).toBe(403); expect(h.warnings().filter((l) => l.includes('API key refused'))).toHaveLength(0); }); }); @@ -593,11 +609,21 @@ describe('[#15350] §6 — the `group` posture, measured on the READ and the WRI // reads the union of its owner\'s organizations, so refusing it would break // working deployments for no security gain. Pinned so the asymmetry with // §3 is a recorded decision rather than an accident. + // + // Admitted is not entitled: the key resolves with no active organization, + // where no stored grant applies, so the shared `org_alpha` grant confers + // nothing and the CAPABILITY gate answers — `403`, never §3's posture + // `401`, and no refusal line. The member control below is the same grant + // serving inside its organization. const h = mount(GROUP); - expect((await listOf(h, RAW_ORGLESS_KEY)).status).toBe(200); + const list = await listOf(h, RAW_ORGLESS_KEY); + expect(list.status).toBe(403); + expect(list.body?.error?.code).toBe('PERMISSION_DENIED'); const post = await createOf(h, RAW_ORGLESS_KEY, 'ds_group_orgless'); - expect(post.status).toBe(201); - expect(h.store().filter((r) => r.name === 'ds_group_orgless')).toHaveLength(1); + expect(post.status).toBe(403); + expect(h.store().filter((r) => r.name === 'ds_group_orgless')).toHaveLength(0); + expect(h.service.createDatasource).not.toHaveBeenCalled(); + expect(h.warnings().filter((l) => l.includes('API key refused'))).toHaveLength(0); }); it('the member control is unchanged under `group`', async () => { @@ -613,11 +639,13 @@ describe('[#15350] §6 — the `group` posture, measured on the READ and the WRI describe('[#15350] §7 — under `single` nothing changes', () => { const SINGLE: Wiring = { kind: 'posture', posture: 'single' }; - it('every entitled key is admitted, stamped or not', async () => { + it('every key is admitted, stamped or not', async () => { const h = mount(SINGLE); expect((await listOf(h, RAW_MEMBER_KEY)).status).toBe(200); expect((await listOf(h, RAW_EXMEMBER_KEY)).status).toBe(200); - expect((await listOf(h, RAW_ORGLESS_KEY)).status).toBe(200); + // Admitted (the capability 403, not a posture 401): with no active + // organization the `org_alpha` grant applies nowhere. + expect((await listOf(h, RAW_ORGLESS_KEY)).status).toBe(403); expect(h.warnings().filter((l) => l.includes('API key refused'))).toHaveLength(0); }); diff --git a/packages/services/service-datasource/src/__tests__/entitled-caller.fixture.ts b/packages/services/service-datasource/src/__tests__/entitled-caller.fixture.ts index 2be8019e0bc..a42458b6d39 100644 --- a/packages/services/service-datasource/src/__tests__/entitled-caller.fixture.ts +++ b/packages/services/service-datasource/src/__tests__/entitled-caller.fixture.ts @@ -47,6 +47,24 @@ export const UNENTITLED_CREDENTIAL = 'Bearer unentitled-session'; export const ENTITLED_USER = 'u_entitled'; export const UNENTITLED_USER = 'u_plain'; +/** + * The organization the grant belongs to, and the one both sessions are active + * in. A stored grant applies only in its own organization — a row with no + * organization applies nowhere — so the grant names one and the resolution runs + * in it. Under the default `single` posture the session's claim is kept + * without a membership row, so no `sys_member` row is needed for it. + */ +export const ENTITLED_ORGANIZATION = 'o1'; + +/** + * The session a credential resolves to: the user, active in + * {@link ENTITLED_ORGANIZATION}. Exported so a suite that adds sessions of its + * own (a held one, say) builds them in the same organization. + */ +export function sessionFor(user: U) { + return { user, session: { userId: user.id, activeOrganizationId: ENTITLED_ORGANIZATION } }; +} + /** The permission set that carries the grant. */ const GRANT_SET_ID = 'ps_datasource_operator'; @@ -64,7 +82,8 @@ const GRANT_SET_NAME = 'datasource_operator'; /** * The `auth` service double: a `getSession` over the two credentials above, - * spelled on the legacy `api` member the contract still declares. + * spelled on the legacy `api` member the contract still declares. Each session + * is active in {@link ENTITLED_ORGANIZATION}. */ export function createSessionAuthService() { const sessions: Record = { @@ -75,7 +94,7 @@ export function createSessionAuthService() { api: { getSession: async ({ headers }: { headers: Headers }) => { const id = sessions[headers?.get?.('authorization') ?? '']; - return id ? { user: { id } } : null; + return id ? sessionFor({ id }) : null; }, }, }; @@ -86,8 +105,8 @@ export function createSessionAuthService() { * * Only the two objects carrying the grant answer rows; every other read — the * memberships, positions and identity tables the resolver also consults — - * returns empty, which is what a deployment with no orgs and no custom roles - * really looks like. The rows carry no `active` flag and no validity window on + * returns empty, which is what a `single`-posture deployment with one + * organization and no custom roles really looks like. The rows carry no `active` flag and no validity window on * purpose: absent means active/unbounded by the resolver's own predicates * (`isRowActive` / `isGrantActive`), so these fixtures pin the CAPABILITY path * rather than a validity edge case that belongs to `@objectstack/core`'s suite. @@ -101,9 +120,10 @@ export function createGrantsEngine() { id: 'ups_datasource_operator', user_id: ENTITLED_USER, permission_set_id: GRANT_SET_ID, permission_set: GRANT_SET_NAME, - // Unscoped (null org) — the grant is platform-scoped, matching - // `manage_platform_settings`'s own `scope: 'platform'`. - organization_id: null, + // Owned by the sessions' organization: an organization-less + // grant row applies in no organization, so it would confer + // nothing (`grantAppliesInTenant`). + organization_id: ENTITLED_ORGANIZATION, }] : []; } diff --git a/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts b/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts index a7555e3c24e..cf7a53382d5 100644 --- a/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts +++ b/packages/services/service-settings/src/settings-admission-tenancy-posture.test.ts @@ -164,8 +164,10 @@ function matchesWhere(row: any, where: any): boolean { /** * The permission store in the SHIPPED aggregation shapes. All four principals - * hold `setup.access` + `setup.write`, so a refusal below can only be the - * tenancy wall — never a missing capability. + * hold `setup.access` + `setup.write` in `org_A` — the organization every + * stamped key and session names (a grant row with no organization applies in + * none) — so a refusal below can only be the tenancy wall — never a missing + * capability. * * `u_exmember`'s key still carries `active_organization_id: 'org_A'`; the row * that made that stamp true is simply gone from `sys_member`. That is the whole @@ -190,10 +192,10 @@ const PERMISSION_TABLES: Record = { sys_position: [], sys_position_permission_set: [], sys_user_permission_set: [ - { id: 'ups_member', user_id: 'u_member', permission_set_id: 'ps_settings', permission_set: 'settings_admin', organization_id: null }, - { id: 'ups_exmember', user_id: 'u_exmember', permission_set_id: 'ps_settings', permission_set: 'settings_admin', organization_id: null }, - { id: 'ups_orgless', user_id: 'u_orgless', permission_set_id: 'ps_settings', permission_set: 'settings_admin', organization_id: null }, - { id: 'ups_session', user_id: 'u_session_exmember', permission_set_id: 'ps_settings', permission_set: 'settings_admin', organization_id: null }, + { id: 'ups_member', user_id: 'u_member', permission_set_id: 'ps_settings', permission_set: 'settings_admin', organization_id: 'org_A' }, + { id: 'ups_exmember', user_id: 'u_exmember', permission_set_id: 'ps_settings', permission_set: 'settings_admin', organization_id: 'org_A' }, + { id: 'ups_orgless', user_id: 'u_orgless', permission_set_id: 'ps_settings', permission_set: 'settings_admin', organization_id: 'org_A' }, + { id: 'ups_session', user_id: 'u_session_exmember', permission_set_id: 'ps_settings', permission_set: 'settings_admin', organization_id: 'org_A' }, ], sys_permission_set: [ { id: 'ps_settings', name: 'settings_admin', system_permissions: ['setup.access', 'setup.write'] }, From e80cdbec069890859fe5865aee222c5e19139f9f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:08:49 +0000 Subject: [PATCH 41/44] wip(test): re-stamp the cloud-connection, datasource and settings grant fixtures Claude-Session: https://claude.ai/code/session_01QzqbxJk3oD6sqe47Q84ET8 Co-authored-by: Claude --- ...lace-install-local-tenancy-admission.test.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts b/packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts index 89be8f9968a..07ae1e000c3 100644 --- a/packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-tenancy-admission.test.ts @@ -122,11 +122,14 @@ function matchesWhere(row: any, where: any): boolean { /** * A permission store in the SHIPPED aggregation shapes: keys in `sys_api_key`, * memberships in `sys_member`, capabilities through `sys_user_permission_set` - * → `sys_permission_set`. All three principals hold `manage_metadata` through - * the Default Organization's `admin_full_access` grant — the platform-admin - * shape, which applies whichever organization the caller acts in and carries - * the platform-operator rung a walled install demands — so a refusal below can - * only be the tenancy wall — never a missing capability. + * → `sys_permission_set`. All three principals hold `manage_metadata` twice + * over: in `org_A`, the organization every key is stamped with and the session + * is active in (a grant applies only in its own organization), and through the + * Default Organization's `admin_full_access` grant — the platform-admin shape, + * which applies whichever organization the caller acts in and carries the + * platform-operator rung a walled install demands wherever the REQUESTED + * posture is not walled. So a refusal below can only be the tenancy wall — + * never a missing capability. * * `u_exmember`'s key still carries `active_organization_id: 'org_A'`; the row * that made that stamp true is simply gone from `sys_member`. That is the whole @@ -155,9 +158,13 @@ function permissionStore(opts: { syncSchemas: () => Promise }) { sys_position_permission_set: [], sys_user_permission_set: [ { id: 'ups_member', user_id: 'u_member', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: 'org_default' }, + { id: 'ups_member_org_a', user_id: 'u_member', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: 'org_A' }, { id: 'ups_exmember', user_id: 'u_exmember', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: 'org_default' }, + { id: 'ups_exmember_org_a', user_id: 'u_exmember', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: 'org_A' }, { id: 'ups_orgless', user_id: 'u_orgless', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: 'org_default' }, + { id: 'ups_orgless_org_a', user_id: 'u_orgless', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: 'org_A' }, { id: 'ups_session', user_id: 'u_session', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: 'org_default' }, + { id: 'ups_session_org_a', user_id: 'u_session', permission_set_id: 'ps_install', permission_set: 'admin_full_access', organization_id: 'org_A' }, ], sys_permission_set: [ { id: 'ps_install', name: 'admin_full_access', system_permissions: ['manage_metadata', 'studio.access'] }, From d39797eb08ace79d57fec7d1dfe9903e3951b5a0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 09:09:43 +0000 Subject: [PATCH 42/44] test: re-stamp the two grant fixtures main brought with an organization-less holding Claude-Session: https://claude.ai/code/session_01QzqbxJk3oD6sqe47Q84ET8 Co-authored-by: Claude --- .../plugin-auth/src/sys-session-revoke.test.ts | 16 ++++++++++++++-- .../src/delegated-admin-gate.test.ts | 8 +++++--- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/packages/plugins/plugin-auth/src/sys-session-revoke.test.ts b/packages/plugins/plugin-auth/src/sys-session-revoke.test.ts index d3a03272166..38947c54905 100644 --- a/packages/plugins/plugin-auth/src/sys-session-revoke.test.ts +++ b/packages/plugins/plugin-auth/src/sys-session-revoke.test.ts @@ -87,7 +87,19 @@ const userIdFor = (engine: any, email: string): string => { return String(row.id); }; -/** ADR-0068 D2 standing — the unscoped `admin_full_access` grant, never the legacy `role` scalar. */ +/** The Default Organization (the `sys_organization` row slugged `default`), staged when the engine holds none. */ +const defaultOrganizationIdOf = async (engine: any): Promise => { + const existing = (engine.tables.get('sys_organization') ?? []).find((r: any) => r.slug === 'default'); + if (existing) return String(existing.id); + await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }); + return 'org_default'; +}; + +/** + * ADR-0068 D2 standing — the Default Organization's `admin_full_access` grant + * (ADR-0131 D5), never the legacy `role` scalar. An organization-less grant + * anchors nothing. + */ const grantPlatformAdmin = async (engine: any, userId: string) => { if (!(engine.tables.get('sys_permission_set') ?? []).some((r: any) => r.id === PS_ADMIN)) { await engine.insert('sys_permission_set', { id: PS_ADMIN, name: ADMIN_FULL_ACCESS }); @@ -96,7 +108,7 @@ const grantPlatformAdmin = async (engine: any, userId: string) => { user_id: userId, permission_set_id: PS_ADMIN, permission_set: 'admin_full_access', - organization_id: null, + organization_id: await defaultOrganizationIdOf(engine), }); }; diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts index 7477ccb58d4..b8e98052876 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate.test.ts @@ -1412,8 +1412,10 @@ function makeOwnNameHarness() { ], sys_user_position: [ { id: 'held_rep', user_id: 'u_east_1', position: 'rep_pos', business_unit_id: 'bu_es' }, - { id: 'boss_duty', user_id: 'u_boss', position: 'duty_pos' }, - { id: 'boss_plain', user_id: 'u_boss', position: 'duty_plain' }, + // A holding takes effect only in its own organization, so the delegator's + // holdings are stamped with the one the delegation below runs in. + { id: 'boss_duty', user_id: 'u_boss', position: 'duty_pos', organization_id: DELEGATION_ORG }, + { id: 'boss_plain', user_id: 'u_boss', position: 'duty_plain', organization_id: DELEGATION_ORG }, ], sys_business_unit_member: [{ id: 'm1', business_unit_id: 'bu_es', user_id: 'u_east_1' }], sys_user: [{ id: 'u_east_1' }, { id: 'u_boss' }, { id: 'u_deleg' }], @@ -1523,7 +1525,7 @@ describe("DelegatedAdminGate — a delegate is judged on the permission set of t it('a delegatable position whose own-name set carries an adminScope cannot be self-delegated', async () => { const delegation = (position: string) => n.gate.assert({ - object: 'sys_user_position', operation: 'insert', context: { userId: 'u_boss', positions: [], principal: 'member' }, + object: 'sys_user_position', operation: 'insert', context: { userId: 'u_boss', positions: [], principal: 'member', organizationId: DELEGATION_ORG }, data: { user_id: 'u_deleg', position, delegated_from: 'u_boss', valid_until: iso(T0 + 5 * DAY), reason: 'cover' }, }); const err = await deniedOf(() => delegation('duty_pos')); From b7df995dd1fd9b77a451732a48bbbac8e89d53fa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 09:47:41 +0000 Subject: [PATCH 43/44] chore(runtime): re-record the test-typecheck ledger after the re-stamped fixture renamed one pinned signature Claude-Session: https://claude.ai/code/session_01QzqbxJk3oD6sqe47Q84ET8 Co-authored-by: Claude --- packages/runtime/test-typecheck-debt.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/runtime/test-typecheck-debt.json b/packages/runtime/test-typecheck-debt.json index b819768d54a..8b471b8b1cf 100644 --- a/packages/runtime/test-typecheck-debt.json +++ b/packages/runtime/test-typecheck-debt.json @@ -116,7 +116,7 @@ "TS7006: Parameter 'opts' implicitly has an 'any' type.": 1, "TS7006: Parameter 'ql' implicitly has an 'any' type.": 1, "TS7006: Parameter 'row' implicitly has an 'any' type.": 1, - "TS7053: Element implicitly has an 'any' type because expression of type 'any' can't be used to index type '{ sys_api_key: { id: string; key: string; revoked: boolean; user_id: string; active_organization_id: string; expires_at: string; }[]; sys_member: { user_id: string; organization_id: string; role: string; }[]; ... 4 more ...; sys_user_position: never[]; }'.": 1 + "TS7053: Element implicitly has an 'any' type because expression of type 'any' can't be used to index type '{ sys_api_key: { id: string; key: string; revoked: boolean; user_id: string; active_organization_id: string; expires_at: string; }[]; sys_member: { user_id: string; organization_id: string; role: string; }[]; ... 5 more ...; sys_user_position: never[]; }'.": 1 }, "src/seed-loader.test.ts": { "TS2322: Type '…' is not assignable to type '…'.": 4, From 2ba92fc90d5d3f6865da34129f3cf43e3efca047 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:51:13 +0000 Subject: [PATCH 44/44] fix(test,text): the holder-scan double refuses combinators on sys_organization; the stale unscoped-grant wording goes; runtime carries a patch bump Claude-Session: https://claude.ai/code/session_01QzqbxJk3oD6sqe47Q84ET8 Co-authored-by: Claude --- ...583-7a2-organization-less-grant-readers-retired.md | 2 ++ packages/core/src/security/admin-standing-surface.ts | 4 ++-- ...tstrap-platform-admin-existing-holder-scan.test.ts | 9 ++++++++- packages/runtime/src/domains/automation.ts | 11 ++++++----- 4 files changed, 18 insertions(+), 8 deletions(-) diff --git a/.changeset/24583-7a2-organization-less-grant-readers-retired.md b/.changeset/24583-7a2-organization-less-grant-readers-retired.md index e93381476d8..cd50487b8df 100644 --- a/.changeset/24583-7a2-organization-less-grant-readers-retired.md +++ b/.changeset/24583-7a2-organization-less-grant-readers-retired.md @@ -1,6 +1,7 @@ --- '@objectstack/core': minor '@objectstack/plugin-security': minor +'@objectstack/runtime': patch --- feat(core,plugin-security)!: a grant applies only in its own organization — an organization-less grant row confers nothing, and the Default Organization's `admin_full_access` grant is the only platform-admin anchor @@ -15,6 +16,7 @@ Clause-②: yes (narrowing) - **Platform-admin standing.** FROM: under `single`, an organization-less `admin_full_access` grant anchored `platform_admin`, beside the Default Organization's. TO: only the Default Organization's grant anchors it (still in every organization, and with none active). Walled postures are unchanged: standing there comes from `OS_PLATFORM_OWNER_EMAIL` alone. - **`bootstrapPlatformAdmin` (`@objectstack/plugin-security`).** The existing-admin check reads only the Default Organization's grant. When no Default Organization can be named (a walled posture, or none to name), it reads no grant at all. The walled "no owner email declared" error drops its sentence about a legacy unscoped grant row. - **The delegated-administration gate.** A held position counts toward self-delegation only when its row belongs to the caller's active organization. An organization-less holding, or a caller with no active organization, holds nothing there, which is what the resolver grants. An organization administrator's removal of an organization-less row is refused as "not one of the organization you administer". +- **The run-lifecycle refusal (`@objectstack/runtime`).** Under `single` it now names the Default Organization's `admin_full_access` grant as the remedy, where it named an unscoped one. - **What a lean composition sees.** A composition that registers no organization object (`sys_organization`, which plugin-auth ships) still stores grants without an organization, and those grants now confer nothing. No shipped composition does this: `os serve` registers the security plugin only together with plugin-auth. **What an operator does.** Nothing beyond the upgrade itself: run `os migrate organization-ownership` before serving a database that 17.x or an 18.0 pre-release created. The server refuses such a database until you do. If an embedding composes `@objectstack/plugin-security` without plugin-auth, register the organization object so that grants carry an organization. diff --git a/packages/core/src/security/admin-standing-surface.ts b/packages/core/src/security/admin-standing-surface.ts index 44ce39080f5..f656fa4e952 100644 --- a/packages/core/src/security/admin-standing-surface.ts +++ b/packages/core/src/security/admin-standing-surface.ts @@ -214,7 +214,7 @@ export const ADMIN_STANDING_SURFACE: Readonly reason: 'ADR-0057 D4 platform-RBAC position assignments (§4). A position can carry permission sets ' + '(its catalog definition\'s `permissionSets`, ADR-0131 D3/D4) but never platform-admin ' - + 'standing — §6b requires the set to be reached through an unscoped USER grant, so a ' + + 'standing — §6b requires the set to be reached through the Default Organization\'s USER grant, so a ' + 'position-bound `admin_full_access` resolves the set name into `permissions` and leaves ' + '`hasPlatformAdminGrant` false.', }, @@ -266,7 +266,7 @@ export const ADMIN_STANDING_NON_TABLE_INPUTS: readonly AdminStandingNonTableInpu kind: 'security-catalog', name: 'admin_full_access', reason: - 'The permission-set definition an unscoped `admin_full_access` user grant names, read from ' + 'The permission-set definition the anchoring `admin_full_access` user grant (the Default Organization\'s) names, read from ' + 'the security catalog by name (ADR-0131 D3/D4) — the platform\'s own declaration, shipped ' + 'on the security plugin\'s manifest. A name the catalog does not hold confers nothing, so ' + 'every grant-derived platform administrator rests on it. It is code-declared and refused ' diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts index b6335f32115..c8db8e5b254 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts @@ -379,7 +379,14 @@ function makeSyntheticQl( if (object === 'sys_account') return [{ id: 'acc_p', user_id: 'usr_promotable' }]; // [ADR-0131 D5] The Default Organization, which owns the promotion's grant. if (object === 'sys_organization') { - return bound(organizations.filter((r) => Object.entries(where).every(([k, v]) => r[k] === v))); + return bound( + organizations.filter((r) => + Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported operator ${k}`); + return r[k] === v; + }), + ), + ); } return []; }, diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index a46ab02c13f..1898b79bd33 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -761,9 +761,10 @@ function runLifecycleStandingSentence(): string { * - the declared administrator list (`OS_PLATFORM_OWNER_EMAIL`, matched * against the caller's own VERIFIED stored email) confers the rung on EVERY * posture — the config anchor is additive; - * - the unscoped `admin_full_access` grant (ADR-0068 D2) confers it under - * `single` ONLY: under `group` / `isolated` its anchor is retired, so on a - * walled deployment that grant confers nothing. + * - the Default Organization's `admin_full_access` grant (ADR-0068 D2, + * ADR-0131 D5) confers it under `single` ONLY: under `group` / `isolated` + * its anchor is retired, so on a walled deployment that grant confers + * nothing. * * One fixed sentence naming the grant therefore sent a walled operator to do a * thing that then failed silently at the permission layer. The middle part @@ -822,8 +823,8 @@ function runLifecycleDenyMessage(): string { * `resolveUserAuthzGrants` §4 pushes it onto the array. The rung is derived * from the platform-admin anchors and never from a position name: on every * posture, a declared `OS_PLATFORM_OWNER_EMAIL` address on the caller's own - * verified stored row; under `single` only, also the unscoped - * `admin_full_access` grant (ADR-0068 D2), whose anchor a walled posture + * verified stored row; under `single` only, also the Default Organization's + * `admin_full_access` grant (ADR-0068 D2, ADR-0131 D5), whose anchor a walled posture * retires. So it is what the ruling MEANT, and it is byte-for-byte what * `hasPlatformAdminStanding` returns. *