From 420fb8ce8b02288ddbcb8ab86c2346c0565cae17 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:06:14 +0000 Subject: [PATCH 01/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] =?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/35] 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/35] 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/35] 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/35] =?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/35] 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/35] =?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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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/35] 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 c6f775cbd70cb9c373db8f328a3fd0a7948fc746 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 06:08:09 +0000 Subject: [PATCH 35/35] =?UTF-8?q?fix(text):=20the=20delta=20review's=20tex?= =?UTF-8?q?t=20items=20=E2=80=94=20fresh=20installs=20restated,=20the=20br?= =?UTF-8?q?idge=20exports=20declared,=20the=20operator's=20first=20boot=20?= =?UTF-8?q?stated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text, pins and changesets only (seat delta review of b5010d9c, items 3.1-3.5). - cli.mdx says "Fresh installs are supported under every tenancy posture" again (os dev and the ceremony section), and the ceremony section states the sequence on an existing database: start this release once (refused, its schema sync creates the sibling tables), back up, plan, apply, start. The changeset's operator paragraph says the same. - This PR's changeset adds @objectstack/objectql and declares, in its adr-0087 marker and body, the removal of WALLED_ORGANIZATION_LESS_BRIDGE and isWalledOrganizationLessBridgeObject. Step 4's pending changesets (15212-8b-walled, 15212-8b-single, 15212-walled-posture-boots) and 24529 no longer announce the bridge, a later 18.x release or a refusing ceremony. - The two walled test docblocks (service-settings, service-datasource) and the apply refusal test's titles say what holds now. - check:closed-ceremony-text also reads "in a later 18.x release", a ceremony that still refuses, 18.0 refusing the command, and "until then the ceremony refuses". Ablation: 24529's base text reds it. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_014UATXWqAXqkaZkbG92ox6V --- ...15212-8b-single-platform-insert-stamped.md | 2 +- ...15212-8b-walled-platform-insert-refused.md | 8 ++--- .changeset/15212-walled-posture-boots.md | 6 ++-- .../24529-declared-webhooks-from-registry.md | 2 +- ...81-organization-ownership-ceremony-open.md | 7 +++-- content/docs/deployment/cli.mdx | 29 ++++++++++++++----- ...ply.organization-ownership-refusal.test.ts | 3 +- .../datasource-secret-binder.walled.test.ts | 15 +++++----- .../src/settings-global-secret-walled.test.ts | 17 ++++++----- scripts/check-closed-ceremony-text.mjs | 10 +++++++ 10 files changed, 62 insertions(+), 37 deletions(-) diff --git a/.changeset/15212-8b-single-platform-insert-stamped.md b/.changeset/15212-8b-single-platform-insert-stamped.md index 482d7ecf440..a04dc9de9c6 100644 --- a/.changeset/15212-8b-single-platform-insert-stamped.md +++ b/.changeset/15212-8b-single-platform-insert-stamped.md @@ -12,6 +12,6 @@ Clause-②: no (narrowing) - **Before.** A system-context insert (`isSystem`, no `tenantId`) into a `sys_` / `cloud_` / `ai_` object that carries an organization column, but that the tenancy inventory had not admitted, skipped the organization rule entirely, under every posture. The row landed with `organization_id` NULL. Thirty-five platform objects in this repository were in that population, among them `sys_business_unit`, `sys_business_unit_member`, `sys_member`, `sys_team`, `sys_user_permission_set`, `sys_user_position`, `sys_sharing_rule`, `sys_share_link`, `sys_comment`, `sys_attachment`, `sys_activity`, `sys_email`, `sys_inbox_message`, `sys_secret`, `sys_setting`, `sys_setting_audit`, `sys_webhook` and `sys_flow_credential`. - **Now, under `single`.** That insert goes through the same decision every application object already gets. With exactly one organization (the Default Organization), the row is stamped with it. With none, or with several, the insert is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500), and the message names the missing organization. Nothing is written. -- **Unchanged.** Under a walled posture (`group` / `isolated`) the per-object exclusion stands as before; the walled half of this change ships in a later 18.x release. A write that carries an organization, on the execution context or on the record, is never re-stamped or refused. Objects with no organization column, objects declaring `tenancy: { enabled: false }`, and federated objects are outside the rule. Updates are untouched. The engine's diagnostics-only tenant-audit mute is unchanged. +- **Unchanged.** Under a walled posture (`group` / `isolated`) the per-object exclusion stands as before; the walled half of this change is the companion `@objectstack/objectql` changeset on system inserts under a walled posture. A write that carries an organization, on the execution context or on the record, is never re-stamped or refused. Objects with no organization column, objects declaring `tenancy: { enabled: false }`, and federated objects are outside the rule. Updates are untouched. The engine's diagnostics-only tenant-audit mute is unchanged. **The remedy.** If a refusal names your insert, carry the organization on the write: `ql.insert(object, { ...row, organization_id: orgId }, { context: { isSystem: true } })`, or `{ context: { isSystem: true, tenantId: orgId } }`. On a fresh `single` deployment the auth plugin creates the Default Organization at boot, so a refusal reading `no-organization` means the write ran before it, or in a composition that registers `sys_organization` but mounts no auth plugin to create the Default Organization. diff --git a/.changeset/15212-8b-walled-platform-insert-refused.md b/.changeset/15212-8b-walled-platform-insert-refused.md index 592df71f840..ff9e5806edd 100644 --- a/.changeset/15212-8b-walled-platform-insert-refused.md +++ b/.changeset/15212-8b-walled-platform-insert-refused.md @@ -6,12 +6,12 @@ feat(objectql)!: under a walled posture (`group` / `isolated`), a system insert Clause-②: yes (narrowing) - + -**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9, D11). This is the walled half of the change whose `single` half shipped in 18.0. +**BREAKING** accept-set narrowing, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D9, D11). This is the walled half of the change whose `single` half is the companion `@objectstack/objectql` changeset on system inserts under `single`. - **Before.** Under `group` or `isolated`, a system-context insert (`isSystem`, no `tenantId`) into a `sys_` / `cloud_` / `ai_` object that carries an organization column, but that the tenancy inventory had not admitted, skipped the organization rule. The row landed with `organization_id` NULL, a row no organization owns and the wall hides from every organization. -- **Now.** That insert goes through the same decision every application object already gets under a wall: no organization is derivable, so it is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500, reason `walled-posture`), and nothing is written. Apart from the two tables named below, the posture no longer changes which objects the rule covers. -- **Unchanged.** A write that carries an organization, on the execution context or on the record, is never re-stamped or refused. Objects with no organization column (`systemFields: { tenant: false }`), objects declaring `tenancy: { enabled: false }`, and federated objects are outside the rule. `sys_secret` and `sys_flow_credential` keep their organization-less system inserts under a wall, as before: a global-scope encrypted setting, a datasource credential and a flow credential are deployment-level, and both tables drop their organization column in a later 18.x release, which retires this bridge (`WALLED_ORGANIZATION_LESS_BRIDGE`). Updates are untouched. The engine's diagnostics-only tenant-audit mute still reads the inventory. +- **Now.** That insert goes through the same decision every application object already gets under a wall: no organization is derivable, so it is refused with `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED` (status 500, reason `walled-posture`), and nothing is written. The posture no longer changes which objects the rule covers. +- **Unchanged.** A write that carries an organization, on the execution context or on the record, is never re-stamped or refused. Objects with no organization column (`systemFields: { tenant: false }`), objects declaring `tenancy: { enabled: false }`, and federated objects are outside the rule. `sys_secret` and `sys_flow_credential` are among them: a global-scope encrypted setting, a datasource credential and a flow credential are deployment-level, so both declare `systemFields: { tenant: false }` and their organization-less system inserts land under a wall. Updates are untouched. The engine's diagnostics-only tenant-audit mute still reads the inventory. **The remedy.** If a refusal names your insert, carry the organization on the write: `ql.insert(object, { ...row, organization_id: orgId }, { context: { isSystem: true } })`, or `{ context: { isSystem: true, tenantId: orgId } }`. If the row is deployment-level by design, the object should declare no organization column: `systemFields: { tenant: false }`. diff --git a/.changeset/15212-walled-posture-boots.md b/.changeset/15212-walled-posture-boots.md index 1d3c0737e5d..5db963c0385 100644 --- a/.changeset/15212-walled-posture-boots.md +++ b/.changeset/15212-walled-posture-boots.md @@ -6,8 +6,8 @@ feat(cli): `os serve` (and so `os dev` and `os start`) boots a walled tenancy po Clause-②: yes (widening) -18.0 refused a walled posture at boot, because the walled half of the platform-object organization rule had not shipped. It ships in this release (see the `@objectstack/objectql` changeset on walled system inserts), so the refusal is removed. +The 18.0 pre-releases refused a walled posture at boot, because the walled half of the platform-object organization rule had not shipped. It ships in this release (see the `@objectstack/objectql` changeset on walled system inserts), so the refusal is removed. - **Before.** With `OS_TENANCY_POSTURE=group` or `isolated`, or `OS_MULTI_ORG_ENABLED=true`, `os serve` exited `1` with a FATAL `NOT_IMPLEMENTED` before it read a config. `os doctor` printed a `Tenancy posture` warning saying so, and the unrecognised-posture fix list marked both walled values as refused in 18.0. -- **Now.** A walled posture boots like `single`, under the same prerequisites as before 18.0: the enterprise `@objectstack/organizations` runtime, and `OS_PLATFORM_OWNER_EMAIL`. `os doctor` reports no row for a valid posture, and the fix list offers `group` and `isolated` without the 18.0 note. -- **Unchanged.** An unrecognised posture is still refused at boot. Upgrading an existing database still waits for the upgrade ceremony, which still refuses in this release. +- **Now.** A walled posture boots like `single`, under the same prerequisites as before the 18.0 pre-releases: the enterprise `@objectstack/organizations` runtime, and `OS_PLATFORM_OWNER_EMAIL`. `os doctor` reports no row for a valid posture, and the fix list offers `group` and `isolated` without the refusal note. +- **Unchanged.** An unrecognised posture is still refused at boot. Upgrading an existing database is the ownership ceremony's: `os migrate organization-ownership --out PLAN`, then `--apply --plan-file PLAN --backup-taken`. diff --git a/.changeset/24529-declared-webhooks-from-registry.md b/.changeset/24529-declared-webhooks-from-registry.md index 4fad5d6836d..0162a7c1e4b 100644 --- a/.changeset/24529-declared-webhooks-from-registry.md +++ b/.changeset/24529-declared-webhooks-from-registry.md @@ -27,4 +27,4 @@ On a database an 18.0 install created (expand first — nothing is moved or dele - 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. +Deleting the seeded rows, and re-pointing their `sys_http_delivery` history at the declaration, is the ownership ceremony's `declared-webhooks` step (`os migrate organization-ownership`). diff --git a/.changeset/24581-organization-ownership-ceremony-open.md b/.changeset/24581-organization-ownership-ceremony-open.md index 8bf47e71dcb..16f888daa70 100644 --- a/.changeset/24581-organization-ownership-ceremony-open.md +++ b/.changeset/24581-organization-ownership-ceremony-open.md @@ -4,6 +4,7 @@ '@objectstack/spec': minor '@objectstack/driver-sql': minor '@objectstack/driver-turso': minor +'@objectstack/objectql': minor '@objectstack/plugin-webhooks': minor '@objectstack/platform-objects': minor '@objectstack/service-automation': minor @@ -14,7 +15,7 @@ feat(cli,runtime)!: `os migrate organization-ownership` upgrades a database 17.x Clause-②: yes (narrowing) - + **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). @@ -24,6 +25,6 @@ Clause-②: yes (narrowing) - `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 (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. +- **`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. With no organization column, their organization-less system inserts land under a walled posture on the engine's own rule, so `@objectstack/objectql` no longer exports `WALLED_ORGANIZATION_LESS_BRIDGE` or `isWalledOrganizationLessBridgeObject`, and the system-write refusal's remedy names `systemFields: { tenant: false }`. -**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. +**What an operator does.** Fresh installs are supported under every tenancy posture and need nothing. Before serving a database 17.x or an 18.0 pre-release created with this release: start this release against it once (the boot is refused, and its schema sync has already created the tables this release adds, which the ceremony moves rows into), 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 7509740fbe5..8227b1fcfb2 100644 --- a/content/docs/deployment/cli.mdx +++ b/content/docs/deployment/cli.mdx @@ -198,8 +198,9 @@ os dev --database file:./data/test.db --auth-secret $(openssl rand -hex 32) ##### A 17.x or 18.0 pre-release development database is upgraded in place -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) +Fresh installs are supported under every tenancy posture. 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: @@ -1507,10 +1508,11 @@ upgrading rather than wait for that. #### `os migrate organization-ownership` 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)). +17.x or an 18.0 pre-release (`18.0.0-next.*`) created. +Fresh installs are supported under every tenancy posture and never need it. +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 @@ -1518,8 +1520,19 @@ empty writes a **birth marker** into `sys_migration` (row 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 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. +certified at an older generation, is refused, and the refusal names this +command. + +The sequence on an existing database: + +1. Start this release against it once (`os serve`). The boot is refused, but + its schema sync has already created the tables this release adds, the + sibling logs the ceremony moves rows into among them. A plan drawn before + that boot blocks its `sibling-moves` step, naming the missing table and this + remedy. +2. Take a backup. +3. Write the plan, review it, then apply that reviewed plan (below). +4. Start the server again. ```bash os migrate organization-ownership --out plan.json # the plan: reads only diff --git a/packages/cli/src/commands/migrate/apply.organization-ownership-refusal.test.ts b/packages/cli/src/commands/migrate/apply.organization-ownership-refusal.test.ts index f0b1f5980f6..08fa2c97e86 100644 --- a/packages/cli/src/commands/migrate/apply.organization-ownership-refusal.test.ts +++ b/packages/cli/src/commands/migrate/apply.organization-ownership-refusal.test.ts @@ -67,7 +67,6 @@ const ORGANIZATION_DROP = { description: 'sys_platform_setting.organization_id: column exists in the database but not in metadata (orphaned)', }; -/** A step of `os migrate organization-ownership`, which 18.0 refuses on every database. */ /** The ceremony's two steps, as the refusal prescribes them. */ const CEREMONY_STEPS = [ 'os migrate organization-ownership --out PLAN', @@ -143,7 +142,7 @@ async function runApply(argv: string[]) { const ARGV = ['--database-url', 'file:v17.db', '--allow-destructive', '--yes']; -describe('os migrate apply --allow-destructive: the organization-ownership refusal names no command 18.0 refuses', () => { +describe('os migrate apply --allow-destructive: the organization-ownership refusal names the ceremony\'s steps', () => { for (const [state, flag] of [['no ceremony run recorded', null], ['an unverified run recorded', UNVERIFIED]] as const) { it(`${state}: the human face refuses, exit 1, naming the ceremony's steps, and nothing is written`, async () => { bootWithOrganizationDrop(); diff --git a/packages/services/service-datasource/src/datasource-secret-binder.walled.test.ts b/packages/services/service-datasource/src/datasource-secret-binder.walled.test.ts index 07d6c65aaf2..13ec0ebfed3 100644 --- a/packages/services/service-datasource/src/datasource-secret-binder.walled.test.ts +++ b/packages/services/service-datasource/src/datasource-secret-binder.walled.test.ts @@ -1,17 +1,18 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [ADR-0131 C8 stage 8b, BRIDGE] Binding a datasource credential under a + * [ADR-0131 C8 stage 8b; #24582] Binding a datasource credential under a * walled posture, through a REAL ObjectQL engine over SQLite. * * The binder persists the ciphertext as a `sys_secret` row with the system * context and no organization: a datasource is deployment-level. `sys_secret` - * keeps its organization column until the steps 5+6 landing flips it (epic - * ruling record 6270035914 on #15194, item 2), so under `group` / `isolated` - * that organization-less system insert must keep landing: the walled - * organization-less bridge in `Engine.resolveSystemInsertOrganization` holds - * it. Without the bridge the admin door's credential bind is a 500 - * `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`, and nothing is written. + * declares no organization column (`systemFields: { tenant: false }`, the + * steps 5+6 flip; epic ruling record 6270035914 on #15194, item 2), so under + * `group` / `isolated` that organization-less system insert lands on the + * engine's own rule: with no tenant field, `Engine.resolveSystemInsertOrganization` + * has nothing to stamp or refuse. If the table took an organization column + * back, the admin door's credential bind would be a 500 + * `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`, and nothing would be written. */ import { afterEach, describe, expect, it } from 'vitest'; diff --git a/packages/services/service-settings/src/settings-global-secret-walled.test.ts b/packages/services/service-settings/src/settings-global-secret-walled.test.ts index efdaa92a0ea..bb0446bc213 100644 --- a/packages/services/service-settings/src/settings-global-secret-walled.test.ts +++ b/packages/services/service-settings/src/settings-global-secret-walled.test.ts @@ -1,18 +1,19 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. /** - * [ADR-0131 C8 stage 8b, BRIDGE] A global-scope encrypted setting under a + * [ADR-0131 C8 stage 8b; #24582] A global-scope encrypted setting under a * walled posture, through a REAL ObjectQL engine. * * A global value is deployment-level, so its `sys_secret` ciphertext names no * organization (`settings-secret-organization.test.ts` pins the row shape, - * over a fake engine). `sys_secret` keeps its organization column until the - * steps 5+6 landing flips it (epic ruling record 6270035914 on #15194, item - * 2), so under `group` / `isolated` that organization-less system insert must - * keep landing: the walled organization-less bridge in - * `Engine.resolveSystemInsertOrganization` holds it. Without the bridge the - * operator's global encrypted setting is a 500 - * `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`, and nothing is written. + * over a fake engine). `sys_secret` declares no organization column + * (`systemFields: { tenant: false }`, the steps 5+6 flip; epic ruling record + * 6270035914 on #15194, item 2), so under `group` / `isolated` that + * organization-less system insert lands on the engine's own rule: with no + * tenant field, `Engine.resolveSystemInsertOrganization` has nothing to stamp + * or refuse. If the table took an organization column back, the operator's + * global encrypted setting would be a 500 `ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED`, + * and nothing would be written. * * The writer is the production pair: `SettingsService` building the row and * the plugin's own secret store (`SettingsServicePlugin` → `buildSecretStore`) diff --git a/scripts/check-closed-ceremony-text.mjs b/scripts/check-closed-ceremony-text.mjs index 6e0fe60aa0c..cc4324bc451 100644 --- a/scripts/check-closed-ceremony-text.mjs +++ b/scripts/check-closed-ceremony-text.mjs @@ -49,6 +49,10 @@ export const CLOSED_CEREMONY_PHRASES = Object.freeze([ { 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 }, + { id: 'in-a-later-18x-release', re: /\bin a later 18\.x release\b/i }, + { id: 'ceremony-still-refuses', re: /(?:ceremony|command),? (?:which )?(?:still )?refuses(?: in this release| on every database|, plan and apply)/i }, + { id: 'eighteen-refuses-the-command', re: /\b18\.0 refuses (?:it|this invocation|every (?:plan|invocation)|plan and apply)/i }, + { id: 'until-then-it-refuses', re: /until then (?:the ceremony|this command|the command) refuses/i }, ]); /** Is `path` (repo-relative, `/`-separated) in the population? */ @@ -133,6 +137,10 @@ function selfTest() { 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 deferral to a later release', findingsIn('the walled half of this change ships in a later 18.x release.').some((f) => f.phrase === 'in-a-later-18x-release')); + t('…a ceremony that still refuses', findingsIn('Upgrading waits for the upgrade ceremony, which still refuses in this release.').some((f) => f.phrase === 'ceremony-still-refuses')); + t('…18.0 refusing the command', findingsIn('18.0 refuses this invocation, as it refuses every plan and apply').some((f) => f.phrase === 'eighteen-refuses-the-command')); + t('…and step 4\'s docs sentence', findingsIn('Until then the ceremony refuses, so `os dev` stops at step 2').some((f) => f.phrase === 'until-then-it-refuses')); 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. @@ -142,6 +150,8 @@ function selfTest() { '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`.', + 'The 18.0 pre-releases refused it with `NOT_IMPLEMENTED`: a bare run writes the plan.', + 'A walled posture boots like `single`, under the same prerequisites as before the 18.0 pre-releases.', ]) t(`not a finding: ${open.slice(0, 50)}`, findingsIn(open).length === 0); // The population.