From 420fb8ce8b02288ddbcb8ab86c2346c0565cae17 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 13:06:14 +0000 Subject: [PATCH 01/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] =?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/81] 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/81] 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/81] 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/81] =?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/81] 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/81] =?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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] 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/81] =?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. From 9e368e5fd37b3dc0cad5b1db743902bb4bd18de7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 06:53:00 +0000 Subject: [PATCH 36/81] wip(driver-sql,driver-turso,spec): tenant column NOT NULL at CREATE TABLE (ADR-0131 D1) Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- packages/drivers/driver-sql/src/sql-driver.ts | 30 +++++++++++++++---- .../driver-turso/src/remote-transport.ts | 16 +++++++--- .../registry-tenant-index-declaration.test.ts | 2 +- .../data/injected-system-column-provenance.ts | 2 +- 4 files changed, 39 insertions(+), 11 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 28ad32650fd..69a38680dc2 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -11980,6 +11980,13 @@ export class SqlDriver implements IDataDriver { // ahead of either. this.warnBuiltinColumnCollisions(shardName, obj.fields, builtinColumns, 'shard'); const exists = await this.knex.schema.hasTable(shardName); + // ADR-0131 D1: a shard is created from the BASE's declaration, so it takes + // the base's tenant column NOT NULL — asked of the base through the + // recording resolver, the same answer the declared-index sync below uses + // (the shard has no bookkeeping of its own until `aliasShardBookkeeping`). + // A shard that already exists keeps the nullability it has: the + // organization-ownership ceremony reports such a shard, it never rebuilds it. + const baseTenantField = this.computeAndRecordTenantField(baseTable, obj); // Commit d0e3a885b: a shard carries the base table's declared indexes (below), so its // columns need the same keyable-text decision the managed path makes. const keyedColumns = indexedKeyColumns({ @@ -11995,7 +12002,7 @@ export class SqlDriver implements IDataDriver { this.createAuditTimestampColumn(table, 'updated_at'); for (const [name, field] of Object.entries(obj.fields ?? {})) { if (builtinColumns.has(name)) continue; - this.createColumn(table, name, field, keyedColumns.get(name)); + this.createColumn(table, name, field, keyedColumns.get(name), name === baseTenantField); } }); } else { @@ -12038,7 +12045,7 @@ export class SqlDriver implements IDataDriver { shardName, perShard, new Set(Object.keys(colInfo)), - this.computeAndRecordTenantField(baseTable, obj), + baseTenantField, obj.fields ?? {}, ); } @@ -12580,7 +12587,8 @@ export class SqlDriver implements IDataDriver { if (obj.fields) { for (const [name, field] of Object.entries(obj.fields)) { if (builtinColumns.has(name)) continue; - this.createColumn(table, name, field, keyedColumns.get(name)); + // ADR-0131 D1: a fresh table's tenant column is NOT NULL. + this.createColumn(table, name, field, keyedColumns.get(name), name === tenantField); } } }); @@ -19554,6 +19562,7 @@ export class SqlDriver implements IDataDriver { name: string, field: any, keyed?: { unique: boolean }, + tenantColumn: boolean = false, ) { // [#11567] Stated BEFORE the `multiple` short-circuit and before the type // switch, because the spec's refusal is gated on neither: `FieldSchema` @@ -19604,7 +19613,7 @@ export class SqlDriver implements IDataDriver { // producers already agree. if (isMultiValuedColumn(String(field.type ?? ''), field)) { const jsonCol = this.jsonColumn(table, name); - if (declaresColumnNotNull(field)) jsonCol.notNullable(); + if (declaresColumnNotNull(field) || tenantColumn) jsonCol.notNullable(); return; } @@ -20015,7 +20024,18 @@ export class SqlDriver implements IDataDriver { // [#17231] Asked through {@link declaresColumnNotNull} — the same // question the multi-value short-circuit above now asks, stated once so // this method's two exits cannot answer it differently again. - if (declaresColumnNotNull(field)) col.notNullable(); + // + // ADR-0131 D1 (execution record 6285122667 on the v18 step 7 card): the + // table's tenant column is the one column NOT NULL WITHOUT a declaration + // — "there is no nullable tenant column anywhere". `tenantColumn` is + // passed only by the two CREATE TABLE sites ({@link initObjects}' create + // branch, {@link ensureShardTable}'s), never by an ADD COLUMN: a column + // added to a table that already holds rows is the organization-ownership + // ceremony's to tighten (ruling ② A, 6269955919), and no metadata says + // it — the injected tenant field stays `required: false` with no + // `storage`, and the differ's `ceremonyOwnedNullability` reads the + // difference as no drift. + if (declaresColumnNotNull(field) || tenantColumn) col.notNullable(); this.applyDeclaredColumnDefault(col, field, type); } } diff --git a/packages/drivers/driver-turso/src/remote-transport.ts b/packages/drivers/driver-turso/src/remote-transport.ts index 43b26640187..0f0ee3f0ebc 100644 --- a/packages/drivers/driver-turso/src/remote-transport.ts +++ b/packages/drivers/driver-turso/src/remote-transport.ts @@ -2426,16 +2426,24 @@ export class RemoteTransport { * Build a CREATE TABLE SQL string for the given object definition. * Shared by syncSchema() and syncSchemasBatch() to avoid duplication. */ - private buildCreateTableSQL(tableName: string, objectDef: { fields?: Record }): string { + private buildCreateTableSQL(tableName: string, objectDef: { fields?: Record; tenancy?: any }): string { let sql = `CREATE TABLE "${tableName}" ("id" TEXT PRIMARY KEY, "created_at" TEXT DEFAULT (datetime('now')), "updated_at" TEXT DEFAULT (datetime('now'))`; + // ADR-0131 D1, the local face's `createColumn` twin: a fresh table's + // tenant column is NOT NULL with no declaration saying so. Asked through + // the driver's schema resolver ({@link tenantFieldResolver}) — the by-table + // registry is still empty here. CREATE only: an `ADD COLUMN` below and in + // {@link syncSchemasBatch} stays nullable, and a remote store this face + // created before keeps what it has (the organization-ownership ceremony + // reports the remote face as unable to tighten it). + const tenantField = this.tenantFieldResolver ? this.tenantFieldResolver(objectDef) : null; if (objectDef.fields) { for (const [name, field] of Object.entries(objectDef.fields)) { if (BUILTIN_COLUMNS.has(name)) continue; const type = (field as any).type || 'string'; if (type === 'formula') continue; // Virtual — no column this.assertSafeIdentifier(name); - sql += `, "${name}" ${this.columnDeclarationSQL(field)}`; + sql += `, "${name}" ${this.columnDeclarationSQL(field, name === tenantField)}`; } } @@ -2492,9 +2500,9 @@ export class RemoteTransport { * asymmetry; closing it means deciding whether, and from which shared source, * this face emits declared defaults. */ - private columnDeclarationSQL(field: any): string { + private columnDeclarationSQL(field: any, tenantColumn: boolean = false): string { const colType = this.mapFieldTypeToSQL(field); - return declaresColumnNotNull(field) ? `${colType} NOT NULL` : colType; + return declaresColumnNotNull(field) || tenantColumn ? `${colType} NOT NULL` : colType; } /** diff --git a/packages/objectql/src/registry-tenant-index-declaration.test.ts b/packages/objectql/src/registry-tenant-index-declaration.test.ts index 4a331a2a427..8ac0acf0584 100644 --- a/packages/objectql/src/registry-tenant-index-declaration.test.ts +++ b/packages/objectql/src/registry-tenant-index-declaration.test.ts @@ -167,7 +167,7 @@ describe('#6810 — a registry-backed object reads back valid at both /meta exit readonly: true, system: true, description: - 'Tenant scope (auto-populated by org-scoping on insert; NULL on single-tenant stacks).', + 'Tenant scope (stamped on insert by org-scoping; never NULL — a write that names no organization is refused).', }); }); diff --git a/packages/spec/src/data/injected-system-column-provenance.ts b/packages/spec/src/data/injected-system-column-provenance.ts index 639e775cb5e..08d82d7661e 100644 --- a/packages/spec/src/data/injected-system-column-provenance.ts +++ b/packages/spec/src/data/injected-system-column-provenance.ts @@ -111,7 +111,7 @@ export const TENANT_SCOPE_FIELD_DEF: Readonly> = { readonly: true, system: true, description: - 'Tenant scope (auto-populated by org-scoping on insert; NULL on single-tenant stacks).', + 'Tenant scope (stamped on insert by org-scoping; never NULL — a write that names no organization is refused).', }; /** From e7be5cd3f6d9ac59536030b3af71cdb24758a1d3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 06:55:02 +0000 Subject: [PATCH 37/81] wip: tenant NOT NULL pins (driver-sql, driver-turso remote) Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- .../sql-driver-tenant-column-not-null.test.ts | 156 +++++++++++++++++ ...urso-remote-tenant-column-not-null.test.ts | 161 ++++++++++++++++++ .../data/injected-system-column-provenance.ts | 7 + 3 files changed, 324 insertions(+) create mode 100644 packages/drivers/driver-sql/src/sql-driver-tenant-column-not-null.test.ts create mode 100644 packages/drivers/driver-turso/src/turso-remote-tenant-column-not-null.test.ts diff --git a/packages/drivers/driver-sql/src/sql-driver-tenant-column-not-null.test.ts b/packages/drivers/driver-sql/src/sql-driver-tenant-column-not-null.test.ts new file mode 100644 index 00000000000..5726671832e --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-tenant-column-not-null.test.ts @@ -0,0 +1,156 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0131 D1 at the DDL: a table the driver CREATES carries its tenant column + * NOT NULL, with no declaration saying so (execution record 6285122667 on the + * v18 step 7 card; ruling record 6269955919, ② A: no metadata change). + * + * ## What is pinned + * + * 1. A fresh managed table refuses a NULL tenant value AT THE DATABASE — a raw + * insert through the driver's own Knex, so no engine or driver stamping can + * be what refuses it — on every provisioned dialect cell. + * 2. The same for a rotation shard the driver creates (SQLite: the one dialect + * `supportsRotation` answers for), asked of the base's declaration. + * 3. The differ reports no drift on either: the declaration says nullable + * (`required: false`, no `storage`) and the column says NOT NULL, and that + * difference is the ceremony's (`ceremonyOwnedNullability`), never drift. + * 4. The boundaries, each the other direction of the same predicate: + * - an ordinary field next to it stays nullable (the tenant column is the + * only column made NOT NULL without a declaration); + * - an object opted out of tenancy (`tenancy.enabled: false`) has no tenant + * column, so a field it happens to name `organization_id` stays nullable; + * - a tenant column ADDED to a table that already exists stays nullable: + * a populated table is the organization-ownership ceremony's to tighten, + * and so is a shard that already existed. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { SqlDriver } from './sql-driver.js'; +import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; + +const MATRIX = 'tenant column NOT NULL at CREATE TABLE (ADR-0131 D1)'; + +/** The kernel's injected tenant field, as the driver receives it: nullable by declaration. */ +const ORG = { type: 'lookup', reference: 'sys_organization', required: false }; + +const OWNED = 'tnn_owned'; +const GLOBAL = 'tnn_global'; +const GROWN = 'tnn_grown'; +const TABLES = [OWNED, GLOBAL, GROWN]; + +/** Every NOT NULL refusal spelling: SQLite, PostgreSQL, MySQL. */ +const NOT_NULL_REFUSAL = /NOT NULL constraint failed|violates not-null constraint|cannot be null/i; + +const knexOf = (driver: SqlDriver): any => (driver as any).knex; + +async function nullable(driver: SqlDriver, table: string, column: string): Promise { + const info = await knexOf(driver)(table).columnInfo(); + return info[column].nullable; +} + +function declareTenantColumnSuite(cell: DialectCell): void { + describe(`${MATRIX} on ${cell.label}`, () => { + let driver: SqlDriver; + + beforeEach(async () => { + driver = new SqlDriver(cell.config()); + for (const t of TABLES) await knexOf(driver).schema.dropTableIfExists(t); + }); + + afterEach(async () => { + for (const t of TABLES) await knexOf(driver).schema.dropTableIfExists(t).catch(() => {}); + await driver.disconnect(); + }); + + it('a fresh table refuses a NULL tenant value at the database, and reports no drift', async () => { + await driver.initObjects([{ name: OWNED, fields: { organization_id: ORG, note: { type: 'text' } } }]); + + expect(await nullable(driver, OWNED, 'organization_id')).toBe(false); + expect(await nullable(driver, OWNED, 'note')).toBe(true); + + await expect( + knexOf(driver)(OWNED).insert({ id: 'r1', organization_id: null, note: 'no owner' }), + ).rejects.toThrow(NOT_NULL_REFUSAL); + await knexOf(driver)(OWNED).insert({ id: 'r2', organization_id: 'org_a', note: null }); + expect((await knexOf(driver)(OWNED).select('id')).map((r: { id: string }) => r.id)).toEqual(['r2']); + + const drift = (await driver.detectManagedDrift()).filter((d) => d.table === OWNED); + expect(drift).toEqual([]); + }); + + it('a tenancy-opted-out object has no tenant column: its organization_id stays nullable', async () => { + await driver.initObjects([{ name: GLOBAL, tenancy: { enabled: false }, fields: { organization_id: ORG } }]); + + expect(await nullable(driver, GLOBAL, 'organization_id')).toBe(true); + expect((await driver.detectManagedDrift()).filter((d) => d.table === GLOBAL)).toEqual([]); + }); + + it('a tenant column ADDED to an existing table stays nullable: that table is the ceremony\'s', async () => { + await driver.initObjects([{ name: GROWN, fields: { note: { type: 'text' } } }]); + await knexOf(driver)(GROWN).insert({ id: 'before', note: 'pre-existing row' }); + + await driver.initObjects([{ name: GROWN, fields: { note: { type: 'text' }, organization_id: ORG } }]); + + expect(await nullable(driver, GROWN, 'organization_id')).toBe(true); + expect((await driver.detectManagedDrift()).filter((d) => d.table === GROWN)).toEqual([]); + }); + }); +} + +for (const cell of DIALECT_CELLS) { + declareDialectCell(cell, MATRIX, declareTenantColumnSuite); +} + +describe(`${MATRIX}: rotation shards (SQLite)`, () => { + const T0 = Date.parse('2036-08-01T12:00:00.000Z'); + const DAY = 86_400_000; + const ROTATED = { + name: 'tnn_activity', + fields: { organization_id: ORG, verb: { type: 'text' } }, + lifecycle: { class: 'telemetry', storage: { strategy: 'rotation' as const, shards: 3, unit: 'day' as const } }, + }; + + let driver: SqlDriver; + beforeEach(() => { + driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + }); + afterEach(async () => { + await driver.disconnect(); + }); + + it('a newly rotated shard refuses a NULL tenant value at the database, and reports no drift', async () => { + await driver.initObjects([ROTATED as any]); + // The sweep-time entry point, a day later, carrying no `tenancy` block. + const { current, shards } = await driver.rotateShards(ROTATED as any, T0 + DAY); + expect(shards.length).toBeGreaterThan(1); + + for (const shard of shards) { + expect(await nullable(driver, shard, 'organization_id')).toBe(false); + expect(await nullable(driver, shard, 'verb')).toBe(true); + } + await expect(knexOf(driver)(current).insert({ id: 'a1', organization_id: null, verb: 'x' })).rejects.toThrow( + NOT_NULL_REFUSAL, + ); + + // The shards are not managed tables of their own (the base name is the + // read view), so each is diffed explicitly, against the base's declaration. + const drift = await driver.detectManagedDrift(shards.map((name) => ({ name, fields: ROTATED.fields }))); + expect(drift).toEqual([]); + }); + + it('a shard that already existed keeps the nullability it has: the ceremony reports it, nothing rebuilds it', async () => { + const legacy = `${ROTATED.name}__r20360801`; + await knexOf(driver).schema.createTable(legacy, (t: any) => { + t.string('id').primary(); + t.string('created_at'); + t.string('updated_at'); + t.string('organization_id'); + t.text('verb'); + }); + + await driver.rotateShards(ROTATED as any, T0); + + expect(await nullable(driver, legacy, 'organization_id')).toBe(true); + }); +}); diff --git a/packages/drivers/driver-turso/src/turso-remote-tenant-column-not-null.test.ts b/packages/drivers/driver-turso/src/turso-remote-tenant-column-not-null.test.ts new file mode 100644 index 00000000000..536d9f0376a --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-remote-tenant-column-not-null.test.ts @@ -0,0 +1,161 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * ADR-0131 D1 on the Turso REMOTE face: a table this transport CREATES carries + * its tenant column NOT NULL, exactly as the local face's `createColumn` + * builds it (execution record 6285122667 on the v18 step 7 card; ruling record + * 6269955919, ② A: no declaration says so). + * + * ## The instrument + * + * As `turso-remote-not-null.test.ts`: the remote face is a `TursoDriver` in + * remote mode over a real `@libsql/client` `file:` client, so its DDL runs on + * a real libSQL connection; the local (Knex) face over a file of its own is the + * reference each remote answer is held equal to. No hosted remote is reached. + * + * ## What is pinned + * + * 1. Through each of the three remote schema doors, a fresh table's tenant + * column is NOT NULL — the local face's answer — while an ordinary field + * beside it stays nullable. + * 2. The database refuses a NULL tenant value: a raw statement on the remote + * store's own connection, so nothing above the database stamps the row. + * 3. Neither face reports drift over it: the declaration says nullable, the + * column says NOT NULL, and that difference is the ceremony's. + * 4. The boundaries: an object opted out of tenancy has no tenant column, so a + * field named `organization_id` stays nullable; a tenant column ADDED to a + * remote table that already exists stays nullable — an upgraded remote + * store keeps what it has (the ceremony reports this face as unable to + * tighten it). + */ + +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterAll, afterEach, describe, expect, it } from 'vitest'; +import { createClient, type Client } from '@libsql/client'; +import { TursoDriver } from './turso-driver.js'; + +const scratch = mkdtempSync(join(tmpdir(), 'turso-remote-tenant-not-null-')); +afterAll(() => rmSync(scratch, { recursive: true, force: true })); +let seq = 0; +const nextFile = () => join(scratch, `db-${++seq}.db`); + +const open: Array<{ driver: TursoDriver; client: Client | null }> = []; +afterEach(async () => { + while (open.length) { + const { driver, client } = open.pop()!; + await driver.disconnect().catch(() => {}); + client?.close(); + } +}); + +type Objects = Parameters[0] & object; + +async function remoteOver(file: string): Promise { + const client = createClient({ url: `file:${file}` }); + const driver = new TursoDriver({ url: 'libsql://tenant-not-null.example.turso.io', client }); + open.push({ driver, client }); + await driver.connect(); + expect(driver.transportMode).toBe('remote'); + return driver; +} + +async function localOver(file: string): Promise { + const driver = new TursoDriver({ url: `file:${file}` }); + open.push({ driver, client: null }); + await driver.connect(); + expect(driver.transportMode).toBe('local'); + return driver; +} + +/** Each column's `notnull` flag as the database reports it, through a connection of its own. */ +async function notNullFlags(file: string, table: string): Promise> { + const c = createClient({ url: `file:${file}` }); + try { + const rows = (await c.execute(`PRAGMA table_info("${table}")`)).rows; + return Object.fromEntries(rows.map((r) => [String(r.name), Number(r.notnull)])); + } finally { + c.close(); + } +} + +/** The kernel's injected tenant field, as the driver receives it: nullable by declaration. */ +const ORG = { type: 'lookup', reference: 'sys_organization', required: false }; + +const OWNED = { name: 'tnn_owned', fields: { organization_id: ORG, note: { type: 'text' } } }; +const GLOBAL = { name: 'tnn_global', tenancy: { enabled: false }, fields: { organization_id: ORG } }; +const GROWN_BEFORE = { name: 'tnn_grown', fields: { note: { type: 'text' } } }; +const GROWN_AFTER = { name: 'tnn_grown', fields: { note: { type: 'text' }, organization_id: ORG } }; + +type Door = 'syncSchemasBatch' | 'syncSchema' | 'initObjects'; +const SYNC: Record Promise> = { + syncSchemasBatch: (d, objects) => d.syncSchemasBatch(objects.map((o) => ({ object: o.name, schema: o }))), + syncSchema: async (d, objects) => { + for (const o of objects) await d.syncSchema(o.name, o); + }, + initObjects: (d, objects) => d.initObjects(objects as never), +}; + +describe('CREATE TABLE: a fresh remote table carries its tenant column NOT NULL', () => { + it.each(['syncSchemasBatch', 'syncSchema', 'initObjects'])('synced through %s', async (door) => { + const referenceFile = nextFile(); + await (await localOver(referenceFile)).initObjects([OWNED] as never); + const reference = await notNullFlags(referenceFile, OWNED.name); + expect({ organization_id: reference.organization_id, note: reference.note }).toEqual({ organization_id: 1, note: 0 }); + + const file = nextFile(); + const remote = await remoteOver(file); + await SYNC[door](remote, [OWNED]); + + expect(await notNullFlags(file, OWNED.name)).toEqual(reference); + expect(await remote.detectManagedDrift([OWNED])).toEqual([]); + const local = await localOver(file); + local.registerObjectMetadata([OWNED] as never); + expect(await local.detectManagedDrift([OWNED])).toEqual([]); + }); + + it('the remote database refuses a NULL tenant value', async () => { + const file = nextFile(); + const remote = await remoteOver(file); + await remote.syncSchema(OWNED.name, OWNED); + + const c = createClient({ url: `file:${file}` }); + try { + await expect( + c.execute({ sql: 'INSERT INTO tnn_owned (id, organization_id, note) VALUES (?, NULL, ?)', args: ['r1', 'x'] }), + ).rejects.toThrow(/NOT NULL constraint failed: tnn_owned\.organization_id/); + await c.execute({ sql: 'INSERT INTO tnn_owned (id, organization_id, note) VALUES (?, ?, NULL)', args: ['r2', 'org_a'] }); + expect((await c.execute('SELECT id FROM tnn_owned')).rows.map((r) => r.id)).toEqual(['r2']); + } finally { + c.close(); + } + }); +}); + +describe('the boundaries: no tenant column, or a table that already exists', () => { + it('a tenancy-opted-out object keeps organization_id nullable on both faces', async () => { + const remoteFile = nextFile(); + await (await remoteOver(remoteFile)).syncSchema(GLOBAL.name, GLOBAL); + const localFile = nextFile(); + await (await localOver(localFile)).initObjects([GLOBAL] as never); + + expect((await notNullFlags(remoteFile, GLOBAL.name)).organization_id).toBe(0); + expect((await notNullFlags(localFile, GLOBAL.name)).organization_id).toBe(0); + }); + + it.each(['syncSchemasBatch', 'syncSchema'])( + 'a tenant column added to an existing remote table stays nullable (%s)', + async (door) => { + const file = nextFile(); + const remote = await remoteOver(file); + await remote.syncSchema(GROWN_BEFORE.name, GROWN_BEFORE); + await remote.create(GROWN_BEFORE.name, { id: 'before', note: 'pre-existing row' }); + + await SYNC[door](remote, [GROWN_AFTER]); + + expect((await notNullFlags(file, GROWN_AFTER.name)).organization_id).toBe(0); + expect(await remote.detectManagedDrift([GROWN_AFTER])).toEqual([]); + }, + ); +}); diff --git a/packages/spec/src/data/injected-system-column-provenance.ts b/packages/spec/src/data/injected-system-column-provenance.ts index 08d82d7661e..e4e5e90c478 100644 --- a/packages/spec/src/data/injected-system-column-provenance.ts +++ b/packages/spec/src/data/injected-system-column-provenance.ts @@ -101,6 +101,13 @@ export const AUDIT_FIELD_DEFS = { * Spread verbatim by `applySystemFields` — nothing is layered on top of it. * (#6810 removed the `indexed: opts.multiTenant` that used to be; the tenant * index is declared in the object's `indexes[]` instead.) + * + * ADR-0131 D1: the column is NOT NULL, and nothing here says so on purpose + * (ruling record 6269955919 on the v18 epic, ② A). The SQL driver creates a + * fresh table's tenant column NOT NULL by itself, the organization-ownership + * ceremony tightens an upgraded one, and the schema differ reads the column's + * nullability as theirs, never as drift — so this shape keeps `required: + * false` and no `storage`. */ export const TENANT_SCOPE_FIELD_DEF: Readonly> = { type: 'lookup', From a801a8e46d15a3cf6dd64a30392e8e6c8332d53c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:09:34 +0000 Subject: [PATCH 38/81] wip(driver-sql): fixtures that write organization-less rows model a pre-18 database Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- .../adr0120-three-posture-conformance.test.ts | 13 ++++++ .../src/pre-18-tenant-column.testkit.ts | 43 +++++++++++++++++++ ...er-21227-create-answers-stored-row.test.ts | 28 ++++++++---- ...8577-tenant-scoped-declared-unique.test.ts | 7 +++ ...ared-index-organization-respelling.test.ts | 4 ++ ...acy-tenant-composite-null-distinct.test.ts | 7 +++ ...r-sys-position-organization-unique.test.ts | 7 +++ ...er-sys-setting-organization-unique.test.ts | 10 +++++ .../sql-driver-tenant-column-not-null.test.ts | 4 +- ...sql-driver-tenant-scope-read-doors.test.ts | 4 ++ .../src/sql-driver-tenant-scope.test.ts | 8 +++- ...iver-tenant-scoped-declared-unique.test.ts | 7 +++ .../src/sql-driver-unique-tenancy.test.ts | 13 ++++++ ...-upsert-cross-organization-refusal.test.ts | 4 ++ 14 files changed, 148 insertions(+), 11 deletions(-) create mode 100644 packages/drivers/driver-sql/src/pre-18-tenant-column.testkit.ts diff --git a/packages/drivers/driver-sql/src/adr0120-three-posture-conformance.test.ts b/packages/drivers/driver-sql/src/adr0120-three-posture-conformance.test.ts index 0fa6f81ace3..152ad99f753 100644 --- a/packages/drivers/driver-sql/src/adr0120-three-posture-conformance.test.ts +++ b/packages/drivers/driver-sql/src/adr0120-three-posture-conformance.test.ts @@ -5,6 +5,7 @@ import { mkdtempSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { SqlDriver, classifyIndexKeyPart, parseIndexDdl } from '../src/index.js'; +import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; /** * ADR-0120 §Acceptance tests — posture portability (S13/S14). @@ -284,6 +285,9 @@ describe('ADR-0120 — one app package, three tenancy postures (S13/S14 acceptan // (SQL UNIQUE is NULL-distinct), so every field-level unique was a // silent no-op there. It must now be enforced under `single` exactly as // under `isolated` — the posture is not what makes a constraint real. + // The NULL bucket exists only on a database built before 18.x (ADR-0131 + // D1 makes a fresh tenant column NOT NULL). + await asPre18TenantColumn(driver, 'crm_contact'); await driver.create('crm_contact', { email: 'platform@b.com' }); await expect(driver.create('crm_contact', { email: 'platform@b.com' })) .rejects.toThrow(REJECTED); @@ -305,6 +309,9 @@ describe('ADR-0120 — one app package, three tenancy postures (S13/S14 acceptan await expect(driver.create('crm_case', { organization_id: 'org_a', department: 'ops', code: 'C1' })) .rejects.toThrow(REJECTED); // …and the NULL bucket is constrained too (D3 reaches declared indexes). + // The NULL bucket exists only on a database built before 18.x (ADR-0131 + // D1 makes a fresh tenant column NOT NULL). + await asPre18TenantColumn(driver, 'crm_case'); await driver.create('crm_case', { department: 'ops', code: 'C9' }); await expect(driver.create('crm_case', { department: 'ops', code: 'C9' })) .rejects.toThrow(REJECTED); @@ -314,6 +321,9 @@ describe('ADR-0120 — one app package, three tenancy postures (S13/S14 acceptan // The nine engine idempotency keys live or die on this row: they are // written by sudo with a NULL organization, so a composite would have // enforced nothing on exactly the rows that matter. + // The NULL bucket exists only on a database built before 18.x (ADR-0131 + // D1 makes a fresh tenant column NOT NULL). + await asPre18TenantColumn(driver, 'crm_delivery'); await driver.create('crm_delivery', { source: 'smtp', dedup_key: 'K1' }); await expect(driver.create('crm_delivery', { source: 'smtp', dedup_key: 'K1' })) .rejects.toThrow(REJECTED); @@ -332,6 +342,9 @@ describe('ADR-0120 — one app package, three tenancy postures (S13/S14 acceptan .resolves.toBeTruthy(); // The un-closed NULL hole, pinned as the honest status quo (D5c nudges // toward the respelling that closes it; nothing forces it). + // The NULL bucket exists only on a database built before 18.x (ADR-0131 + // D1 makes a fresh tenant column NOT NULL). + await asPre18TenantColumn(driver, 'crm_team'); await driver.create('crm_team', { name: 'Platform' }); await expect(driver.create('crm_team', { name: 'Platform' })).resolves.toBeTruthy(); }); diff --git a/packages/drivers/driver-sql/src/pre-18-tenant-column.testkit.ts b/packages/drivers/driver-sql/src/pre-18-tenant-column.testkit.ts new file mode 100644 index 00000000000..dbfde756f2b --- /dev/null +++ b/packages/drivers/driver-sql/src/pre-18-tenant-column.testkit.ts @@ -0,0 +1,43 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SqlDriver } from './sql-driver.js'; + +/** + * Return a table the driver just created to the shape a database built BEFORE + * 18.x has: its tenant column nullable. + * + * ADR-0131 D1: since 18.x the driver creates a table's tenant column NOT NULL + * (`createColumn`'s `tenantColumn`). A database built by an earlier release + * still holds the nullable column — and, where the organization-ownership + * ceremony reported a table rather than clearing it (D10 fate 4), real rows + * with no organization in it. A suite whose subject is how the driver treats + * THOSE rows (the NULL-safe unique key part, the legacy composites, the + * respelling migrations) builds its table with `initObjects` and then calls + * this, so it keeps measuring the shape it was written for. + * + * Done through the driver's own drift apply door (`relax_not_null`: the SQLite + * table rebuild, or `ALTER COLUMN … DROP NOT NULL`), never a hand `ALTER`, so + * every index the table carries survives exactly as the driver built it. + */ +export async function asPre18TenantColumn( + driver: SqlDriver, + table: string, + column: string = 'organization_id', +): Promise { + const entry = { + kind: 'nullability_mismatch', + remoteName: table, + table, + column, + expected: 'NULL', + actual: 'NOT NULL', + severity: 'warning', + category: 'needs_confirm', + op: { type: 'relax_not_null', table, column }, + message: `${table}.${column}: the pre-18 nullable tenant column (test fixture).`, + } as unknown as Parameters[0][number]; + const { applied } = await driver.applyMigrationEntries([entry]); + if (applied.length !== 1) { + throw new Error(`asPre18TenantColumn: the drift apply door did not relax ${table}.${column}`); + } +} diff --git a/packages/drivers/driver-sql/src/sql-driver-21227-create-answers-stored-row.test.ts b/packages/drivers/driver-sql/src/sql-driver-21227-create-answers-stored-row.test.ts index 62528ec076c..ff626689f88 100644 --- a/packages/drivers/driver-sql/src/sql-driver-21227-create-answers-stored-row.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-21227-create-answers-stored-row.test.ts @@ -51,6 +51,13 @@ import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dial const TABLE = 'os21227_stored_row'; +/** + * The organization every row in this file names: a fresh table's tenant column + * is NOT NULL (ADR-0131 D1), and this file's subject is the stored row, not + * tenancy — the two tenanted cases below say their own organization. + */ +const ORG = 'org_a'; + const OBJECT = { name: TABLE, fields: { @@ -117,7 +124,7 @@ function suite(face: Face) { }); it('create with a generated id answers the stored row', async () => { - const answer = await driver.create(TABLE, { title: 'generated', qty: 1, meta: { k: [1, 2] } }); + const answer = await driver.create(TABLE, { organization_id: ORG, title: 'generated', qty: 1, meta: { k: [1, 2] } }); expect(typeof answer.id).toBe('string'); expect(answer.id).not.toBe(''); @@ -127,7 +134,7 @@ function suite(face: Face) { }); it('create with a supplied id answers that row, in one statement or two by path', async () => { - const answer = await driver.create(TABLE, { id: 'given-1', title: 'supplied', qty: 2 }); + const answer = await driver.create(TABLE, { id: 'given-1', organization_id: ORG, title: 'supplied', qty: 2 }); expect(answer).toEqual(await stored('given-1')); expect(answer.title).toBe('supplied'); @@ -139,9 +146,9 @@ function suite(face: Face) { it('bulkCreate answers one stored row per written row, in the written order', async () => { const answer = await driver.bulkCreate(TABLE, [ - { title: 'b1' }, - { id: 'given-b2', title: 'b2', qty: 2 }, - { title: 'b3', qty: 3 }, + { organization_id: ORG, title: 'b1' }, + { id: 'given-b2', organization_id: ORG, title: 'b2', qty: 2 }, + { organization_id: ORG, title: 'b3', qty: 3 }, ]); expect(answer).toHaveLength(3); @@ -156,7 +163,7 @@ function suite(face: Face) { }); it('bulkCreate of one row answers one stored row', async () => { - const answer = await driver.bulkCreate(TABLE, [{ id: 'only', title: 'single' }]); + const answer = await driver.bulkCreate(TABLE, [{ id: 'only', organization_id: ORG, title: 'single' }]); expect(answer).toHaveLength(1); expect(answer[0]).toEqual(await stored('only')); @@ -200,8 +207,11 @@ function suite(face: Face) { let single!: Record; let batch!: Record[]; try { - single = await driver.create(TABLE, { id: 'tx-1', title: 'in tx' }, { transaction: trx }); - batch = await driver.bulkCreate(TABLE, [{ id: 'tx-2', title: 'in tx' }, { id: 'tx-3', title: 'in tx' }], { + single = await driver.create(TABLE, { id: 'tx-1', organization_id: ORG, title: 'in tx' }, { transaction: trx }); + batch = await driver.bulkCreate(TABLE, [ + { id: 'tx-2', organization_id: ORG, title: 'in tx' }, + { id: 'tx-3', organization_id: ORG, title: 'in tx' }, + ], { transaction: trx, }); } finally { @@ -220,7 +230,7 @@ function suite(face: Face) { ); statements = []; - const err = await driver.create(TABLE, { id: 'gone', title: 'vanishes' }).then( + const err = await driver.create(TABLE, { id: 'gone', organization_id: ORG, title: 'vanishes' }).then( () => null, (e: unknown) => e as { code?: string; status?: number }, ); diff --git a/packages/drivers/driver-sql/src/sql-driver-8577-tenant-scoped-declared-unique.test.ts b/packages/drivers/driver-sql/src/sql-driver-8577-tenant-scoped-declared-unique.test.ts index fbd8a911723..449ce0fcc1a 100644 --- a/packages/drivers/driver-sql/src/sql-driver-8577-tenant-scoped-declared-unique.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-8577-tenant-scoped-declared-unique.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; import { isUniqueViolationError } from '@objectstack/types'; import { SqlDriver, classifyIndexKeyPart, parseIndexDdl, legacyUniqueReplacements } from '../src/index.js'; +import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; /** * #8577 — the FIFTH act of the #8323 class: two more tenant-scoped objects @@ -375,6 +376,9 @@ describe('#8577 — two more tenant-scoped declared unique indexes become per-or // `suggested-audience-bindings-install-path.test.ts` §3. const d = makeDriver(); await d.initObjects(app(sub, 'fixed') as any); + // Rows with no organization exist only on a database built before 18.x + // (ADR-0131 D1 makes a fresh tenant column NOT NULL). + await asPre18TenantColumn(d, sub.table); expect((await createAsApi(d, sub.table, row(sub, 'seed1', undefined, sub.key))).status).toBe(201); expect(await createAsApi(d, sub.table, row(sub, 'seed2', undefined, sub.key))).toMatchObject( @@ -427,6 +431,9 @@ describe('#8577 — two more tenant-scoped declared unique indexes become per-or */ const seedDeployed = async (d: SqlDriver) => { await d.initObjects(app(sub, 'pre') as any); + // Every deployment in the field was built before 18.x: its tenant + // column is nullable, and `r3` below is a row with no organization. + await asPre18TenantColumn(d, sub.table); await d.create(sub.table, row(sub, 'r1', 'org_jia', sub.key) as any); await d.create(sub.table, row(sub, 'r2', 'org_jia', sub.control) as any); await d.create(sub.table, row(sub, 'r3', undefined, sub.platformKey) as any); diff --git a/packages/drivers/driver-sql/src/sql-driver-declared-index-organization-respelling.test.ts b/packages/drivers/driver-sql/src/sql-driver-declared-index-organization-respelling.test.ts index f7f472036af..1b5f6878fa5 100644 --- a/packages/drivers/driver-sql/src/sql-driver-declared-index-organization-respelling.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-declared-index-organization-respelling.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; import { isUniqueViolationError } from '@objectstack/types'; +import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; import { SqlDriver, classifyIndexKeyPart, @@ -381,6 +382,9 @@ describe('#8323 — declared unique indexes on the platform’s tenant-scoped ob // so every platform-seeded capability could be duplicated at will. const d = makeDriver(); await d.initObjects(FIXED_APP as any); + // Rows with no organization exist only on a database built before 18.x + // (ADR-0131 D1 makes a fresh tenant column NOT NULL). + await asPre18TenantColumn(d, 'sys_capability'); expect((await createAsApi(d, 'sys_capability', { id: 'p1', name: 'manage_metadata' })).status).toBe(201); const duplicateSeed = await createAsApi(d, 'sys_capability', { id: 'p2', name: 'manage_metadata' }); diff --git a/packages/drivers/driver-sql/src/sql-driver-legacy-tenant-composite-null-distinct.test.ts b/packages/drivers/driver-sql/src/sql-driver-legacy-tenant-composite-null-distinct.test.ts index e03cdeedbd2..b609f3f6ae1 100644 --- a/packages/drivers/driver-sql/src/sql-driver-legacy-tenant-composite-null-distinct.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-legacy-tenant-composite-null-distinct.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { isUniqueViolationError } from '@objectstack/types'; import { SqlDriver, classifyIndexKeyPart, parseIndexDdl } from '../src/index.js'; +import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; /** * #23603 row 7 — the OLD hand-written table composite @@ -83,6 +84,9 @@ describe('row 7 — the old table composite is NULL-distinct, field-level unique }); it('refuses a duplicate on an UNTENANTED install too', async () => { + // Rows with no organization exist only on a database built before 18.x + // (ADR-0131 D1 makes a fresh tenant column NOT NULL). + await asPre18TenantColumn(driver, 'kase'); await driver.create('kase', { case_number: 'CASE-2' }); const err = await refusal(driver.create('kase', { case_number: 'CASE-2' })); expect(isUniqueViolationError(err)).toBe(true); @@ -116,6 +120,9 @@ describe('row 7 — the old table composite is NULL-distinct, field-level unique }); it('ACCEPTS a duplicate untenanted number — SQL UNIQUE is NULL-distinct', async () => { + // Rows with no organization exist only on a database built before 18.x + // (ADR-0131 D1 makes a fresh tenant column NOT NULL). + await asPre18TenantColumn(driver, 'kase'); const first = await driver.create('kase', { case_number: 'CASE-11' }); const second = await driver.create('kase', { case_number: 'CASE-11' }); expect(second.id).not.toBe(first.id); diff --git a/packages/drivers/driver-sql/src/sql-driver-sys-position-organization-unique.test.ts b/packages/drivers/driver-sql/src/sql-driver-sys-position-organization-unique.test.ts index ea6087bdb23..cdb04dc46ff 100644 --- a/packages/drivers/driver-sql/src/sql-driver-sys-position-organization-unique.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-sys-position-organization-unique.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; import { isUniqueViolationError } from '@objectstack/types'; import { SqlDriver, classifyIndexKeyPart, parseIndexDdl, legacyUniqueReplacements } from '../src/index.js'; +import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; /** * #8468 — `sys_position.name`, the THIRD instance of the #8323 class. @@ -316,6 +317,9 @@ describe('#8468 — sys_position.name is unique per organization, not per instal // on it. const d = makeDriver(); await d.initObjects(FIXED_APP as any); + // Rows with no organization exist only on a database built before 18.x + // (ADR-0131 D1 makes a fresh tenant column NOT NULL). + await asPre18TenantColumn(d, 'sys_position'); expect( (await createAsApi(d, 'sys_position', { @@ -349,6 +353,9 @@ describe('#8468 — sys_position.name is unique per organization, not per instal */ const seedDeployed = async (d: SqlDriver) => { await d.initObjects(PRE_FIX_APP as any); + // Every deployment in the field was built before 18.x: its tenant column + // is nullable, and it holds rows with no organization. + await asPre18TenantColumn(d, 'sys_position'); await d.create('sys_position', { id: 'pos1', organization_id: 'org_jia', name: 'sales_manager', label: 'Sales Manager', managed_by: 'admin', } as any); diff --git a/packages/drivers/driver-sql/src/sql-driver-sys-setting-organization-unique.test.ts b/packages/drivers/driver-sql/src/sql-driver-sys-setting-organization-unique.test.ts index ba2ebd0925f..742e1bd11d5 100644 --- a/packages/drivers/driver-sql/src/sql-driver-sys-setting-organization-unique.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-sys-setting-organization-unique.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; import { isUniqueViolationError } from '@objectstack/types'; import { SqlDriver, classifyIndexKeyPart, parseIndexDdl, legacyUniqueReplacements } from '../src/index.js'; +import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; /** * #8555 — `sys_setting`, the SIXTH instance of the #8323 class, and the first @@ -315,6 +316,9 @@ describe('#8555 — sys_setting: the declared unique index becomes per-organizat // key at its own layer. const d = makeDriver(); await d.initObjects(app('fixed') as any); + // Rows with no organization exist only on a database built before 18.x + // (ADR-0131 D1 makes a fresh tenant column NOT NULL). + await asPre18TenantColumn(d, TABLE); const platform = { scope: 'global', namespace: 'branding', key: 'logo_url' }; expect((await createAsApi(d, row('seed1', undefined, platform))).status).toBe(201); @@ -358,6 +362,9 @@ describe('#8555 — sys_setting: the declared unique index becomes per-organizat */ const seedDeployed = async (d: SqlDriver) => { await d.initObjects(app('pre') as any); + // Every deployment in the field was built before 18.x: its tenant column + // is nullable, and it holds rows with no organization. + await asPre18TenantColumn(d, TABLE); await d.create(TABLE, row('r1', 'org_jia', KEY) as any); await d.create(TABLE, row('r2', 'org_jia', CONTROL) as any); await d.create(TABLE, row('r3', undefined, { scope: 'global', key: 'platform_default' }) as any); @@ -586,6 +593,9 @@ describe('#8555 — sys_setting: the declared unique index becomes per-organizat it('AFTER the migration: two platform defaults on the `scope=global` layer are refused', async () => { const d = makeDriver(); await d.initObjects(app('fixed') as any); + // Rows with no organization exist only on a database built before 18.x + // (ADR-0131 D1 makes a fresh tenant column NOT NULL). + await asPre18TenantColumn(d, TABLE); await runIdentityMigration(d); expect((await createAsApi(d, row('a', undefined, globalRow))).status).toBe(201); diff --git a/packages/drivers/driver-sql/src/sql-driver-tenant-column-not-null.test.ts b/packages/drivers/driver-sql/src/sql-driver-tenant-column-not-null.test.ts index 5726671832e..d7edf442ea1 100644 --- a/packages/drivers/driver-sql/src/sql-driver-tenant-column-not-null.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-tenant-column-not-null.test.ts @@ -121,7 +121,9 @@ describe(`${MATRIX}: rotation shards (SQLite)`, () => { it('a newly rotated shard refuses a NULL tenant value at the database, and reports no drift', async () => { await driver.initObjects([ROTATED as any]); - // The sweep-time entry point, a day later, carrying no `tenancy` block. + // The sweep-time entry point, carrying no `tenancy` block: today's shard, + // then the next day's. + await driver.rotateShards(ROTATED as any, T0); const { current, shards } = await driver.rotateShards(ROTATED as any, T0 + DAY); expect(shards.length).toBeGreaterThan(1); diff --git a/packages/drivers/driver-sql/src/sql-driver-tenant-scope-read-doors.test.ts b/packages/drivers/driver-sql/src/sql-driver-tenant-scope-read-doors.test.ts index 8777370c739..e42d85da329 100644 --- a/packages/drivers/driver-sql/src/sql-driver-tenant-scope-read-doors.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-tenant-scope-read-doors.test.ts @@ -68,6 +68,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { SqlDriver } from './index.js'; +import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; const TABLE = 'os6792_account'; const UNSCOPED_TABLE = 'os6792_ledger'; @@ -138,6 +139,9 @@ describe('driver-sql — every read door routes through applyTenantScope (#6792) await driver.create(TABLE, { id: 'b2', organization_id: 'org_b', name: 'B2', balance: 40 } as any); // Platform row: belongs to no tenant, so it belongs to no OTHER tenant // either and must stay visible (#2734). + // p1, the NULL-org row, exists only on a database built before 18.x + // (ADR-0131 D1 makes a fresh tenant column NOT NULL). + await asPre18TenantColumn(driver, TABLE); await driver.create(TABLE, { id: 'p1', name: 'P1', balance: 50 } as any); await driver.create(UNSCOPED_TABLE, { id: 'l1', name: 'L1', balance: 1 } as any); diff --git a/packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts b/packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts index 2dd289621e4..7fb8e9d7015 100644 --- a/packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-tenant-scope.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach, afterEach, assert } from 'vitest'; import { ObjectSchema } from '@objectstack/spec/data'; import { SqlDriver } from '../src/index.js'; +import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; /** * Tenant-scope (organization_id) isolation tests. @@ -136,7 +137,10 @@ describe('SqlDriver tenant scope (organization_id)', () => { describe('NULL tenant column = GLOBAL/platform row (#2734)', () => { beforeEach(async () => { // A platform-seeded row with no organization (bootstrap positions, - // permission sets, business units, pre-org first-boot seeds). + // permission sets, business units, pre-org first-boot seeds) — which + // exists only on a database built before 18.x (ADR-0131 D1 makes a + // fresh tenant column NOT NULL). + await asPre18TenantColumn(driver, 'account'); await driver.create('account', { id: 'g1', name: 'Global', tier: 'global' }); }); @@ -465,6 +469,8 @@ describe('SqlDriver tenant scope (organization_id)', () => { }); it('keeps the NULL-tenant global-row carve-out (#2734) on the union path', async () => { + // A pre-18 database's row with no organization (ADR-0131 D1). + await asPre18TenantColumn(driver, 'account'); await driver.create('account', { id: 'g1', name: 'GLOBAL' }); const rows = await driver.find( 'account', diff --git a/packages/drivers/driver-sql/src/sql-driver-tenant-scoped-declared-unique.test.ts b/packages/drivers/driver-sql/src/sql-driver-tenant-scoped-declared-unique.test.ts index 3fb9824742f..6242d96eff2 100644 --- a/packages/drivers/driver-sql/src/sql-driver-tenant-scoped-declared-unique.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-tenant-scoped-declared-unique.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; import { isUniqueViolationError } from '@objectstack/types'; import { SqlDriver, classifyIndexKeyPart, parseIndexDdl, legacyUniqueReplacements } from '../src/index.js'; +import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; /** * #8554 — the FOURTH act of the #8323 class: five more tenant-scoped objects @@ -416,6 +417,9 @@ describe('#8554 — five tenant-scoped declared unique indexes become per-organi // is what prevents that, and seed upsert-by-name relies on it. const d = makeDriver(); await d.initObjects(app(sub, 'fixed') as any); + // Rows with no organization exist only on a database built before 18.x + // (ADR-0131 D1 makes a fresh tenant column NOT NULL). + await asPre18TenantColumn(d, sub.table); expect((await createAsApi(d, sub.table, row(sub, 'seed1', undefined, sub.key))).status).toBe(201); expect(await createAsApi(d, sub.table, row(sub, 'seed2', undefined, sub.key))).toMatchObject( @@ -455,6 +459,9 @@ describe('#8554 — five tenant-scoped declared unique indexes become per-organi */ const seedDeployed = async (d: SqlDriver) => { await d.initObjects(app(sub, 'pre') as any); + // Every deployment in the field was built before 18.x: its tenant + // column is nullable, and `r3` below is a row with no organization. + await asPre18TenantColumn(d, sub.table); await d.create(sub.table, row(sub, 'r1', 'org_jia', sub.key) as any); await d.create(sub.table, row(sub, 'r2', 'org_jia', sub.control) as any); await d.create(sub.table, row(sub, 'r3', undefined, sub.platformKey) as any); diff --git a/packages/drivers/driver-sql/src/sql-driver-unique-tenancy.test.ts b/packages/drivers/driver-sql/src/sql-driver-unique-tenancy.test.ts index 503d7fcf3a2..e43351665a5 100644 --- a/packages/drivers/driver-sql/src/sql-driver-unique-tenancy.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-unique-tenancy.test.ts @@ -3,6 +3,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { applyConversionsToStoredItem } from '@objectstack/spec'; import { SqlDriver, classifyIndexKeyPart, expectedIndexes, parseIndexDdl } from '../src/index.js'; +import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; /** * Unique-scope materialization: tenancy composites (#3696) + the explicit @@ -575,6 +576,9 @@ describe('SqlDriver unique × tenancy (#3696)', () => { }, ]); + // Rows with no organization exist only on a database built before 18.x + // (ADR-0131 D1 makes a fresh tenant column NOT NULL). + await asPre18TenantColumn(driver, 'account'); const first = await driver.create('account', { email: 'dup@example.com' }); expect(first.organization_id ?? null).toBeNull(); await expect(driver.create('account', { email: 'dup@example.com' })).rejects.toThrow( @@ -594,6 +598,9 @@ describe('SqlDriver unique × tenancy (#3696)', () => { ]); // Platform (NULL-organization) row takes the value… + // Rows with no organization exist only on a database built before 18.x + // (ADR-0131 D1 makes a fresh tenant column NOT NULL). + await asPre18TenantColumn(driver, 'template'); await driver.create('template', { key: 'welcome' }); // …an organization may still hold the same value (per-org scope)… const org = await driver.create('template', { organization_id: 'org_a', key: 'welcome' }); @@ -658,6 +665,9 @@ describe('SqlDriver unique × tenancy (#3696)', () => { await expect( driver.create('case_record', { organization_id: 'org_a', department: 'sales', code: 'C-1' }), ).rejects.toThrow(/UNIQUE constraint failed|duplicate key value/); + // Rows with no organization exist only on a database built before 18.x + // (ADR-0131 D1 makes a fresh tenant column NOT NULL). + await asPre18TenantColumn(driver, 'case_record'); await driver.create('case_record', { department: 'sales', code: 'C-2' }); await expect( driver.create('case_record', { department: 'sales', code: 'C-2' }), @@ -696,6 +706,9 @@ describe('SqlDriver unique × tenancy (#3696)', () => { 'name', ]); + // Rows with no organization exist only on a database built before 18.x + // (ADR-0131 D1 makes a fresh tenant column NOT NULL). + await asPre18TenantColumn(driver, 'team'); await driver.create('team', { name: 'Core' }); await expect(driver.create('team', { name: 'Core' })).rejects.toThrow( /UNIQUE constraint failed|duplicate key value/, diff --git a/packages/drivers/driver-sql/src/sql-driver-upsert-cross-organization-refusal.test.ts b/packages/drivers/driver-sql/src/sql-driver-upsert-cross-organization-refusal.test.ts index c32b1ff2e2c..dba201ec8a8 100644 --- a/packages/drivers/driver-sql/src/sql-driver-upsert-cross-organization-refusal.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-upsert-cross-organization-refusal.test.ts @@ -49,6 +49,7 @@ import { describe, it, expect, beforeAll, beforeEach, afterAll } from 'vitest'; import { isUniqueViolationError } from '@objectstack/types'; import { SqlDriver } from '../src/index.js'; +import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; interface WireBearingError extends Error { @@ -261,6 +262,9 @@ function declareCrossOrganizationRefusal(cell: DialectCell): void { * organization. */ it('refuses a conflict on a row that carries no organization, and leaves it untouched', async () => { + // Rows with no organization exist only on a database built before 18.x + // (ADR-0131 D1 makes a fresh tenant column NOT NULL). + await asPre18TenantColumn(driver, ACCOUNT.name); await knexInstance(ACCOUNT.name).insert({ id: 'os21185_platform_row', email: 'platform@os21185.test', title: 'platform' }); const before = await snapshot(); From a894313052ce3c8649550607b1cf25cc133eb451 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:15:50 +0000 Subject: [PATCH 39/81] wip(driver-turso): fixtures that write organization-less rows model a pre-18 store Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- .../src/pre-18-tenant-column.testkit.ts | 59 +++++++++++++++++++ ...o-local-remote-tenant-scope-parity.test.ts | 5 +- ...e-upsert-cross-organization-parity.test.ts | 6 +- .../src/turso-remote-drift-detection.test.ts | 10 +++- .../turso-remote-inherited-members.test.ts | 27 +++++++-- .../turso-remote-migration-entries.test.ts | 4 ++ .../turso-remote-transaction-refusal.test.ts | 8 +-- 7 files changed, 105 insertions(+), 14 deletions(-) create mode 100644 packages/drivers/driver-turso/src/pre-18-tenant-column.testkit.ts diff --git a/packages/drivers/driver-turso/src/pre-18-tenant-column.testkit.ts b/packages/drivers/driver-turso/src/pre-18-tenant-column.testkit.ts new file mode 100644 index 00000000000..8ba7b5e8125 --- /dev/null +++ b/packages/drivers/driver-turso/src/pre-18-tenant-column.testkit.ts @@ -0,0 +1,59 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { TursoDriver } from './turso-driver.js'; + +type ObjectDef = { name: string; fields?: Record; tenancy?: any; indexes?: any[] }; + +/** + * Build an object's table on either `TursoDriver` face in the shape a database + * built BEFORE 18.x has: its tenant column nullable. + * + * ADR-0131 D1: since 18.x both faces create a table's tenant column NOT NULL. + * A store built by an earlier release still holds the nullable column, and + * rows with no organization where the organization-ownership ceremony reported + * a table rather than clearing it. A suite whose subject is how a face treats + * THOSE rows (the chokepoint's NULL-organization arm, the NULL-safe unique key + * part) builds its table through this, so it keeps measuring that shape. + * + * - **local face:** `initObjects`, then the driver's own drift apply door + * (`relax_not_null`, the SQLite table rebuild), never a hand `ALTER`. + * - **remote face:** that face has no door that relaxes a column (its + * `driftApplyLimit`), so the table is created the way a pre-18 remote sync + * created it — the transport's own `CREATE TABLE` with the tenant column's + * `NOT NULL` left off — and `initObjects` then runs over the existing table + * (its additive sync and declared indexes) as it would on an upgraded store. + */ +export async function initObjectsPre18(driver: TursoDriver, objectDef: ObjectDef): Promise { + const d = driver as any; + const column: string | null = d.computeTenantField(objectDef); + if (column === null) throw new Error(`initObjectsPre18: ${objectDef.name} has no tenant column`); + + if (driver.transportMode !== 'remote') { + await driver.initObjects([objectDef] as never); + const entry = { + kind: 'nullability_mismatch', + remoteName: objectDef.name, + table: objectDef.name, + column, + expected: 'NULL', + actual: 'NOT NULL', + severity: 'warning', + category: 'needs_confirm', + op: { type: 'relax_not_null', table: objectDef.name, column }, + message: `${objectDef.name}.${column}: the pre-18 nullable tenant column (test fixture).`, + }; + const { applied } = await driver.applyMigrationEntries([entry as never]); + if (applied.length !== 1) throw new Error(`initObjectsPre18: ${objectDef.name}.${column} was not relaxed`); + return; + } + + const transport = d.remoteTransport; + const created: string = transport.buildCreateTableSQL(objectDef.name, objectDef); + const constrained = new RegExp(`("${column}" [A-Z]+) NOT NULL`); + if (!constrained.test(created)) { + throw new Error(`initObjectsPre18: the remote CREATE TABLE for ${objectDef.name} does not constrain ${column}`); + } + await transport.ensureConnected(); + await transport.client.execute(created.replace(constrained, '$1')); + await driver.initObjects([objectDef] as never); +} diff --git a/packages/drivers/driver-turso/src/turso-local-remote-tenant-scope-parity.test.ts b/packages/drivers/driver-turso/src/turso-local-remote-tenant-scope-parity.test.ts index 1e814c6257a..c017d79f44f 100644 --- a/packages/drivers/driver-turso/src/turso-local-remote-tenant-scope-parity.test.ts +++ b/packages/drivers/driver-turso/src/turso-local-remote-tenant-scope-parity.test.ts @@ -42,6 +42,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import type { DriverOptions } from '@objectstack/spec/data'; import { TursoDriver } from './turso-driver.js'; import { asLibsqlClient, makeLibsqlSqliteStub, type LibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; +import { initObjectsPre18 } from './pre-18-tenant-column.testkit.js'; interface WireBearingError extends Error { code?: string; @@ -126,7 +127,9 @@ describe('a tenant-scoped call on BOTH TursoDriver faces answers one row set', ( drivers = { local, remote }; for (const driver of Object.values(drivers)) { - await driver.initObjects([{ ...LEDGER, fields: { ...LEDGER.fields } }]); + // The seed's platform row carries no organization, which only a store + // built before 18.x can hold (ADR-0131 D1). + await initObjectsPre18(driver, { ...LEDGER, fields: { ...LEDGER.fields } }); for (const row of SEED) await driver.create(LEDGER.name, { ...row }, UNSCOPED); } }); diff --git a/packages/drivers/driver-turso/src/turso-local-remote-upsert-cross-organization-parity.test.ts b/packages/drivers/driver-turso/src/turso-local-remote-upsert-cross-organization-parity.test.ts index d34b2126019..6001d366e3b 100644 --- a/packages/drivers/driver-turso/src/turso-local-remote-upsert-cross-organization-parity.test.ts +++ b/packages/drivers/driver-turso/src/turso-local-remote-upsert-cross-organization-parity.test.ts @@ -247,9 +247,13 @@ describe("an upsert whose conflict lands on another organization's row, on BOTH const refused = await captureError(() => driver.upsert(ACCOUNT.name, { email: OTHER_EMAIL, title: 'overwritten?' }, ['email'], { tenantId: CALLER_ORG }), ); + // No tenant context, as before; the payload names an organization because + // a fresh tenant column is NOT NULL (ADR-0131 D1) and the database checks + // the proposed row before it resolves the conflict. It names the CALLER's, + // so the merge keeping OTHER_ORG below still proves it was not overwritten. const merged = await driver.upsert( ACCOUNT.name, - { email: OTHER_EMAIL, title: 'merged without context' }, + { email: OTHER_EMAIL, organization_id: CALLER_ORG, title: 'merged without context' }, ['email'], { bypassTenantAudit: true }, ); diff --git a/packages/drivers/driver-turso/src/turso-remote-drift-detection.test.ts b/packages/drivers/driver-turso/src/turso-remote-drift-detection.test.ts index fe13d49705a..81d10c3c9dc 100644 --- a/packages/drivers/driver-turso/src/turso-remote-drift-detection.test.ts +++ b/packages/drivers/driver-turso/src/turso-remote-drift-detection.test.ts @@ -51,6 +51,7 @@ import { createClient, type Client } from '@libsql/client'; import { TursoDriver } from './turso-driver.js'; import { makeLibsqlSqliteStub } from './libsql-sqlite-stub.testkit.js'; import { replicaFiles } from './replica-file.testkit.js'; +import { initObjectsPre18 } from './pre-18-tenant-column.testkit.js'; const scratch = mkdtempSync(join(tmpdir(), 'turso-remote-drift-')); afterAll(() => rmSync(scratch, { recursive: true, force: true })); @@ -147,7 +148,10 @@ type Drift = Awaited>; describe('each drifted shape reads alike on the remote and the local face', () => { /** The physical drift each case puts on disk after a clean remote sync, and the finding it must produce. */ - const CASES: Array<[string, string[], (drift: Drift) => void]> = [ + // The fourth element: the case's rows carry no organization, which only a + // store built before 18.x can hold (ADR-0131 D1), so `tenant_t` is built in + // that shape first (`initObjectsPre18`). + const CASES: Array<[string, string[], (drift: Drift) => void, boolean?]> = [ [ 'an extra physical column the declaration omits', ['ALTER TABLE "t" ADD COLUMN "legacy" TEXT', 'CREATE INDEX "idx_t_legacy" ON "t" ("legacy")'], @@ -182,12 +186,14 @@ describe('each drifted shape reads alike on the remote and the local face', () = (drift) => expect(drift).toContainEqual(expect.objectContaining({ table: 'tenant_t', category: 'destructive', op: expect.objectContaining({ type: 'recreate_index' }), })), + true, ], ]; - it.each(CASES)('%s', async (_label, statements, finding) => { + it.each(CASES)('%s', async (_label, statements, finding, pre18Tenant) => { const file = nextFile(); const { driver: remote } = await remoteOver(file); + if (pre18Tenant) await initObjectsPre18(remote, TENANT); await SYNC.syncSchemasBatch(remote, ALL); for (const sql of statements) await onDisk(file, sql); const local = await localOver(file, ALL); 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 061bf6e7625..da9e947375f 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 @@ -46,6 +46,7 @@ import { afterAll, afterEach, describe, expect, it } from 'vitest'; import { createClient, type Client } from '@libsql/client'; import { SqlDriver } from '@objectstack/driver-sql'; import { REMOTE_FACE_ANSWERS, TursoDriver } from './turso-driver.js'; +import { initObjectsPre18 } from './pre-18-tenant-column.testkit.js'; interface WireBearingError extends Error { code?: string; @@ -100,8 +101,16 @@ const nextFile = () => join(scratch, `db-${++seq}.db`); type Face = 'remote' | 'local'; -/** A driver of the given face over a fresh SQLite file, synced and seeded. */ -async function seeded(face: Face, file = nextFile()): Promise { +/** + * A driver of the given face over a fresh SQLite file, synced and seeded. + * + * `t3` carries no organization, which only a store built before 18.x can hold + * (ADR-0131 D1), so `probe_org` is built in that shape (`initObjectsPre18`). + * `pre18Tenant: false` builds it as 18.x does, through the schema doors alone, + * and seeds only the rows that name an organization — for a case whose subject + * is what those doors count. + */ +async function seeded(face: Face, file = nextFile(), pre18Tenant = true): Promise { const client = face === 'remote' ? createClient({ url: `file:${file}` }) : null; const driver = face === 'remote' @@ -109,9 +118,15 @@ async function seeded(face: Face, file = nextFile()): Promise { : new TursoDriver({ url: `file:${file}` }); open.push({ driver, client }); await driver.connect(); - await driver.initObjects([PROBE, TENANT]); + if (pre18Tenant) { + await driver.initObjects([PROBE]); + await initObjectsPre18(driver, TENANT); + } else { + await driver.initObjects([PROBE, TENANT]); + } await driver.bulkCreate('probe_t', PROBE_ROWS.map((row) => ({ ...row }))); - await driver.bulkCreate('probe_org', TENANT_ROWS.map((row) => ({ ...row }))); + const tenantRows = pre18Tenant ? TENANT_ROWS : TENANT_ROWS.filter((row) => row.organization_id !== null); + await driver.bulkCreate('probe_org', tenantRows.map((row) => ({ ...row }))); return driver; } @@ -405,7 +420,7 @@ 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 }); + expect((await seeded('remote', file, false)).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 }); @@ -415,7 +430,7 @@ describe('getSchemaSyncStats on the remote face: what its schema doors counted', // 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 }); + expect((await seeded('local', nextFile(), false)).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\"", () => { diff --git a/packages/drivers/driver-turso/src/turso-remote-migration-entries.test.ts b/packages/drivers/driver-turso/src/turso-remote-migration-entries.test.ts index 01a99bb828c..00d55e68e42 100644 --- a/packages/drivers/driver-turso/src/turso-remote-migration-entries.test.ts +++ b/packages/drivers/driver-turso/src/turso-remote-migration-entries.test.ts @@ -41,6 +41,7 @@ import { join } from 'node:path'; import { afterAll, afterEach, describe, expect, it } from 'vitest'; import { createClient, type Client } from '@libsql/client'; import { TursoDriver } from './turso-driver.js'; +import { initObjectsPre18 } from './pre-18-tenant-column.testkit.js'; const scratch = mkdtempSync(join(tmpdir(), 'turso-remote-migrate-')); afterAll(() => rmSync(scratch, { recursive: true, force: true })); @@ -196,6 +197,9 @@ describe('the index entries', () => { async function bareTenantKey(duplicates: boolean) { const file = nextFile(); const remote = await remoteOver(file); + // Duplicates the bare key admitted carry no organization, which only a + // store built before 18.x can hold (ADR-0131 D1). + if (duplicates) await initObjectsPre18(remote, TENANT); await remote.syncSchemasBatch([{ object: 'tenant_t', schema: TENANT }]); const synced = await indexesOf(file, 'tenant_t'); await onDisk( diff --git a/packages/drivers/driver-turso/src/turso-remote-transaction-refusal.test.ts b/packages/drivers/driver-turso/src/turso-remote-transaction-refusal.test.ts index 767b386b1fd..aa8452c5b22 100644 --- a/packages/drivers/driver-turso/src/turso-remote-transaction-refusal.test.ts +++ b/packages/drivers/driver-turso/src/turso-remote-transaction-refusal.test.ts @@ -308,14 +308,14 @@ describe('[#18616] the refusal fires on the HANDLE, not on remote mode', () => { it('an explicitly undefined handle is not a handle', async () => { const { driver, stub } = await makeRemote(); - await driver.create('crm_note', { id: 'n2', title: 'also kept' }, { transaction: undefined }); + await driver.create('crm_note', { id: 'n2', organization_id: 'orgA', title: 'also kept' }, { transaction: undefined }); expect(rowCount(stub)).toBe(1); }); it('LOCAL: rollback still undoes the write — the pin this card mirrors', async () => { const driver = await makeLocal(); const trx = await driver.beginTransaction(); - await driver.create('crm_note', { id: 'l1', title: 'RollbackUser' }, { transaction: trx }); + await driver.create('crm_note', { id: 'l1', organization_id: 'orgA', title: 'RollbackUser' }, { transaction: trx }); await driver.rollback(trx); expect(await driver.find('crm_note', { where: { id: 'l1' } })).toHaveLength(0); }); @@ -323,7 +323,7 @@ describe('[#18616] the refusal fires on the HANDLE, not on remote mode', () => { it('LOCAL: commit still keeps the write', async () => { const driver = await makeLocal(); const trx = await driver.beginTransaction(); - await driver.create('crm_note', { id: 'l2', title: 'TrxUser' }, { transaction: trx }); + await driver.create('crm_note', { id: 'l2', organization_id: 'orgA', title: 'TrxUser' }, { transaction: trx }); await driver.commit(trx); expect(await driver.find('crm_note', { where: { id: 'l2' } })).toHaveLength(1); }); @@ -331,7 +331,7 @@ describe('[#18616] the refusal fires on the HANDLE, not on remote mode', () => { it('REPLICA: the third face still opens and rolls back a transaction', async () => { const driver = await makeReplica(); const trx = await driver.beginTransaction(); - await driver.create('crm_note', { id: 'r1', title: 'ReplicaUser' }, { transaction: trx }); + await driver.create('crm_note', { id: 'r1', organization_id: 'orgA', title: 'ReplicaUser' }, { transaction: trx }); await driver.rollback(trx); expect(await driver.find('crm_note', { where: { id: 'r1' } })).toHaveLength(0); }); From 30077cbd9e393c7e34af1a7bddd3f009a1901b8b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:16:25 +0000 Subject: [PATCH 40/81] wip: changeset for the tenant column NOT NULL at CREATE TABLE Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- .../24583-tenant-column-not-null-ddl.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changeset/24583-tenant-column-not-null-ddl.md diff --git a/.changeset/24583-tenant-column-not-null-ddl.md b/.changeset/24583-tenant-column-not-null-ddl.md new file mode 100644 index 00000000000..34fd87052cd --- /dev/null +++ b/.changeset/24583-tenant-column-not-null-ddl.md @@ -0,0 +1,20 @@ +--- +'@objectstack/driver-sql': minor +'@objectstack/driver-turso': minor +'@objectstack/spec': patch +--- + +feat(driver-sql,driver-turso)!: a table the driver creates carries its tenant column NOT NULL, so a write that names no organization is refused at the database + +Clause-②: yes (narrowing) + + + +**BREAKING** accept-set narrowing at the database, shipped as `minor` under the repo's launch-window convention for breaking changes (ADR-0131 D1: "There is no nullable tenant column anywhere"). + +- **Before.** The SQL driver created a table's tenant column (`organization_id`, or a declared `tenancy.tenantField`) nullable, as the injected field's declaration says (`required: false`, no `storage`). A write that reached the driver without an organization landed a row no organization owns. +- **Now.** Every table the driver CREATES takes its tenant column `NOT NULL`, with no metadata change: the plain managed table (`initObjects`), every newly rotated shard (asked of the base table's declaration), and a table the Turso remote face creates (`syncSchema`, `syncSchemasBatch`, `initObjects`). A write with no organization on such a table is refused by the database (`NOT NULL constraint failed: .organization_id`, `violates not-null constraint`, or `cannot be null`). That includes an `upsert` whose payload omits the organization: the database checks the proposed row before it resolves the conflict. +- **Unchanged.** A tenant column ADDED to a table that already exists stays nullable, and so does an existing shard or an existing remote table: a table that already holds rows is the organization-ownership ceremony's to tighten (`os migrate organization-ownership`), which reports a shard and the Turso remote face as unable to. The schema differ reports no drift over the tenant column's nullability either way. An object with no tenant column (`tenancy: { enabled: false }`, `systemFields: { tenant: false }`) is untouched, even when it carries a field named `organization_id`. +- **Who sees it.** A composed `os serve` / `os dev` / `os start` with auth stamps every write with its organization, so nothing changes there. A production `os serve` whose `AuthPlugin` did not compose (no `OS_AUTH_SECRET`) and a lean embedding (`@objectstack/objectql/core` or `createStandaloneStack` over a SQL driver, with no organization object) used to write those rows with a NULL organization; now each such write fails, loudly. + +**The remedy.** Carry the organization on the write: `ql.insert(object, { ...row, organization_id: orgId })`, or `{ context: { tenantId: orgId } }`. Compose authentication (set `OS_AUTH_SECRET`) so the platform derives it. If the object's rows legitimately belong to no organization, declare it so and it has no tenant column: `systemFields: { tenant: false }` or `tenancy: { enabled: false }`. From 08ebe7e0643dbdf39db69396e338c3897cb56278 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:30:22 +0000 Subject: [PATCH 41/81] wip(plugin-sharing): fixtures name their organization, or model a pre-18 store Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- .../reconcile-refused-grant-continues.test.ts | 17 +++++++++++++++ ...cord-share-guest-recipient-retired.test.ts | 4 +++- .../record-share-organization-stamp.test.ts | 21 ++++++++++++++++++- .../src/rule-criteria-org-scope.test.ts | 17 +++++++++++++++ .../src/share-link-internal-columns.test.ts | 4 +++- .../src/share-link-organization-stamp.test.ts | 20 +++++++++++++++++- .../src/share-link-password.test.ts | 7 ++++++- ...ink-service.email-refusal-cost.pin.test.ts | 4 +++- 8 files changed, 88 insertions(+), 6 deletions(-) diff --git a/packages/plugins/plugin-sharing/src/reconcile-refused-grant-continues.test.ts b/packages/plugins/plugin-sharing/src/reconcile-refused-grant-continues.test.ts index 26d77bb9313..d13d96d6c09 100644 --- a/packages/plugins/plugin-sharing/src/reconcile-refused-grant-continues.test.ts +++ b/packages/plugins/plugin-sharing/src/reconcile-refused-grant-continues.test.ts @@ -60,6 +60,22 @@ import type { ExecutionContext } from '@objectstack/spec/kernel'; import { SharingService } from './sharing-service.js'; import { SharingRuleService } from './sharing-rule-service.js'; +/** + * Return driver-created tables to a pre-18 store's shape, tenant column + * nullable, through the driver's own drift apply door (`relax_not_null`). + * The organization-less rows this file seeds exist only on such a store: + * ADR-0131 D1 makes a fresh tenant column NOT NULL. + */ +async function relaxTenantColumns(driver: SqlDriver, tables: readonly string[]): Promise { + for (const table of tables) { + const op = { type: 'relax_not_null', table, column: 'organization_id' }; + const { applied } = await driver.applyMigrationEntries([ + { kind: 'nullability_mismatch', remoteName: table, table, column: 'organization_id', category: 'needs_confirm', op } as never, + ]); + if (applied.length !== 1) throw new Error(`relaxTenantColumns: ${table} was not relaxed`); + } +} + const OBJECT = 'os14754_deal'; const ORG_A = 'org_a'; const ORG_B = 'org_b'; @@ -163,6 +179,7 @@ async function boot(posture: 'single' | 'isolated' | 'group' = 'isolated') { { name: 'sys_record_share', fields: SHARE_FIELDS } as never, { name: 'sys_sharing_rule', fields: RULE_FIELDS } as never, ]); + await relaxTenantColumns(driver, [OBJECT, 'sys_record_share', 'sys_sharing_rule']); await driver.create(OBJECT, { id: 'rec_first', stage: 'won', owner_id: 'u_a', organization_id: ORG_A } as never); await driver.create(OBJECT, { id: 'rec_orgless', stage: 'won', owner_id: 'u_p' } as never); diff --git a/packages/plugins/plugin-sharing/src/record-share-guest-recipient-retired.test.ts b/packages/plugins/plugin-sharing/src/record-share-guest-recipient-retired.test.ts index 253249d71a9..9520cfad90d 100644 --- a/packages/plugins/plugin-sharing/src/record-share-guest-recipient-retired.test.ts +++ b/packages/plugins/plugin-sharing/src/record-share-guest-recipient-retired.test.ts @@ -23,7 +23,9 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { SysRecordShare } from './objects/sys-record-share.object.js'; -const SYS = { context: { isSystem: true } } as never; +// The write names its organization: a fresh tenant column is NOT NULL (ADR-0131 +// D1), and this lean engine registers no organization object to derive one from. +const SYS = { context: { isSystem: true, tenantId: 'org_os_qa' } } as never; /** The four values a row may carry, in the select's declared order. */ const ROW_RECIPIENTS = ['user', 'group', 'position', 'unit_and_subordinates']; diff --git a/packages/plugins/plugin-sharing/src/record-share-organization-stamp.test.ts b/packages/plugins/plugin-sharing/src/record-share-organization-stamp.test.ts index aa8fe9d144a..d8e033ca741 100644 --- a/packages/plugins/plugin-sharing/src/record-share-organization-stamp.test.ts +++ b/packages/plugins/plugin-sharing/src/record-share-organization-stamp.test.ts @@ -91,6 +91,20 @@ const ORG_FIELDS: Record> = { name: { type: 'text', name: 'name', label: 'Name' }, }; +/** + * Return driver-created tables to a pre-18 store's shape, tenant column + * nullable, through the driver's own drift apply door (`relax_not_null`). + */ +async function relaxTenantColumns(driver: SqlDriver, tables: readonly string[]): Promise { + for (const table of tables) { + const op = { type: 'relax_not_null', table, column: 'organization_id' }; + const { applied } = await driver.applyMigrationEntries([ + { kind: 'nullability_mismatch', remoteName: table, table, column: 'organization_id', category: 'needs_confirm', op } as never, + ]); + if (applied.length !== 1) throw new Error(`relaxTenantColumns: ${table} was not relaxed`); + } +} + const ORG_A = 'org_a'; const ORG_B = 'org_b'; @@ -150,7 +164,8 @@ async function boot(posture?: 'single' | 'isolated' | 'group', organizations: re // `no-organization-yet` reading the earlier blocks rely on — and provisioned // only for the cases that need the install to hold several. if (organizations.length > 0) { - ql.registerObject({ name: 'sys_organization', label: 'Organization', isSystem: true, fields: ORG_FIELDS } as never); + // The real `sys_organization` carries no tenant column of its own. + ql.registerObject({ name: 'sys_organization', label: 'Organization', isSystem: true, systemFields: { tenant: false }, fields: ORG_FIELDS } as never); } await driver.initObjects([ { name: OBJECT, fields: DEAL_FIELDS } as never, @@ -158,6 +173,10 @@ async function boot(posture?: 'single' | 'isolated' | 'group', organizations: re { name: 'sys_sharing_rule', fields: RULE_FIELDS } as never, ...(organizations.length > 0 ? [{ name: 'sys_organization', fields: ORG_FIELDS } as never] : []), ]); + // `deal_p1` and its organization-less grants exist only on a store built + // before 18.x, whose tenant columns are nullable (ADR-0131 D1 makes a fresh + // one NOT NULL): the fixture is that store. + await relaxTenantColumns(driver, [OBJECT, 'sys_record_share', 'sys_sharing_rule']); for (const id of organizations) await driver.create('sys_organization', { id, name: id } as never); // Seeded through the driver: the fixture is the DATA at rest. diff --git a/packages/plugins/plugin-sharing/src/rule-criteria-org-scope.test.ts b/packages/plugins/plugin-sharing/src/rule-criteria-org-scope.test.ts index 3bc2ebc03e8..b989233dc91 100644 --- a/packages/plugins/plugin-sharing/src/rule-criteria-org-scope.test.ts +++ b/packages/plugins/plugin-sharing/src/rule-criteria-org-scope.test.ts @@ -86,6 +86,22 @@ import type { ExecutionContext } from '@objectstack/spec/kernel'; import { SharingService } from './sharing-service.js'; import { SharingRuleService } from './sharing-rule-service.js'; +/** + * Return driver-created tables to a pre-18 store's shape, tenant column + * nullable, through the driver's own drift apply door (`relax_not_null`). + * The organization-less rows this file seeds exist only on such a store: + * ADR-0131 D1 makes a fresh tenant column NOT NULL. + */ +async function relaxTenantColumns(driver: SqlDriver, tables: readonly string[]): Promise { + for (const table of tables) { + const op = { type: 'relax_not_null', table, column: 'organization_id' }; + const { applied } = await driver.applyMigrationEntries([ + { kind: 'nullability_mismatch', remoteName: table, table, column: 'organization_id', category: 'needs_confirm', op } as never, + ]); + if (applied.length !== 1) throw new Error(`relaxTenantColumns: ${table} was not relaxed`); + } +} + const OBJECT = 'os10119_deal'; const DEAL_FIELDS: Record> = { @@ -206,6 +222,7 @@ async function boot(posture?: 'single' | 'isolated' | 'group'): Promise { name: 'sys_record_share', fields: SHARE_FIELDS } as never, { name: 'sys_sharing_rule', fields: RULE_FIELDS } as never, ]); + await relaxTenantColumns(driver, [OBJECT, 'sys_record_share', 'sys_sharing_rule']); // Seeded through the driver, not the engine: the fixture is the DATA at // rest, and routing it through the engine's write path would drag the diff --git a/packages/plugins/plugin-sharing/src/share-link-internal-columns.test.ts b/packages/plugins/plugin-sharing/src/share-link-internal-columns.test.ts index dcfe71845b9..7b101ff972c 100644 --- a/packages/plugins/plugin-sharing/src/share-link-internal-columns.test.ts +++ b/packages/plugins/plugin-sharing/src/share-link-internal-columns.test.ts @@ -110,7 +110,9 @@ async function boot() { await engine.init(); for (const o of [SysShareLink, TARGET]) engine.registry.registerObject(o as never, '@objectstack/plugin-sharing'); await engine.syncSchemas(); - await (engine as any).insert('pin_doc', { id: 'doc_1', title: 'Shared doc' }, { context: { isSystem: true } }); + // Seeded under one organization: a fresh tenant column is NOT NULL (ADR-0131 + // D1), and this lean engine registers no organization object to derive one from. + await (engine as any).insert('pin_doc', { id: 'doc_1', title: 'Shared doc' }, { context: { isSystem: true, tenantId: 'org_pin' } }); const service = new ShareLinkService({ engine: engine as unknown as SharingEngine }); const http = new MockHttp(); registerShareLinkRoutes(http, service, engine as unknown as SharingEngine, { diff --git a/packages/plugins/plugin-sharing/src/share-link-organization-stamp.test.ts b/packages/plugins/plugin-sharing/src/share-link-organization-stamp.test.ts index 590ab9bb58e..1d5d6a0bc0d 100644 --- a/packages/plugins/plugin-sharing/src/share-link-organization-stamp.test.ts +++ b/packages/plugins/plugin-sharing/src/share-link-organization-stamp.test.ts @@ -37,6 +37,22 @@ import type { ExecutionContext } from '@objectstack/spec/kernel'; import { ShareLinkService } from './share-link-service.js'; import { SysShareLink } from './objects/index.js'; +/** + * Return driver-created tables to a pre-18 store's shape, tenant column + * nullable, through the driver's own drift apply door (`relax_not_null`). + * The organization-less rows this file seeds exist only on such a store: + * ADR-0131 D1 makes a fresh tenant column NOT NULL. + */ +async function relaxTenantColumns(driver: SqlDriver, tables: readonly string[]): Promise { + for (const table of tables) { + const op = { type: 'relax_not_null', table, column: 'organization_id' }; + const { applied } = await driver.applyMigrationEntries([ + { kind: 'nullability_mismatch', remoteName: table, table, column: 'organization_id', category: 'needs_confirm', op } as never, + ]); + if (applied.length !== 1) throw new Error(`relaxTenantColumns: ${table} was not relaxed`); + } +} + const OBJECT = 'os15212_brief'; const ORG_A = 'org_a'; const ORG_B = 'org_b'; @@ -78,10 +94,12 @@ async function boot(posture: 'single' | 'isolated', organizations: readonly stri await ql.init(); ql.registerObject({ name: OBJECT, label: 'Brief', fields: BRIEF_FIELDS } as never); ql.registerObject(SysShareLink as never); - ql.registerObject({ name: 'sys_organization', label: 'Organization', isSystem: true, fields: ORG_FIELDS } as never); + // The real `sys_organization` carries no tenant column of its own. + ql.registerObject({ name: 'sys_organization', label: 'Organization', isSystem: true, systemFields: { tenant: false }, fields: ORG_FIELDS } as never); await driver.initObjects( [OBJECT, 'sys_share_link', 'sys_organization'].map((name) => ({ name, fields: (ql.getSchema(name) as any).fields }) as never), ); + await relaxTenantColumns(driver, [OBJECT, 'sys_share_link']); for (const id of organizations) await driver.create('sys_organization', { id, name: id } as never); await driver.create(OBJECT, { id: 'brief_a', name: 'A', organization_id: ORG_A } as never); await driver.create(OBJECT, { id: 'brief_p', name: 'P' } as never); diff --git a/packages/plugins/plugin-sharing/src/share-link-password.test.ts b/packages/plugins/plugin-sharing/src/share-link-password.test.ts index c2536402e0b..294377712d6 100644 --- a/packages/plugins/plugin-sharing/src/share-link-password.test.ts +++ b/packages/plugins/plugin-sharing/src/share-link-password.test.ts @@ -190,6 +190,8 @@ async function drive( const B = '/api/v1/share-links'; const CREATOR = { userId: 'usr_creator' }; +/** The organization the fixture's records belong to. */ +const SEED_ORG = 'org_pin'; const PASSWORD = 'correct horse 21839 $ battery'; /** @@ -249,7 +251,10 @@ async function boot( engine.registry.registerObject(o as never, '@objectstack/plugin-sharing'); } await engine.syncSchemas(); - const sys = { context: { isSystem: true } }; + // Seeded under one organization, as every row on a real install is: a fresh + // tenant column is NOT NULL (ADR-0131 D1), and this lean engine registers no + // organization object to derive one from. A minted link takes its record's. + const sys = { context: { isSystem: true, tenantId: SEED_ORG } }; await (engine as any).insert('pin_doc', { id: 'doc_1', title: 'Shared doc' }, sys); await (engine as any).insert('ai_conversations', { id: 'conv_1', title: 'Chat' }, sys); await (engine as any).insert( diff --git a/packages/plugins/plugin-sharing/src/share-link-service.email-refusal-cost.pin.test.ts b/packages/plugins/plugin-sharing/src/share-link-service.email-refusal-cost.pin.test.ts index 7f08b1f9e7a..1169940bed6 100644 --- a/packages/plugins/plugin-sharing/src/share-link-service.email-refusal-cost.pin.test.ts +++ b/packages/plugins/plugin-sharing/src/share-link-service.email-refusal-cost.pin.test.ts @@ -84,7 +84,9 @@ beforeAll(async () => { await engine.init(); for (const o of [SysShareLink, TARGET]) engine.registry.registerObject(o as any, '@objectstack/plugin-sharing-test'); await engine.syncSchemas(); - await engine.insert(OBJECT, { id: RECORD, title: 'Shared doc' }, { context: { isSystem: true } } as any); + // Seeded under one organization: a fresh tenant column is NOT NULL (ADR-0131 + // D1), and this lean engine registers no organization object to derive one from. + await engine.insert(OBJECT, { id: RECORD, title: 'Shared doc' }, { context: { isSystem: true, tenantId: 'org_pin' } } as any); // Count the privileged hash read — the `password_hash` dereference the // internal-column strip forces through the engine's accessor. From 8aabcfacaef3c860a6eb3043cab2d1b04252de53 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:37:07 +0000 Subject: [PATCH 42/81] wip(service-messaging): lean fixture engines hold a Default Organization Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- .../src/actor-reference.pin.test.ts | 4 +++ .../src/default-organization.testkit.ts | 32 +++++++++++++++++++ ...livery-headers-at-rest.integration.test.ts | 4 +++ .../dispatcher-idle-cost.integration.test.ts | 4 +++ ...p-dispatcher-idle-cost.integration.test.ts | 4 +++ ...ox-ack-claim-ownership.integration.test.ts | 4 +++ ...p-outbox-parked-record.integration.test.ts | 4 +++ ...http-signature-at-rest.integration.test.ts | 4 +++ .../http-delivery-display-title.test.ts | 4 +++ .../notification-display-title.test.ts | 4 +++ ...ox-ack-claim-ownership.integration.test.ts | 4 +++ ...utbox-ack-precondition.integration.test.ts | 4 +++ ...redential-lossy-column.integration.test.ts | 4 +++ .../plugin-enqueue-wakes-dispatcher.test.ts | 4 +++ ...liver-concurrent-claim.integration.test.ts | 4 +++ 15 files changed, 88 insertions(+) create mode 100644 packages/services/service-messaging/src/default-organization.testkit.ts diff --git a/packages/services/service-messaging/src/actor-reference.pin.test.ts b/packages/services/service-messaging/src/actor-reference.pin.test.ts index 0bc3e2077a6..3b5488f512d 100644 --- a/packages/services/service-messaging/src/actor-reference.pin.test.ts +++ b/packages/services/service-messaging/src/actor-reference.pin.test.ts @@ -22,6 +22,7 @@ import { InboxMessage } from './objects/inbox-message.object.js'; import { NotificationReceipt } from './objects/notification-receipt.object.js'; import { MessagingService } from './messaging-service.js'; import { createInboxChannel } from './inbox-channel.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYS = { context: { isSystem: true } } as const; const GHOST = 'usr_ghost_21913'; @@ -47,6 +48,9 @@ beforeEach(async () => { engine.registry.registerObject(InboxMessage as any, '@objectstack/service-messaging'); engine.registry.registerObject(NotificationReceipt as any, '@objectstack/service-messaging'); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const user = await engine.insert('sys_user', { name: 'Ada', email: 'ada@example.test' }, SYS); userId = String((user as any).id); }); diff --git a/packages/services/service-messaging/src/default-organization.testkit.ts b/packages/services/service-messaging/src/default-organization.testkit.ts new file mode 100644 index 00000000000..0619d628750 --- /dev/null +++ b/packages/services/service-messaging/src/default-organization.testkit.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Give a lean test engine what every real `single` install has: an + * organization object holding its Default Organization. + * + * ADR-0131 D1: a table the SQL driver creates carries its tenant column NOT + * NULL. The engine stamps an organization-less system write with the derived + * organization under `single` (D9) — but only where the composition registers + * the organization object; a bare `ObjectQL` + `SqlDriver` registers none, so + * the write reaches the database with no organization and is refused there. + * A suite whose subject is not tenancy (claims, acks, idle cost, at-rest + * shapes) calls this once its own objects are registered, so its system + * writes land the way they do on a real install. + * + * The object mirrors the platform's: managed by better-auth, so it carries no + * tenant column of its own. + */ +export const DEFAULT_ORGANIZATION_ID = 'org_default'; + +export async function installDefaultOrganization(engine: { + registry: { registerObject(def: unknown, owner: string): unknown }; + syncSchemas(): Promise; + insert(object: string, data: Record, options?: unknown): Promise; +}): Promise { + engine.registry.registerObject( + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, + '@objectstack/service-messaging', + ); + await engine.syncSchemas(); + await engine.insert('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }, { context: { isSystem: true } }); +} diff --git a/packages/services/service-messaging/src/delivery-headers-at-rest.integration.test.ts b/packages/services/service-messaging/src/delivery-headers-at-rest.integration.test.ts index 45cd2ced109..8a3189d7cc3 100644 --- a/packages/services/service-messaging/src/delivery-headers-at-rest.integration.test.ts +++ b/packages/services/service-messaging/src/delivery-headers-at-rest.integration.test.ts @@ -54,6 +54,7 @@ import { SqlHttpOutbox } from './sql-http-outbox.js'; import { HttpDispatcher } from './http-dispatcher.js'; import { HttpDelivery, SYS_HTTP_DELIVERY } from './objects/http-delivery.object.js'; import type { FetchImpl } from './http-sender.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** * [#10740] `IHttpOutbox.redeliver` now requires its caller to state the @@ -108,6 +109,9 @@ describe('sys_http_delivery — authored headers vs the data API (#8118)', () => engine.registry.registerObject(HttpDelivery as any, '@objectstack/service-messaging'); // Real DDL through the real path — physical columns exactly as prod. await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); return { driver, engine: engine! }; } diff --git a/packages/services/service-messaging/src/dispatcher-idle-cost.integration.test.ts b/packages/services/service-messaging/src/dispatcher-idle-cost.integration.test.ts index d54d192165d..0100d688896 100644 --- a/packages/services/service-messaging/src/dispatcher-idle-cost.integration.test.ts +++ b/packages/services/service-messaging/src/dispatcher-idle-cost.integration.test.ts @@ -41,6 +41,7 @@ import { NotificationDelivery } from './objects/notification-delivery.object.js' import { NotificationDispatcher } from './dispatcher.js'; import type { MessagingChannel } from './channel.js'; import type { INotificationOutbox } from './outbox.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** The production default. */ const PARTITIONS = 8; @@ -85,6 +86,9 @@ beforeEach(async () => { await engine.init(); engine.registry.registerObject(NotificationDelivery as any, '@objectstack/service-messaging'); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); calls = { update: 0, find: 0, other: 0 }; sent = []; diff --git a/packages/services/service-messaging/src/http-dispatcher-idle-cost.integration.test.ts b/packages/services/service-messaging/src/http-dispatcher-idle-cost.integration.test.ts index af74f3364a4..628407e8269 100644 --- a/packages/services/service-messaging/src/http-dispatcher-idle-cost.integration.test.ts +++ b/packages/services/service-messaging/src/http-dispatcher-idle-cost.integration.test.ts @@ -42,6 +42,7 @@ import { HttpDispatcher } from './http-dispatcher.js'; import { hashPartition } from './backoff.js'; import type { FetchImpl } from './http-sender.js'; import type { EnqueueHttpInput, IHttpOutbox } from './http-outbox.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** The production default. */ const PARTITIONS = 8; @@ -86,6 +87,9 @@ beforeEach(async () => { await engine.init(); engine.registry.registerObject(HttpDelivery as any, '@objectstack/service-messaging'); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); calls = { update: 0, find: 0, other: 0 }; posted = []; diff --git a/packages/services/service-messaging/src/http-outbox-ack-claim-ownership.integration.test.ts b/packages/services/service-messaging/src/http-outbox-ack-claim-ownership.integration.test.ts index f122a5009c6..8ac892bbe52 100644 --- a/packages/services/service-messaging/src/http-outbox-ack-claim-ownership.integration.test.ts +++ b/packages/services/service-messaging/src/http-outbox-ack-claim-ownership.integration.test.ts @@ -70,6 +70,7 @@ import type { HttpDelivery as HttpDeliveryRow, IHttpOutbox, } from './http-outbox.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const TTL = 60_000; /** Step-1 instant: node A's claim. */ @@ -119,6 +120,9 @@ function sqlBackend(): Backend { await engine.init(); engine.registry.registerObject(HttpDelivery as any, '@objectstack/service-messaging'); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); return new SqlHttpOutbox(engine as any, { partitionCount: 1 }); }, async destroy() { diff --git a/packages/services/service-messaging/src/http-outbox-parked-record.integration.test.ts b/packages/services/service-messaging/src/http-outbox-parked-record.integration.test.ts index d76fabaf05a..76cbcb923f2 100644 --- a/packages/services/service-messaging/src/http-outbox-parked-record.integration.test.ts +++ b/packages/services/service-messaging/src/http-outbox-parked-record.integration.test.ts @@ -54,6 +54,7 @@ import { HttpDispatcher } from './http-dispatcher.js'; import { HttpRedeliverError, type IHttpOutbox } from './http-outbox.js'; import { HttpDelivery, SYS_HTTP_DELIVERY } from './objects/http-delivery.object.js'; import type { FetchImpl } from './http-sender.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** * [#10740] `IHttpOutbox.redeliver` now requires its caller to state the @@ -106,6 +107,9 @@ describe('sys_http_delivery — parked drop records are not redeliverable (#8069 await engine.init(); engine.registry.registerObject(HttpDelivery as any, '@objectstack/service-messaging'); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); return { driver, engine: engine! }; } diff --git a/packages/services/service-messaging/src/http-signature-at-rest.integration.test.ts b/packages/services/service-messaging/src/http-signature-at-rest.integration.test.ts index e2eeeb57c70..73e519b373e 100644 --- a/packages/services/service-messaging/src/http-signature-at-rest.integration.test.ts +++ b/packages/services/service-messaging/src/http-signature-at-rest.integration.test.ts @@ -44,6 +44,7 @@ import { SqlHttpOutbox } from './sql-http-outbox.js'; import { HttpDispatcher } from './http-dispatcher.js'; import { HttpDelivery, SYS_HTTP_DELIVERY } from './objects/http-delivery.object.js'; import type { FetchImpl } from './http-sender.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** * [#10740] `IHttpOutbox.redeliver` now requires its caller to state the @@ -109,6 +110,9 @@ describe('sys_http_delivery — signing secret at rest (#7722)', () => { // driver from the object definition, so its physical columns are // exactly the ones production would have. await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); return { driver, engine: engine! }; } diff --git a/packages/services/service-messaging/src/objects/http-delivery-display-title.test.ts b/packages/services/service-messaging/src/objects/http-delivery-display-title.test.ts index db7c6b8a4f8..c54fd8aa5a9 100644 --- a/packages/services/service-messaging/src/objects/http-delivery-display-title.test.ts +++ b/packages/services/service-messaging/src/objects/http-delivery-display-title.test.ts @@ -22,6 +22,7 @@ import { ObjectQL, resolveRecordTitle } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resolveDisplayField } from '@objectstack/spec/data'; import { HttpDelivery } from './http-delivery.object.js'; +import { installDefaultOrganization } from '../default-organization.testkit.js'; const SYS = { context: { isSystem: true } } as any; @@ -75,6 +76,9 @@ describe('[#20015] sys_http_delivery resolves a real record title under ADR-0079 await engine.init(); engine.registry.registerObject(HttpDelivery as any, 'com.objectstack.test.20015'); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); }); afterAll(async () => { diff --git a/packages/services/service-messaging/src/objects/notification-display-title.test.ts b/packages/services/service-messaging/src/objects/notification-display-title.test.ts index aaae125e24c..29e00ca1238 100644 --- a/packages/services/service-messaging/src/objects/notification-display-title.test.ts +++ b/packages/services/service-messaging/src/objects/notification-display-title.test.ts @@ -50,6 +50,7 @@ import { NotificationDelivery } from './notification-delivery.object.js'; import { NotificationPreference } from './notification-preference.object.js'; import { NotificationReceipt } from './notification-receipt.object.js'; import { NotificationSubscription } from './notification-subscription.object.js'; +import { installDefaultOrganization } from '../default-organization.testkit.js'; const SYS = { context: { isSystem: true } } as any; const NOW = new Date('2026-09-25T00:00:00Z'); @@ -164,6 +165,9 @@ describe('[#20044] notification objects resolve a real record title under ADR-00 engine.registry.registerObject(schema, 'com.objectstack.test.20044'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); }); afterAll(async () => { diff --git a/packages/services/service-messaging/src/outbox-ack-claim-ownership.integration.test.ts b/packages/services/service-messaging/src/outbox-ack-claim-ownership.integration.test.ts index f9183516d65..a9638df8430 100644 --- a/packages/services/service-messaging/src/outbox-ack-claim-ownership.integration.test.ts +++ b/packages/services/service-messaging/src/outbox-ack-claim-ownership.integration.test.ts @@ -52,6 +52,7 @@ import { MemoryNotificationOutbox } from './memory-outbox.js'; import { SqlNotificationOutbox } from './sql-outbox.js'; import { NotificationDelivery } from './objects/notification-delivery.object.js'; import type { INotificationOutbox, NotificationDeliveryRecord } from './outbox.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** The refusal identity — `name` + ADR-0112 `code`, never a bare throw. */ const REFUSAL = { name: 'NotificationAckError', code: 'DELIVERY_NOT_ELIGIBLE' }; @@ -97,6 +98,9 @@ function sqlBackend(): Backend { await engine.init(); engine.registry.registerObject(NotificationDelivery as any, '@objectstack/service-messaging'); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); return new SqlNotificationOutbox(engine as any, { partitionCount: 1 }); }, async destroy() { diff --git a/packages/services/service-messaging/src/outbox-ack-precondition.integration.test.ts b/packages/services/service-messaging/src/outbox-ack-precondition.integration.test.ts index 6a224c01b63..a0b4d4ec4c7 100644 --- a/packages/services/service-messaging/src/outbox-ack-precondition.integration.test.ts +++ b/packages/services/service-messaging/src/outbox-ack-precondition.integration.test.ts @@ -58,6 +58,7 @@ import { MemoryNotificationOutbox } from './memory-outbox.js'; import { SqlNotificationOutbox } from './sql-outbox.js'; import { NotificationDelivery } from './objects/notification-delivery.object.js'; import type { ClaimedDeliveryRecord, INotificationOutbox, NotificationDeliveryRecord } from './outbox.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** * The refusal identity both backends must produce. `DELIVERY_NOT_ELIGIBLE` is @@ -96,6 +97,9 @@ function sqlBackend(): Backend { await engine.init(); engine.registry.registerObject(NotificationDelivery as any, '@objectstack/service-messaging'); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); return new SqlNotificationOutbox(engine as any, { partitionCount: 1 }); }, async destroy() { diff --git a/packages/services/service-messaging/src/outbox-claim-credential-lossy-column.integration.test.ts b/packages/services/service-messaging/src/outbox-claim-credential-lossy-column.integration.test.ts index 59b3464df64..0008d2817f1 100644 --- a/packages/services/service-messaging/src/outbox-claim-credential-lossy-column.integration.test.ts +++ b/packages/services/service-messaging/src/outbox-claim-credential-lossy-column.integration.test.ts @@ -62,6 +62,7 @@ import { NotificationDelivery } from './objects/notification-delivery.object.js' import { HttpDelivery } from './objects/http-delivery.object.js'; import type { ClaimedDeliveryRecord, INotificationOutbox, NotificationDeliveryRecord } from './outbox.js'; import type { HttpDelivery as HttpDeliveryRow, IHttpOutbox } from './http-outbox.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; // ── The `real` column double ──────────────────────────────────────────────── @@ -175,6 +176,9 @@ async function sqliteEngine(...objects: unknown[]): Promise { await engine.init(); for (const object of objects) engine.registry.registerObject(object as any, '@objectstack/service-messaging'); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); return engine; } diff --git a/packages/services/service-messaging/src/plugin-enqueue-wakes-dispatcher.test.ts b/packages/services/service-messaging/src/plugin-enqueue-wakes-dispatcher.test.ts index 2cd804df0a6..f939def6bca 100644 --- a/packages/services/service-messaging/src/plugin-enqueue-wakes-dispatcher.test.ts +++ b/packages/services/service-messaging/src/plugin-enqueue-wakes-dispatcher.test.ts @@ -40,6 +40,7 @@ import type { MessagingService } from './messaging-service.js'; import { DELIVERY_OBJECT } from './sql-outbox.js'; import { SYS_HTTP_DELIVERY } from './objects/http-delivery.object.js'; import { hashPartition } from './backoff.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** No timer-driven tick inside the test's lifetime. */ const DISPATCH_INTERVAL_MS = 60_000; @@ -93,6 +94,9 @@ async function bootMessagingKernel() { objectql.registerDriver(driver, true); openDrivers.push(driver); await objectql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(objectql); return { engine: kernel.getService('data'), diff --git a/packages/services/service-messaging/src/redeliver-concurrent-claim.integration.test.ts b/packages/services/service-messaging/src/redeliver-concurrent-claim.integration.test.ts index 559e9f3deb6..5dad6b5f6e3 100644 --- a/packages/services/service-messaging/src/redeliver-concurrent-claim.integration.test.ts +++ b/packages/services/service-messaging/src/redeliver-concurrent-claim.integration.test.ts @@ -39,6 +39,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { SqlHttpOutbox } from './sql-http-outbox.js'; import { MemoryHttpOutbox } from './memory-http-outbox.js'; import { HttpDelivery, SYS_HTTP_DELIVERY } from './objects/http-delivery.object.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; let engine: ObjectQL; let driver: SqlDriver; @@ -54,6 +55,9 @@ beforeEach(async () => { await engine.init(); engine.registry.registerObject(HttpDelivery as any, '@objectstack/service-messaging'); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); }); afterEach(async () => { From 0dc0359d6993bb83d76d0fd29c37849eddbb1166 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:46:24 +0000 Subject: [PATCH 43/81] wip(plugin-audit): lean fixture engines hold a Default Organization Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- ...hanged-fields-backfill.integration.test.ts | 4 +++ .../src/activity-field-redaction.test.ts | 4 +++ ...ctivity-milestone-kind.integration.test.ts | 4 +++ .../src/activity-predicate-guard.test.ts | 4 +++ ...tivity-read-visibility.integration.test.ts | 4 +++ ...tivity-withheld-update.integration.test.ts | 4 +++ .../src/audit-log-field-redaction.test.ts | 4 +++ .../src/audit-log-predicate-guard.test.ts | 4 +++ ...it-log-read-visibility.integration.test.ts | 4 +++ .../audit-record-label-display-field.test.ts | 4 +++ ...e-locale-settings-seam.integration.test.ts | 4 +++ ...ition-follows-metadata.integration.test.ts | 4 +++ ...t-writers.own-ui-state.integration.test.ts | 4 +++ .../comment-edit-stamp.integration.test.ts | 4 +++ .../comment-parent-pair.integration.test.ts | 10 ++++++ ...omment-reaction-access.integration.test.ts | 4 +++ .../src/default-organization.testkit.ts | 34 +++++++++++++++++++ ...-record-read-admission.integration.test.ts | 4 +++ ...arent-record-read-leaf.integration.test.ts | 7 ++++ ...w-instant-preservation.integration.test.ts | 4 +++ ...comment-reactions-door.integration.test.ts | 4 +++ .../src/default-organization.testkit.ts | 6 ++-- 22 files changed, 127 insertions(+), 2 deletions(-) create mode 100644 packages/plugins/plugin-audit/src/default-organization.testkit.ts diff --git a/packages/plugins/plugin-audit/src/activity-changed-fields-backfill.integration.test.ts b/packages/plugins/plugin-audit/src/activity-changed-fields-backfill.integration.test.ts index 7c3e33023ab..ece3ec454e9 100644 --- a/packages/plugins/plugin-audit/src/activity-changed-fields-backfill.integration.test.ts +++ b/packages/plugins/plugin-audit/src/activity-changed-fields-backfill.integration.test.ts @@ -24,6 +24,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AuditPlugin } from './audit-plugin.js'; import { backfillActivityChangedFields } from './activity-changed-fields-backfill.js'; import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const ACTIVITY = 'sys_activity'; const ITEM = 'acb_item'; @@ -71,6 +72,9 @@ describe('[ADR-0139 E6] backfillActivityChangedFields', () => { engine.registerDriver(driver, true); engine.registry.registerObject(itemObject as any, HARNESS_PACKAGE); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const id = (await engine.insert(ITEM, { name: 'x', stage: 'open', f_a: '1', f_b: '1' }, { context: SYS })).id; const update = (patch: Row) => engine.update(ITEM, patch, { where: { id }, context: SYS }); diff --git a/packages/plugins/plugin-audit/src/activity-field-redaction.test.ts b/packages/plugins/plugin-audit/src/activity-field-redaction.test.ts index 6540d261fd5..ea9c893f57f 100644 --- a/packages/plugins/plugin-audit/src/activity-field-redaction.test.ts +++ b/packages/plugins/plugin-audit/src/activity-field-redaction.test.ts @@ -41,6 +41,7 @@ import type { EngineQueryOptions } from '@objectstack/spec/data'; import { AuditPlugin } from './audit-plugin.js'; import { resolveServedFields } from './activity-field-redaction.js'; import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const ACTIVITY = 'sys_activity'; const ITEM = 'afr_item'; @@ -159,6 +160,9 @@ describe('[#21081] sys_activity value-bearing columns are served through the sec engine.registry.registerObject(itemObject as any, HARNESS_PACKAGE); engine.registry.registerObject(labelledObject as any, HARNESS_PACKAGE); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // Every row below is written by the real audit writer's CRUD mirror. ids.item = (await engine.insert( diff --git a/packages/plugins/plugin-audit/src/activity-milestone-kind.integration.test.ts b/packages/plugins/plugin-audit/src/activity-milestone-kind.integration.test.ts index 91844faf349..620d5d5dbbf 100644 --- a/packages/plugins/plugin-audit/src/activity-milestone-kind.integration.test.ts +++ b/packages/plugins/plugin-audit/src/activity-milestone-kind.integration.test.ts @@ -62,6 +62,7 @@ import { AuditPlugin } from './audit-plugin.js'; import { keptRowsOfParent } from './activity-field-redaction.js'; import { declaredMilestoneWatchedFields, milestoneKeyOf, planActivityStamp } from './activity-changed-fields.js'; import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const ACTIVITY = 'sys_activity'; const ITEM = 'amk_item'; @@ -166,6 +167,9 @@ describe('[#22771] a fired milestone is served with metadata.kind, judged like t engine.registerDriver(driver, true); engine.registry.registerObject(itemObject as any, HARNESS_PACKAGE); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // Every mirror row below is written by the real audit writer's CRUD mirror. const update = (patch: Row) => engine.update(ITEM, patch, { where: { id: itemId }, context: SYS }); diff --git a/packages/plugins/plugin-audit/src/activity-predicate-guard.test.ts b/packages/plugins/plugin-audit/src/activity-predicate-guard.test.ts index 493a46cd97a..361986ec82d 100644 --- a/packages/plugins/plugin-audit/src/activity-predicate-guard.test.ts +++ b/packages/plugins/plugin-audit/src/activity-predicate-guard.test.ts @@ -39,6 +39,7 @@ import { AuditPlugin } from './audit-plugin.js'; import { redactActivityRows } from './activity-field-redaction.js'; import { ACTIVITY_QUERY_GUARD, AUDIT_LOG_QUERY_GUARD, namedValueBearingColumns, pinnedParentObject } from './parent-field-query-guard.js'; import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const ACTIVITY = 'sys_activity'; const ITEM = 'apg_item'; @@ -216,6 +217,9 @@ describe('[#21154] a query over the activity text by a reader withheld a parent engine.registry.registerObject(object as any, HARNESS_PACKAGE); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // Every row is written by the real audit writer's CRUD mirror. ids.item = (await engine.insert( diff --git a/packages/plugins/plugin-audit/src/activity-read-visibility.integration.test.ts b/packages/plugins/plugin-audit/src/activity-read-visibility.integration.test.ts index fb84a1ce94f..a43f150e3f3 100644 --- a/packages/plugins/plugin-audit/src/activity-read-visibility.integration.test.ts +++ b/packages/plugins/plugin-audit/src/activity-read-visibility.integration.test.ts @@ -38,6 +38,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AuditPlugin } from './audit-plugin.js'; import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const ACTIVITY = 'sys_activity'; /** A parent object whose rows each caller reads only when it owns them. */ @@ -94,6 +95,9 @@ describe('sys_activity read visibility — the plugin read path', () => { engine.registry.registerObject(ownedObject as any, HARNESS_PACKAGE); engine.registry.registerObject(openObject as any, HARNESS_PACKAGE); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // Parent readability: a non-admin caller reads the ledger rows it owns. engine.registerMiddleware(async (op: any, next: () => Promise) => { diff --git a/packages/plugins/plugin-audit/src/activity-withheld-update.integration.test.ts b/packages/plugins/plugin-audit/src/activity-withheld-update.integration.test.ts index fc14952edcb..38ad7a9f0f4 100644 --- a/packages/plugins/plugin-audit/src/activity-withheld-update.integration.test.ts +++ b/packages/plugins/plugin-audit/src/activity-withheld-update.integration.test.ts @@ -49,6 +49,7 @@ import { AuditPlugin } from './audit-plugin.js'; import { computeWithheldUpdateFilter, keptRowsOfParent } from './activity-field-redaction.js'; import { planActivityStamp } from './activity-changed-fields.js'; import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const ACTIVITY = 'sys_activity'; /** A record the member reads with two fields withheld. */ @@ -171,6 +172,9 @@ describe('[#21388] an update whose every recorded change is withheld from the re engine.registry.registerObject(sealedObject as any, HARNESS_PACKAGE); engine.registry.registerObject(prefixObject as any, HARNESS_PACKAGE); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // Every row below is written by the real audit writer's CRUD mirror. const update = (object: string, id: string, patch: Row) => diff --git a/packages/plugins/plugin-audit/src/audit-log-field-redaction.test.ts b/packages/plugins/plugin-audit/src/audit-log-field-redaction.test.ts index 82c74850aea..f7a0d234b5d 100644 --- a/packages/plugins/plugin-audit/src/audit-log-field-redaction.test.ts +++ b/packages/plugins/plugin-audit/src/audit-log-field-redaction.test.ts @@ -48,6 +48,7 @@ import type { EngineQueryOptions } from '@objectstack/spec/data'; import { AuditPlugin } from './audit-plugin.js'; import { redactAuditLogRows } from './audit-log-field-redaction.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const LEDGER = 'sys_audit_log'; const ITEM = 'alr_item'; @@ -155,6 +156,9 @@ describe('[#21155] sys_audit_log before/after snapshots are served through the s engine.registerDriver(driver, true); engine.registry.registerObject(itemObject as any, HARNESS_PACKAGE); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // Every record-write row below is written by the real audit writer's CRUD mirror. ids.live = (await engine.insert( diff --git a/packages/plugins/plugin-audit/src/audit-log-predicate-guard.test.ts b/packages/plugins/plugin-audit/src/audit-log-predicate-guard.test.ts index 145b7e3780f..1c336a8e268 100644 --- a/packages/plugins/plugin-audit/src/audit-log-predicate-guard.test.ts +++ b/packages/plugins/plugin-audit/src/audit-log-predicate-guard.test.ts @@ -36,6 +36,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import type { EngineAggregateOptions, EngineQueryOptions } from '@objectstack/spec/data'; import { AuditPlugin } from './audit-plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const LEDGER = 'sys_audit_log'; const ITEM = 'alg_item'; @@ -163,6 +164,9 @@ describe('[#21154] a query over the ledger snapshots by a reader withheld a pare engine.registerDriver(driver, true); for (const object of [itemObject, openObject]) engine.registry.registerObject(object as any, HARNESS_PACKAGE); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // Every ledger row is written by the real audit writer's CRUD mirror. ids.item = (await engine.insert(ITEM, { name: 'item', f_masked: V.masked1, f_unserved: V.unserved1 }, { context: SYS })).id; diff --git a/packages/plugins/plugin-audit/src/audit-log-read-visibility.integration.test.ts b/packages/plugins/plugin-audit/src/audit-log-read-visibility.integration.test.ts index b914a29712e..9fca6247875 100644 --- a/packages/plugins/plugin-audit/src/audit-log-read-visibility.integration.test.ts +++ b/packages/plugins/plugin-audit/src/audit-log-read-visibility.integration.test.ts @@ -37,6 +37,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AuditPlugin } from './audit-plugin.js'; import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const LEDGER = 'sys_audit_log'; /** A parent object whose records each caller reads only when it owns them. */ @@ -117,6 +118,9 @@ describe('[#21175] sys_audit_log read visibility — the plugin read path', () = engine.registry.registerObject(ownedObject as any, HARNESS_PACKAGE); engine.registry.registerObject(openObject as any, HARNESS_PACKAGE); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // Parent readability: a non-admin caller reads the vault records it owns. engine.registerMiddleware(async (op: any, next: () => Promise) => { diff --git a/packages/plugins/plugin-audit/src/audit-record-label-display-field.test.ts b/packages/plugins/plugin-audit/src/audit-record-label-display-field.test.ts index 88871c68cfc..2ddd93624a3 100644 --- a/packages/plugins/plugin-audit/src/audit-record-label-display-field.test.ts +++ b/packages/plugins/plugin-audit/src/audit-record-label-display-field.test.ts @@ -39,6 +39,7 @@ import type { EngineQueryOptions } from '@objectstack/spec/data'; import { AuditPlugin } from './audit-plugin.js'; import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const ACTIVITY = 'sys_activity'; const HARNESS_PACKAGE = 'com.objectstack.audit.test.record-label-display-field'; @@ -163,6 +164,9 @@ describe('[#21878] the activity record label is the ADR-0079 title, with the id engine.registerDriver(driver, true); for (const o of objects) engine.registry.registerObject(o as any, HARNESS_PACKAGE); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const insert = async (object: string, data: Row) => String((await engine.insert(object, data, { context: SYS })).id); diff --git a/packages/plugins/plugin-audit/src/audit-write-locale-settings-seam.integration.test.ts b/packages/plugins/plugin-audit/src/audit-write-locale-settings-seam.integration.test.ts index 10baf3d402d..eee4f9ece55 100644 --- a/packages/plugins/plugin-audit/src/audit-write-locale-settings-seam.integration.test.ts +++ b/packages/plugins/plugin-audit/src/audit-write-locale-settings-seam.integration.test.ts @@ -10,6 +10,7 @@ import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AuditPlugin } from './audit-plugin.js'; import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** * [#24235] A summary line is stored ONCE, at write time, so the writer's held @@ -80,6 +81,9 @@ describe('audit writers — a localization change reaches the very next audited engine.registry.registerObject(sysSetting as any, HARNESS_PACKAGE); engine.registry.registerObject(task as any, HARNESS_PACKAGE); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); sql = async (statement, bindings) => { const result = await (driver as unknown as { execute(s: string, b: unknown[]): Promise }).execute( diff --git a/packages/plugins/plugin-audit/src/audit-writers.definition-follows-metadata.integration.test.ts b/packages/plugins/plugin-audit/src/audit-writers.definition-follows-metadata.integration.test.ts index 42d36e85f82..2f1ccb43c32 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.definition-follows-metadata.integration.test.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.definition-follows-metadata.integration.test.ts @@ -12,6 +12,7 @@ import { SECRET_MASK } from '@objectstack/objectql/core'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AuditPlugin } from './audit-plugin.js'; import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** * [#24565] A definition changed through the metadata door takes effect in the @@ -95,6 +96,9 @@ describe('audit writers answer from the current definition after a metadata-door HARNESS_PACKAGE, ); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); protocol = kernel.getService('protocol') as typeof protocol; }, 120_000); diff --git a/packages/plugins/plugin-audit/src/audit-writers.own-ui-state.integration.test.ts b/packages/plugins/plugin-audit/src/audit-writers.own-ui-state.integration.test.ts index db1206ce1fd..e47102ddb20 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.own-ui-state.integration.test.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.own-ui-state.integration.test.ts @@ -12,6 +12,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { SysUserPreference } from '@objectstack/platform-objects/identity'; import { AuditPlugin } from './audit-plugin.js'; import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** * [#23804, ruling record 6212163130] A user's own write to their own per-user @@ -94,6 +95,9 @@ describe('audit writers — a user\'s own UI-state write leaves no ledger row, o engine.registry.registerObject(SysUserPreference as any, HARNESS_PACKAGE); engine.registry.registerObject(sharingRule as any, HARNESS_PACKAGE); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); sql = async (statement, bindings) => { const result = await (driver as unknown as { execute(s: string, b: unknown[]): Promise }).execute( diff --git a/packages/plugins/plugin-audit/src/comment-edit-stamp.integration.test.ts b/packages/plugins/plugin-audit/src/comment-edit-stamp.integration.test.ts index a6368622a8e..48895c799e9 100644 --- a/packages/plugins/plugin-audit/src/comment-edit-stamp.integration.test.ts +++ b/packages/plugins/plugin-audit/src/comment-edit-stamp.integration.test.ts @@ -41,6 +41,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { mapDataError } from '@objectstack/types'; import { AuditPlugin } from './audit-plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const COMMENT = 'sys_comment'; /** A parent object every caller reads. */ @@ -85,6 +86,9 @@ describe('sys_comment — the platform stamps an edit of the body (#23124)', () engine.registerDriver(driver, true); engine.registry.registerObject(boardObject as any, HARNESS_PACKAGE); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); }, 120_000); afterAll(async () => { diff --git a/packages/plugins/plugin-audit/src/comment-parent-pair.integration.test.ts b/packages/plugins/plugin-audit/src/comment-parent-pair.integration.test.ts index f556480e1e5..fac39863728 100644 --- a/packages/plugins/plugin-audit/src/comment-parent-pair.integration.test.ts +++ b/packages/plugins/plugin-audit/src/comment-parent-pair.integration.test.ts @@ -31,6 +31,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AuditPlugin } from './audit-plugin.js'; import { parseCommentThreadId } from './comment-access-hooks.js'; import { backfillCommentParentPair } from './comment-parent-pair-backfill.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const COMMENT = 'sys_comment'; const REACTION = 'sys_comment_reaction'; @@ -179,6 +180,9 @@ describe('[ADR-0139 E7] the comment-thread read gate over the stamped parent pai engine.registry.registerObject(objectOf(GATED) as any, HARNESS_PACKAGE); engine.registry.registerObject(objectOf(FAR, { datasource: 'warehouse' }) as any, HARNESS_PACKAGE); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); installSecurityMiddleware(engine); engine.registerMiddleware(async (op: any, next: () => Promise) => { if (READ_OPS.has(op.operation) && op.context && !op.context.isSystem && !isAdmin(op.context) && op.ast) { @@ -421,6 +425,9 @@ describe('[ADR-0139 E7] backfillCommentParentPair', () => { ({ kernel, engine } = await bootKernel(driver)); engine.registry.registerObject(objectOf(DOC) as any, HARNESS_PACKAGE); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (let i = 0; i < 5; i++) { const doc = String((await engine.insert(DOC, { name: `d${i}` }, { context: SYS })).id); rowIds.push(String((await engine.insert(COMMENT, commentOn(DOC, doc, `c${i}`), { context: SYS })).id)); @@ -526,6 +533,9 @@ describe('[ADR-0139 D3] on a driver that refuses the leaf, a comment read fails ({ kernel, engine } = await bootKernel(new RefusingDriver({ filename: ':memory:' }))); engine.registry.registerObject(objectOf(DOC) as any, HARNESS_PACKAGE); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const doc = String((await engine.insert(DOC, { name: 'one', owner: MEMBER_A.userId }, { context: SYS })).id); await engine.insert(COMMENT, commentOn(DOC, doc, 'one'), { context: SYS }); }, 120_000); diff --git a/packages/plugins/plugin-audit/src/comment-reaction-access.integration.test.ts b/packages/plugins/plugin-audit/src/comment-reaction-access.integration.test.ts index 46592fc9cf1..654072f748c 100644 --- a/packages/plugins/plugin-audit/src/comment-reaction-access.integration.test.ts +++ b/packages/plugins/plugin-audit/src/comment-reaction-access.integration.test.ts @@ -52,6 +52,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { mapDataError } from '@objectstack/types'; import { AuditPlugin } from './audit-plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const COMMENT = 'sys_comment'; const REACTION = 'sys_comment_reaction'; @@ -126,6 +127,9 @@ describe('sys_comment_reaction — the plugin read and write paths', () => { engine.registry.registerObject(ownedObject as any, HARNESS_PACKAGE); engine.registry.registerObject(openObject as any, HARNESS_PACKAGE); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // Parent readability: a non-admin caller reads the ledger rows it owns. engine.registerMiddleware(async (op: any, next: () => Promise) => { diff --git a/packages/plugins/plugin-audit/src/default-organization.testkit.ts b/packages/plugins/plugin-audit/src/default-organization.testkit.ts new file mode 100644 index 00000000000..d2133c344e1 --- /dev/null +++ b/packages/plugins/plugin-audit/src/default-organization.testkit.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Give a lean test engine what every real `single` install has: an + * organization object holding its Default Organization. + * + * ADR-0131 D1: a table the SQL driver creates carries its tenant column NOT + * NULL. The engine stamps an organization-less system write with the derived + * organization under `single` (D9) — but only where the composition registers + * the organization object; a bare `ObjectQL` + `SqlDriver` registers none, so + * the write reaches the database with no organization and is refused there. + * A suite whose subject is not tenancy (claims, acks, idle cost, at-rest + * shapes) calls this once its own objects are registered, so its system + * writes land the way they do on a real install. + * + * The object mirrors the platform's: managed by better-auth, so it carries no + * tenant column of its own. + */ +export const DEFAULT_ORGANIZATION_ID = 'org_default'; + +export async function installDefaultOrganization(engine: { + registry: { registerObject(def: unknown, owner: string): unknown }; + syncSchemas(): Promise; + getDriverForObject(object: string): { create(object: string, data: Record): Promise } | undefined; +}): Promise { + engine.registry.registerObject( + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, + '@objectstack/plugin-audit', + ); + await engine.syncSchemas(); + // Seeded through the driver: the organization is data at rest, not a write + // the suite under test should observe (an audit or activity mirror would). + await engine.getDriverForObject('sys_organization')!.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); +} diff --git a/packages/plugins/plugin-audit/src/parent-record-read-admission.integration.test.ts b/packages/plugins/plugin-audit/src/parent-record-read-admission.integration.test.ts index cb0d7f7fde1..8b857497f73 100644 --- a/packages/plugins/plugin-audit/src/parent-record-read-admission.integration.test.ts +++ b/packages/plugins/plugin-audit/src/parent-record-read-admission.integration.test.ts @@ -47,6 +47,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AuditPlugin } from './audit-plugin.js'; import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const ACTIVITY = 'sys_activity'; const LEDGER = 'sys_audit_log'; @@ -144,6 +145,9 @@ describe('parent-record read gates: the object-level read admission', () => { engine.registry.registerObject(objectOf(VAULT, 'Vault') as any, HARNESS_PACKAGE); engine.registry.registerObject(objectOf(OPEN, 'Board') as any, HARNESS_PACKAGE); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // plugin-security's refusal of a vault read, for anyone but the admin. engine.registerMiddleware(async (op: any, next: () => Promise) => { diff --git a/packages/plugins/plugin-audit/src/parent-record-read-leaf.integration.test.ts b/packages/plugins/plugin-audit/src/parent-record-read-leaf.integration.test.ts index ec4e6d88237..2b78169020e 100644 --- a/packages/plugins/plugin-audit/src/parent-record-read-leaf.integration.test.ts +++ b/packages/plugins/plugin-audit/src/parent-record-read-leaf.integration.test.ts @@ -51,6 +51,7 @@ import { AuditPlugin } from './audit-plugin.js'; import { LEDGER_AUDIT_CAPABILITY } from './audit-log-read-visibility.js'; import { PARENT_GATE_SCAN_LIMIT } from './parent-record-read-gate.js'; import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const ACTIVITY = 'sys_activity'; const LEDGER = 'sys_audit_log'; @@ -207,6 +208,9 @@ describe('[ADR-0139 E5] the parent-record read gates on the leaf path', () => { engine.registry.registerObject(objectOf(GATED) as any, HARNESS_PACKAGE); engine.registry.registerObject(objectOf(FAR, { datasource: 'warehouse' }) as any, HARNESS_PACKAGE); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); installSecurityMiddleware(engine); // D5's second class: an object-scoped read middleware narrows the gated parent. engine.registerMiddleware(async (op: any, next: () => Promise) => { @@ -438,6 +442,9 @@ describe('[ADR-0139 D3] on a driver that refuses the leaf, the read fails and sa ({ kernel, engine } = await bootKernel(new RefusingDriver({ filename: ':memory:' }))); engine.registry.registerObject(objectOf(DOC) as any, HARNESS_PACKAGE); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); await engine.insert(DOC, { name: 'one', owner: MEMBER_A.userId }, { context: SYS }); }, 120_000); diff --git a/packages/plugins/plugin-audit/src/read-audit-view-instant-preservation.integration.test.ts b/packages/plugins/plugin-audit/src/read-audit-view-instant-preservation.integration.test.ts index 3a9eb5e5c78..67643eeae35 100644 --- a/packages/plugins/plugin-audit/src/read-audit-view-instant-preservation.integration.test.ts +++ b/packages/plugins/plugin-audit/src/read-audit-view-instant-preservation.integration.test.ts @@ -74,6 +74,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { installReadAuditWriter, READ_AUDIT_ACTION } from './read-audit.js'; import { SysAuditLog } from './objects/index.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** The ledger under test — the REAL shipped object definition, not a stand-in. */ const LEDGER = 'sys_audit_log'; @@ -147,6 +148,9 @@ describe('[#16829] the record-view ledger keeps the VIEW instant through the rea engine.registry.registerObject(SysAuditLog as any, HARNESS_PACKAGE); // Real DDL for both tables, including the builtin audit timestamp columns. await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); await engine.insert( AUDITED_OBJECT, diff --git a/packages/plugins/plugin-audit/src/sys-comment-reactions-door.integration.test.ts b/packages/plugins/plugin-audit/src/sys-comment-reactions-door.integration.test.ts index bb5a7cb0627..980f8bde8f0 100644 --- a/packages/plugins/plugin-audit/src/sys-comment-reactions-door.integration.test.ts +++ b/packages/plugins/plugin-audit/src/sys-comment-reactions-door.integration.test.ts @@ -28,6 +28,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { mapDataError } from '@objectstack/types'; import { AuditPlugin } from './audit-plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const COMMENT = 'sys_comment'; /** A parent object every caller reads. */ @@ -74,6 +75,9 @@ describe('sys_comment — a write naming the retired reactions column is refused engine.registerDriver(driver, true); engine.registry.registerObject(openObject as any, HARNESS_PACKAGE); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const board = await engine.insert(OPEN, { name: 'board' }, { context: SYS }); threadId = `${OPEN}:${board.id}`; diff --git a/packages/services/service-messaging/src/default-organization.testkit.ts b/packages/services/service-messaging/src/default-organization.testkit.ts index 0619d628750..2f217e6830f 100644 --- a/packages/services/service-messaging/src/default-organization.testkit.ts +++ b/packages/services/service-messaging/src/default-organization.testkit.ts @@ -21,12 +21,14 @@ export const DEFAULT_ORGANIZATION_ID = 'org_default'; export async function installDefaultOrganization(engine: { registry: { registerObject(def: unknown, owner: string): unknown }; syncSchemas(): Promise; - insert(object: string, data: Record, options?: unknown): Promise; + getDriverForObject(object: string): { create(object: string, data: Record): Promise } | undefined; }): Promise { engine.registry.registerObject( { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, '@objectstack/service-messaging', ); await engine.syncSchemas(); - await engine.insert('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }, { context: { isSystem: true } }); + // Seeded through the driver: the organization is data at rest, not a write + // the suite under test should observe (an audit or activity mirror would). + await engine.getDriverForObject('sys_organization')!.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); } From 592372e963b780773ddd1d9772c46719d9ca953d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:56:31 +0000 Subject: [PATCH 44/81] wip(service-storage): fixtures hold a Default Organization, or model a pre-18 store Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- .../attachment-read-leaf.integration.test.ts | 7 ++++ ...ttachment-refused-attach-tombstone.test.ts | 4 +++ .../backfill-sys-file-public-scope.test.ts | 8 ++++- .../src/chunk-part-record-concurrency.test.ts | 4 +++ .../src/default-organization.testkit.ts | 34 +++++++++++++++++++ ...wned-file-hydration-door-agreement.test.ts | 4 +++ .../src/file-attachment-service.test.ts | 10 ++++++ .../src/pre-18-tenant-column.testkit.ts | 27 +++++++++++++++ .../src/sys-upload-session-ttl-sweep.test.ts | 4 +++ ...t-audit-update-delete-half-repairs.test.ts | 7 ++++ ...stone-hydration-download-agreement.test.ts | 4 +++ .../src/upload-scope-vocabulary.test.ts | 4 +++ 12 files changed, 116 insertions(+), 1 deletion(-) create mode 100644 packages/services/service-storage/src/default-organization.testkit.ts create mode 100644 packages/services/service-storage/src/pre-18-tenant-column.testkit.ts diff --git a/packages/services/service-storage/src/attachment-read-leaf.integration.test.ts b/packages/services/service-storage/src/attachment-read-leaf.integration.test.ts index 3f82990ea50..236f6d6e339 100644 --- a/packages/services/service-storage/src/attachment-read-leaf.integration.test.ts +++ b/packages/services/service-storage/src/attachment-read-leaf.integration.test.ts @@ -42,6 +42,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { SysAttachment } from '@objectstack/platform-objects'; import type { ISecurityService } from '@objectstack/spec/contracts'; import { installAttachmentReadVisibility } from './attachment-access-hooks.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const ATTACHMENT = 'sys_attachment'; /** Owner-scoped: a member reads its own records. */ @@ -204,6 +205,9 @@ describe('[ADR-0139 E7] the attachment read gate on the leaf path', () => { engine.registry.registerObject(objectOf(GATED) as any, HARNESS_PACKAGE); engine.registry.registerObject(objectOf(FAR, { datasource: 'warehouse' }) as any, HARNESS_PACKAGE); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); installSecurityMiddleware(engine); // D5's second class: an object-scoped read middleware narrows the gated parent. engine.registerMiddleware(async (op: any, next: () => Promise) => { @@ -378,6 +382,9 @@ describe('[ADR-0139 D3] on a driver that refuses the leaf, the attachment read f ({ kernel, engine } = await bootKernel(new RefusingDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }))); engine.registry.registerObject(objectOf(DOC) as any, HARNESS_PACKAGE); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); installAttachmentReadVisibility(engine as any, silent, () => kernel.getService('security')); const doc = await engine.insert(DOC, { name: 'one', owner: MEMBER_A.userId }, { context: SYS }); await engine.insert(ATTACHMENT, attachmentTo(DOC, doc.id), { context: SYS }); diff --git a/packages/services/service-storage/src/attachment-refused-attach-tombstone.test.ts b/packages/services/service-storage/src/attachment-refused-attach-tombstone.test.ts index 730fe108007..bcfa1cdf2e1 100644 --- a/packages/services/service-storage/src/attachment-refused-attach-tombstone.test.ts +++ b/packages/services/service-storage/src/attachment-refused-attach-tombstone.test.ts @@ -47,6 +47,7 @@ import { type AttachmentSecurityLike, type AttachmentSharingLike, } from './attachment-access-hooks.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const DAY_MS = 86_400_000; const SYS = { context: { isSystem: true } } as const; @@ -106,6 +107,9 @@ async function boot(opts: { verdict?: Verdict; master?: MasterOutcome; sharing?: } as any, 'test-22466'); ql.registry.registerObject({ name: 'att_parent', fields: { name: { type: 'text' } } } as any, 'test-22466'); await ql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(ql); installAttachmentLifecycleHooks(ql as any, logger); const sharing: AttachmentSharingLike | null = diff --git a/packages/services/service-storage/src/backfill-sys-file-public-scope.test.ts b/packages/services/service-storage/src/backfill-sys-file-public-scope.test.ts index c0cd83e45ff..a88f0d03f95 100644 --- a/packages/services/service-storage/src/backfill-sys-file-public-scope.test.ts +++ b/packages/services/service-storage/src/backfill-sys-file-public-scope.test.ts @@ -50,6 +50,7 @@ import type { SysFilePublicScopeBackfillOptions as EntryOptions, SysFilePublicScopeBackfillReport as EntryReport, } from './index.js'; +import { relaxTenantColumns } from './pre-18-tenant-column.testkit.js'; const SYSTEM = { context: { isSystem: true } }; @@ -91,6 +92,9 @@ describe('sys_file public-scope backfill (#22443 ruling B) — a real ObjectQL o fields: { title: { type: 'text' }, image: { type: 'image' } }, } as any, 'test-22443'); await engine.syncSchemas(); + // The legacy organization-less rows below exist only on a store built before + // 18.x (ADR-0131 D1 makes a fresh tenant column NOT NULL): this is that store. + await relaxTenantColumns(sql as any, ['sys_file']); storage = { upload: vi.fn(async () => {}), @@ -120,8 +124,10 @@ describe('sys_file public-scope backfill (#22443 ruling B) — a real ObjectQL o * condition that reaches `copyOwnedFile` (copy-on-claim), which re-inserts * the source row's scope on the copy. */ + // The claiming write acts in an organization: `product` is created by this + // release, whose tenant column is NOT NULL (ADR-0131 D1). const copyBySecondReference = (recordId: string, fileId: string) => - engine.insert('product', { id: recordId, title: recordId, image: fileId }); + engine.insert('product', { id: recordId, title: recordId, image: fileId }, { context: { tenantId: 'org_a' } } as any); it('a deployment with no public rows runs clean: it counts zero and writes nothing', async () => { await engine.insert('sys_file', { ...legacyPublicRow('f_user'), scope: 'user' }, SYSTEM as any); diff --git a/packages/services/service-storage/src/chunk-part-record-concurrency.test.ts b/packages/services/service-storage/src/chunk-part-record-concurrency.test.ts index 9ea667946d6..1b50a191504 100644 --- a/packages/services/service-storage/src/chunk-part-record-concurrency.test.ts +++ b/packages/services/service-storage/src/chunk-part-record-concurrency.test.ts @@ -34,6 +34,7 @@ import type { UploadSessionProgress, UploadSessionRecord } from './metadata-stor import { registerStorageRoutes, CHUNK_RECORD_ATTEMPTS } from './storage-routes.js'; import { SystemFile } from './objects/system-file.object.js'; import { SystemUploadSession } from './objects/system-upload-session.object.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const BASE = '/api/v1/storage'; const INIT = `${BASE}/upload/chunked`; @@ -105,6 +106,9 @@ async function realEngine(): Promise { ql.registry.registerObject(SystemFile as any, 'com.objectstack.storage'); ql.registry.registerObject(SystemUploadSession as any, 'com.objectstack.storage'); await ql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(ql); let writes = 0; const realUpdate = ql.update.bind(ql); (ql as any).update = (object: string, data: any, options: any) => { diff --git a/packages/services/service-storage/src/default-organization.testkit.ts b/packages/services/service-storage/src/default-organization.testkit.ts new file mode 100644 index 00000000000..fe7d76ecd6a --- /dev/null +++ b/packages/services/service-storage/src/default-organization.testkit.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Give a lean test engine what every real `single` install has: an + * organization object holding its Default Organization. + * + * ADR-0131 D1: a table the SQL driver creates carries its tenant column NOT + * NULL. The engine stamps an organization-less system write with the derived + * organization under `single` (D9) — but only where the composition registers + * the organization object; a bare `ObjectQL` + `SqlDriver` registers none, so + * the write reaches the database with no organization and is refused there. + * A suite whose subject is not tenancy (claims, acks, idle cost, at-rest + * shapes) calls this once its own objects are registered, so its system + * writes land the way they do on a real install. + * + * The object mirrors the platform's: managed by better-auth, so it carries no + * tenant column of its own. + */ +export const DEFAULT_ORGANIZATION_ID = 'org_default'; + +export async function installDefaultOrganization(engine: { + registry: { registerObject(def: unknown, owner: string): unknown }; + syncSchemas(): Promise; + getDriverForObject(object: string): { create(object: string, data: Record): Promise } | undefined; +}): Promise { + engine.registry.registerObject( + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, + '@objectstack/service-storage', + ); + await engine.syncSchemas(); + // Seeded through the driver: the organization is data at rest, not a write + // the suite under test should observe (an audit or activity mirror would). + await engine.getDriverForObject('sys_organization')!.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); +} diff --git a/packages/services/service-storage/src/field-owned-file-hydration-door-agreement.test.ts b/packages/services/service-storage/src/field-owned-file-hydration-door-agreement.test.ts index 803ce0d5791..34a2dc3f55c 100644 --- a/packages/services/service-storage/src/field-owned-file-hydration-door-agreement.test.ts +++ b/packages/services/service-storage/src/field-owned-file-hydration-door-agreement.test.ts @@ -65,6 +65,7 @@ import type { EngineQueryOptions } from '@objectstack/spec/data'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { StorageServicePlugin } from './storage-service-plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const URL_ROUTE = '/api/v1/storage/files/:fileId/url'; const SYS = { isSystem: true }; @@ -249,6 +250,9 @@ describe('#22637 — a file field\'s metadata and the download door agree, for a ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // Seeded BEFORE kernel:ready, so the field-reference hooks (which would // copy an already-owned id into a fresh row) do not rewrite the fixture. diff --git a/packages/services/service-storage/src/file-attachment-service.test.ts b/packages/services/service-storage/src/file-attachment-service.test.ts index 1e50909b94c..f85eb0c18c0 100644 --- a/packages/services/service-storage/src/file-attachment-service.test.ts +++ b/packages/services/service-storage/src/file-attachment-service.test.ts @@ -125,6 +125,16 @@ async function engineWithParents(): Promise { ); ql.registry.registerObject({ name: 'att_memo', fields: { name: { type: 'text' } } } as any, 'test-23306'); await ql.syncSchemas(); + // `case_global` belongs to no organization, a row only a store built before + // 18.x can hold (ADR-0131 D1 makes a fresh tenant column NOT NULL): `att_case` + // is that store's table, relaxed through the driver's own drift apply door. + const relaxed = await (ql.getDriverForObject('att_case') as any).applyMigrationEntries([ + { + kind: 'nullability_mismatch', remoteName: 'att_case', table: 'att_case', column: 'organization_id', category: 'needs_confirm', + op: { type: 'relax_not_null', table: 'att_case', column: 'organization_id' }, + }, + ]); + expect(relaxed.applied).toHaveLength(1); // plugin-audit's REAL `enable.files` gate (and the rest of its writers), as // AuditPlugin installs them on the composed engine. installAuditWriters(ql as any); diff --git a/packages/services/service-storage/src/pre-18-tenant-column.testkit.ts b/packages/services/service-storage/src/pre-18-tenant-column.testkit.ts new file mode 100644 index 00000000000..e7648974357 --- /dev/null +++ b/packages/services/service-storage/src/pre-18-tenant-column.testkit.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Return driver-created tables to the shape a store built BEFORE 18.x has: + * the tenant column nullable. + * + * ADR-0131 D1: since 18.x the SQL driver creates a table's tenant column NOT + * NULL. A store an earlier release built still holds the nullable column and + * its organization-less legacy rows (until the organization-ownership + * ceremony attributes them, or reports them). A suite whose subject is those + * rows — a backfill over them, a sweep that must still reach them — builds + * its tables, then calls this before seeding them. Done through the driver's + * own drift apply door (`relax_not_null`), never a hand `ALTER`. + */ +export async function relaxTenantColumns( + driver: { applyMigrationEntries(entries: never[]): Promise<{ applied: unknown[] }> }, + tables: readonly string[], + column = 'organization_id', +): Promise { + for (const table of tables) { + const op = { type: 'relax_not_null', table, column }; + const { applied } = await driver.applyMigrationEntries([ + { kind: 'nullability_mismatch', remoteName: table, table, column, category: 'needs_confirm', op } as never, + ]); + if (applied.length !== 1) throw new Error(`relaxTenantColumns: ${table}.${column} was not relaxed`); + } +} diff --git a/packages/services/service-storage/src/sys-upload-session-ttl-sweep.test.ts b/packages/services/service-storage/src/sys-upload-session-ttl-sweep.test.ts index 2a3f03c3c20..8b53b38efa6 100644 --- a/packages/services/service-storage/src/sys-upload-session-ttl-sweep.test.ts +++ b/packages/services/service-storage/src/sys-upload-session-ttl-sweep.test.ts @@ -51,6 +51,7 @@ import { LifecycleService, applySystemFields, assertEngineDeleteDispatch } from import type { LifecycleEngineLike, LifecycleObjectLike } from '@objectstack/objectql'; import type { DriverQuery } from '@objectstack/spec/contracts'; import { SystemUploadSession } from './objects/system-upload-session.object.js'; +import { relaxTenantColumns } from './pre-18-tenant-column.testkit.js'; const DAY_MS = 86_400_000; /** The instant the sweep runs. Every window below is expressed against it. */ @@ -127,6 +128,9 @@ async function seeded(opts?: { lifecycle?: unknown }) { }); openDrivers.push(driver); await driver.initObjects([schema]); + // The legacy organization-less rows below exist only on a store built before + // 18.x (ADR-0131 D1 makes a fresh tenant column NOT NULL): this is that store. + await relaxTenantColumns(driver as any, ['sys_upload_session']); await driver.create('sys_upload_session', { id: 's_legacy_null', diff --git a/packages/services/service-storage/src/tenant-audit-update-delete-half-repairs.test.ts b/packages/services/service-storage/src/tenant-audit-update-delete-half-repairs.test.ts index e19db0abd7f..c9fcd06720a 100644 --- a/packages/services/service-storage/src/tenant-audit-update-delete-half-repairs.test.ts +++ b/packages/services/service-storage/src/tenant-audit-update-delete-half-repairs.test.ts @@ -77,6 +77,7 @@ import type { FileRecord, UploadSessionRecord } from './metadata-store.js'; import { registerStorageRoutes } from './storage-routes.js'; import { SystemFile } from './objects/system-file.object.js'; import { SystemUploadSession } from './objects/system-upload-session.object.js'; +import { relaxTenantColumns } from './pre-18-tenant-column.testkit.js'; const fileRec = (id: string): FileRecord => ({ id, @@ -708,6 +709,9 @@ beforeEach(async () => { warns = []; (driver as any).logger = { warn: (msg: string, meta: any) => warns.push({ msg, meta }) }; await driver.initObjects([AUDITED_OBJECT]); + // The legacy organization-less rows below exist only on a store built before + // 18.x (ADR-0131 D1 makes a fresh tenant column NOT NULL): this is that store. + await relaxTenantColumns(driver as any, ['sys_file']); await driver.create('sys_file', { id: 'own', key: 'a', status: 'pending', organization_id: 'org_A' }, { tenantId: 'org_A' }); await driver.create('sys_file', { id: 'other', key: 'b', status: 'pending', organization_id: 'org_B' }, { tenantId: 'org_B' }); await driver.create('sys_file', { id: 'legacy', key: 'c', status: 'pending', organization_id: null }, { tenantId: 'org_A' }); @@ -921,6 +925,9 @@ describe('[#21908] D2. ⛔ under the opt-in, a door tenant still cannot reach an ql.registry.registerObject(SystemFile as any, 'com.objectstack.storage'); ql.registry.registerObject(SystemUploadSession as any, 'com.objectstack.storage'); await ql.syncSchemas(); + // The legacy organization-less rows below exist only on a store built before + // 18.x (ADR-0131 D1 makes a fresh tenant column NOT NULL): this is that store. + await relaxTenantColumns(sql as any, ['sys_file', 'sys_upload_session']); for (const [id, org] of [['own', 'org_A'], ['other', 'org_B'], ['legacy', null]] as const) { await ql.insert('sys_file', { ...fileRec(id), organization_id: org }, { context: SYS }); await ql.insert('sys_upload_session', { ...sessionRec(id), organization_id: org }, { context: SYS }); diff --git a/packages/services/service-storage/src/tombstone-hydration-download-agreement.test.ts b/packages/services/service-storage/src/tombstone-hydration-download-agreement.test.ts index ee84cf718a6..46d968eff3d 100644 --- a/packages/services/service-storage/src/tombstone-hydration-download-agreement.test.ts +++ b/packages/services/service-storage/src/tombstone-hydration-download-agreement.test.ts @@ -82,6 +82,7 @@ import { registerStorageRoutes } from './storage-routes.js'; // here rather than exploding at module load, which is what keeps the pre-fix // run a MEASUREMENT of the divergence instead of an import crash. import * as lifecycle from './attachment-lifecycle.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const URL_ROUTE = '/api/v1/storage/files/:fileId/url'; const RAW_ROUTE = '/api/v1/storage/files/:fileId'; @@ -185,6 +186,9 @@ describe('#11427 — file-field hydration and the download path agree about one } as any, 'test-11427'); // Real DDL for all three tables before the first row is written. await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // ── The shared fixture: four sys_file rows in the residual state ────── const files = [ diff --git a/packages/services/service-storage/src/upload-scope-vocabulary.test.ts b/packages/services/service-storage/src/upload-scope-vocabulary.test.ts index 5a3853ac31d..05cafa31422 100644 --- a/packages/services/service-storage/src/upload-scope-vocabulary.test.ts +++ b/packages/services/service-storage/src/upload-scope-vocabulary.test.ts @@ -37,6 +37,7 @@ import { SystemUploadSession } from './objects/system-upload-session.object.js'; import { LocalStorageAdapter } from './local-storage-adapter.js'; import { StorageMetadataStore } from './metadata-store.js'; import { registerStorageRoutes } from './storage-routes.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const PRESIGNED = '/api/v1/storage/upload/presigned'; const CHUNKED = '/api/v1/storage/upload/chunked'; @@ -126,6 +127,9 @@ describe('the upload-starting doors refuse a scope outside the vocabulary (#2247 engine.registry.registerObject(SystemFile as any, 'com.objectstack.storage'); engine.registry.registerObject(SystemUploadSession as any, 'com.objectstack.storage'); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); adapter = new LocalStorageAdapter({ rootDir, signingSecret: 'test-secret' }); store = new StorageMetadataStore(engine as any); From b940bd5c73226206e5ad87b73df19504f524c39e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 07:59:55 +0000 Subject: [PATCH 45/81] wip(plugin-security): lean fixture engines hold a Default Organization Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- .../by-id-write-unreadable-not-found.test.ts | 4 +++ .../claim-seed-ownership-dispatch.pin.test.ts | 4 +++ .../src/default-organization.testkit.ts | 34 +++++++++++++++++++ ...ed-admin-gate-holding-organization.test.ts | 4 +++ .../src/explain-cross-class-refusal.test.ts | 4 +++ .../src/explain-json-column-refusal.test.ts | 4 +++ ...plain-read-absent-every-write-verb.test.ts | 4 +++ .../explain-read-absent-record-story.test.ts | 4 +++ .../src/file-field-hydration-refused.test.ts | 4 +++ .../grant-holder-membership-refusal.test.ts | 4 +++ .../src/grant-permission-set-name.test.ts | 4 +++ .../src/insert-check-post-image.test.ts | 4 +++ .../objects/reserved-identity-names.test.ts | 4 +++ ...-security-assignment-display-title.test.ts | 4 +++ .../src/position-catalog-refusal.test.ts | 4 +++ ...icate-write-unreadable-not-matched.test.ts | 4 +++ .../rls-accessible-org-ids-plumbing.test.ts | 4 +++ .../src/rls-boolean-comparand-door.test.ts | 4 +++ .../rls-check-by-id-update-post-hook.test.ts | 4 +++ .../src/rls-check-contains-membership.test.ts | 4 +++ ...ls-check-cross-class-field-refused.test.ts | 4 +++ .../src/rls-check-defaults-to-using.test.ts | 4 +++ .../src/rls-check-multi-row-writes.test.ts | 4 +++ .../src/rls-check-stored-form.test.ts | 4 +++ ...ist-literal-comparand-fails-closed.test.ts | 4 +++ .../rls-null-comparand-fails-closed.test.ts | 4 +++ .../src/rls-number-comparand-door.test.ts | 4 +++ ...s-one-value-comparand-fails-closed.test.ts | 4 +++ .../src/rls-phantom-column-negation.test.ts | 4 +++ ...-stored-list-ordering-fails-closed.test.ts | 4 +++ ...riable-root-comparand-fails-closed.test.ts | 4 +++ ...s-user-permission-set-window-order.test.ts | 4 +++ .../tenant-wall-organization-removal.test.ts | 4 +++ 33 files changed, 162 insertions(+) create mode 100644 packages/plugins/plugin-security/src/default-organization.testkit.ts diff --git a/packages/plugins/plugin-security/src/by-id-write-unreadable-not-found.test.ts b/packages/plugins/plugin-security/src/by-id-write-unreadable-not-found.test.ts index 9f24cd14ae2..31915c2bde0 100644 --- a/packages/plugins/plugin-security/src/by-id-write-unreadable-not-found.test.ts +++ b/packages/plugins/plugin-security/src/by-id-write-unreadable-not-found.test.ts @@ -34,6 +34,7 @@ import { recordNotFoundError } from '@objectstack/core'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYS = { context: { isSystem: true } } as never; const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; @@ -98,6 +99,9 @@ async function boot(): Promise { ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); await engine.insert(PARENT, [ { id: 'p_plain', name: 'plain' }, { id: 'p_cascade', name: 'has a hidden child' }, diff --git a/packages/plugins/plugin-security/src/claim-seed-ownership-dispatch.pin.test.ts b/packages/plugins/plugin-security/src/claim-seed-ownership-dispatch.pin.test.ts index 0fc9ba7ae93..251750d4d57 100644 --- a/packages/plugins/plugin-security/src/claim-seed-ownership-dispatch.pin.test.ts +++ b/packages/plugins/plugin-security/src/claim-seed-ownership-dispatch.pin.test.ts @@ -53,6 +53,7 @@ import { } from '@objectstack/spec/data'; import { claimSeedOwnership } from './claim-seed-ownership.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYS = { context: { isSystem: true } } as const; const ADMIN = 'usr_admin_human'; @@ -115,6 +116,9 @@ async function boot() { objects: [PROBE_OBJECT], } as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); engines.push(engine); const metadataHook: Seen[] = []; diff --git a/packages/plugins/plugin-security/src/default-organization.testkit.ts b/packages/plugins/plugin-security/src/default-organization.testkit.ts new file mode 100644 index 00000000000..733227cb42e --- /dev/null +++ b/packages/plugins/plugin-security/src/default-organization.testkit.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Give a lean test engine what every real `single` install has: an + * organization object holding its Default Organization. + * + * ADR-0131 D1: a table the SQL driver creates carries its tenant column NOT + * NULL. The engine stamps an organization-less system write with the derived + * organization under `single` (D9) — but only where the composition registers + * the organization object; a bare `ObjectQL` + `SqlDriver` registers none, so + * the write reaches the database with no organization and is refused there. + * A suite whose subject is not tenancy (claims, acks, idle cost, at-rest + * shapes) calls this once its own objects are registered, so its system + * writes land the way they do on a real install. + * + * The object mirrors the platform's: managed by better-auth, so it carries no + * tenant column of its own. + */ +export const DEFAULT_ORGANIZATION_ID = 'org_default'; + +export async function installDefaultOrganization(engine: { + registry: { registerObject(def: unknown, owner: string): unknown }; + syncSchemas(): Promise; + getDriverForObject(object: string): { create(object: string, data: Record): Promise } | undefined; +}): Promise { + engine.registry.registerObject( + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, + '@objectstack/plugin-security', + ); + await engine.syncSchemas(); + // Seeded through the driver: the organization is data at rest, not a write + // the suite under test should observe (an audit or activity mirror would). + await engine.getDriverForObject('sys_organization')!.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); +} diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts index 22493f8a1e3..5c4748fa212 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts @@ -33,6 +33,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { DelegatedAdminGate } from './delegated-admin-gate.js'; import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const ORG_A = 'org_a_acme'; /** Sorts BEFORE every `org_a` / `*_a_*` id, like the sibling suites. */ @@ -86,6 +87,9 @@ async function bootEngine() { objects: OBJECTS, } as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); engines.push(engine); return engine; } diff --git a/packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts b/packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts index f1341a53456..77d1baf81a7 100644 --- a/packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts +++ b/packages/plugins/plugin-security/src/explain-cross-class-refusal.test.ts @@ -44,6 +44,7 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import type { ExplainDecision } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYS_CTX = { isSystem: true, userId: 'usr_system' }; const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; @@ -109,6 +110,9 @@ async function boot(makeDriver: () => Driver, predicate: string) { ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); booted.push({ engine, driver, table: OBJ }); const set = PermissionSetSchema.parse({ diff --git a/packages/plugins/plugin-security/src/explain-json-column-refusal.test.ts b/packages/plugins/plugin-security/src/explain-json-column-refusal.test.ts index 4876471bad6..6ff17e0cf67 100644 --- a/packages/plugins/plugin-security/src/explain-json-column-refusal.test.ts +++ b/packages/plugins/plugin-security/src/explain-json-column-refusal.test.ts @@ -57,6 +57,7 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import type { ExplainDecision } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYS_CTX = { isSystem: true, userId: 'usr_system' }; const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; @@ -122,6 +123,9 @@ async function boot(makeDriver: () => Driver, predicates: Record ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); booted.push({ engine, driver, table: OBJ }); const set = PermissionSetSchema.parse({ diff --git a/packages/plugins/plugin-security/src/explain-read-absent-every-write-verb.test.ts b/packages/plugins/plugin-security/src/explain-read-absent-every-write-verb.test.ts index 06078162097..861bfeb741b 100644 --- a/packages/plugins/plugin-security/src/explain-read-absent-every-write-verb.test.ts +++ b/packages/plugins/plugin-security/src/explain-read-absent-every-write-verb.test.ts @@ -46,6 +46,7 @@ import type { ExplainDecision, ExplainOperation } from '@objectstack/spec/securi import { SecurityPlugin } from './security-plugin.js'; import { rlsOperationForVerb } from './lifecycle-verb-rls-operation.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYS = { context: { isSystem: true } } as never; const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; @@ -161,6 +162,9 @@ beforeAll(async () => { ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // One unreadable and one readable row per (axis, verb), so a delete or a // transfer that lands never changes another cell's row. for (const { set } of AXES) { diff --git a/packages/plugins/plugin-security/src/explain-read-absent-record-story.test.ts b/packages/plugins/plugin-security/src/explain-read-absent-record-story.test.ts index b0f85c1eab4..73985629001 100644 --- a/packages/plugins/plugin-security/src/explain-read-absent-record-story.test.ts +++ b/packages/plugins/plugin-security/src/explain-read-absent-record-story.test.ts @@ -48,6 +48,7 @@ import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; import { explainAccess, type ExplainEngineDeps } from './explain-engine.js'; import { PermissionEvaluator } from './permission-evaluator.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYS = { context: { isSystem: true } } as never; const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; @@ -128,6 +129,9 @@ beforeAll(async () => { ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // Both masters belong to another user; only the second is shared to the // caller, at `read`, which lets the caller read it and edit neither. await engine.insert(MASTER, [ diff --git a/packages/plugins/plugin-security/src/file-field-hydration-refused.test.ts b/packages/plugins/plugin-security/src/file-field-hydration-refused.test.ts index d16e63fb4f4..6c0cb7efcb3 100644 --- a/packages/plugins/plugin-security/src/file-field-hydration-refused.test.ts +++ b/packages/plugins/plugin-security/src/file-field-hydration-refused.test.ts @@ -26,6 +26,7 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { SecurityPlugin } from './security-plugin'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const DOC = 'sec_file_refusal_doc'; const SYS_CTX = { isSystem: true, userId: 'usr_system' }; @@ -90,6 +91,9 @@ describe('a file field the security layer refused reads as refused, not as "no f ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const services: Record = { manifest: { register: vi.fn() }, diff --git a/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts b/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts index d450b652659..ddc650a322b 100644 --- a/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts +++ b/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts @@ -39,6 +39,7 @@ import { GRANT_HOLDER_MEMBERSHIP_HOOK_PACKAGE, registerGrantHolderMembershipRefusal, } from './grant-holder-membership-refusal.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; // --------------------------------------------------------------------------- // Fixture @@ -139,6 +140,9 @@ async function boot(opts: BootOptions = {}) { ], } as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // `sys_user` stays registered and unprovisioned, as in the sibling suites: // the engine's own `user_id` existence probe then cannot run and stands // down, so what is measured here is the membership predicate alone. diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts index 3da2f565814..f186d175992 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts @@ -41,6 +41,7 @@ import { grantSetNotInCatalogMessage, registerGrantPermissionSetNameHooks, } from './grant-permission-set-name.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; // --------------------------------------------------------------------------- // Fixture @@ -93,6 +94,9 @@ async function boot() { objects: [SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, SysMember], } as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // The authz resolver reads these; the auth plugin registers them in a deployment. for (const o of [SysUser, SysMember]) { if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as never, 'qa.authz-read-set'); diff --git a/packages/plugins/plugin-security/src/insert-check-post-image.test.ts b/packages/plugins/plugin-security/src/insert-check-post-image.test.ts index bc1fc946c92..a94a9516dcc 100644 --- a/packages/plugins/plugin-security/src/insert-check-post-image.test.ts +++ b/packages/plugins/plugin-security/src/insert-check-post-image.test.ts @@ -94,6 +94,7 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import type { PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; // ── the app, transcribed from the card ───────────────────────────────────── @@ -357,6 +358,9 @@ async function boot(makeDriver: () => unknown): Promise { objects: OBJECTS, } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); engines.push(engine); // The app's stamp, on BOTH write events — the card's `runAs: 'system'` shape: diff --git a/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts b/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts index 14f576274a2..36f295026c5 100644 --- a/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts +++ b/packages/plugins/plugin-security/src/objects/reserved-identity-names.test.ts @@ -43,6 +43,7 @@ import { reservedIdentityNameMessage, } from './reserved-identity-names.js'; import { DelegatedAdminGate } from '../delegated-admin-gate.js'; +import { installDefaultOrganization } from '../default-organization.testkit.js'; const engines: ObjectQL[] = []; afterEach(async () => { @@ -67,6 +68,9 @@ async function boot(): Promise { objects: [SysUserPosition], } as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); engines.push(engine); return engine; } diff --git a/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts b/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts index 2590775ca7e..3598ee458cc 100644 --- a/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts +++ b/packages/plugins/plugin-security/src/objects/sys-security-assignment-display-title.test.ts @@ -48,6 +48,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { resolveDisplayField } from '@objectstack/spec/data'; import { SysUserPermissionSet } from './sys-user-permission-set.object.js'; import { SysUserPosition } from './sys-user-position.object.js'; +import { installDefaultOrganization } from '../default-organization.testkit.js'; const SYS = { context: { isSystem: true } } as any; @@ -123,6 +124,9 @@ describe('[#20044] permission-assignment tables resolve a real record title unde engine.registry.registerObject(schema, 'com.objectstack.test.20044'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); }); afterAll(async () => { diff --git a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts index cef87436f6a..7ef149d209a 100644 --- a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts +++ b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts @@ -36,6 +36,7 @@ import { SysMetadataActivation } from '@objectstack/platform-objects/system'; import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { namesWithoutCatalogRow, positionNotInCatalogMessage } from './position-catalog-refusal.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; // --------------------------------------------------------------------------- // Fixture @@ -122,6 +123,9 @@ async function boot(opts: { walled?: boolean } = {}) { objects: [SysUserPosition, SysUserPermissionSet, SysMember, SysMetadataActivation, QA_INQUIRY], } as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // [#21516] The authz resolver reads these on every grant resolution; in a // deployment the auth and security plugins register them. This harness // composes neither, so the ones its app does not declare are registered diff --git a/packages/plugins/plugin-security/src/predicate-write-unreadable-not-matched.test.ts b/packages/plugins/plugin-security/src/predicate-write-unreadable-not-matched.test.ts index d778b4ae7c9..ae146f0e0a5 100644 --- a/packages/plugins/plugin-security/src/predicate-write-unreadable-not-matched.test.ts +++ b/packages/plugins/plugin-security/src/predicate-write-unreadable-not-matched.test.ts @@ -42,6 +42,7 @@ import { MAX_BULK_PER_ROW_HOOK_ROWS } from '@objectstack/spec/data'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYS = { context: { isSystem: true } } as never; const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; @@ -118,6 +119,9 @@ async function boot(opts: { bulkRows?: number } = {}): Promise { ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); await engine.insert(PARENT, [{ id: 'p_hook', name: 'hooked' }], SYS); await engine.insert(CHILD, [ // readable (own) and writable diff --git a/packages/plugins/plugin-security/src/rls-accessible-org-ids-plumbing.test.ts b/packages/plugins/plugin-security/src/rls-accessible-org-ids-plumbing.test.ts index dafbdde98c7..dbdd7441e57 100644 --- a/packages/plugins/plugin-security/src/rls-accessible-org-ids-plumbing.test.ts +++ b/packages/plugins/plugin-security/src/rls-accessible-org-ids-plumbing.test.ts @@ -53,6 +53,7 @@ import type { PermissionSet } from '@objectstack/spec/security'; import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; // ── the fixture: six rows across THREE organizations ─────────────────────── // @@ -153,6 +154,9 @@ async function boot( objects: OBJECTS, } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); engines.push(engine); const services: Record = { manifest: { register: vi.fn() }, diff --git a/packages/plugins/plugin-security/src/rls-boolean-comparand-door.test.ts b/packages/plugins/plugin-security/src/rls-boolean-comparand-door.test.ts index 7e11069617a..0acc7032b5f 100644 --- a/packages/plugins/plugin-security/src/rls-boolean-comparand-door.test.ts +++ b/packages/plugins/plugin-security/src/rls-boolean-comparand-door.test.ts @@ -47,6 +47,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** Every compiled policy filter `compileCelToFilter` handed the seam, deep-frozen, by predicate. */ const compiled = new Map(); @@ -127,6 +128,9 @@ async function boot(config: Record, predicate: string) { ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); cleanups.push(async () => { if (config.client === 'pg') await (driver as unknown as { execute(sql: string): Promise }).execute(`drop table if exists ${OBJ}`); await engine.destroy(); diff --git a/packages/plugins/plugin-security/src/rls-check-by-id-update-post-hook.test.ts b/packages/plugins/plugin-security/src/rls-check-by-id-update-post-hook.test.ts index 76b54355898..bb702ecb565 100644 --- a/packages/plugins/plugin-security/src/rls-check-by-id-update-post-hook.test.ts +++ b/packages/plugins/plugin-security/src/rls-check-by-id-update-post-hook.test.ts @@ -48,6 +48,7 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import type { PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** The organization the caller holds. */ const OWN_ORG = 'org_a'; @@ -187,6 +188,9 @@ async function boot(makeDriver: () => unknown): Promise { objects: OBJECTS, } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); engines.push(engine); // The organization shape's stamp: it reads the parent OUTSIDE RLS and diff --git a/packages/plugins/plugin-security/src/rls-check-contains-membership.test.ts b/packages/plugins/plugin-security/src/rls-check-contains-membership.test.ts index 8732e695b54..efdf811b3c4 100644 --- a/packages/plugins/plugin-security/src/rls-check-contains-membership.test.ts +++ b/packages/plugins/plugin-security/src/rls-check-contains-membership.test.ts @@ -33,6 +33,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYS_CTX = { isSystem: true, userId: 'usr_system' }; const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; @@ -78,6 +79,9 @@ async function boot(makeDriver: () => Driver, clause: 'using' | 'check') { ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); booted.push(engine); const set = PermissionSetSchema.parse({ diff --git a/packages/plugins/plugin-security/src/rls-check-cross-class-field-refused.test.ts b/packages/plugins/plugin-security/src/rls-check-cross-class-field-refused.test.ts index 21cd16e5120..5cdd6693f9f 100644 --- a/packages/plugins/plugin-security/src/rls-check-cross-class-field-refused.test.ts +++ b/packages/plugins/plugin-security/src/rls-check-cross-class-field-refused.test.ts @@ -44,6 +44,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYS_CTX = { isSystem: true, userId: 'usr_system' }; const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; @@ -106,6 +107,9 @@ async function boot(makeDriver: () => Driver, clause: 'using' | 'check', predica ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); booted.push({ engine, driver, table: OBJ }); const set = PermissionSetSchema.parse({ diff --git a/packages/plugins/plugin-security/src/rls-check-defaults-to-using.test.ts b/packages/plugins/plugin-security/src/rls-check-defaults-to-using.test.ts index 1dc2877baec..43d5337a0eb 100644 --- a/packages/plugins/plugin-security/src/rls-check-defaults-to-using.test.ts +++ b/packages/plugins/plugin-security/src/rls-check-defaults-to-using.test.ts @@ -45,6 +45,7 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import type { PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OWN_ORG = 'org_a'; const OTHER_ORG = 'org_b'; @@ -124,6 +125,9 @@ async function boot(makeDriver: () => unknown, ps: PermissionSet): Promise = { manifest: { register: vi.fn() }, diff --git a/packages/plugins/plugin-security/src/rls-check-multi-row-writes.test.ts b/packages/plugins/plugin-security/src/rls-check-multi-row-writes.test.ts index 389b4bd2f68..cace4a04cde 100644 --- a/packages/plugins/plugin-security/src/rls-check-multi-row-writes.test.ts +++ b/packages/plugins/plugin-security/src/rls-check-multi-row-writes.test.ts @@ -53,6 +53,7 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import type { PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECTS = [ { @@ -138,6 +139,9 @@ async function boot(makeDriver: () => unknown, ps: PermissionSet): Promise = { manifest: { register: vi.fn() }, diff --git a/packages/plugins/plugin-security/src/rls-check-stored-form.test.ts b/packages/plugins/plugin-security/src/rls-check-stored-form.test.ts index b244ff4a834..69f5193bd3f 100644 --- a/packages/plugins/plugin-security/src/rls-check-stored-form.test.ts +++ b/packages/plugins/plugin-security/src/rls-check-stored-form.test.ts @@ -78,6 +78,7 @@ import { storedFormCheckJudge, storedFormImage, } from './rls-check-stored-form.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** A plain object — a filter node or an operator map. */ function isPlain(value: unknown): value is Record { @@ -172,6 +173,9 @@ async function boot(makeDriver: () => Driver, predicate: string, using: string = ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); booted.push(engine); const set = PermissionSetSchema.parse({ diff --git a/packages/plugins/plugin-security/src/rls-list-literal-comparand-fails-closed.test.ts b/packages/plugins/plugin-security/src/rls-list-literal-comparand-fails-closed.test.ts index 82bd2c6716e..cf3f8e62c36 100644 --- a/packages/plugins/plugin-security/src/rls-list-literal-comparand-fails-closed.test.ts +++ b/packages/plugins/plugin-security/src/rls-list-literal-comparand-fails-closed.test.ts @@ -26,6 +26,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJ = 'qa_ticket'; const SYS_CTX = { isSystem: true, userId: 'usr_system' }; @@ -63,6 +64,9 @@ async function boot(clause: 'using' | 'check', predicate: string) { }], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); engines.push(engine); const set = PermissionSetSchema.parse({ diff --git a/packages/plugins/plugin-security/src/rls-null-comparand-fails-closed.test.ts b/packages/plugins/plugin-security/src/rls-null-comparand-fails-closed.test.ts index 508520afc7b..26b68085b90 100644 --- a/packages/plugins/plugin-security/src/rls-null-comparand-fails-closed.test.ts +++ b/packages/plugins/plugin-security/src/rls-null-comparand-fails-closed.test.ts @@ -48,6 +48,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJ = 'qa_ticket'; const SYS_CTX = { isSystem: true, userId: 'usr_system' }; @@ -120,6 +121,9 @@ async function boot(makeDriver: () => unknown, clause: 'using' | 'check', predic }], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); engines.push(engine); const set = PermissionSetSchema.parse({ diff --git a/packages/plugins/plugin-security/src/rls-number-comparand-door.test.ts b/packages/plugins/plugin-security/src/rls-number-comparand-door.test.ts index d846da268b5..156d5e24f8f 100644 --- a/packages/plugins/plugin-security/src/rls-number-comparand-door.test.ts +++ b/packages/plugins/plugin-security/src/rls-number-comparand-door.test.ts @@ -35,6 +35,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYS_CTX = { isSystem: true, userId: 'usr_system' }; const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; @@ -76,6 +77,9 @@ async function boot(predicate: string) { ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); booted.push(engine); const set = PermissionSetSchema.parse({ diff --git a/packages/plugins/plugin-security/src/rls-one-value-comparand-fails-closed.test.ts b/packages/plugins/plugin-security/src/rls-one-value-comparand-fails-closed.test.ts index aaf582dd534..4fc68de5556 100644 --- a/packages/plugins/plugin-security/src/rls-one-value-comparand-fails-closed.test.ts +++ b/packages/plugins/plugin-security/src/rls-one-value-comparand-fails-closed.test.ts @@ -36,6 +36,7 @@ import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { RLS_DENY_FILTER } from './rls-compiler.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJ = 'qa_ticket'; const SYS_CTX = { isSystem: true, userId: 'usr_system' }; @@ -86,6 +87,9 @@ async function boot(makeDriver: () => unknown, clause: 'using' | 'check', predic ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); engines.push(engine); await engine.insert('qa_person', [{ id: 'p1', name: 'P1' }, { id: 'p2', name: 'P2' }], { context: SYS_CTX } as never); diff --git a/packages/plugins/plugin-security/src/rls-phantom-column-negation.test.ts b/packages/plugins/plugin-security/src/rls-phantom-column-negation.test.ts index c342dd6fc84..4ccfd76ccb9 100644 --- a/packages/plugins/plugin-security/src/rls-phantom-column-negation.test.ts +++ b/packages/plugins/plugin-security/src/rls-phantom-column-negation.test.ts @@ -76,6 +76,7 @@ import type { PermissionSet } from '@objectstack/spec/security'; import { RLSCompiler, RLS_DENY_FILTER } from './rls-compiler.js'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; // ── the fixture, transcribed from the card: three rows, one of them public ── @@ -246,6 +247,9 @@ async function boot(makeDriver: () => unknown, ps: PermissionSet): Promise = { manifest: { register: vi.fn() }, diff --git a/packages/plugins/plugin-security/src/rls-stored-list-ordering-fails-closed.test.ts b/packages/plugins/plugin-security/src/rls-stored-list-ordering-fails-closed.test.ts index ce822eff5d2..6a461a6522b 100644 --- a/packages/plugins/plugin-security/src/rls-stored-list-ordering-fails-closed.test.ts +++ b/packages/plugins/plugin-security/src/rls-stored-list-ordering-fails-closed.test.ts @@ -57,6 +57,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { PermissionSetSchema } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJ = 'qa_ticket'; const SYS_CTX = { isSystem: true, userId: 'usr_system' }; @@ -109,6 +110,9 @@ async function boot(makeDriver: () => unknown, clause: 'using' | 'check', predic ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); engines.push(engine); await engine.insert('qa_person', [{ id: 'p1', name: 'P1' }, { id: 'p2', name: 'P2' }], { context: SYS_CTX } as never); diff --git a/packages/plugins/plugin-security/src/rls-variable-root-comparand-fails-closed.test.ts b/packages/plugins/plugin-security/src/rls-variable-root-comparand-fails-closed.test.ts index b474bc2b944..792003816e4 100644 --- a/packages/plugins/plugin-security/src/rls-variable-root-comparand-fails-closed.test.ts +++ b/packages/plugins/plugin-security/src/rls-variable-root-comparand-fails-closed.test.ts @@ -35,6 +35,7 @@ import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.tes import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { RLS_DENY_FILTER } from './rls-compiler.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJ = 'qa_ticket'; const GUARD_SET = 'qa_ticket_guard'; @@ -84,6 +85,9 @@ async function boot(policies: Policy[]) { ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // [#21516] The authz resolver reads these on every grant resolution; in a // deployment the auth and security plugins register them. This harness // composes neither, so the ones its app does not declare are registered diff --git a/packages/plugins/plugin-security/src/sys-user-permission-set-window-order.test.ts b/packages/plugins/plugin-security/src/sys-user-permission-set-window-order.test.ts index dd39d862bc5..11f4048d458 100644 --- a/packages/plugins/plugin-security/src/sys-user-permission-set-window-order.test.ts +++ b/packages/plugins/plugin-security/src/sys-user-permission-set-window-order.test.ts @@ -43,6 +43,7 @@ import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const MEMBER_DEFAULT = { name: 'member_default', label: 'Member', objects: {} } as unknown as PermissionSet; const QA_ADMIN = { @@ -86,6 +87,9 @@ async function boot() { objects: [SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, SysMember], } as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const o of [SysUser, SysMember]) { if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as never, 'qa.authz-read-set'); } diff --git a/packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts b/packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts index 6fe5ddba4e1..28b47b7ff8c 100644 --- a/packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts +++ b/packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts @@ -53,6 +53,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import type { PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { SysUserPosition, SysUserPermissionSet } from './objects/index.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** The organization the caller holds (its active organization). */ const OWN_ORG = 'org_a'; @@ -145,6 +146,9 @@ async function boot(makeDriver: () => unknown, posture: Posture = 'isolated'): P objects: OBJECTS, } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); engines.push(engine); // The stored-row half: a hook that clears the column when asked to by name. From b0621ecb315627b4d62979ee5293c8f98b739613 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:10:19 +0000 Subject: [PATCH 46/81] wip(plugin-security): fixtures model a pre-18 store where they hold organization-less rows Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- ...latform-admin-authenticable-target.test.ts | 5 ++ ...latform-admin-existing-holder-scan.test.ts | 5 ++ ...platform-admin-promotion-selection.test.ts | 5 ++ .../src/builtin-positions.boot.test.ts | 5 ++ .../src/catalog-reference-report.test.ts | 5 ++ .../claim-seed-ownership-dispatch.pin.test.ts | 8 +-- ...lled-by-parent-chain-middle-master.test.ts | 5 ++ ...ed-admin-gate-holding-organization.test.ts | 8 +-- .../src/explain-asker-gate.test.ts | 5 ++ .../src/explain-enforce-parity.test.ts | 17 ++++++ .../src/explain-principal-person.test.ts | 5 ++ .../explain-removed-member-principal.test.ts | 5 ++ .../grant-holder-membership-refusal.test.ts | 8 +-- ...nt-permission-set-name.equivalence.test.ts | 5 ++ .../src/grant-permission-set-name.test.ts | 8 +-- .../src/grant-readers-by-name.golden.test.ts | 5 ++ .../src/grant-readers-unnamed-grant.test.ts | 5 ++ .../default-row-scope-declarations.test.ts | 5 ++ ...ed-sets-identity-default-row-scope.test.ts | 5 ++ .../src/orgless-position-name-fold.test.ts | 5 ++ .../src/position-catalog-refusal.test.ts | 8 +-- .../src/pre-18-tenant-column.testkit.ts | 56 +++++++++++++++++++ ...lve-authz-grant-set-by-name.golden.test.ts | 5 ++ .../resolve-authz-grant-set-by-name.test.ts | 5 ++ .../src/sys-audit-log-row-scope.test.ts | 5 ++ ...s-user-permission-set-window-order.test.ts | 8 +-- .../tenant-wall-organization-removal.test.ts | 8 +-- 27 files changed, 191 insertions(+), 28 deletions(-) create mode 100644 packages/plugins/plugin-security/src/pre-18-tenant-column.testkit.ts diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts index b7046c0ca9d..7718259ab8f 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-authenticable-target.test.ts @@ -54,6 +54,7 @@ import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testki import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const SYSTEM_CTX = { isSystem: true }; @@ -110,6 +111,10 @@ async function boot(): Promise { ], } as any); await engine.syncSchemas(); + // The organization-less rows below (global sets, positions, grants) exist only + // on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column NOT + // NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); // [ADR-0131 D3/D5] The Default Organization a `single` boot creates before any // `kernel:ready` hook runs; it owns the promotion's grant. await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }, { context: { isSystem: true } }); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts index cbf1a3b10b9..6ceb8250d85 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-existing-holder-scan.test.ts @@ -82,6 +82,7 @@ import { import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const SYSTEM_CTX = { isSystem: true }; const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; @@ -131,6 +132,10 @@ async function boot(): Promise { objects: [SysPermissionSet, SysUserPermissionSet, SysUser, SysAccount, SysOrganization], } as any); await engine.syncSchemas(); + // The organization-less rows below (global sets, positions, grants) exist only + // on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column NOT + // NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); // [ADR-0131 D3/D5] The Default Organization a `single` boot creates before any // `kernel:ready` hook runs; it owns the promotion's grant. await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }, { context: { isSystem: true } }); diff --git a/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts b/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts index 8aa6ec10b77..55b45d567f1 100644 --- a/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts +++ b/packages/plugins/plugin-security/src/bootstrap-platform-admin-promotion-selection.test.ts @@ -74,6 +74,7 @@ import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testki import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const SYSTEM_CTX = { isSystem: true }; const OWNER_ENV = 'OS_PLATFORM_OWNER_EMAIL'; @@ -128,6 +129,10 @@ async function boot(): Promise { objects: [SysPermissionSet, SysUserPermissionSet, SysUser, SysAccount, SysOrganization], } as any); await engine.syncSchemas(); + // The organization-less rows below (global sets, positions, grants) exist only + // on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column NOT + // NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); // [ADR-0131 D3/D5] The Default Organization a `single` boot creates before any // `kernel:ready` hook runs; it owns the promotion's grant. await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }, { context: { isSystem: true } }); diff --git a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts index 24c2d6cbbfd..535d57c2e00 100644 --- a/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts +++ b/packages/plugins/plugin-security/src/builtin-positions.boot.test.ts @@ -70,6 +70,7 @@ import { securityObjects, SECURITY_PLUGIN_ID } from './manifest.js'; import { securityBuiltinPositions } from './builtin-positions.js'; import { withLegacyCatalogTables } from './__tests__/legacy-catalog-objects.testkit.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const SYS = { isSystem: true } as const; const POSTURE_ENV = 'OS_TENANCY_POSTURE'; @@ -231,6 +232,10 @@ async function boot(scenario: Scenario): Promise { objects: withLegacyCatalogTables([SysUser, SysAccount, SysMember, SysOrganization, ...securityObjects]), } as any); await engine.syncSchemas(); + // The organization-less rows below (global sets, positions, grants) exist only + // on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column NOT + // NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); if (scenario.authoredPosition) { // How a metadata author's saved definition is hydrated: no package. (engine as any).registry.registerItem('position', { name: 'door_authored', label: 'Door Authored' }, 'name'); 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 f5ddf50b64f..3242f2b9f99 100644 --- a/packages/plugins/plugin-security/src/catalog-reference-report.test.ts +++ b/packages/plugins/plugin-security/src/catalog-reference-report.test.ts @@ -53,6 +53,7 @@ import { type CatalogReferenceReport, type CatalogReferenceReportEngine, } from './catalog-reference-report.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const SYS = { isSystem: true } as const; const POSTURE_ENV = 'OS_TENANCY_POSTURE'; @@ -132,6 +133,10 @@ async function boot(opts: { posture?: 'single' | 'isolated' } = {}): Promise p.name === 'member_default')!; @@ -222,6 +223,10 @@ beforeAll(async () => { ], } as never); await engine.syncSchemas(); + // The organization-less rows below (global sets, positions, grants) exist only + // on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column NOT + // NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); // The two delegators exist as users, so the on-behalf-of link resolves rather // than failing closed. Each holds the manager's set through a stored position diff --git a/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts b/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts index 5c4748fa212..054b4ea6f01 100644 --- a/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts +++ b/packages/plugins/plugin-security/src/delegated-admin-gate-holding-organization.test.ts @@ -33,7 +33,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { DelegatedAdminGate } from './delegated-admin-gate.js'; import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; -import { installDefaultOrganization } from './default-organization.testkit.js'; +import { relaxEveryTenantColumn } from './pre-18-tenant-column.testkit.js'; const ORG_A = 'org_a_acme'; /** Sorts BEFORE every `org_a` / `*_a_*` id, like the sibling suites. */ @@ -87,9 +87,9 @@ async function bootEngine() { objects: OBJECTS, } as any); await engine.syncSchemas(); - // A real `single` install's Default Organization: the system writes below - // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). - await installDefaultOrganization(engine); + // The organization-less holdings and rows below exist only on a store built + // before 18.x (ADR-0131 D1 makes a fresh tenant column NOT NULL): this is it. + await relaxEveryTenantColumn(engine, OBJECTS); engines.push(engine); return engine; } diff --git a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts index 5d2ce28d622..2e1cd4fb7a8 100644 --- a/packages/plugins/plugin-security/src/explain-asker-gate.test.ts +++ b/packages/plugins/plugin-security/src/explain-asker-gate.test.ts @@ -66,6 +66,7 @@ import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; import { PermissionEvaluator } from './permission-evaluator.js'; import { explainAccess, explainAccessForAsker, type ExplainEngineDeps, type ExplainInput } from './explain-engine.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const SYS = { context: { isSystem: true } } as never; const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; @@ -164,6 +165,10 @@ beforeAll(async () => { ], } as never); await engine.syncSchemas(); + // The organization-less rows below (global sets, positions, grants) exist only + // on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column NOT + // NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); const e = engine as unknown as { insert: (o: string, row: unknown, opts: never) => Promise }; for (const id of [MEMBER, ADMIN.userId, BLIND_ADMIN.userId, DELEGATE.userId, REFUSED_ADMIN.userId, REFUSED_DELEGATE.userId, EDIT_ONLY_USER]) { diff --git a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts index 05b51fa168e..50ca7bc91b9 100644 --- a/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts +++ b/packages/plugins/plugin-security/src/explain-enforce-parity.test.ts @@ -90,6 +90,7 @@ import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const SYS = { context: { isSystem: true } } as never; const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; @@ -326,6 +327,10 @@ async function bootRls(predicate: string, opts: { grantCrud?: boolean } = {}) { ], } as never); await engine.syncSchemas(); + // The organization-less rows below (global sets, positions, grants) exist only + // on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column NOT + // NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); await engine.insert(OBJ, [ { id: 'r1', status: 'open', title: 'x', amount: 5 }, { id: 'r2', status: 'open', title: 'open', amount: 7 }, @@ -471,6 +476,10 @@ async function bootPrincipal(source: PostureSource) { ], } as never); await engine.syncSchemas(); + // The organization-less rows below (global sets, positions, grants) exist only + // on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column NOT + // NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); const e = engine as unknown as { insert: (o: string, row: Record, opts: never) => Promise }; for (const id of [ALPHA, BETA]) await e.insert('sys_organization', { id, name: id, slug: id }, SYS); @@ -651,6 +660,10 @@ async function bootSharing(opts: { faultReadFilter?: boolean; authored?: boolean ], } as never); await engine.syncSchemas(); + // The organization-less rows below (global sets, positions, grants) exist only + // on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column NOT + // NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); await engine.insert(LINE, [ { id: 'l_other', name: 'owned by someone else', owner_id: 'u_somebody' }, { id: 'l_own', name: 'owned by the control', owner_id: 'u_own' }, @@ -819,6 +832,10 @@ async function bootDelegated(posture: TenancyPosture) { ], } as never); await engine.syncSchemas(); + // The organization-less rows below (global sets, positions, grants) exist only + // on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column NOT + // NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); const e = engine as unknown as { insert: (o: string, row: Record, opts: never) => Promise }; for (const id of [ALPHA, BETA]) await e.insert('sys_organization', { id, name: id, slug: id }, SYS); diff --git a/packages/plugins/plugin-security/src/explain-principal-person.test.ts b/packages/plugins/plugin-security/src/explain-principal-person.test.ts index 05d0f396ca3..ae191a90bf2 100644 --- a/packages/plugins/plugin-security/src/explain-principal-person.test.ts +++ b/packages/plugins/plugin-security/src/explain-principal-person.test.ts @@ -37,6 +37,7 @@ import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const SYS = { context: { isSystem: true } } as never; const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; @@ -115,6 +116,10 @@ beforeAll(async () => { ], } as never); await engine.syncSchemas(); + // The organization-less rows below (global sets, positions, grants) exist only + // on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column NOT + // NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); const e = engine as unknown as { insert: (o: string, row: unknown, opts: never) => Promise }; await e.insert('sys_user', [ diff --git a/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts b/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts index 7a62538a884..6540a058b42 100644 --- a/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts +++ b/packages/plugins/plugin-security/src/explain-removed-member-principal.test.ts @@ -81,6 +81,7 @@ import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const SYS = { context: { isSystem: true } } as never; const MEMBER_DEFAULT = defaultPermissionSets.find((p) => p.name === 'member_default')!; @@ -134,6 +135,10 @@ async function boot(makeDriver: () => Driver, posture: TenancyPosture) { ], } as never); await engine.syncSchemas(); + // The organization-less rows below (global sets, positions, grants) exist only + // on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column NOT + // NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); const e = engine as unknown as { insert: (o: string, row: Record, opts: never) => Promise }; for (const id of [ALPHA, BETA]) await e.insert('sys_organization', { id, name: id, slug: id }, SYS); diff --git a/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts b/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts index ddc650a322b..6f4c2f78590 100644 --- a/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts +++ b/packages/plugins/plugin-security/src/grant-holder-membership-refusal.test.ts @@ -39,7 +39,7 @@ import { GRANT_HOLDER_MEMBERSHIP_HOOK_PACKAGE, registerGrantHolderMembershipRefusal, } from './grant-holder-membership-refusal.js'; -import { installDefaultOrganization } from './default-organization.testkit.js'; +import { relaxEveryTenantColumn } from './pre-18-tenant-column.testkit.js'; // --------------------------------------------------------------------------- // Fixture @@ -140,9 +140,9 @@ async function boot(opts: BootOptions = {}) { ], } as any); await engine.syncSchemas(); - // A real `single` install's Default Organization: the system writes below - // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). - await installDefaultOrganization(engine); + // The organization-less holdings and rows below exist only on a store built + // before 18.x (ADR-0131 D1 makes a fresh tenant column NOT NULL): this is it. + await relaxEveryTenantColumn(engine, [SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet]); // `sys_user` stays registered and unprovisioned, as in the sibling suites: // the engine's own `user_id` existence probe then cannot run and stands // down, so what is measured here is the membership predicate alone. diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts index f31058574e1..7cacc318b33 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.equivalence.test.ts @@ -52,6 +52,7 @@ import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js' import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const SYS = { isSystem: true } as const; const POSTURE_ENV = 'OS_TENANCY_POSTURE'; @@ -116,6 +117,10 @@ async function grantsByPrincipal(posture: Posture): Promise = { diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts index f186d175992..cb0d4a6adfa 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts @@ -41,7 +41,7 @@ import { grantSetNotInCatalogMessage, registerGrantPermissionSetNameHooks, } from './grant-permission-set-name.js'; -import { installDefaultOrganization } from './default-organization.testkit.js'; +import { relaxEveryTenantColumn } from './pre-18-tenant-column.testkit.js'; // --------------------------------------------------------------------------- // Fixture @@ -94,9 +94,9 @@ async function boot() { objects: [SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, SysMember], } as any); await engine.syncSchemas(); - // A real `single` install's Default Organization: the system writes below - // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). - await installDefaultOrganization(engine); + // The organization-less holdings and rows below exist only on a store built + // before 18.x (ADR-0131 D1 makes a fresh tenant column NOT NULL): this is it. + await relaxEveryTenantColumn(engine, [SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, SysMember]); // The authz resolver reads these; the auth plugin registers them in a deployment. for (const o of [SysUser, SysMember]) { if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as never, 'qa.authz-read-set'); diff --git a/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts b/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts index f9788eac1af..df771499430 100644 --- a/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts +++ b/packages/plugins/plugin-security/src/grant-readers-by-name.golden.test.ts @@ -52,6 +52,7 @@ import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js' import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; /** The environment-authored set the member holds a grant of, as the security catalog declares it. */ const LEGACY_REPORTS = { name: 'legacy_reports', label: 'Legacy reports' }; @@ -138,6 +139,10 @@ async function readersByPrincipal(posture: Posture): Promise = { diff --git a/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts b/packages/plugins/plugin-security/src/grant-readers-unnamed-grant.test.ts index 13633aaf183..3074702beae 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 @@ -44,6 +44,7 @@ import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js' import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const SYS = { isSystem: true } as const; const CATALOG_SETS: Array<{ name: string; label?: string }> = [...defaultPermissionSets, { name: 'legacy_reports', label: 'Legacy' }]; @@ -81,6 +82,10 @@ async function boot(): Promise { ], } as any); await engine.syncSchemas(); + // The organization-less rows below (global sets, positions, grants) exist only + // on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column NOT + // NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); engines.push(engine); const services: Record = { manifest: { register: vi.fn() }, diff --git a/packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts b/packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts index b4e1518f70e..4428dbcae8f 100644 --- a/packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts +++ b/packages/plugins/plugin-security/src/objects/default-row-scope-declarations.test.ts @@ -52,6 +52,7 @@ import { SysPosition, SysPermissionSet, SysPositionPermissionSet } from '../__te import { SysUserPosition } from './sys-user-position.object.js'; import { SysUserPermissionSet } from './sys-user-permission-set.object.js'; import { bindTestSecurityCatalog } from '../__tests__/security-catalog.testkit.js'; +import { relaxEveryRegisteredTenantColumn } from '../pre-18-tenant-column.testkit.js'; type Policy = { name: string; @@ -404,6 +405,10 @@ async function bootBaseline(declared: boolean) { objects: [...declarers, objects.get('sys_member')!, SysUserPermissionSet, SysUserPosition], } as never); await engine.syncSchemas(); + // The organization-less rows below (global sets, positions, grants) exist only + // on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column NOT + // NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); for (const o of [SysPosition, SysPermissionSet, SysPositionPermissionSet]) { if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as never, 'qa.authz-read-set'); } diff --git a/packages/plugins/plugin-security/src/objects/shipped-sets-identity-default-row-scope.test.ts b/packages/plugins/plugin-security/src/objects/shipped-sets-identity-default-row-scope.test.ts index e7edb8c0f35..5473ae07428 100644 --- a/packages/plugins/plugin-security/src/objects/shipped-sets-identity-default-row-scope.test.ts +++ b/packages/plugins/plugin-security/src/objects/shipped-sets-identity-default-row-scope.test.ts @@ -48,6 +48,7 @@ import { SysPosition, SysPermissionSet, SysPositionPermissionSet } from '../__te import { SysUserPosition } from './sys-user-position.object.js'; import { SysUserPermissionSet } from './sys-user-permission-set.object.js'; import { bindTestSecurityCatalog } from '../__tests__/security-catalog.testkit.js'; +import { relaxEveryRegisteredTenantColumn } from '../pre-18-tenant-column.testkit.js'; type Policy = { name: string; object?: string; operation: string; using?: string; enabled?: boolean }; type ObjectDef = { name: string; fields?: Record; defaultRowScope?: Policy }; @@ -264,6 +265,10 @@ async function boot(shape: Shape, sets: PermissionSet[]) { objects: [...schemas, SysUserPermissionSet, SysUserPosition], } as never); await engine.syncSchemas(); + // The organization-less rows below (global sets, positions, grants) exist only + // on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column NOT + // NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); for (const o of [SysPosition, SysPermissionSet, SysPositionPermissionSet]) { if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as never, 'qa.authz-read-set'); } diff --git a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts index b2eb89e8a98..e3dd7acb8ca 100644 --- a/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts +++ b/packages/plugins/plugin-security/src/orgless-position-name-fold.test.ts @@ -60,6 +60,7 @@ import { SysOrganization, SysUser, SysMember } from '@objectstack/platform-objec import { SecurityPlugin } from './security-plugin.js'; import { buildContextForUser } from './explain-engine.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const SYS = { context: { isSystem: true } } as any; /** The organization that authors the colliding sets. Nobody below is its member except `USER_OTHER`. */ @@ -141,6 +142,10 @@ async function boot(): Promise { ], } as any); await engine.syncSchemas(); + // The organization-less rows below (global sets, positions, grants) exist only + // on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column NOT + // NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); engines.push(engine); const e = engine as any; await e.insert('sys_organization', { id: ORG_OTHER, name: ORG_OTHER, slug: 'other' }, SYS); diff --git a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts index 7ef149d209a..fb3a5029fc5 100644 --- a/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts +++ b/packages/plugins/plugin-security/src/position-catalog-refusal.test.ts @@ -36,7 +36,7 @@ import { SysMetadataActivation } from '@objectstack/platform-objects/system'; import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { namesWithoutCatalogRow, positionNotInCatalogMessage } from './position-catalog-refusal.js'; -import { installDefaultOrganization } from './default-organization.testkit.js'; +import { relaxEveryTenantColumn } from './pre-18-tenant-column.testkit.js'; // --------------------------------------------------------------------------- // Fixture @@ -123,9 +123,9 @@ async function boot(opts: { walled?: boolean } = {}) { objects: [SysUserPosition, SysUserPermissionSet, SysMember, SysMetadataActivation, QA_INQUIRY], } as any); await engine.syncSchemas(); - // A real `single` install's Default Organization: the system writes below - // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). - await installDefaultOrganization(engine); + // The organization-less holdings and rows below exist only on a store built + // before 18.x (ADR-0131 D1 makes a fresh tenant column NOT NULL): this is it. + await relaxEveryTenantColumn(engine, [SysUserPosition, SysUserPermissionSet, SysMember, SysMetadataActivation, QA_INQUIRY]); // [#21516] The authz resolver reads these on every grant resolution; in a // deployment the auth and security plugins register them. This harness // composes neither, so the ones its app does not declare are registered diff --git a/packages/plugins/plugin-security/src/pre-18-tenant-column.testkit.ts b/packages/plugins/plugin-security/src/pre-18-tenant-column.testkit.ts new file mode 100644 index 00000000000..b0cbb5efa38 --- /dev/null +++ b/packages/plugins/plugin-security/src/pre-18-tenant-column.testkit.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Return engine-synced tables to the shape a store built BEFORE 18.x has: the + * tenant column nullable. + * + * ADR-0131 D1: since 18.x the SQL driver creates a table's tenant column NOT + * NULL. A store an earlier release built still holds the nullable column and + * its organization-less rows — the holdings, grants and seeds this package's + * NULL readers still answer for until the organization-ownership ceremony has + * attributed them. A suite whose subject is those rows calls this after + * `syncSchemas()` and before seeding them. Done through each table's driver + * and its own drift apply door (`relax_not_null`), never a hand `ALTER`. + */ +export async function relaxTenantColumns( + engine: { getDriverForObject(object: string): unknown }, + tables: readonly string[], + column = 'organization_id', +): Promise { + for (const table of tables) { + const driver = engine.getDriverForObject(table) as { + applyMigrationEntries(entries: never[]): Promise<{ applied: unknown[] }>; + }; + const op = { type: 'relax_not_null', table, column }; + const { applied } = await driver.applyMigrationEntries([ + { kind: 'nullability_mismatch', remoteName: table, table, column, category: 'needs_confirm', op } as never, + ]); + if (applied.length !== 1) throw new Error(`relaxTenantColumns: ${table}.${column} was not relaxed`); + } +} + +/** + * {@link relaxTenantColumns} over every object of an app that carries a tenant + * column as the engine registered it — the shape a whole pre-18 store has. + */ +export async function relaxEveryTenantColumn( + engine: { getDriverForObject(object: string): unknown; getSchema(object: string): unknown }, + objects: readonly { name: string }[], +): Promise { + const tenant = objects + .map((o) => o.name) + .filter((name) => { + const schema = engine.getSchema(name) as { fields?: Record; tenancy?: { enabled?: boolean } } | undefined; + return !!schema?.fields?.organization_id && schema.tenancy?.enabled !== false; + }); + await relaxTenantColumns(engine, tenant); +} + +/** {@link relaxEveryTenantColumn} over every object the engine's registry holds. */ +export async function relaxEveryRegisteredTenantColumn(engine: { + getDriverForObject(object: string): unknown; + getSchema(object: string): unknown; + registry: { getAllObjects(): readonly { name: string }[] }; +}): Promise { + await relaxEveryTenantColumn(engine, engine.registry.getAllObjects()); +} diff --git a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts index ba69e021ba0..0c8568f07ee 100644 --- a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts +++ b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.golden.test.ts @@ -46,6 +46,7 @@ import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js' import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const SYS = { isSystem: true } as const; const POSTURE_ENV = 'OS_TENANCY_POSTURE'; @@ -113,6 +114,10 @@ async function resolverByPrincipal(posture: Posture): Promise = { diff --git a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.test.ts b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.test.ts index 3617b4ff735..af6a6380779 100644 --- a/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.test.ts +++ b/packages/plugins/plugin-security/src/resolve-authz-grant-set-by-name.test.ts @@ -35,6 +35,7 @@ import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js' import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const SYS = { isSystem: true } as const; const POSTURE_ENV = 'OS_TENANCY_POSTURE'; @@ -75,6 +76,10 @@ async function newEngine(): Promise { ], } as any); await engine.syncSchemas(); + // The organization-less rows below (global sets, positions, grants) exist only + // on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column NOT + // NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); engines.push(engine); vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); return engine; diff --git a/packages/plugins/plugin-security/src/sys-audit-log-row-scope.test.ts b/packages/plugins/plugin-security/src/sys-audit-log-row-scope.test.ts index a8ff3270d4f..dae76c50ba2 100644 --- a/packages/plugins/plugin-security/src/sys-audit-log-row-scope.test.ts +++ b/packages/plugins/plugin-security/src/sys-audit-log-row-scope.test.ts @@ -32,6 +32,7 @@ import type { PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; import { isPlatformTenantPolicy } from './platform-tenant-policies.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const LEDGER = 'sys_audit_log'; const POLICY = 'sys_audit_log_org'; @@ -121,6 +122,10 @@ async function boot(opts: { posture: Posture; sets?: PermissionSet[]; ledger?: R ], } as never); await engine.syncSchemas(); + // The organization-less rows below (global sets, positions, grants) exist only + // on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column NOT + // NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); await engine.insert('sys_organization', [{ id: 'org_a', name: 'A' }, ...(single ? [] : [{ id: 'org_b', name: 'B' }])], SYS); const before = opts.ledger === LEDGER_BEFORE; const ledgerRows = [ diff --git a/packages/plugins/plugin-security/src/sys-user-permission-set-window-order.test.ts b/packages/plugins/plugin-security/src/sys-user-permission-set-window-order.test.ts index 11f4048d458..f3870413f39 100644 --- a/packages/plugins/plugin-security/src/sys-user-permission-set-window-order.test.ts +++ b/packages/plugins/plugin-security/src/sys-user-permission-set-window-order.test.ts @@ -43,7 +43,7 @@ import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysPositionPermissionSet } from './__tests__/legacy-catalog-objects.testkit.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; -import { installDefaultOrganization } from './default-organization.testkit.js'; +import { relaxEveryTenantColumn } from './pre-18-tenant-column.testkit.js'; const MEMBER_DEFAULT = { name: 'member_default', label: 'Member', objects: {} } as unknown as PermissionSet; const QA_ADMIN = { @@ -87,9 +87,9 @@ async function boot() { objects: [SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, SysMember], } as any); await engine.syncSchemas(); - // A real `single` install's Default Organization: the system writes below - // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). - await installDefaultOrganization(engine); + // The organization-less holdings and rows below exist only on a store built + // before 18.x (ADR-0131 D1 makes a fresh tenant column NOT NULL): this is it. + await relaxEveryTenantColumn(engine, [SysPosition, SysUserPosition, SysPermissionSet, SysPositionPermissionSet, SysUserPermissionSet, SysMember]); for (const o of [SysUser, SysMember]) { if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as never, 'qa.authz-read-set'); } diff --git a/packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts b/packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts index 28b47b7ff8c..74a7b80542c 100644 --- a/packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts +++ b/packages/plugins/plugin-security/src/tenant-wall-organization-removal.test.ts @@ -53,7 +53,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import type { PermissionSet } from '@objectstack/spec/security'; import { SecurityPlugin } from './security-plugin.js'; import { SysUserPosition, SysUserPermissionSet } from './objects/index.js'; -import { installDefaultOrganization } from './default-organization.testkit.js'; +import { relaxEveryTenantColumn } from './pre-18-tenant-column.testkit.js'; /** The organization the caller holds (its active organization). */ const OWN_ORG = 'org_a'; @@ -146,9 +146,9 @@ async function boot(makeDriver: () => unknown, posture: Posture = 'isolated'): P objects: OBJECTS, } as never); await engine.syncSchemas(); - // A real `single` install's Default Organization: the system writes below - // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). - await installDefaultOrganization(engine); + // The organization-less holdings and rows below exist only on a store built + // before 18.x (ADR-0131 D1 makes a fresh tenant column NOT NULL): this is it. + await relaxEveryTenantColumn(engine, OBJECTS); engines.push(engine); // The stored-row half: a hook that clears the column when asked to by name. From 3832d75098dcea1c4a27c162bd26aac21b231711 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:16:08 +0000 Subject: [PATCH 47/81] wip(rest): lean fixture engines hold a Default Organization; testkit idempotent Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- .../src/default-organization.testkit.ts | 23 +++++++--- .../src/default-organization.testkit.ts | 23 +++++++--- ...ggregation-filter-array-membership.test.ts | 4 ++ ...egation-filter-json-column-refusal.test.ts | 4 ++ ...ation-filter-temporal-storage-rule.test.ts | 4 ++ .../aggregation-filter-where-doors.test.ts | 4 ++ ...aggregation-flag-comparand-refusal.test.ts | 4 ++ ...analytics-contains-membership-door.test.ts | 4 ++ ...cs-dataset-aggregate-policies-door.test.ts | 4 ++ ...lytics-dataset-json-dimension-door.test.ts | 4 ++ ...lytics-dataset-measure-number-door.test.ts | 4 ++ ...analytics-dataset-multi-value-door.test.ts | 4 ++ ...nalytics-dataset-row-wildcard-door.test.ts | 4 ++ .../analytics-field-permission-gate.test.ts | 4 ++ .../analytics-hop-object-reference.test.ts | 4 ++ .../src/analytics-masked-field-gate.test.ts | 4 ++ .../analytics-nested-relation-filter.test.ts | 4 ++ ...nalytics-not-multivalue-null-guard.test.ts | 4 ++ ...lytics-relationship-path-admission.test.ts | 4 ++ ...s-class-refusal-remedy-on-the-wire.test.ts | 4 ++ .../data-aggregate-field-type-door.test.ts | 4 ++ .../src/data-boolean-comparand-door.test.ts | 4 ++ .../src/data-date-read-year-below-100.test.ts | 4 ++ .../rest/src/data-date-write-iso-only.test.ts | 4 ++ .../data-field-comparand-permission.test.ts | 4 ++ .../rest/src/data-group-by-json-door.test.ts | 4 ++ ...ta-json-stored-group-distinct-door.test.ts | 4 ++ .../rest/src/data-nested-object-door.test.ts | 4 ++ .../data-nested-relation-permission.test.ts | 4 ++ .../src/data-no-operator-object-door.test.ts | 4 ++ .../src/data-number-comparand-door.test.ts | 4 ++ .../data-query-calendar-day-last-day.test.ts | 4 ++ ...-query-calendar-day-year-below-100.test.ts | 4 ++ .../src/data-query-date-year-range.test.ts | 4 ++ ...data-query-epoch-ms-date-comparand.test.ts | 4 ++ .../data-query-having-temporal-door.test.ts | 4 ++ ...-query-unprojected-declared-fields.test.ts | 7 +++ ...ata-resolved-token-past-date-range.test.ts | 4 ++ .../data-resolved-token-year-range.test.ts | 4 ++ ...ata-search-multi-valued-membership.test.ts | 4 ++ .../data-temporal-write-real-day-iso.test.ts | 4 ++ .../rest/src/data-temporal-year-range.test.ts | 4 ++ .../data-write-result-declared-fields.test.ts | 7 +++ .../rest/src/default-organization.testkit.ts | 45 +++++++++++++++++++ .../src/import-control-opt-out-gate.test.ts | 4 ++ packages/rest/src/import-integration.test.ts | 10 +++++ .../rest/src/import-job-integration.test.ts | 14 ++++-- .../import-row-dropped-fields-20701.test.ts | 4 ++ .../src/import-row-schema-drift-20701.test.ts | 4 ++ .../rest/src/import-template-route.test.ts | 13 ++++++ .../rest/src/import-undo-controls.test.ts | 4 ++ ...eta-delete-drop-storage-other-rows.test.ts | 4 ++ .../rest/src/public-form-required.test.ts | 4 ++ .../rest-aggregate-compensated-sum.test.ts | 4 ++ .../src/rest-aggregate-numeric-having.test.ts | 4 ++ .../rest/src/rest-aggregate-positions.test.ts | 4 ++ ...e-update-stored-row-representation.test.ts | 4 ++ .../src/default-organization.testkit.ts | 23 +++++++--- .../src/default-organization.testkit.ts | 23 +++++++--- 59 files changed, 356 insertions(+), 28 deletions(-) create mode 100644 packages/rest/src/default-organization.testkit.ts diff --git a/packages/plugins/plugin-audit/src/default-organization.testkit.ts b/packages/plugins/plugin-audit/src/default-organization.testkit.ts index d2133c344e1..5ca7ea9f37d 100644 --- a/packages/plugins/plugin-audit/src/default-organization.testkit.ts +++ b/packages/plugins/plugin-audit/src/default-organization.testkit.ts @@ -20,15 +20,26 @@ export const DEFAULT_ORGANIZATION_ID = 'org_default'; export async function installDefaultOrganization(engine: { registry: { registerObject(def: unknown, owner: string): unknown }; + getSchema(object: string): unknown; syncSchemas(): Promise; - getDriverForObject(object: string): { create(object: string, data: Record): Promise } | undefined; + getDriverForObject(object: string): + | { + create(object: string, data: Record): Promise; + findOne(object: string, query: unknown): Promise; + } + | undefined; }): Promise { - engine.registry.registerObject( - { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, - '@objectstack/plugin-audit', - ); + // Idempotent: a suite that syncs one engine more than once may call it each time. + if (!engine.getSchema('sys_organization')) { + engine.registry.registerObject( + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, + '@objectstack/plugin-audit', + ); + } await engine.syncSchemas(); // Seeded through the driver: the organization is data at rest, not a write // the suite under test should observe (an audit or activity mirror would). - await engine.getDriverForObject('sys_organization')!.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); + const driver = engine.getDriverForObject('sys_organization')!; + if (await driver.findOne('sys_organization', { where: { id: DEFAULT_ORGANIZATION_ID } })) return; + await driver.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); } diff --git a/packages/plugins/plugin-security/src/default-organization.testkit.ts b/packages/plugins/plugin-security/src/default-organization.testkit.ts index 733227cb42e..67b123c1ad9 100644 --- a/packages/plugins/plugin-security/src/default-organization.testkit.ts +++ b/packages/plugins/plugin-security/src/default-organization.testkit.ts @@ -20,15 +20,26 @@ export const DEFAULT_ORGANIZATION_ID = 'org_default'; export async function installDefaultOrganization(engine: { registry: { registerObject(def: unknown, owner: string): unknown }; + getSchema(object: string): unknown; syncSchemas(): Promise; - getDriverForObject(object: string): { create(object: string, data: Record): Promise } | undefined; + getDriverForObject(object: string): + | { + create(object: string, data: Record): Promise; + findOne(object: string, query: unknown): Promise; + } + | undefined; }): Promise { - engine.registry.registerObject( - { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, - '@objectstack/plugin-security', - ); + // Idempotent: a suite that syncs one engine more than once may call it each time. + if (!engine.getSchema('sys_organization')) { + engine.registry.registerObject( + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, + '@objectstack/plugin-security', + ); + } await engine.syncSchemas(); // Seeded through the driver: the organization is data at rest, not a write // the suite under test should observe (an audit or activity mirror would). - await engine.getDriverForObject('sys_organization')!.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); + const driver = engine.getDriverForObject('sys_organization')!; + if (await driver.findOne('sys_organization', { where: { id: DEFAULT_ORGANIZATION_ID } })) return; + await driver.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); } diff --git a/packages/rest/src/aggregation-filter-array-membership.test.ts b/packages/rest/src/aggregation-filter-array-membership.test.ts index 8fe9fbfb347..38c7b17a397 100644 --- a/packages/rest/src/aggregation-filter-array-membership.test.ts +++ b/packages/rest/src/aggregation-filter-array-membership.test.ts @@ -47,6 +47,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_agg_filter_membership_20873'; @@ -147,6 +148,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(DOC as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); if (populated) for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); const protocol = new ObjectStackProtocolImplementation(engine as any); diff --git a/packages/rest/src/aggregation-filter-json-column-refusal.test.ts b/packages/rest/src/aggregation-filter-json-column-refusal.test.ts index dc2488badd3..7fe40b2bb39 100644 --- a/packages/rest/src/aggregation-filter-json-column-refusal.test.ts +++ b/packages/rest/src/aggregation-filter-json-column-refusal.test.ts @@ -52,6 +52,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_agg_filter_json_col_21007'; @@ -215,6 +216,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(DOC as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); if (populated) for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); warn = vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); diff --git a/packages/rest/src/aggregation-filter-temporal-storage-rule.test.ts b/packages/rest/src/aggregation-filter-temporal-storage-rule.test.ts index 8d5047f841e..17936ba474f 100644 --- a/packages/rest/src/aggregation-filter-temporal-storage-rule.test.ts +++ b/packages/rest/src/aggregation-filter-temporal-storage-rule.test.ts @@ -34,6 +34,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'ledger_order'; @@ -89,6 +90,9 @@ async function boot(populated: boolean) { await engine.init(); engine.registry.registerObject(LEDGER_ORDER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); if (populated) await engine.insert(OBJECT, ROWS as any); const protocol = new ObjectStackProtocolImplementation(engine as any); diff --git a/packages/rest/src/aggregation-filter-where-doors.test.ts b/packages/rest/src/aggregation-filter-where-doors.test.ts index a05b29d3d99..c0b798b5f49 100644 --- a/packages/rest/src/aggregation-filter-where-doors.test.ts +++ b/packages/rest/src/aggregation-filter-where-doors.test.ts @@ -46,6 +46,7 @@ import { ObjectQL, applyInMemoryAggregation } from '@objectstack/objectql'; import { SqlDriver, withheldFilterDiagnosticOf } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'ledger_order'; @@ -114,6 +115,9 @@ async function boot( await engine.init(); engine.registry.registerObject(object as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); if (populated) await engine.insert(OBJECT, rows as any); const calls = { aggregate: 0, find: 0 }; diff --git a/packages/rest/src/aggregation-flag-comparand-refusal.test.ts b/packages/rest/src/aggregation-flag-comparand-refusal.test.ts index b88b01f965e..d6a53f481a3 100644 --- a/packages/rest/src/aggregation-flag-comparand-refusal.test.ts +++ b/packages/rest/src/aggregation-flag-comparand-refusal.test.ts @@ -55,6 +55,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver, withheldFilterDiagnosticOf } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_agg_flag_comparand_20981'; @@ -117,6 +118,9 @@ describe('[#20981] a non-boolean $exists / $null in a per-aggregation filter or await engine.init(); engine.registry.registerObject(DOC as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); diff --git a/packages/rest/src/analytics-contains-membership-door.test.ts b/packages/rest/src/analytics-contains-membership-door.test.ts index bd9fdec3e2d..da4ad6f4f9e 100644 --- a/packages/rest/src/analytics-contains-membership-door.test.ts +++ b/packages/rest/src/analytics-contains-membership-door.test.ts @@ -35,6 +35,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/service-analytics'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_contains_membership_item'; @@ -142,6 +143,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(ITEM as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); // The plugin's own composition over the real engine: both auto-bridges, diff --git a/packages/rest/src/analytics-dataset-aggregate-policies-door.test.ts b/packages/rest/src/analytics-dataset-aggregate-policies-door.test.ts index db2346b126d..0dbc4d923a9 100644 --- a/packages/rest/src/analytics-dataset-aggregate-policies-door.test.ts +++ b/packages/rest/src/analytics-dataset-aggregate-policies-door.test.ts @@ -40,6 +40,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/service-analytics'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_dataset_policy_ledger'; @@ -192,6 +193,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); const realExecute = (engine as any).execute.bind(engine); diff --git a/packages/rest/src/analytics-dataset-json-dimension-door.test.ts b/packages/rest/src/analytics-dataset-json-dimension-door.test.ts index ac9369f9253..990e1acc776 100644 --- a/packages/rest/src/analytics-dataset-json-dimension-door.test.ts +++ b/packages/rest/src/analytics-dataset-json-dimension-door.test.ts @@ -44,6 +44,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/service-analytics'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_dataset_json_dim_ledger'; /** The object the ledger's `account` lookup references — joined through the dataset's `include`. */ @@ -166,6 +167,9 @@ for (const cell of CELLS) { engine.registry.registerObject(ACCOUNT_OBJECT as any); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ACCOUNTS) await engine.insert(ACCOUNT, { ...row } as any); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); diff --git a/packages/rest/src/analytics-dataset-measure-number-door.test.ts b/packages/rest/src/analytics-dataset-measure-number-door.test.ts index 775f163a1ea..7d1bc04ff70 100644 --- a/packages/rest/src/analytics-dataset-measure-number-door.test.ts +++ b/packages/rest/src/analytics-dataset-measure-number-door.test.ts @@ -45,6 +45,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/service-analytics'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_dataset_measure_number_ledger'; @@ -187,6 +188,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); const realExecute = (engine as any).execute.bind(engine); diff --git a/packages/rest/src/analytics-dataset-multi-value-door.test.ts b/packages/rest/src/analytics-dataset-multi-value-door.test.ts index 0ebe3edcfd7..86f3cd26eb6 100644 --- a/packages/rest/src/analytics-dataset-multi-value-door.test.ts +++ b/packages/rest/src/analytics-dataset-multi-value-door.test.ts @@ -41,6 +41,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/service-analytics'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_dataset_multi_value_ledger'; const OPTIONS = [{ label: 'A', value: 'a' }, { label: 'B', value: 'b' }]; @@ -144,6 +145,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); const realExecute = (engine as any).execute.bind(engine); diff --git a/packages/rest/src/analytics-dataset-row-wildcard-door.test.ts b/packages/rest/src/analytics-dataset-row-wildcard-door.test.ts index 95ec535503f..82909054c72 100644 --- a/packages/rest/src/analytics-dataset-row-wildcard-door.test.ts +++ b/packages/rest/src/analytics-dataset-row-wildcard-door.test.ts @@ -49,6 +49,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/service-analytics'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_dataset_row_wildcard_ledger'; @@ -145,6 +146,9 @@ for (const strategy of ['native', 'objectql'] as const) { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); const realExecute = (engine as any).execute.bind(engine); diff --git a/packages/rest/src/analytics-field-permission-gate.test.ts b/packages/rest/src/analytics-field-permission-gate.test.ts index 96c908f1353..93bf7d15bbe 100644 --- a/packages/rest/src/analytics-field-permission-gate.test.ts +++ b/packages/rest/src/analytics-field-permission-gate.test.ts @@ -44,6 +44,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { SecurityPlugin } from '@objectstack/plugin-security'; import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/service-analytics'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_an_fls_ledger'; const OWNER = 'rest_an_fls_owner'; @@ -188,6 +189,9 @@ async function boot(strategy: 'native' | 'objectql'): Promise { ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const services: Record = { manifest: { register: vi.fn() }, diff --git a/packages/rest/src/analytics-hop-object-reference.test.ts b/packages/rest/src/analytics-hop-object-reference.test.ts index 7dda9909152..19f88683eb7 100644 --- a/packages/rest/src/analytics-hop-object-reference.test.ts +++ b/packages/rest/src/analytics-hop-object-reference.test.ts @@ -39,6 +39,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { SecurityPlugin } from '@objectstack/plugin-security'; import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/service-analytics'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const LEDGER = 'rest_an_hop_ledger'; /** The target of lookup `owner`: readable by the member. */ @@ -138,6 +139,9 @@ async function boot(strategy: 'native' | 'objectql'): Promise { ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const services: Record = { manifest: { register: vi.fn() }, diff --git a/packages/rest/src/analytics-masked-field-gate.test.ts b/packages/rest/src/analytics-masked-field-gate.test.ts index 8f55d6c76f2..3998c6a9773 100644 --- a/packages/rest/src/analytics-masked-field-gate.test.ts +++ b/packages/rest/src/analytics-masked-field-gate.test.ts @@ -43,6 +43,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { SecurityPlugin } from '@objectstack/plugin-security'; import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/service-analytics'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_an_mask_ledger'; const OWNER = 'rest_an_mask_owner'; @@ -189,6 +190,9 @@ async function boot(strategy: 'native' | 'objectql'): Promise { ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const services: Record = { manifest: { register: vi.fn() }, diff --git a/packages/rest/src/analytics-nested-relation-filter.test.ts b/packages/rest/src/analytics-nested-relation-filter.test.ts index b1d449ace1d..86aeef47842 100644 --- a/packages/rest/src/analytics-nested-relation-filter.test.ts +++ b/packages/rest/src/analytics-nested-relation-filter.test.ts @@ -51,6 +51,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { SecurityPlugin } from '@objectstack/plugin-security'; import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/service-analytics'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_an_nested_ledger'; const OWNER = 'rest_an_nested_owner'; @@ -194,6 +195,9 @@ async function boot(opts: { caps?: 'objectql'; getReadScope?: (object: string) = ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const services: Record = { manifest: { register: vi.fn() }, diff --git a/packages/rest/src/analytics-not-multivalue-null-guard.test.ts b/packages/rest/src/analytics-not-multivalue-null-guard.test.ts index 6fd92fd5e6e..ff77f4dde11 100644 --- a/packages/rest/src/analytics-not-multivalue-null-guard.test.ts +++ b/packages/rest/src/analytics-not-multivalue-null-guard.test.ts @@ -55,6 +55,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { SecurityPlugin } from '@objectstack/plugin-security'; import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/service-analytics'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_an_notmv_ledger'; const OWNER = 'rest_an_notmv_owner'; @@ -179,6 +180,9 @@ async function boot(caps?: 'objectql'): Promise { ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const services: Record = { manifest: { register: vi.fn() }, diff --git a/packages/rest/src/analytics-relationship-path-admission.test.ts b/packages/rest/src/analytics-relationship-path-admission.test.ts index 0b37bbc37a4..0be69e61fd8 100644 --- a/packages/rest/src/analytics-relationship-path-admission.test.ts +++ b/packages/rest/src/analytics-relationship-path-admission.test.ts @@ -39,6 +39,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { SecurityPlugin } from '@objectstack/plugin-security'; import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/service-analytics'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const LEDGER = 'rest_an_rpa_ledger'; /** Reached through a relationship path; the member holds no read grant on it. */ @@ -222,6 +223,9 @@ async function boot(strategy: 'native' | 'objectql'): Promise { ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const services: Record = { manifest: { register: vi.fn() }, diff --git a/packages/rest/src/cross-class-refusal-remedy-on-the-wire.test.ts b/packages/rest/src/cross-class-refusal-remedy-on-the-wire.test.ts index 3c0a8720e33..463db27795d 100644 --- a/packages/rest/src/cross-class-refusal-remedy-on-the-wire.test.ts +++ b/packages/rest/src/cross-class-refusal-remedy-on-the-wire.test.ts @@ -41,6 +41,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { SecurityPlugin } from '@objectstack/plugin-security'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** The record matcher's remedy, as its message opens. */ const MATCHER_REMEDY = @@ -114,6 +115,9 @@ async function boot(f: Fixture) { ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const set = PermissionSetSchema.parse({ name: 'qa_cross_class_remedy', diff --git a/packages/rest/src/data-aggregate-field-type-door.test.ts b/packages/rest/src/data-aggregate-field-type-door.test.ts index a0e0fbb234c..4ab219e8e9b 100644 --- a/packages/rest/src/data-aggregate-field-type-door.test.ts +++ b/packages/rest/src/data-aggregate-field-type-door.test.ts @@ -49,6 +49,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_aggregate_type_20914'; const TARGET = 'rest_aggregate_type_target_20914'; @@ -155,6 +156,9 @@ for (const cell of CELLS) { engine.registry.registerObject(TARGET_OBJECT as any); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const id of ['t1', 't2']) await engine.insert(TARGET, { id, name: id } as any); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); diff --git a/packages/rest/src/data-boolean-comparand-door.test.ts b/packages/rest/src/data-boolean-comparand-door.test.ts index 6194e6782c5..f8358372402 100644 --- a/packages/rest/src/data-boolean-comparand-door.test.ts +++ b/packages/rest/src/data-boolean-comparand-door.test.ts @@ -43,6 +43,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_boolean_door_21382'; @@ -174,6 +175,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(TASKS as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); // Reads of THIS object — the protocol's own metadata traffic is not the question. diff --git a/packages/rest/src/data-date-read-year-below-100.test.ts b/packages/rest/src/data-date-read-year-below-100.test.ts index 96446029f6e..0395db7d4b2 100644 --- a/packages/rest/src/data-date-read-year-below-100.test.ts +++ b/packages/rest/src/data-date-read-year-below-100.test.ts @@ -48,6 +48,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest20280_days'; @@ -136,6 +137,9 @@ function measure(cell: Cell, config: Record): void { await engine.init(); engine.registry.registerObject(DAYS_OBJECT as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const protocol = new ObjectStackProtocolImplementation(engine as any); const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); diff --git a/packages/rest/src/data-date-write-iso-only.test.ts b/packages/rest/src/data-date-write-iso-only.test.ts index f28ec5f17ea..43bfd07c18e 100644 --- a/packages/rest/src/data-date-write-iso-only.test.ts +++ b/packages/rest/src/data-date-write-iso-only.test.ts @@ -41,6 +41,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_date_20481'; @@ -125,6 +126,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); await engine.insert(OBJECT, { id: 'o1', customer_id: 'c1', placed_on: '2026-01-10' } as any); // Writes of THIS object — the protocol's own metadata traffic is not the question. diff --git a/packages/rest/src/data-field-comparand-permission.test.ts b/packages/rest/src/data-field-comparand-permission.test.ts index 025bd82aca2..f83a8166628 100644 --- a/packages/rest/src/data-field-comparand-permission.test.ts +++ b/packages/rest/src/data-field-comparand-permission.test.ts @@ -30,6 +30,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { SecurityPlugin } from '@objectstack/plugin-security'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_comparand_perm_ledger'; @@ -144,6 +145,9 @@ describe('a hidden field as a cross-field comparand answers the key form\'s refu ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const services: Record = { manifest: { register: vi.fn() }, diff --git a/packages/rest/src/data-group-by-json-door.test.ts b/packages/rest/src/data-group-by-json-door.test.ts index d97e6b141da..e900f537e33 100644 --- a/packages/rest/src/data-group-by-json-door.test.ts +++ b/packages/rest/src/data-group-by-json-door.test.ts @@ -40,6 +40,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_group_by_json_20783'; @@ -135,6 +136,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); // Reads of THIS object — the protocol's own metadata traffic is not the question. diff --git a/packages/rest/src/data-json-stored-group-distinct-door.test.ts b/packages/rest/src/data-json-stored-group-distinct-door.test.ts index 993069cceb6..c167ecd9ba6 100644 --- a/packages/rest/src/data-json-stored-group-distinct-door.test.ts +++ b/packages/rest/src/data-json-stored-group-distinct-door.test.ts @@ -42,6 +42,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_json_stored_key_20808'; const TARGET = 'rest_json_stored_key_target_20808'; @@ -170,6 +171,9 @@ for (const cell of CELLS) { engine.registry.registerObject(TARGET_OBJECT as any); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const id of ['t1', 't2']) await engine.insert(TARGET, { id, name: id } as any); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); diff --git a/packages/rest/src/data-nested-object-door.test.ts b/packages/rest/src/data-nested-object-door.test.ts index 551f106faf6..956ac5ee152 100644 --- a/packages/rest/src/data-nested-object-door.test.ts +++ b/packages/rest/src/data-nested-object-door.test.ts @@ -49,6 +49,7 @@ import { ObjectQL, RELATION_FILTER_ID_CAP } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_nested_obj_20745'; const OWNER = 'rest_nested_own_20745'; @@ -182,6 +183,9 @@ for (const cell of CELLS) { engine.registry.registerObject(OWNER_OBJECT as any); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of OWNERS) await engine.insert(OWNER, { ...row } as any); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); diff --git a/packages/rest/src/data-nested-relation-permission.test.ts b/packages/rest/src/data-nested-relation-permission.test.ts index 747b5c3f16c..72de5ec4377 100644 --- a/packages/rest/src/data-nested-relation-permission.test.ts +++ b/packages/rest/src/data-nested-relation-permission.test.ts @@ -33,6 +33,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { SecurityPlugin } from '@objectstack/plugin-security'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_nested_perm_ledger'; const OWNER = 'rest_nested_perm_owner'; @@ -120,6 +121,9 @@ describe('[#20802] the nested-relation form reads the related object as the call ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const services: Record = { manifest: { register: vi.fn() }, diff --git a/packages/rest/src/data-no-operator-object-door.test.ts b/packages/rest/src/data-no-operator-object-door.test.ts index f715073679d..459a645f802 100644 --- a/packages/rest/src/data-no-operator-object-door.test.ts +++ b/packages/rest/src/data-no-operator-object-door.test.ts @@ -45,6 +45,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_no_op_object_20546'; const OWNER = 'rest_no_op_owner_20546'; @@ -157,6 +158,9 @@ for (const cell of CELLS) { engine.registry.registerObject(OWNER_OBJECT as any); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of OWNERS) await engine.insert(OWNER, { ...row } as any); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); diff --git a/packages/rest/src/data-number-comparand-door.test.ts b/packages/rest/src/data-number-comparand-door.test.ts index 40708777f58..5636eb3d692 100644 --- a/packages/rest/src/data-number-comparand-door.test.ts +++ b/packages/rest/src/data-number-comparand-door.test.ts @@ -50,6 +50,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_number_door_20351'; @@ -204,6 +205,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); // Reads of THIS object — the protocol's own metadata traffic is not the question. diff --git a/packages/rest/src/data-query-calendar-day-last-day.test.ts b/packages/rest/src/data-query-calendar-day-last-day.test.ts index bf1b7bb7b96..bed70473c1b 100644 --- a/packages/rest/src/data-query-calendar-day-last-day.test.ts +++ b/packages/rest/src/data-query-calendar-day-last-day.test.ts @@ -45,6 +45,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_calendar_day_20600'; const HOST_ZONE = 'America/New_York'; @@ -142,6 +143,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const protocol = new ObjectStackProtocolImplementation(engine as any); const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); diff --git a/packages/rest/src/data-query-calendar-day-year-below-100.test.ts b/packages/rest/src/data-query-calendar-day-year-below-100.test.ts index 014dd3d6f6b..d8a042ba844 100644 --- a/packages/rest/src/data-query-calendar-day-year-below-100.test.ts +++ b/packages/rest/src/data-query-calendar-day-year-below-100.test.ts @@ -51,6 +51,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_calendar_day_20550'; const HOST_ZONE = 'America/New_York'; @@ -147,6 +148,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const protocol = new ObjectStackProtocolImplementation(engine as any); const rest = new RestServer(createMockServer() as any, protocol as any, { api: { requireAuth: false } } as any); diff --git a/packages/rest/src/data-query-date-year-range.test.ts b/packages/rest/src/data-query-date-year-range.test.ts index 6dd199f5de5..274a9c4e99b 100644 --- a/packages/rest/src/data-query-date-year-range.test.ts +++ b/packages/rest/src/data-query-date-year-range.test.ts @@ -38,6 +38,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'ledger_year'; @@ -106,6 +107,9 @@ async function boot() { await engine.init(); engine.registry.registerObject(LEDGER_YEAR as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); await engine.insert(OBJECT, ROWS as any); // Reads of THIS object — the protocol's own metadata reads are not the question. diff --git a/packages/rest/src/data-query-epoch-ms-date-comparand.test.ts b/packages/rest/src/data-query-epoch-ms-date-comparand.test.ts index e5137ba441c..aabf958cf0c 100644 --- a/packages/rest/src/data-query-epoch-ms-date-comparand.test.ts +++ b/packages/rest/src/data-query-epoch-ms-date-comparand.test.ts @@ -30,6 +30,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'ledger_order'; @@ -95,6 +96,9 @@ async function boot(populated: boolean): Promise { await engine.init(); engine.registry.registerObject(LEDGER_ORDER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); if (populated) await engine.insert(OBJECT, ROWS as any); const protocol = new ObjectStackProtocolImplementation(engine as any); diff --git a/packages/rest/src/data-query-having-temporal-door.test.ts b/packages/rest/src/data-query-having-temporal-door.test.ts index 57a00504bc1..5943e1dd46d 100644 --- a/packages/rest/src/data-query-having-temporal-door.test.ts +++ b/packages/rest/src/data-query-having-temporal-door.test.ts @@ -31,6 +31,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'ledger_having'; @@ -87,6 +88,9 @@ async function boot() { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); await engine.insert(OBJECT, ROWS as any); // Reads of THIS object — the protocol's own metadata reads are not the question. diff --git a/packages/rest/src/data-query-unprojected-declared-fields.test.ts b/packages/rest/src/data-query-unprojected-declared-fields.test.ts index ce1e5915b11..f2133e3c404 100644 --- a/packages/rest/src/data-query-unprojected-declared-fields.test.ts +++ b/packages/rest/src/data-query-unprojected-declared-fields.test.ts @@ -37,6 +37,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rq_contact'; const LADDER = 'rq_ladder'; @@ -122,6 +123,9 @@ async function bootOne(): Promise { engine.registry.registerObject(CONTACT_V1 as any); engine.registry.registerObject(LADDER_V1 as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); await engine.insert(OBJECT, [ { id: 'c1', name: 'Ada', email: 'ada@example.com', mailing_street: '1 Retired Way', mailing_city: 'Oldtown' }, { id: 'c2', name: 'Bo', email: 'bo@example.com', mailing_street: '2 Retired Way', mailing_city: 'Oldtown' }, @@ -138,6 +142,9 @@ async function bootTwo() { await engine.init(); engine.registry.registerObject(CONTACT_V2 as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); engine.registry.registerObject(LADDER_V2 as any); const protocol = new ObjectStackProtocolImplementation(engine as any); diff --git a/packages/rest/src/data-resolved-token-past-date-range.test.ts b/packages/rest/src/data-resolved-token-past-date-range.test.ts index a620d9d25d4..c6cb22173eb 100644 --- a/packages/rest/src/data-resolved-token-past-date-range.test.ts +++ b/packages/rest/src/data-resolved-token-past-date-range.test.ts @@ -30,6 +30,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; // Wed 2026-09-30 12:00 UTC: every resolved value below is read off this instant. const PINNED_NOW = new Date('2026-09-30T12:00:00.000Z'); @@ -100,6 +101,9 @@ describe('[#21068] a date macro past the instants a Date holds, at the public do await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); // Reads of THIS object — the protocol's own metadata traffic is not the question. diff --git a/packages/rest/src/data-resolved-token-year-range.test.ts b/packages/rest/src/data-resolved-token-year-range.test.ts index e32a09d2fba..e60c3e3e0f2 100644 --- a/packages/rest/src/data-resolved-token-year-range.test.ts +++ b/packages/rest/src/data-resolved-token-year-range.test.ts @@ -33,6 +33,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; // Wed 2026-09-30 12:00 UTC: every resolved day below is read off this instant. const PINNED_NOW = new Date('2026-09-30T12:00:00.000Z'); @@ -117,6 +118,9 @@ describe('[#20844] a relative-date placeholder resolved outside its field\'s yea await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); // Reads of THIS object — the protocol's own metadata traffic is not the question. diff --git a/packages/rest/src/data-search-multi-valued-membership.test.ts b/packages/rest/src/data-search-multi-valued-membership.test.ts index 637a1449542..7a45a2daeeb 100644 --- a/packages/rest/src/data-search-multi-valued-membership.test.ts +++ b/packages/rest/src/data-search-multi-valued-membership.test.ts @@ -36,6 +36,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** `examples/app-todo`'s `todo_task.tags` shape: a `select` declared `multiple: true`, in the auto-default set. */ const TASK = { @@ -154,6 +155,9 @@ for (const cell of CELLS) { engine.registry.registerObject(TASK as any); engine.registry.registerObject(NOTE as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of TASK_ROWS) await engine.insert(TASK.name, { ...row } as any); for (const row of NOTE_ROWS) await engine.insert(NOTE.name, { ...row } as any); vi.spyOn((engine as any).logger, 'warn').mockImplementation(() => undefined); diff --git a/packages/rest/src/data-temporal-write-real-day-iso.test.ts b/packages/rest/src/data-temporal-write-real-day-iso.test.ts index 2c0345de061..f9b145ec796 100644 --- a/packages/rest/src/data-temporal-write-real-day-iso.test.ts +++ b/packages/rest/src/data-temporal-write-real-day-iso.test.ts @@ -86,6 +86,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { renderValidationMessage } from '@objectstack/spec/system'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_temporal_20525'; const HOST_ZONE = 'America/New_York'; @@ -185,6 +186,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); await engine.insert(OBJECT, { id: 'o1', customer_id: 'c1', placed_on: '2026-01-10', opened_at: '2026-01-10T09:00:00Z' } as any); // Writes of THIS object — the protocol's own metadata traffic is not the question. diff --git a/packages/rest/src/data-temporal-year-range.test.ts b/packages/rest/src/data-temporal-year-range.test.ts index 22b51886daa..11fd86a7474 100644 --- a/packages/rest/src/data-temporal-year-range.test.ts +++ b/packages/rest/src/data-temporal-year-range.test.ts @@ -47,6 +47,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_year_20264'; @@ -195,6 +196,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); // Reads and writes of THIS object — the protocol's own metadata traffic is not the question. diff --git a/packages/rest/src/data-write-result-declared-fields.test.ts b/packages/rest/src/data-write-result-declared-fields.test.ts index cd17c750d15..5cc74f00c63 100644 --- a/packages/rest/src/data-write-result-declared-fields.test.ts +++ b/packages/rest/src/data-write-result-declared-fields.test.ts @@ -35,6 +35,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rq_contact'; const RETIRED = ['mailing_street', 'mailing_city'] as const; @@ -102,6 +103,9 @@ async function bootOne(): Promise { await engine.init(); engine.registry.registerObject(CONTACT_V1 as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); await engine.insert(OBJECT, SEEDED.map((id) => ({ id, name: id, email: `${id}@example.com`, api_token: `tok_${id}`, mailing_street: `${id} Retired Way`, mailing_city: 'Oldtown', @@ -120,6 +124,9 @@ async function bootTwo() { await engine.init(); engine.registry.registerObject(CONTACT_V2 as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const events: Captured[] = []; engine.setRealtimeService({ diff --git a/packages/rest/src/default-organization.testkit.ts b/packages/rest/src/default-organization.testkit.ts new file mode 100644 index 00000000000..0bafb98f970 --- /dev/null +++ b/packages/rest/src/default-organization.testkit.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Give a lean test engine what every real `single` install has: an + * organization object holding its Default Organization. + * + * ADR-0131 D1: a table the SQL driver creates carries its tenant column NOT + * NULL. The engine stamps an organization-less system write with the derived + * organization under `single` (D9) — but only where the composition registers + * the organization object; a bare `ObjectQL` + `SqlDriver` registers none, so + * the write reaches the database with no organization and is refused there. + * A suite whose subject is not tenancy (claims, acks, idle cost, at-rest + * shapes) calls this once its own objects are registered, so its system + * writes land the way they do on a real install. + * + * The object mirrors the platform's: managed by better-auth, so it carries no + * tenant column of its own. + */ +export const DEFAULT_ORGANIZATION_ID = 'org_default'; + +export async function installDefaultOrganization(engine: { + registry: { registerObject(def: unknown, owner: string): unknown }; + getSchema(object: string): unknown; + syncSchemas(): Promise; + getDriverForObject(object: string): + | { + create(object: string, data: Record): Promise; + findOne(object: string, query: unknown): Promise; + } + | undefined; +}): Promise { + // Idempotent: a suite that syncs one engine more than once may call it each time. + if (!engine.getSchema('sys_organization')) { + engine.registry.registerObject( + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, + '@objectstack/rest', + ); + } + await engine.syncSchemas(); + // Seeded through the driver: the organization is data at rest, not a write + // the suite under test should observe (an audit or activity mirror would). + const driver = engine.getDriverForObject('sys_organization')!; + if (await driver.findOne('sys_organization', { where: { id: DEFAULT_ORGANIZATION_ID } })) return; + await driver.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); +} diff --git a/packages/rest/src/import-control-opt-out-gate.test.ts b/packages/rest/src/import-control-opt-out-gate.test.ts index 3573f353c0c..8b34d02812a 100644 --- a/packages/rest/src/import-control-opt-out-gate.test.ts +++ b/packages/rest/src/import-control-opt-out-gate.test.ts @@ -36,6 +36,7 @@ import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protoco import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core'; import { SysImportJob } from '@objectstack/platform-objects/audit'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; function makeSqliteDriver() { return new SqlDriver({ @@ -109,6 +110,9 @@ async function boot() { await engine.init(); for (const o of [DEAL, CASE, HIDDEN, SysImportJob]) engine.registry.registerObject(o as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); } diff --git a/packages/rest/src/import-integration.test.ts b/packages/rest/src/import-integration.test.ts index 57c0a636519..d1f81f112b1 100644 --- a/packages/rest/src/import-integration.test.ts +++ b/packages/rest/src/import-integration.test.ts @@ -30,6 +30,7 @@ import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObje import { MetadataManager } from '@objectstack/metadata'; import { RestServer } from './rest-server'; import { loadExcelJs } from './xlsx-module.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; // --------------------------------------------------------------------------- // The real backend: better-sqlite3 `:memory:`, constructed the canonical way @@ -158,6 +159,9 @@ async function boot(services?: Map) { // Real DDL — the NOT NULL / column types the importer's dry run claims to // predict are now physically there to be violated. await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); await engine.insert('user', { id: 'u1', name: '张三', email: 'zhang@x.com' }); await engine.insert('user', { id: 'u2', name: '李四', email: 'li@x.com' }); @@ -651,6 +655,9 @@ describe('import route — a mapping target that names no field (#20150)', () => ({ route, engine } = await boot()); engine.registry.registerObject(NOTE as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // A dotted path whose head names no field of the object is no target // (`task` declares no `mailing_address`; #20149 made a declared PART of a // compound field a target, pinned in the describe block below). @@ -776,6 +783,9 @@ describe('import route — a declared part of a compound field (#20149)', () => ({ route, engine } = await boot()); engine.registry.registerObject(CONTACT as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // The customer template's five columns, unchanged (hotcrm#1836). register('contact_address_feed', [ { source: 'ID', target: 'id' }, diff --git a/packages/rest/src/import-job-integration.test.ts b/packages/rest/src/import-job-integration.test.ts index 33cf9b98588..cfa9745e7e7 100644 --- a/packages/rest/src/import-job-integration.test.ts +++ b/packages/rest/src/import-job-integration.test.ts @@ -30,6 +30,7 @@ import { SysImportJob } from '@objectstack/platform-objects/audit'; // from here so that moving it is observable at the enforcement point. import { IMPORT_JOB_MAX_ROWS } from '@objectstack/spec/api'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; // The real backend: better-sqlite3 `:memory:`, constructed the canonical way // (`examples/app-crm`, `cli db clean`, PR #5715's `makeDefaultDriver()`). @@ -102,6 +103,9 @@ async function boot(decorateDriver?: (driver: any) => void) { engine.registry.registerObject(SYS_IMPORT_JOB as any); // Real DDL for both tables before the first job row is written. await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // [#21516] The protocol reads the stored-metadata family; the engine refuses a // name its registry does not hold, so the harness registers the family as a boot @@ -291,15 +295,17 @@ describe('async import job — real engine + protocol integration', () => { // job is running — but the 3-row job has no mid-loop checkpoint, so the // loop still completes every row before noticing. const slowCtx = await boot((driver) => { + // Every argument is forwarded: the options carry the organization the + // engine stamps (ADR-0131 D9), which a NOT NULL tenant column needs (D1). const bulkCreate = driver.bulkCreate.bind(driver); - driver.bulkCreate = async (o: string, rows2: any[]) => { + driver.bulkCreate = async (o: string, rows2: any[], ...rest: any[]) => { if (o === 'task') await new Promise((r) => setImmediate(r)); - return bulkCreate(o, rows2); + return bulkCreate(o, rows2, ...rest); }; const create = driver.create.bind(driver); - driver.create = async (o: string, data: any) => { + driver.create = async (o: string, data: any, ...rest: any[]) => { if (o === 'task') await new Promise((r) => setImmediate(r)); - return create(o, data); + return create(o, data, ...rest); }; }); const created = await callCreate(slowCtx.create, { diff --git a/packages/rest/src/import-row-dropped-fields-20701.test.ts b/packages/rest/src/import-row-dropped-fields-20701.test.ts index bfc8a671a56..5449d158895 100644 --- a/packages/rest/src/import-row-dropped-fields-20701.test.ts +++ b/packages/rest/src/import-row-dropped-fields-20701.test.ts @@ -41,6 +41,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { SysImportJob } from '@objectstack/platform-objects/audit'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'proj_drops_20701'; @@ -91,6 +92,9 @@ async function boot() { engine.registry.registerObject(PROJ as any); engine.registry.registerObject(SysImportJob as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); await engine.insert(OBJECT, { id: 'e1', title: 'open one', n: 1, status: 'open' } as any); // A record whose `readonlyWhen` is already true; seeded under `isSystem`. await engine.insert(OBJECT, { id: 'e2', title: 'closed one', n: 1, status: 'closed', locked: 'L' } as any, { context: { isSystem: true } } as any); diff --git a/packages/rest/src/import-row-schema-drift-20701.test.ts b/packages/rest/src/import-row-schema-drift-20701.test.ts index 1ce9441a493..0ee17a0ba0f 100644 --- a/packages/rest/src/import-row-schema-drift-20701.test.ts +++ b/packages/rest/src/import-row-schema-drift-20701.test.ts @@ -46,6 +46,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { SysImportJob } from '@objectstack/platform-objects/audit'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'proj_drift_20701'; @@ -94,6 +95,9 @@ async function boot() { engine.registry.registerObject(PROJ as any); engine.registry.registerObject(SysImportJob as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); await engine.insert(OBJECT, { id: 'e1', title: 'existing', code: 'X', must: 'm' } as any); // The drift: `late` is declared after the DDL ran. engine.registry.registerObject(PROJ_DRIFTED as any); diff --git a/packages/rest/src/import-template-route.test.ts b/packages/rest/src/import-template-route.test.ts index 4a02af2ad1b..6ccd4f856d4 100644 --- a/packages/rest/src/import-template-route.test.ts +++ b/packages/rest/src/import-template-route.test.ts @@ -23,6 +23,7 @@ import { RestServer } from './rest-server'; import { parseXlsxToRows } from './import-prepare.js'; import { isTemplateRequired, templateInsertDefault } from './import-template.js'; import { loadXlsxWorkbook } from './xlsx-test-loader.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; function makeSqliteDriver() { return new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); @@ -124,6 +125,9 @@ async function boot(opts: BootOptions = {}) { await engine.init(); for (const o of [ACCOUNT, LINE, DEAL]) engine.registry.registerObject(o as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); await engine.insert('account', { id: 'a1', name: 'Acme' }); // [#21516] The protocol reads the stored-metadata family; the engine refuses a // name its registry does not hold, so the harness registers the family as a boot @@ -489,6 +493,9 @@ describe('?template=true — a required field the engine fills from its option d const { get, importRoute, engine } = await boot(); engine.registry.registerObject(TICKET as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const bytes = (await get({ template: 'true' }, 'ticket')).body(); const wb = await loadXlsxWorkbook(bytes); @@ -603,6 +610,9 @@ describe('the `*` agrees with the engine: starred exactly when the import door r } as any); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const disagreements: string[] = []; for (const [key, { def: authored, blank, stored, served }] of Object.entries(PARITY)) { @@ -752,6 +762,9 @@ async function bootExportFixture(security?: Record) { }, } as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); await engine.insert('user', { id: 'u1', name: '张三' }); await engine.insert('user', { id: 'u2', name: '李四' }); await engine.insert('task', { id: '1', title: '写代码', done: true, priority: 'high', due: '2026-06-30T00:00:00.000Z', owner: 'u1' }); diff --git a/packages/rest/src/import-undo-controls.test.ts b/packages/rest/src/import-undo-controls.test.ts index 6fdb890c8a5..7bbbf9980a8 100644 --- a/packages/rest/src/import-undo-controls.test.ts +++ b/packages/rest/src/import-undo-controls.test.ts @@ -38,6 +38,7 @@ import { SysMetadataAuditObject, SysMetadataCommitObject, SysMetadataHistoryObje import { SysImportJob } from '@objectstack/platform-objects/audit'; import { UndoImportJobResponseSchema } from '@objectstack/spec/api'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; function makeSqliteDriver() { return new SqlDriver({ @@ -130,6 +131,9 @@ async function boot() { await engine.init(); for (const o of [DEAL, CASE, TICKET, SysImportJob]) engine.registry.registerObject(o as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const o of [SysMetadataObject, SysMetadataHistoryObject, SysMetadataAuditObject, SysMetadataCommitObject]) { if (!engine.registry.getObject(o.name)) engine.registry.registerObject(o as any); } diff --git a/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts b/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts index 507e6c9d2aa..8686ca6ee50 100644 --- a/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts +++ b/packages/rest/src/meta-delete-drop-storage-other-rows.test.ts @@ -42,6 +42,7 @@ import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protoco import { SysMetadata, SysMetadataHistoryObject, SysMetadataAuditObject } from '@objectstack/platform-objects/metadata'; import { hashSpec } from '@objectstack/metadata-core'; import { RestServer } from './rest-server.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** `registry.registerObject` requires a package id (see the sibling real-stack tests). */ const TEST_PACKAGE_ID = 'objectstack-test'; @@ -97,6 +98,9 @@ async function boot(environmentId?: string) { engine.registry.registerObject(SysMetadataHistoryObject as any, TEST_PACKAGE_ID); engine.registry.registerObject(SysMetadataAuditObject as any, TEST_PACKAGE_ID); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const protocol = new ObjectStackProtocolImplementation(engine as any, undefined, environmentId); const config = { api: { requireAuth: false }, metadata: { enableCache: false } }; diff --git a/packages/rest/src/public-form-required.test.ts b/packages/rest/src/public-form-required.test.ts index 28c59f1e98e..88b9fc40239 100644 --- a/packages/rest/src/public-form-required.test.ts +++ b/packages/rest/src/public-form-required.test.ts @@ -34,6 +34,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'pf_required_lead_24131'; const SLUG = 'pf-required-24131'; @@ -126,6 +127,9 @@ describe('[#24131] POST /forms/:slug/submit enforces the form\'s own `required`' await engine.init(); engine.registry.registerObject(LEAD as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const protocol: any = new ObjectStackProtocolImplementation(engine as any); const realGetMetaItems = protocol.getMetaItems.bind(protocol); diff --git a/packages/rest/src/rest-aggregate-compensated-sum.test.ts b/packages/rest/src/rest-aggregate-compensated-sum.test.ts index eb34a866cbf..7a228ad0753 100644 --- a/packages/rest/src/rest-aggregate-compensated-sum.test.ts +++ b/packages/rest/src/rest-aggregate-compensated-sum.test.ts @@ -38,6 +38,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_agg_20489'; @@ -105,6 +106,9 @@ describe('[#20489] sum / avg — one double on both SQLite paths, engine and RES await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); let i = 0; for (const [g, { values }] of Object.entries(GROUPS)) { for (const w of values) await engine.insert(OBJECT, { id: `r${i++}`, g, w } as any); diff --git a/packages/rest/src/rest-aggregate-numeric-having.test.ts b/packages/rest/src/rest-aggregate-numeric-having.test.ts index d6af006929b..9bbe00eaa5b 100644 --- a/packages/rest/src/rest-aggregate-numeric-having.test.ts +++ b/packages/rest/src/rest-aggregate-numeric-having.test.ts @@ -39,6 +39,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'rest_agg_20335'; @@ -155,6 +156,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // One row per call: MySQL answers no RETURNING for a batch insert. for (const row of ROWS) await engine.insert(OBJECT, row as any); diff --git a/packages/rest/src/rest-aggregate-positions.test.ts b/packages/rest/src/rest-aggregate-positions.test.ts index 50d6f23b959..bea9649329b 100644 --- a/packages/rest/src/rest-aggregate-positions.test.ts +++ b/packages/rest/src/rest-aggregate-positions.test.ts @@ -31,6 +31,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { RestServer } from './rest-server'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'ledger_positions'; @@ -91,6 +92,9 @@ async function boot() { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); await engine.insert(OBJECT, ROWS as any); // Reads of THIS object — the protocol's own metadata reads are not the question. diff --git a/packages/rest/src/validate-update-stored-row-representation.test.ts b/packages/rest/src/validate-update-stored-row-representation.test.ts index 7e63b92c3fc..62aadcc578c 100644 --- a/packages/rest/src/validate-update-stored-row-representation.test.ts +++ b/packages/rest/src/validate-update-stored-row-representation.test.ts @@ -18,6 +18,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'pv_repr'; @@ -53,6 +54,9 @@ async function boot() { await engine.init(); engine.registry.registerObject({ name: OBJECT, label: 'Preview Representation', fields } as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const stored: Record = { title: 'stored' }; for (const [column, [, , value]] of Object.entries(COLUMNS)) { stored[column] = value; diff --git a/packages/services/service-messaging/src/default-organization.testkit.ts b/packages/services/service-messaging/src/default-organization.testkit.ts index 2f217e6830f..22a2407f50d 100644 --- a/packages/services/service-messaging/src/default-organization.testkit.ts +++ b/packages/services/service-messaging/src/default-organization.testkit.ts @@ -20,15 +20,26 @@ export const DEFAULT_ORGANIZATION_ID = 'org_default'; export async function installDefaultOrganization(engine: { registry: { registerObject(def: unknown, owner: string): unknown }; + getSchema(object: string): unknown; syncSchemas(): Promise; - getDriverForObject(object: string): { create(object: string, data: Record): Promise } | undefined; + getDriverForObject(object: string): + | { + create(object: string, data: Record): Promise; + findOne(object: string, query: unknown): Promise; + } + | undefined; }): Promise { - engine.registry.registerObject( - { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, - '@objectstack/service-messaging', - ); + // Idempotent: a suite that syncs one engine more than once may call it each time. + if (!engine.getSchema('sys_organization')) { + engine.registry.registerObject( + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, + '@objectstack/service-messaging', + ); + } await engine.syncSchemas(); // Seeded through the driver: the organization is data at rest, not a write // the suite under test should observe (an audit or activity mirror would). - await engine.getDriverForObject('sys_organization')!.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); + const driver = engine.getDriverForObject('sys_organization')!; + if (await driver.findOne('sys_organization', { where: { id: DEFAULT_ORGANIZATION_ID } })) return; + await driver.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); } diff --git a/packages/services/service-storage/src/default-organization.testkit.ts b/packages/services/service-storage/src/default-organization.testkit.ts index fe7d76ecd6a..92328ba84e7 100644 --- a/packages/services/service-storage/src/default-organization.testkit.ts +++ b/packages/services/service-storage/src/default-organization.testkit.ts @@ -20,15 +20,26 @@ export const DEFAULT_ORGANIZATION_ID = 'org_default'; export async function installDefaultOrganization(engine: { registry: { registerObject(def: unknown, owner: string): unknown }; + getSchema(object: string): unknown; syncSchemas(): Promise; - getDriverForObject(object: string): { create(object: string, data: Record): Promise } | undefined; + getDriverForObject(object: string): + | { + create(object: string, data: Record): Promise; + findOne(object: string, query: unknown): Promise; + } + | undefined; }): Promise { - engine.registry.registerObject( - { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, - '@objectstack/service-storage', - ); + // Idempotent: a suite that syncs one engine more than once may call it each time. + if (!engine.getSchema('sys_organization')) { + engine.registry.registerObject( + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, + '@objectstack/service-storage', + ); + } await engine.syncSchemas(); // Seeded through the driver: the organization is data at rest, not a write // the suite under test should observe (an audit or activity mirror would). - await engine.getDriverForObject('sys_organization')!.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); + const driver = engine.getDriverForObject('sys_organization')!; + if (await driver.findOne('sys_organization', { where: { id: DEFAULT_ORGANIZATION_ID } })) return; + await driver.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); } From f1111e11d85a1e6670b1054393c31a804d70b321 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:25:42 +0000 Subject: [PATCH 48/81] wip(service-automation): fixtures hold a Default Organization, or model a pre-18 store Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- ...t-record-id-projection.integration.test.ts | 4 ++ ...stored-metadata-family.integration.test.ts | 4 ++ ...etadata-filter-refusal.integration.test.ts | 4 ++ ...ro-delivery-visibility.integration.test.ts | 4 ++ .../src/connector-pull.integration.test.ts | 4 ++ .../src/default-organization.testkit.ts | 45 +++++++++++++++++++ ...field-expression-scale.integration.test.ts | 4 ++ ...w-metadata-save-arming.integration.test.ts | 4 ++ .../src/paused-run-visibility.test.ts | 4 ++ .../src/resume-gone-suspension-settle.test.ts | 10 ++++- .../src/runas-attribution-contract.test.ts | 4 ++ .../runas-system-stamping.integration.test.ts | 10 +++++ .../sys-automation-run-display-title.test.ts | 4 ++ ...user-default-row-scope.integration.test.ts | 13 +++++- 14 files changed, 116 insertions(+), 2 deletions(-) create mode 100644 packages/services/service-automation/src/default-organization.testkit.ts diff --git a/packages/services/service-automation/src/builtin/get-record-id-projection.integration.test.ts b/packages/services/service-automation/src/builtin/get-record-id-projection.integration.test.ts index 5ba3ad067df..9626fa2ea72 100644 --- a/packages/services/service-automation/src/builtin/get-record-id-projection.integration.test.ts +++ b/packages/services/service-automation/src/builtin/get-record-id-projection.integration.test.ts @@ -38,6 +38,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { AutomationServicePlugin } from '../plugin.js'; import { AutomationEngine } from '../engine.js'; import { registerCrudNodes } from './crud-nodes.js'; +import { installDefaultOrganization } from '../default-organization.testkit.js'; const SYS = { isSystem: true } as const; @@ -80,6 +81,9 @@ describe('get_record answers the row id as output.id whatever its fields project ql.registerDriver(driver, true); ql.registry.registerObject(OBJECT as any, 'pin-get-record-id', 'pin-get-record-id'); await ql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(ql); const row: any = await ql.insert(OBJECT.name, { title: 'alpha', status: 'open' }, { context: SYS }); rowId = row.id; diff --git a/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts b/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts index 9bb8396ff33..35263bca12b 100644 --- a/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts +++ b/packages/services/service-automation/src/builtin/get-record-stored-metadata-family.integration.test.ts @@ -48,6 +48,7 @@ import { hashSpec } from '@objectstack/metadata-core'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { AutomationServicePlugin } from '../plugin.js'; import type { AutomationEngine } from '../engine.js'; +import { installDefaultOrganization } from '../default-organization.testkit.js'; const SYS = { isSystem: true } as const; const KEYED = /^hmac-sha256:[0-9a-f]{64}$/; @@ -148,6 +149,9 @@ describe('flow get_record serves the stored-metadata family the way the data doo ql.registerDriver(driver, true); ql.registry.registerObject(COPY_OBJECT as any, 'pin-21519', 'pin-21519'); await ql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(ql); const metadata = JSON.stringify(DS_BODY); await ql.insert('sys_metadata', { diff --git a/packages/services/service-automation/src/builtin/get-record-stored-metadata-filter-refusal.integration.test.ts b/packages/services/service-automation/src/builtin/get-record-stored-metadata-filter-refusal.integration.test.ts index 5d8ee9859b5..5a439f7b29e 100644 --- a/packages/services/service-automation/src/builtin/get-record-stored-metadata-filter-refusal.integration.test.ts +++ b/packages/services/service-automation/src/builtin/get-record-stored-metadata-filter-refusal.integration.test.ts @@ -48,6 +48,7 @@ import { hashSpec } from '@objectstack/metadata-core'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { AutomationServicePlugin } from '../plugin.js'; import type { AutomationEngine, NodeExecutor } from '../engine.js'; +import { installDefaultOrganization } from '../default-organization.testkit.js'; const SYS = { isSystem: true } as const; const KEYED = /^hmac-sha256:[0-9a-f]{64}$/; @@ -152,6 +153,9 @@ describe("flow get_record refuses a filter that evaluates the stored-metadata fa ql.registerDriver(driver, true); ql.registry.registerObject(COPY_OBJECT as any, 'pin-21623', 'pin-21623'); await ql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(ql); const metadata = JSON.stringify(DS_BODY); await ql.insert('sys_metadata', { diff --git a/packages/services/service-automation/src/builtin/notify-zero-delivery-visibility.integration.test.ts b/packages/services/service-automation/src/builtin/notify-zero-delivery-visibility.integration.test.ts index efc69d8b412..e7323f58154 100644 --- a/packages/services/service-automation/src/builtin/notify-zero-delivery-visibility.integration.test.ts +++ b/packages/services/service-automation/src/builtin/notify-zero-delivery-visibility.integration.test.ts @@ -116,6 +116,7 @@ import type { FlowRunSummary } from '@objectstack/spec/automation'; import { AutomationEngine } from '../engine.js'; import { registerNotifyNode } from './notify-node.js'; import { formatRunSummaryLine } from '../run-summary.js'; +import { installDefaultOrganization } from '../default-organization.testkit.js'; /** The employer organization whose admin members are the intended recipients. */ const ORG_EMPLOYER = 'org_employer_alpha'; @@ -299,6 +300,9 @@ async function boot(kind: DriverKind) { const PKG = '@objectstack/service-messaging'; for (const o of FIXTURES) ql.registry.registerObject(o as any, PKG, PKG); await ql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(ql); data = ql as unknown as IDataEngine; } else { data = inProcessEngine(); diff --git a/packages/services/service-automation/src/connector-pull.integration.test.ts b/packages/services/service-automation/src/connector-pull.integration.test.ts index 01bad9c8260..4f5414f450a 100644 --- a/packages/services/service-automation/src/connector-pull.integration.test.ts +++ b/packages/services/service-automation/src/connector-pull.integration.test.ts @@ -34,6 +34,7 @@ import { AutomationServicePlugin } from './plugin.js'; import type { EngineQueryOptions } from '@objectstack/spec/data'; import type { IAutomationService } from '@objectstack/spec/contracts'; import type { ConnectorPullProtocol } from './connector-pull.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const CONTACT = { name: 'contact', @@ -147,6 +148,9 @@ describe('[#20919] connector pull, end to end through a real rest connector', () await ql.init(); ql.registry.registerObject(CONTACT as any); await ql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(ql); const automation = new AutomationServicePlugin({ suspendedRunStore: 'memory' }); kernel = new LiteKernel({ logger: { level: 'silent' } } as never); diff --git a/packages/services/service-automation/src/default-organization.testkit.ts b/packages/services/service-automation/src/default-organization.testkit.ts new file mode 100644 index 00000000000..ebbb9e4f8dd --- /dev/null +++ b/packages/services/service-automation/src/default-organization.testkit.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Give a lean test engine what every real `single` install has: an + * organization object holding its Default Organization. + * + * ADR-0131 D1: a table the SQL driver creates carries its tenant column NOT + * NULL. The engine stamps an organization-less system write with the derived + * organization under `single` (D9) — but only where the composition registers + * the organization object; a bare `ObjectQL` + `SqlDriver` registers none, so + * the write reaches the database with no organization and is refused there. + * A suite whose subject is not tenancy (claims, acks, idle cost, at-rest + * shapes) calls this once its own objects are registered, so its system + * writes land the way they do on a real install. + * + * The object mirrors the platform's: managed by better-auth, so it carries no + * tenant column of its own. + */ +export const DEFAULT_ORGANIZATION_ID = 'org_default'; + +export async function installDefaultOrganization(engine: { + registry: { registerObject(def: unknown, owner: string): unknown }; + getSchema(object: string): unknown; + syncSchemas(): Promise; + getDriverForObject(object: string): + | { + create(object: string, data: Record): Promise; + findOne(object: string, query: unknown): Promise; + } + | undefined; +}): Promise { + // Idempotent: a suite that syncs one engine more than once may call it each time. + if (!engine.getSchema('sys_organization')) { + engine.registry.registerObject( + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, + '@objectstack/service-automation', + ); + } + await engine.syncSchemas(); + // Seeded through the driver: the organization is data at rest, not a write + // the suite under test should observe (an audit or activity mirror would). + const driver = engine.getDriverForObject('sys_organization')!; + if (await driver.findOne('sys_organization', { where: { id: DEFAULT_ORGANIZATION_ID } })) return; + await driver.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); +} diff --git a/packages/services/service-automation/src/flow-field-expression-scale.integration.test.ts b/packages/services/service-automation/src/flow-field-expression-scale.integration.test.ts index 266ddab51f3..c9fb8297010 100644 --- a/packages/services/service-automation/src/flow-field-expression-scale.integration.test.ts +++ b/packages/services/service-automation/src/flow-field-expression-scale.integration.test.ts @@ -44,6 +44,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { AutomationServicePlugin } from './plugin.js'; import type { AutomationEngine } from './engine.js'; import { VALUE_SLOT_TEMPLATE_REFUSAL } from '@objectstack/spec/automation'; +import { installDefaultOrganization } from './default-organization.testkit.js'; function makeSqliteDriver() { return new SqlDriver({ @@ -114,6 +115,9 @@ describe('flow-computed money lands within its declared scale (#11060, oracle fo ql.registerDriver(driver, true); ql.registry.registerObject(quote as any, 'scale-test', 'scale-test'); await ql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(ql); } const quoteByTitle = (title: string) => diff --git a/packages/services/service-automation/src/flow-metadata-save-arming.integration.test.ts b/packages/services/service-automation/src/flow-metadata-save-arming.integration.test.ts index 290ed9e226a..945ab28bd1c 100644 --- a/packages/services/service-automation/src/flow-metadata-save-arming.integration.test.ts +++ b/packages/services/service-automation/src/flow-metadata-save-arming.integration.test.ts @@ -36,6 +36,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import type { AutomationContext } from '@objectstack/spec/contracts'; import { AutomationServicePlugin } from './plugin.js'; import type { AutomationEngine, FlowTrigger, FlowTriggerBinding } from './engine.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** The saved surface these tests drive: `ObjectStackProtocolImplementation`'s write doors. */ interface MetadataWriteDoors { @@ -133,6 +134,9 @@ describe('a flow saved through the metadata API is armed on the running engine ( 'qa-21725', ); await ql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(ql); const rec = objectqlRecordTrigger(ql); automation.registerTrigger(rec.trigger); diff --git a/packages/services/service-automation/src/paused-run-visibility.test.ts b/packages/services/service-automation/src/paused-run-visibility.test.ts index c5870b58869..10eda9d33fc 100644 --- a/packages/services/service-automation/src/paused-run-visibility.test.ts +++ b/packages/services/service-automation/src/paused-run-visibility.test.ts @@ -80,6 +80,7 @@ import { AutomationEngine } from './engine.js'; import type { RunRecord, SuspendedRun, SuspendedRunStore } from './engine.js'; import { InMemorySuspendedRunStore } from './suspended-run-store.js'; import { AutomationServicePlugin } from './plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const silent = { info() {}, warn() {}, error() {}, debug() {}, child() { return silent; } } as never; const flush = () => new Promise((r) => setTimeout(r, 0)); @@ -547,6 +548,9 @@ describe('#8050 cold boot over the same sqlite file (full stack)', () => { (ql as any).registry.registerObject(o.def as never, o.owner); } await ql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(ql); const automation = kernel.getService('automation'); automation.registerNodeExecutor(holdExecutor); diff --git a/packages/services/service-automation/src/resume-gone-suspension-settle.test.ts b/packages/services/service-automation/src/resume-gone-suspension-settle.test.ts index c1e0aea5198..56576bb564c 100644 --- a/packages/services/service-automation/src/resume-gone-suspension-settle.test.ts +++ b/packages/services/service-automation/src/resume-gone-suspension-settle.test.ts @@ -53,6 +53,7 @@ import type { RunRecord, SuspendedRun, SuspendedRunStore, SuspensionClaimOutcome import { InMemorySuspendedRunStore } from './suspended-run-store.js'; import { AutomationServicePlugin } from './plugin.js'; import { SysAutomationRun } from './sys-automation-run.object.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const silent = { info() {}, warn() {}, error() {}, debug() {}, child() { return silent; } } as never; const SYSTEM = { isSystem: true } as never; @@ -125,6 +126,9 @@ describe('a gone-for-good resume settles the run row (full stack, sqlite file)', await driver.connect(); ql.registerDriver(driver, true); await ql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(ql); const automation = kernel.getService('automation'); return { kernel, ql, automation }; @@ -435,7 +439,11 @@ describe('the boot re-arm runs before a late-bound flow arrives (plugin boot ord } as never); await kernel.use(new AutomationServicePlugin()); await kernel.bootstrap(); - return { kernel, ql: kernel.getService('objectql'), automation: kernel.getService('automation') }; + const ql = kernel.getService('objectql'); + // A real `single` install's Default Organization: the run rows below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(ql); + return { kernel, ql, automation: kernel.getService('automation') }; } it('the re-armed resume finds the flow not bound yet, and the run stays parked and resumes once the boot completes', async () => { diff --git a/packages/services/service-automation/src/runas-attribution-contract.test.ts b/packages/services/service-automation/src/runas-attribution-contract.test.ts index f31b5ec148a..80cf75defc6 100644 --- a/packages/services/service-automation/src/runas-attribution-contract.test.ts +++ b/packages/services/service-automation/src/runas-attribution-contract.test.ts @@ -53,6 +53,7 @@ import { ObjectQLPlugin, type ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { AutomationServicePlugin } from './plugin.js'; import type { AutomationEngine } from './engine.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** Real backend: better-sqlite3 `:memory:` through driver-sql. */ function makeSqliteDriver() { @@ -178,6 +179,9 @@ describe("runAs:'system' attribution contract — elevation decides authorizatio ql.registerDriver(driver, true); ql.registry.registerObject(crmTask as any, 'attribution-test', 'attribution-test'); await ql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(ql); // Observe the SAME session envelope the audit stamp hooks and // plugin-audit's `writeAudit` read. Registered at priority 100 so it runs diff --git a/packages/services/service-automation/src/runas-system-stamping.integration.test.ts b/packages/services/service-automation/src/runas-system-stamping.integration.test.ts index 2cd4c60b178..e4d51b72ad5 100644 --- a/packages/services/service-automation/src/runas-system-stamping.integration.test.ts +++ b/packages/services/service-automation/src/runas-system-stamping.integration.test.ts @@ -162,6 +162,16 @@ describe("runAs:'system' create_record stamps organization_id / owner_id / creat it('a USER-LESS system run (schedule shape) stamps nothing — and that is the contract, not a gap', async () => { await boot(); + // The row this run writes names no organization, which only a store built + // before 18.x can hold: a fresh tenant column is NOT NULL (ADR-0131 D1), and + // this composition registers no organization object to derive one from. + const relaxed = await (ql.getDriverForObject('crm_task') as any).applyMigrationEntries([ + { + kind: 'nullability_mismatch', remoteName: 'crm_task', table: 'crm_task', column: 'organization_id', category: 'needs_confirm', + op: { type: 'relax_not_null', table: 'crm_task', column: 'organization_id' }, + }, + ]); + expect(relaxed.applied).toHaveLength(1); automation.registerFlow('night_sweep', sweepFlow('night_sweep', { title: 'renew C' }) as any); // What ScheduleTrigger actually supplies: an event and params, NO user, NO diff --git a/packages/services/service-automation/src/sys-automation-run-display-title.test.ts b/packages/services/service-automation/src/sys-automation-run-display-title.test.ts index 663a33c2092..99cd6b87d41 100644 --- a/packages/services/service-automation/src/sys-automation-run-display-title.test.ts +++ b/packages/services/service-automation/src/sys-automation-run-display-title.test.ts @@ -26,6 +26,7 @@ import { ObjectQL, resolveRecordTitle } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resolveDisplayField } from '@objectstack/spec/data'; import { SysAutomationRun } from './sys-automation-run.object.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYS = { context: { isSystem: true } } as any; @@ -68,6 +69,9 @@ describe('[#20015] sys_automation_run resolves a real record title under ADR-007 await engine.init(); engine.registry.registerObject(SysAutomationRun as any, 'com.objectstack.test.20015'); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); }); afterAll(async () => { diff --git a/packages/services/service-automation/src/sys-user-default-row-scope.integration.test.ts b/packages/services/service-automation/src/sys-user-default-row-scope.integration.test.ts index 9b54bb93f69..53c94e25df4 100644 --- a/packages/services/service-automation/src/sys-user-default-row-scope.integration.test.ts +++ b/packages/services/service-automation/src/sys-user-default-row-scope.integration.test.ts @@ -150,7 +150,18 @@ describe("sys_user's one `||` default keeps the reader's own row on every user-c } // The one grant: a global (organisation-less) assignment of the `'*'` reader, // written straight into the table — a system write here must name one of - // the two organisations, and a global grant names none. + // the two organisations, and a global grant names none. Such a row exists + // only on a store built before 18.x (ADR-0131 D1 makes a fresh tenant + // column NOT NULL), so the table is that store's, relaxed through the + // driver's own drift apply door. + const relaxed = await (driver as any).applyMigrationEntries([ + { + kind: 'nullability_mismatch', remoteName: 'sys_user_permission_set', table: 'sys_user_permission_set', + column: 'organization_id', category: 'needs_confirm', + op: { type: 'relax_not_null', table: 'sys_user_permission_set', column: 'organization_id' }, + }, + ]); + expect(relaxed.applied).toHaveLength(1); await (driver as unknown as { knex: (t: string) => { insert(r: object): Promise } }) .knex('sys_user_permission_set') .insert({ id: 'ups_me', user_id: 'usr_me', permission_set: STAR_READER.name, organization_id: null }); From 7d7ad20dcf89d17be0a838b7e8ed4902647bd5ee Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:27:33 +0000 Subject: [PATCH 49/81] wip(trigger-record-change): lean fixture engines hold a Default Organization Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- .../before-update-flow-payload-reach.test.ts | 4 ++ .../src/default-organization.testkit.ts | 45 +++++++++++++++++++ .../src/record-change-integration.test.ts | 28 ++++++++++++ .../src/reentrant-start-condition.test.ts | 4 ++ 4 files changed, 81 insertions(+) create mode 100644 packages/triggers/trigger-record-change/src/default-organization.testkit.ts diff --git a/packages/triggers/trigger-record-change/src/before-update-flow-payload-reach.test.ts b/packages/triggers/trigger-record-change/src/before-update-flow-payload-reach.test.ts index c1e23718824..f89e282947e 100644 --- a/packages/triggers/trigger-record-change/src/before-update-flow-payload-reach.test.ts +++ b/packages/triggers/trigger-record-change/src/before-update-flow-payload-reach.test.ts @@ -109,6 +109,7 @@ import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts'; // whole module graph measures loading, not behaviour. import { SqlDriver } from '@objectstack/driver-sql'; import { RecordChangeTriggerPlugin } from './plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** * `registerObject` / `registerHook` / `registerFunction` are concrete-engine @@ -899,6 +900,9 @@ describe('[#15356/#14744] S5 on the real SQL driver — the mutation reaches no (objectql.registry as unknown as TestObjectRegistry).registerObject(o.def as never, o.owner); } await objectql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(objectql); objectql.registerFunction('sq_mutate_nested', async (args: any) => { const tags = (args.automation?.record as any)?.tags; diff --git a/packages/triggers/trigger-record-change/src/default-organization.testkit.ts b/packages/triggers/trigger-record-change/src/default-organization.testkit.ts new file mode 100644 index 00000000000..86b4e254f31 --- /dev/null +++ b/packages/triggers/trigger-record-change/src/default-organization.testkit.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Give a lean test engine what every real `single` install has: an + * organization object holding its Default Organization. + * + * ADR-0131 D1: a table the SQL driver creates carries its tenant column NOT + * NULL. The engine stamps an organization-less system write with the derived + * organization under `single` (D9) — but only where the composition registers + * the organization object; a bare `ObjectQL` + `SqlDriver` registers none, so + * the write reaches the database with no organization and is refused there. + * A suite whose subject is not tenancy (claims, acks, idle cost, at-rest + * shapes) calls this once its own objects are registered, so its system + * writes land the way they do on a real install. + * + * The object mirrors the platform's: managed by better-auth, so it carries no + * tenant column of its own. + */ +export const DEFAULT_ORGANIZATION_ID = 'org_default'; + +export async function installDefaultOrganization(engine: { + registry: { registerObject(def: unknown, owner: string): unknown }; + getSchema(object: string): unknown; + syncSchemas(): Promise; + getDriverForObject(object: string): + | { + create(object: string, data: Record): Promise; + findOne(object: string, query: unknown): Promise; + } + | undefined; +}): Promise { + // Idempotent: a suite that syncs one engine more than once may call it each time. + if (!engine.getSchema('sys_organization')) { + engine.registry.registerObject( + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, + '@objectstack/trigger-record-change', + ); + } + await engine.syncSchemas(); + // Seeded through the driver: the organization is data at rest, not a write + // the suite under test should observe (an audit or activity mirror would). + const driver = engine.getDriverForObject('sys_organization')!; + if (await driver.findOne('sys_organization', { where: { id: DEFAULT_ORGANIZATION_ID } })) return; + await driver.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); +} diff --git a/packages/triggers/trigger-record-change/src/record-change-integration.test.ts b/packages/triggers/trigger-record-change/src/record-change-integration.test.ts index 8c791676e40..156170426e4 100644 --- a/packages/triggers/trigger-record-change/src/record-change-integration.test.ts +++ b/packages/triggers/trigger-record-change/src/record-change-integration.test.ts @@ -39,6 +39,7 @@ import { RecordChangeTriggerPlugin } from './plugin.js'; // `packages/runtime/src/**` (which is what `plugin-auth` and `dogfood` // declare) because the helper imports nothing — it is the whole read. import { captureExpectedReadRefusals } from '../../../runtime/src/expected-read-refusal-noise.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** * `check:slot-lookup` (#4251) — a NEW `kernel.getService(...)` site must carry @@ -417,6 +418,9 @@ describe('a system write must not fire a record-change flow UNSCOPED (#3760)', ( await attachSqlite(objectql); objectql.registry.registerObject(objectDef('sysw'), 'test', 'test'); await objectql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(objectql); // No `runAs` — the spec default 'user'. This is the shape an author (very // often an AI) writes without realising it can run without a user. automation.registerFlow('sysw_stamp', stampFlow('sysw_stamp', 'sysw') as any); @@ -462,6 +466,9 @@ describe('a system write must not fire a record-change flow UNSCOPED (#3760)', ( await attachSqlite(objectql); objectql.registry.registerObject(objectDef('sysw2'), 'test', 'test'); await objectql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(objectql); automation.registerFlow('sysw2_stamp', stampFlow('sysw2_stamp', 'sysw2') as any); const created = await data.insert('sysw2', { status: 'new' }, { context: { userId: 'u_trigger' } }); @@ -490,6 +497,9 @@ describe('record-change trigger — end-to-end (#1491)', () => { await attachSqlite(objectql); objectql.registry.registerObject(objectDef('wid'), 'test', 'test'); await objectql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(objectql); automation.registerFlow('stamp_flow', stampFlow('stamp_flow', 'wid') as any); // The flow bound to the trigger… @@ -525,6 +535,9 @@ describe('record-change trigger — end-to-end (#1491)', () => { ql.registry.registerObject(objectDef('wid2'), 'test', 'test'); ql.registry.registerItem('flow', flowDef, 'name', 'test'); await ql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(ql); }, }; @@ -566,6 +579,9 @@ describe('record-change trigger — end-to-end (#1491)', () => { await attachSqlite(objectql); objectql.registry.registerObject(objectDef('wid3'), 'test', 'test'); await objectql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(objectql); automation.registerFlow('mirror_write', mirrorWriteFlow('mirror_write', 'wid3') as any); expect((automation as any).getActiveTriggerBindings()).toContainEqual({ @@ -600,6 +616,9 @@ describe('record-change trigger — end-to-end (#1491)', () => { await attachSqlite(objectql); objectql.registry.registerObject(objectDef('wid5'), 'test', 'test'); await objectql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(objectql); automation.registerFlow('urgent_alert', urgentAlertFlow('urgent_alert', 'wid5') as any); // Create leg — a brand-new URGENT record: `previous == null` makes the @@ -662,6 +681,9 @@ describe('record-change trigger — end-to-end (#1491)', () => { 'test', 'test', ); await objectql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(objectql); automation.registerFlow('before_write_probe', { name: 'before_write_probe', label: 'Before-update probe', type: 'record_change', @@ -812,6 +834,9 @@ describe('hydration schema gate — real ObjectQL engine findOne count (#8482)', objectql.registry.registerObject(plainObjectDef('gate_plain'), 'test', 'test'); objectql.registry.registerObject(auditObjectDef('gate_plain_audit'), 'test', 'test'); await objectql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(objectql); automation.registerFlow( 'gate_plain_audit_flow', afterUpdateAuditFlow('gate_plain_audit_flow', 'gate_plain', 'gate_plain_audit') as any, @@ -848,6 +873,9 @@ describe('hydration schema gate — real ObjectQL engine findOne count (#8482)', objectql.registry.registerObject(formulaObjectDef('gate_calc'), 'test', 'test'); objectql.registry.registerObject(auditObjectDef('gate_calc_audit'), 'test', 'test'); await objectql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(objectql); automation.registerFlow( 'gate_calc_audit_flow', afterUpdateAuditFlow('gate_calc_audit_flow', 'gate_calc', 'gate_calc_audit') as any, diff --git a/packages/triggers/trigger-record-change/src/reentrant-start-condition.test.ts b/packages/triggers/trigger-record-change/src/reentrant-start-condition.test.ts index 295ea9066a9..701d940a774 100644 --- a/packages/triggers/trigger-record-change/src/reentrant-start-condition.test.ts +++ b/packages/triggers/trigger-record-change/src/reentrant-start-condition.test.ts @@ -64,6 +64,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/service-automation'; import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts'; import { RecordChangeTriggerPlugin } from './plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** See `record-change-integration.test.ts` for why this shape is named. */ type TestObjectQLEngine = IObjectQLEngine & { @@ -271,6 +272,9 @@ describe("a record-change flow's start condition is evaluated on its own write-b objectql.registry.registerObject(o.def as never, o.owner); } await objectql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(objectql); automation.registerFlow('esc_escalation', escalationFlow('esc_escalation', 'esc_case') as never); // Created OUTSIDE the condition (`priority` is not critical). The flow is From 0c5b9ee5de8f961efd5e8c4afa91a45aa1f4ac69 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:30:17 +0000 Subject: [PATCH 50/81] wip(examples): lean fixture engines hold a Default Organization Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- .../test/default-organization.testkit.ts | 45 +++++++++++++++++++ .../test/opportunity-stage-hook.test.ts | 4 ++ .../approval-resume-relation-expand.test.ts | 4 ++ .../test/default-organization.testkit.ts | 45 +++++++++++++++++++ .../test/hook-body-persisted-writes.test.ts | 4 ++ .../new-project-wizard-initial-status.test.ts | 4 ++ .../test/paid-invoice-line-lock.test.ts | 4 ++ .../test/task-reopen-hook-refusal.test.ts | 4 ++ .../test/default-organization.testkit.ts | 45 +++++++++++++++++++ .../test/derived-flag-removal.test.ts | 4 ++ .../app-todo/test/loop-body-iteration.test.ts | 4 ++ .../overdue-escalation-days-overdue.test.ts | 4 ++ .../test/task-completion-trigger.test.ts | 7 +++ .../app-todo/test/task-recurrence.test.ts | 4 ++ 14 files changed, 182 insertions(+) create mode 100644 examples/app-crm/test/default-organization.testkit.ts create mode 100644 examples/app-showcase/test/default-organization.testkit.ts create mode 100644 examples/app-todo/test/default-organization.testkit.ts diff --git a/examples/app-crm/test/default-organization.testkit.ts b/examples/app-crm/test/default-organization.testkit.ts new file mode 100644 index 00000000000..e2c6f2c3722 --- /dev/null +++ b/examples/app-crm/test/default-organization.testkit.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Give a lean test engine what every real `single` install has: an + * organization object holding its Default Organization. + * + * ADR-0131 D1: a table the SQL driver creates carries its tenant column NOT + * NULL. The engine stamps an organization-less system write with the derived + * organization under `single` (D9) — but only where the composition registers + * the organization object; a bare `ObjectQL` + `SqlDriver` registers none, so + * the write reaches the database with no organization and is refused there. + * A suite whose subject is not tenancy (claims, acks, idle cost, at-rest + * shapes) calls this once its own objects are registered, so its system + * writes land the way they do on a real install. + * + * The object mirrors the platform's: managed by better-auth, so it carries no + * tenant column of its own. + */ +export const DEFAULT_ORGANIZATION_ID = 'org_default'; + +export async function installDefaultOrganization(engine: { + registry: { registerObject(def: unknown, owner: string): unknown }; + getSchema(object: string): unknown; + syncSchemas(): Promise; + getDriverForObject(object: string): + | { + create(object: string, data: Record): Promise; + findOne(object: string, query: unknown): Promise; + } + | undefined; +}): Promise { + // Idempotent: a suite that syncs one engine more than once may call it each time. + if (!engine.getSchema('sys_organization')) { + engine.registry.registerObject( + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, + '@objectstack/example-crm', + ); + } + await engine.syncSchemas(); + // Seeded through the driver: the organization is data at rest, not a write + // the suite under test should observe (an audit or activity mirror would). + const driver = engine.getDriverForObject('sys_organization')!; + if (await driver.findOne('sys_organization', { where: { id: DEFAULT_ORGANIZATION_ID } })) return; + await driver.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); +} diff --git a/examples/app-crm/test/opportunity-stage-hook.test.ts b/examples/app-crm/test/opportunity-stage-hook.test.ts index 7970cf69ea8..e509ad8839f 100644 --- a/examples/app-crm/test/opportunity-stage-hook.test.ts +++ b/examples/app-crm/test/opportunity-stage-hook.test.ts @@ -56,6 +56,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { Account, Opportunity, OpportunityLineItem } from '../src/objects/index.js'; import { allHooks } from '../src/hooks/index.js'; import { OpportunityStageHook } from '../src/hooks/opportunity.hook.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** The app id `AppPlugin` derives its `packageId` from. */ const PACKAGE_ID = 'app:com.example.crm'; @@ -96,6 +97,9 @@ async function bootCrm(hooks: unknown[] = allHooks): Promise { engine.registry.registerObject(def as never, PACKAGE_ID, 'crm'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); // Exactly the AppPlugin call, minus the sandbox body runner this app has no // use for (its single hook is an inline code handler). diff --git a/examples/app-showcase/test/approval-resume-relation-expand.test.ts b/examples/app-showcase/test/approval-resume-relation-expand.test.ts index 349e41e2088..36dcd7cb85d 100644 --- a/examples/app-showcase/test/approval-resume-relation-expand.test.ts +++ b/examples/app-showcase/test/approval-resume-relation-expand.test.ts @@ -58,6 +58,7 @@ import { TaskDoneNotifyOwnerFlow, NotifyOwnerSubflow, } from '../src/automation/flows/index.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** Everything the approvals service treats as an unconditional actor. */ const SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] } as any; @@ -128,6 +129,9 @@ async function bootShowcaseApprovals( objectql.registry.registerObject(obj, 'showcase', 'showcase'); } await objectql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(objectql); for (const flow of flows) automation.registerFlow(flow.name, flow); diff --git a/examples/app-showcase/test/default-organization.testkit.ts b/examples/app-showcase/test/default-organization.testkit.ts new file mode 100644 index 00000000000..9b19e1b8999 --- /dev/null +++ b/examples/app-showcase/test/default-organization.testkit.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Give a lean test engine what every real `single` install has: an + * organization object holding its Default Organization. + * + * ADR-0131 D1: a table the SQL driver creates carries its tenant column NOT + * NULL. The engine stamps an organization-less system write with the derived + * organization under `single` (D9) — but only where the composition registers + * the organization object; a bare `ObjectQL` + `SqlDriver` registers none, so + * the write reaches the database with no organization and is refused there. + * A suite whose subject is not tenancy (claims, acks, idle cost, at-rest + * shapes) calls this once its own objects are registered, so its system + * writes land the way they do on a real install. + * + * The object mirrors the platform's: managed by better-auth, so it carries no + * tenant column of its own. + */ +export const DEFAULT_ORGANIZATION_ID = 'org_default'; + +export async function installDefaultOrganization(engine: { + registry: { registerObject(def: unknown, owner: string): unknown }; + getSchema(object: string): unknown; + syncSchemas(): Promise; + getDriverForObject(object: string): + | { + create(object: string, data: Record): Promise; + findOne(object: string, query: unknown): Promise; + } + | undefined; +}): Promise { + // Idempotent: a suite that syncs one engine more than once may call it each time. + if (!engine.getSchema('sys_organization')) { + engine.registry.registerObject( + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, + '@objectstack/example-showcase', + ); + } + await engine.syncSchemas(); + // Seeded through the driver: the organization is data at rest, not a write + // the suite under test should observe (an audit or activity mirror would). + const driver = engine.getDriverForObject('sys_organization')!; + if (await driver.findOne('sys_organization', { where: { id: DEFAULT_ORGANIZATION_ID } })) return; + await driver.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); +} diff --git a/examples/app-showcase/test/hook-body-persisted-writes.test.ts b/examples/app-showcase/test/hook-body-persisted-writes.test.ts index 5116c3bce62..24c4bec744e 100644 --- a/examples/app-showcase/test/hook-body-persisted-writes.test.ts +++ b/examples/app-showcase/test/hook-body-persisted-writes.test.ts @@ -49,6 +49,7 @@ import { QuickJSScriptRunner, hookBodyRunnerFactory } from '@objectstack/runtime import { Account, Project, Task, Inquiry } from '../src/data/objects/index.js'; import { allHooks } from '../src/data/hooks/index.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** The app id `AppPlugin` derives its `packageId` from. */ const APP_ID = 'com.objectstack.showcase'; @@ -87,6 +88,9 @@ async function bootShowcase(hooks: unknown[] = allHooks): Promise { engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); engine.bindHooks(hooks as never[], { packageId: PACKAGE_ID, diff --git a/examples/app-showcase/test/new-project-wizard-initial-status.test.ts b/examples/app-showcase/test/new-project-wizard-initial-status.test.ts index 7e43d075526..95d3ac23d7b 100644 --- a/examples/app-showcase/test/new-project-wizard-initial-status.test.ts +++ b/examples/app-showcase/test/new-project-wizard-initial-status.test.ts @@ -37,6 +37,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import stack from '../objectstack.config.js'; import { Account, Project } from '../src/data/objects/index.js'; import { NewProjectWizardPage } from '../src/ui/pages/new-project-wizard.page.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; type Rule = { type?: string; @@ -82,6 +83,9 @@ async function bootShowcase(): Promise { engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); return engine; } diff --git a/examples/app-showcase/test/paid-invoice-line-lock.test.ts b/examples/app-showcase/test/paid-invoice-line-lock.test.ts index db43677304f..94ab53e55e2 100644 --- a/examples/app-showcase/test/paid-invoice-line-lock.test.ts +++ b/examples/app-showcase/test/paid-invoice-line-lock.test.ts @@ -34,6 +34,7 @@ import { PAID_INVOICE_LINE_REMOVE_REFUSAL, PAID_INVOICE_LINE_MOVE_REFUSAL, } from '../src/data/hooks/index.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const APP_ID = 'com.objectstack.showcase'; const PACKAGE_ID = `app:${APP_ID}`; @@ -61,6 +62,9 @@ async function bootShowcase(hooks: unknown[] = allHooks): Promise { engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); engine.bindHooks(hooks as never[], { packageId: PACKAGE_ID, bodyRunner: hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: APP_ID }), diff --git a/examples/app-showcase/test/task-reopen-hook-refusal.test.ts b/examples/app-showcase/test/task-reopen-hook-refusal.test.ts index 24cd29553b0..88a351b8c5a 100644 --- a/examples/app-showcase/test/task-reopen-hook-refusal.test.ts +++ b/examples/app-showcase/test/task-reopen-hook-refusal.test.ts @@ -38,6 +38,7 @@ import { import { Account, Project, Task } from '../src/data/objects/index.js'; import { allHooks, TASK_REOPEN_REFUSAL } from '../src/data/hooks/index.js'; import { ReopenTaskAction } from '../src/ui/actions/index.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const APP_ID = 'com.objectstack.showcase'; const PACKAGE_ID = `app:${APP_ID}`; @@ -65,6 +66,9 @@ async function bootShowcase(hooks: unknown[] = allHooks): Promise { engine.registry.registerObject(def as never, PACKAGE_ID, 'showcase'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); engine.bindHooks(hooks as never[], { packageId: PACKAGE_ID, bodyRunner: hookBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: APP_ID }), diff --git a/examples/app-todo/test/default-organization.testkit.ts b/examples/app-todo/test/default-organization.testkit.ts new file mode 100644 index 00000000000..c48a5fcb116 --- /dev/null +++ b/examples/app-todo/test/default-organization.testkit.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Give a lean test engine what every real `single` install has: an + * organization object holding its Default Organization. + * + * ADR-0131 D1: a table the SQL driver creates carries its tenant column NOT + * NULL. The engine stamps an organization-less system write with the derived + * organization under `single` (D9) — but only where the composition registers + * the organization object; a bare `ObjectQL` + `SqlDriver` registers none, so + * the write reaches the database with no organization and is refused there. + * A suite whose subject is not tenancy (claims, acks, idle cost, at-rest + * shapes) calls this once its own objects are registered, so its system + * writes land the way they do on a real install. + * + * The object mirrors the platform's: managed by better-auth, so it carries no + * tenant column of its own. + */ +export const DEFAULT_ORGANIZATION_ID = 'org_default'; + +export async function installDefaultOrganization(engine: { + registry: { registerObject(def: unknown, owner: string): unknown }; + getSchema(object: string): unknown; + syncSchemas(): Promise; + getDriverForObject(object: string): + | { + create(object: string, data: Record): Promise; + findOne(object: string, query: unknown): Promise; + } + | undefined; +}): Promise { + // Idempotent: a suite that syncs one engine more than once may call it each time. + if (!engine.getSchema('sys_organization')) { + engine.registry.registerObject( + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, + '@objectstack/example-todo', + ); + } + await engine.syncSchemas(); + // Seeded through the driver: the organization is data at rest, not a write + // the suite under test should observe (an audit or activity mirror would). + const driver = engine.getDriverForObject('sys_organization')!; + if (await driver.findOne('sys_organization', { where: { id: DEFAULT_ORGANIZATION_ID } })) return; + await driver.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); +} diff --git a/examples/app-todo/test/derived-flag-removal.test.ts b/examples/app-todo/test/derived-flag-removal.test.ts index 846f36f1741..f912eba3efb 100644 --- a/examples/app-todo/test/derived-flag-removal.test.ts +++ b/examples/app-todo/test/derived-flag-removal.test.ts @@ -65,6 +65,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { Task } from '../src/objects/task.object.js'; import taskHook from '../src/objects/task.hook.js'; import TodoApp from '../objectstack.config.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** The two fields this card retired. */ const REMOVED_FIELDS = ['is_completed', 'is_overdue'] as const; @@ -94,6 +95,9 @@ async function bootEngine(objectDef: unknown = Task) { openDrivers.push(driver); objectql.registry.registerObject(objectDef, 'todo', 'todo'); await objectql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(objectql); objectql.bindHooks([taskHook], { packageId: 'app:com.example.todo' }); return objectql; } diff --git a/examples/app-todo/test/loop-body-iteration.test.ts b/examples/app-todo/test/loop-body-iteration.test.ts index 2556232fdd9..38cf4b32568 100644 --- a/examples/app-todo/test/loop-body-iteration.test.ts +++ b/examples/app-todo/test/loop-body-iteration.test.ts @@ -44,6 +44,7 @@ import { AutomationServicePlugin, type AutomationEngine } from '@objectstack/ser import { OverdueEscalationFlow, TaskReminderFlow } from '../src/flows/index.js'; import { Task } from '../src/objects/task.object.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** One captured `messaging.emit()` call — the notice as the service received it. */ interface CapturedEmit { @@ -109,6 +110,9 @@ async function bootTodoKernel(): Promise<{ openDrivers.push(driver); objectql.registry.registerObject(Task, 'todo', 'todo'); await objectql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(objectql); automation.registerFlow(OverdueEscalationFlow.name, OverdueEscalationFlow); automation.registerFlow(TaskReminderFlow.name, TaskReminderFlow); diff --git a/examples/app-todo/test/overdue-escalation-days-overdue.test.ts b/examples/app-todo/test/overdue-escalation-days-overdue.test.ts index bdedcee3088..2657908ed3d 100644 --- a/examples/app-todo/test/overdue-escalation-days-overdue.test.ts +++ b/examples/app-todo/test/overdue-escalation-days-overdue.test.ts @@ -36,6 +36,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { OverdueEscalationFlow } from '../src/flows/index.js'; import { Task } from '../src/objects/task.object.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const openDrivers: Array<{ disconnect?: () => Promise }> = []; afterEach(async () => { @@ -106,6 +107,9 @@ async function bootTodoData(): Promise { openDrivers.push(driver); objectql.registry.registerObject(Task, 'todo', 'todo'); await objectql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(objectql); return kernel.getService('data'); } diff --git a/examples/app-todo/test/task-completion-trigger.test.ts b/examples/app-todo/test/task-completion-trigger.test.ts index dbafeebe6af..c7f53052ee9 100644 --- a/examples/app-todo/test/task-completion-trigger.test.ts +++ b/examples/app-todo/test/task-completion-trigger.test.ts @@ -45,6 +45,7 @@ import { allFlows, TaskCompletionFlow } from '../src/flows/index.js'; import { Task } from '../src/objects/task.object.js'; import taskHook from '../src/objects/task.hook.js'; import TodoApp from '../objectstack.config.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -83,6 +84,9 @@ async function bootTodoKernel(): Promise<{ openDrivers.push(driver); objectql.registry.registerObject(Task, 'todo', 'todo'); await objectql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(objectql); // [#7036] The app's lifecycle hook, bound the way `AppPlugin` binds it from // `defineStack({ hooks })`. This kernel is assembled by hand (no AppPlugin), @@ -359,6 +363,9 @@ describe('#7036 — completing a task is possible for a normal user', () => { openDrivers.push(driver); objectql.registry.registerObject(Task, 'todo', 'todo'); await objectql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(objectql); // NB: no `bindHooks` — that is the whole fixture. const id = await newTask(data); diff --git a/examples/app-todo/test/task-recurrence.test.ts b/examples/app-todo/test/task-recurrence.test.ts index 677a9547ff9..382639f0004 100644 --- a/examples/app-todo/test/task-recurrence.test.ts +++ b/examples/app-todo/test/task-recurrence.test.ts @@ -53,6 +53,7 @@ import { allFlows, TaskCompletionFlow } from '../src/flows/index.js'; import { todoFunctions, computeNextTaskDueDate } from '../src/functions/index.js'; import { Task } from '../src/objects/task.object.js'; import taskHook from '../src/objects/task.hook.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); @@ -106,6 +107,9 @@ async function bootTodoKernel(): Promise<{ objectql.registry.registerObject(Task, 'todo', 'todo'); objectql.bindHooks([taskHook], { packageId: 'app:com.example.todo' }); await objectql.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(objectql); automation.setFunctionResolver( (name: string) => (todoFunctions as Record)[name] as any, From a0dbaa9f3e78dfecd2f2ead59f53bb02d88dff5f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:31:57 +0000 Subject: [PATCH 51/81] wip(runtime): lean fixture engines hold a Default Organization Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- ...ytics-cube-measure-field-type-door.test.ts | 4 ++ .../src/analytics-json-dimension-door.test.ts | 4 ++ .../analytics-query-window-validity.test.ts | 4 ++ .../src/default-organization.testkit.ts | 45 +++++++++++++++++++ ...patcher-unique-violation-wire-code.test.ts | 4 ++ .../data-conflict-caller-visibility.test.ts | 4 ++ .../data-delete-expected-version.test.ts | 4 ++ .../data-update-expected-version.test.ts | 4 ++ .../domains/data-write-request-gate.test.ts | 4 ++ .../share-links-internal-hash-probe.test.ts | 4 ++ .../share-links-messages-audience.test.ts | 4 ++ .../share-links-password-encoding.test.ts | 4 ++ .../share-links-public-cache-headers.test.ts | 4 ++ .../domains/share-links-refusal-order.test.ts | 4 ++ ...x-relay-unique-violation-wire-code.test.ts | 4 ++ 15 files changed, 101 insertions(+) create mode 100644 packages/runtime/src/default-organization.testkit.ts diff --git a/packages/runtime/src/analytics-cube-measure-field-type-door.test.ts b/packages/runtime/src/analytics-cube-measure-field-type-door.test.ts index 9bbf0f4b665..54bc9c1aced 100644 --- a/packages/runtime/src/analytics-cube-measure-field-type-door.test.ts +++ b/packages/runtime/src/analytics-cube-measure-field-type-door.test.ts @@ -31,6 +31,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { AnalyticsServicePlugin } from '@objectstack/service-analytics'; import { createDispatcherPlugin } from './dispatcher-plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'os21044_route_ledger'; @@ -152,6 +153,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); for (const [face, caps] of [ diff --git a/packages/runtime/src/analytics-json-dimension-door.test.ts b/packages/runtime/src/analytics-json-dimension-door.test.ts index 3d387cf3574..3186bfc0782 100644 --- a/packages/runtime/src/analytics-json-dimension-door.test.ts +++ b/packages/runtime/src/analytics-json-dimension-door.test.ts @@ -47,6 +47,7 @@ import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/serv import { DatasetSchema } from '@objectstack/spec/ui'; import { createDispatcherPlugin } from './dispatcher-plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; // [#21061] The analytics domain refuses an anonymous caller first (ADR-0056 D2), // so this harness signs its caller in: an `auth` slot in the shape @@ -167,6 +168,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); // Count what reaches the engine for THIS object, on both bridges. diff --git a/packages/runtime/src/analytics-query-window-validity.test.ts b/packages/runtime/src/analytics-query-window-validity.test.ts index e4725774767..33c1a26a369 100644 --- a/packages/runtime/src/analytics-query-window-validity.test.ts +++ b/packages/runtime/src/analytics-query-window-validity.test.ts @@ -42,6 +42,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { AnalyticsServicePlugin, type AnalyticsService } from '@objectstack/service-analytics'; import { createDispatcherPlugin } from './dispatcher-plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; // [#21061] The analytics domain refuses an anonymous caller first (ADR-0056 D2), // so this harness signs its caller in: an `auth` slot in the shape @@ -159,6 +160,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(DEAL as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); // Count what reaches the engine for THIS object, on both bridges. diff --git a/packages/runtime/src/default-organization.testkit.ts b/packages/runtime/src/default-organization.testkit.ts new file mode 100644 index 00000000000..6c95d77ab86 --- /dev/null +++ b/packages/runtime/src/default-organization.testkit.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Give a lean test engine what every real `single` install has: an + * organization object holding its Default Organization. + * + * ADR-0131 D1: a table the SQL driver creates carries its tenant column NOT + * NULL. The engine stamps an organization-less system write with the derived + * organization under `single` (D9) — but only where the composition registers + * the organization object; a bare `ObjectQL` + `SqlDriver` registers none, so + * the write reaches the database with no organization and is refused there. + * A suite whose subject is not tenancy (claims, acks, idle cost, at-rest + * shapes) calls this once its own objects are registered, so its system + * writes land the way they do on a real install. + * + * The object mirrors the platform's: managed by better-auth, so it carries no + * tenant column of its own. + */ +export const DEFAULT_ORGANIZATION_ID = 'org_default'; + +export async function installDefaultOrganization(engine: { + registry: { registerObject(def: unknown, owner: string): unknown }; + getSchema(object: string): unknown; + syncSchemas(): Promise; + getDriverForObject(object: string): + | { + create(object: string, data: Record): Promise; + findOne(object: string, query: unknown): Promise; + } + | undefined; +}): Promise { + // Idempotent: a suite that syncs one engine more than once may call it each time. + if (!engine.getSchema('sys_organization')) { + engine.registry.registerObject( + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, + '@objectstack/runtime', + ); + } + await engine.syncSchemas(); + // Seeded through the driver: the organization is data at rest, not a write + // the suite under test should observe (an audit or activity mirror would). + const driver = engine.getDriverForObject('sys_organization')!; + if (await driver.findOne('sys_organization', { where: { id: DEFAULT_ORGANIZATION_ID } })) return; + await driver.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); +} diff --git a/packages/runtime/src/dispatcher-unique-violation-wire-code.test.ts b/packages/runtime/src/dispatcher-unique-violation-wire-code.test.ts index 0f7ce2c29b6..52a78f2522b 100644 --- a/packages/runtime/src/dispatcher-unique-violation-wire-code.test.ts +++ b/packages/runtime/src/dispatcher-unique-violation-wire-code.test.ts @@ -45,6 +45,7 @@ import { SysMetadataActivation } from '@objectstack/platform-objects/system'; import { HttpDispatcher } from './http-dispatcher.js'; import type { HttpProtocolContext } from './http-dispatcher.js'; import { bindTestSecurityCatalog } from './security/security-catalog.testkit.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const PREFIX = '/api/v1'; /** `registry.registerObject` requires a package id (see the sibling real-stack tests). */ @@ -116,6 +117,9 @@ async function bootEngine(objects: readonly object[]): Promise { await engine.init(); for (const o of objects) engine.registry.registerObject(o as any, TEST_PACKAGE_ID); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); return engine; } diff --git a/packages/runtime/src/domains/data-conflict-caller-visibility.test.ts b/packages/runtime/src/domains/data-conflict-caller-visibility.test.ts index d86cd39c03e..be143198e4f 100644 --- a/packages/runtime/src/domains/data-conflict-caller-visibility.test.ts +++ b/packages/runtime/src/domains/data-conflict-caller-visibility.test.ts @@ -51,6 +51,7 @@ import { RestServer } from '@objectstack/rest'; import { declaredEnvelopeForThrow } from '@objectstack/plugin-hono-server'; import { resolveThrownHttpError } from '@objectstack/types'; import { HttpDispatcher } from '../http-dispatcher.js'; +import { installDefaultOrganization } from '../default-organization.testkit.js'; const PREFIX = '/api/v1'; const DATA = `${PREFIX}/data`; @@ -228,6 +229,9 @@ describe('[#23586] a 409 CONCURRENT_UPDATE reports only what the caller may read ], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const services: Record = { manifest: { register: vi.fn() }, diff --git a/packages/runtime/src/domains/data-delete-expected-version.test.ts b/packages/runtime/src/domains/data-delete-expected-version.test.ts index ee5a1a832e4..5c6316e99a6 100644 --- a/packages/runtime/src/domains/data-delete-expected-version.test.ts +++ b/packages/runtime/src/domains/data-delete-expected-version.test.ts @@ -50,6 +50,7 @@ import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protoco import { RestServer, repeatedQueryParamMessage } from '@objectstack/rest'; import { resolveThrownHttpError } from '@objectstack/types'; import { HttpDispatcher } from '../http-dispatcher.js'; +import { installDefaultOrganization } from '../default-organization.testkit.js'; const PREFIX = '/api/v1'; const DATA = `${PREFIX}/data`; @@ -90,6 +91,9 @@ async function bootStore() { await engine.init(); engine.registry.registerObject(TASK as any, TEST_PACKAGE_ID); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const protocol = new ObjectStackProtocolImplementation(engine as any); await protocol.createData({ object: 'task', data: { id: 'r1', name: 'first' } } as any); const row: any = await engine.findOne('task', { where: { id: 'r1' } }); diff --git a/packages/runtime/src/domains/data-update-expected-version.test.ts b/packages/runtime/src/domains/data-update-expected-version.test.ts index 26dd3571f2b..910db9134cb 100644 --- a/packages/runtime/src/domains/data-update-expected-version.test.ts +++ b/packages/runtime/src/domains/data-update-expected-version.test.ts @@ -52,6 +52,7 @@ import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protoco import { RestServer } from '@objectstack/rest'; import { resolveThrownHttpError } from '@objectstack/types'; import { HttpDispatcher } from '../http-dispatcher.js'; +import { installDefaultOrganization } from '../default-organization.testkit.js'; const PREFIX = '/api/v1'; const DATA = `${PREFIX}/data`; @@ -96,6 +97,9 @@ async function bootStore() { await engine.init(); engine.registry.registerObject(TASK as any, TEST_PACKAGE_ID); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const protocol = new ObjectStackProtocolImplementation(engine as any); await protocol.createData({ object: 'task', data: { id: 'r1', name: BEFORE } } as any); const row: any = await engine.findOne('task', { where: { id: 'r1' } }); diff --git a/packages/runtime/src/domains/data-write-request-gate.test.ts b/packages/runtime/src/domains/data-write-request-gate.test.ts index ecfc4395a40..fa033e7f3d4 100644 --- a/packages/runtime/src/domains/data-write-request-gate.test.ts +++ b/packages/runtime/src/domains/data-write-request-gate.test.ts @@ -82,6 +82,7 @@ import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protoco import { RestServer } from '@objectstack/rest'; import { resolveThrownHttpError } from '@objectstack/types'; import { HttpDispatcher } from '../http-dispatcher.js'; +import { installDefaultOrganization } from '../default-organization.testkit.js'; const PREFIX = '/api/v1'; const DATA = `${PREFIX}/data`; @@ -136,6 +137,9 @@ async function bootStore() { await engine.init(); for (const object of OBJECTS) engine.registry.registerObject(object as any, TEST_PACKAGE_ID); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const protocol = new ObjectStackProtocolImplementation(engine as any); for (const object of OBJECTS) { await protocol.createData({ object: object.name, data: { id: 'r1', name: BEFORE } } as any); diff --git a/packages/runtime/src/domains/share-links-internal-hash-probe.test.ts b/packages/runtime/src/domains/share-links-internal-hash-probe.test.ts index c592b62cab1..e35e51acc29 100644 --- a/packages/runtime/src/domains/share-links-internal-hash-probe.test.ts +++ b/packages/runtime/src/domains/share-links-internal-hash-probe.test.ts @@ -32,6 +32,7 @@ import { HttpDispatcher } from '../http-dispatcher.js'; import type { DomainHandlerDeps } from '../domain-handler-registry.js'; import type { HttpProtocolContext } from '../http-dispatcher.js'; import { handleShareLinksRequest } from './share-links.js'; +import { installDefaultOrganization } from '../default-organization.testkit.js'; const SHARED = 'pin_doc'; /** The system context, erased once here; the options bags that carry it stay typed. */ @@ -89,6 +90,9 @@ async function harness() { await engine.init(); for (const o of [SysShareLink, TARGET]) engine.registry.registerObject(o as any, '@objectstack/runtime-test'); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); await engine.insert(SHARED, { id: RECORD, title: 'Shared doc' }, { context: { isSystem: true } } as any); const svc = new ShareLinkService({ engine: engine as any }); const deps = makeDeps(engine, svc); diff --git a/packages/runtime/src/domains/share-links-messages-audience.test.ts b/packages/runtime/src/domains/share-links-messages-audience.test.ts index b5362f50280..749deeeff21 100644 --- a/packages/runtime/src/domains/share-links-messages-audience.test.ts +++ b/packages/runtime/src/domains/share-links-messages-audience.test.ts @@ -61,6 +61,7 @@ import { HttpDispatcher } from '../http-dispatcher.js'; import type { DomainHandlerDeps } from '../domain-handler-registry.js'; import type { HttpProtocolContext } from '../http-dispatcher.js'; import { handleShareLinksRequest } from './share-links.js'; +import { installDefaultOrganization } from '../default-organization.testkit.js'; const CONVERSATION = 'conv_23371'; const DOC = 'doc_23371'; @@ -192,6 +193,9 @@ beforeAll(async () => { engine.registry.registerObject(o as any, '@objectstack/runtime-test'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const sys = { context: { isSystem: true } } as any; await engine.insert('ai_conversations', { id: CONVERSATION, title: 'Shared chat' }, sys); await engine.insert( diff --git a/packages/runtime/src/domains/share-links-password-encoding.test.ts b/packages/runtime/src/domains/share-links-password-encoding.test.ts index 2c32090d2de..1efa902856c 100644 --- a/packages/runtime/src/domains/share-links-password-encoding.test.ts +++ b/packages/runtime/src/domains/share-links-password-encoding.test.ts @@ -29,6 +29,7 @@ import { HttpDispatcher } from '../http-dispatcher.js'; import type { DomainHandlerDeps } from '../domain-handler-registry.js'; import type { HttpProtocolContext } from '../http-dispatcher.js'; import { handleShareLinksRequest } from './share-links.js'; +import { installDefaultOrganization } from '../default-organization.testkit.js'; const CJK = '分享密码二二零四九'; const EMOJI = 'open 🔐🦊 sesame'; @@ -100,6 +101,9 @@ async function harness(password: string) { await engine.init(); for (const o of [SysShareLink, CONVERSATIONS, MESSAGES]) engine.registry.registerObject(o as any, '@objectstack/runtime-test'); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const sys = { context: { isSystem: true } } as any; await engine.insert('ai_conversations', { id: 'conv_1', title: 'Chat' }, sys); await engine.insert( diff --git a/packages/runtime/src/domains/share-links-public-cache-headers.test.ts b/packages/runtime/src/domains/share-links-public-cache-headers.test.ts index 68d1bd9885f..08ddc01702a 100644 --- a/packages/runtime/src/domains/share-links-public-cache-headers.test.ts +++ b/packages/runtime/src/domains/share-links-public-cache-headers.test.ts @@ -23,6 +23,7 @@ import { HttpDispatcher } from '../http-dispatcher.js'; import type { DomainHandlerDeps } from '../domain-handler-registry.js'; import type { HttpProtocolContext } from '../http-dispatcher.js'; import { handleShareLinksRequest } from './share-links.js'; +import { installDefaultOrganization } from '../default-organization.testkit.js'; const SHARED = 'pin_doc'; const RECORD = 'doc_1'; @@ -79,6 +80,9 @@ async function harness() { await engine.init(); for (const o of [SysShareLink, TARGET]) engine.registry.registerObject(o as any, '@objectstack/runtime-test'); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); await engine.insert(SHARED, { id: RECORD, title: 'Shared doc' }, { context: { isSystem: true } } as any); const svc = new ShareLinkService({ engine: engine as any }); const deps = makeDeps(engine, svc); diff --git a/packages/runtime/src/domains/share-links-refusal-order.test.ts b/packages/runtime/src/domains/share-links-refusal-order.test.ts index 7041251ea14..492301ae0c9 100644 --- a/packages/runtime/src/domains/share-links-refusal-order.test.ts +++ b/packages/runtime/src/domains/share-links-refusal-order.test.ts @@ -74,6 +74,7 @@ import { HttpDispatcher } from '../http-dispatcher.js'; import type { DomainHandlerDeps } from '../domain-handler-registry.js'; import type { HttpProtocolContext } from '../http-dispatcher.js'; import { handleShareLinksRequest } from './share-links.js'; +import { installDefaultOrganization } from '../default-organization.testkit.js'; const OBJECT = 'refusal_doc'; const RECORD = 'doc_1'; @@ -169,6 +170,9 @@ beforeAll(async () => { await engine.init(); for (const o of [SysShareLink, TARGET]) engine.registry.registerObject(o as any, '@objectstack/runtime-test'); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); await engine.insert(OBJECT, { id: RECORD, title: 'Shared doc' }, { context: { isSystem: true } } as any); const svc = new ShareLinkService({ engine: engine as any }); diff --git a/packages/runtime/src/sandbox-relay-unique-violation-wire-code.test.ts b/packages/runtime/src/sandbox-relay-unique-violation-wire-code.test.ts index 574eba8c7dc..7cb4bea6275 100644 --- a/packages/runtime/src/sandbox-relay-unique-violation-wire-code.test.ts +++ b/packages/runtime/src/sandbox-relay-unique-violation-wire-code.test.ts @@ -37,6 +37,7 @@ import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protoco import { HttpDispatcher } from './http-dispatcher.js'; import { actionBodyRunnerFactory, hookBodyRunnerFactory } from './sandbox/body-runner.js'; import { QuickJSScriptRunner } from './sandbox/quickjs-runner.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const PREFIX = '/api/v1'; /** `registry.registerObject` requires a package id (see the sibling real-stack tests). */ @@ -113,6 +114,9 @@ beforeAll(async () => { await engine.init(); for (const o of [UDOC, HDOC]) engine.registry.registerObject(o as any, TEST_PACKAGE_ID); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const ql = engine as any; const bindBody = actionBodyRunnerFactory(new QuickJSScriptRunner(), { ql: engine, appId: 'udoc_app' }); From da20f306e9b67ac977a04bb4308d11d788b082ae Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:36:19 +0000 Subject: [PATCH 52/81] wip(runtime): residual fixtures name their organization or model a pre-18 store Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- ...action-engine-facade-find-envelope.test.ts | 4 +++ ...asource-mapping-before-schema-sync.test.ts | 3 ++- .../src/default-datasource-plugin.test.ts | 3 ++- ...ult-organization-statements.golden.test.ts | 16 +++++++++++ ...ion-schema-conformance.integration.test.ts | 4 +++ .../notifications.hono.integration.test.ts | 4 +++ ...ninstall-shared-object.integration.test.ts | 3 ++- .../src/pre-18-tenant-column.testkit.ts | 27 +++++++++++++++++++ ...ion-holder-real-driver.integration.test.ts | 4 +++ ...nancy-autonumber-split.integration.test.ts | 10 +++++++ .../standalone-stack-hydrate-metadata.test.ts | 3 ++- 11 files changed, 77 insertions(+), 4 deletions(-) create mode 100644 packages/runtime/src/pre-18-tenant-column.testkit.ts diff --git a/packages/runtime/src/action-engine-facade-find-envelope.test.ts b/packages/runtime/src/action-engine-facade-find-envelope.test.ts index a6be5b7e434..140f8bcb268 100644 --- a/packages/runtime/src/action-engine-facade-find-envelope.test.ts +++ b/packages/runtime/src/action-engine-facade-find-envelope.test.ts @@ -46,6 +46,7 @@ import { assertEngineDeleteDispatch } from '@objectstack/metadata-core'; import { ObjectQL } from '@objectstack/objectql'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { buildActionEngineFacade, ACTION_ENGINE_FIND_ENVELOPE_PRESCRIPTION } from './action-execution.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const deps: any = { resolveService: () => undefined, getObjectQL: async () => undefined }; @@ -201,6 +202,9 @@ async function makeRealEngine() { await engine.init(); engine.registry.registerObject(PROBE_OBJECT, 'test'); await engine.syncSchemas?.(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const ctx = { isSystem: true } as any; // t3 carries a non-null `deleted_at`, so a filter that is HONOURED returns diff --git a/packages/runtime/src/datasource-mapping-before-schema-sync.test.ts b/packages/runtime/src/datasource-mapping-before-schema-sync.test.ts index 290857cf7f5..2f261960bd2 100644 --- a/packages/runtime/src/datasource-mapping-before-schema-sync.test.ts +++ b/packages/runtime/src/datasource-mapping-before-schema-sync.test.ts @@ -104,7 +104,8 @@ function platformPlugin() { }); }, start: async (ctx: any) => { - await ctx.getService('objectql').insert(PLATFORM_OBJECT, { title: BOOT_ROW }, { context: { isSystem: true } }); + // Written in an organization: a fresh tenant column is NOT NULL (ADR-0131 D1). + await ctx.getService('objectql').insert(PLATFORM_OBJECT, { title: BOOT_ROW }, { context: { isSystem: true, tenantId: 'org_a' } }); }, }; } diff --git a/packages/runtime/src/default-datasource-plugin.test.ts b/packages/runtime/src/default-datasource-plugin.test.ts index 77d5553375a..b04bb0cbd09 100644 --- a/packages/runtime/src/default-datasource-plugin.test.ts +++ b/packages/runtime/src/default-datasource-plugin.test.ts @@ -193,7 +193,8 @@ describe('DefaultDatasourcePlugin — the default datasource as a declaration (# expect(defaultName).toBeDefined(); // Identity, not equivalence: the engine's default driver IS the host's instance. expect(engine.getDriverByName?.(defaultName!)).toBe(hostBuilt); - await engine.insert('note', { title: 'through-the-adopted-default' }); + // Written in an organization: a fresh tenant column is NOT NULL (ADR-0131 D1). + await engine.insert('note', { title: 'through-the-adopted-default' }, { context: { tenantId: 'org_a' } } as never); const rows = await engine.find('note'); expect(rows.map((r: any) => r.title)).toContain('through-the-adopted-default'); // [commit 13a6cb4ad] The capture is a PIN, not a mute: the probe's two log lines diff --git a/packages/runtime/src/default-organization-statements.golden.test.ts b/packages/runtime/src/default-organization-statements.golden.test.ts index 5709d7a1190..a4232804ff2 100644 --- a/packages/runtime/src/default-organization-statements.golden.test.ts +++ b/packages/runtime/src/default-organization-statements.golden.test.ts @@ -37,6 +37,7 @@ import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; import { AppPlugin } from './app-plugin.js'; import { DefaultDatasourcePlugin } from './default-datasource-plugin.js'; import { createDispatcherPlugin } from './dispatcher-plugin.js'; +import { relaxTenantColumns } from './pre-18-tenant-column.testkit.js'; const BOOT_TIMEOUT = 180_000; const ORIGIN = 'http://localhost:3000'; @@ -170,6 +171,10 @@ describe('[#24523] statements per warm request: an organization-owned admin gran }); it('CONTROL — the organization-less grant: the same counts, on either slug', async () => { + // An organization-less grant exists only on a store built before 18.x + // (ADR-0131 D1 makes a fresh tenant column NOT NULL): this table is that + // store's, relaxed through its driver's own drift apply door. + await relaxTenantColumns((engine as any).getDriver('sys_user_permission_set'), ['sys_user_permission_set']); const grant = await adminGrant(); const [organization] = await find('sys_organization'); await engine.update('sys_user_permission_set', { id: grant.id, organization_id: null }, { context: SYS }); @@ -188,6 +193,17 @@ describe('[#24523] statements per warm request: an organization-owned admin gran describe('[#24523 / #21868] a sys_organization write retires the memoised Default Organization', () => { it('⭐ the Default Organization deleted and recreated: the next request reads the new id', async () => { + // Deleting an organization runs the engine's referential cascade, whose + // `set_null` on every `organization_id` lookup a fresh, NOT NULL tenant + // column refuses (ADR-0131 D1). This test's subject is the memo, not the + // cascade, so the store is a pre-18 one: every tenant table it holds is + // relaxed through its driver's own drift apply door. + for (const o of (engine as any).registry.getAllObjects() as Array<{ name: string; fields?: Record; tenancy?: { enabled?: boolean } }>) { + if (!o.fields?.organization_id || o.tenancy?.enabled === false) continue; + const driver = (engine as any).getDriver(o.name); + if (!(await driver?.knex?.schema.hasTable(o.name))) continue; + await relaxTenantColumns(driver, [o.name]); + } const [old] = await find('sys_organization', { slug: 'default' }); await standing(); // the control above wrote the organization: read it once expect(await standing()).toEqual({ standing: true, organizationReads: 0 }); diff --git a/packages/runtime/src/notification-schema-conformance.integration.test.ts b/packages/runtime/src/notification-schema-conformance.integration.test.ts index 4e8bcb57ceb..deb12892ef6 100644 --- a/packages/runtime/src/notification-schema-conformance.integration.test.ts +++ b/packages/runtime/src/notification-schema-conformance.integration.test.ts @@ -50,6 +50,7 @@ import type { ServiceObject } from '@objectstack/spec/data'; import { createDispatcherPlugin } from './dispatcher-plugin.js'; import { DriverPlugin } from './driver-plugin.js'; import { captureExpectedReadRefusals } from './expected-read-refusal-noise.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; // One inbox per concern. The mark-read routes MUTATE read-state, so a shared // user would make these suites order-dependent — the unread fixture the gap @@ -311,6 +312,9 @@ describe('[#5792] the notification wire bodies conform to the schemas the catalo authzEngine.registerObject(o.def, o.owner); await authzEngine.syncObjectSchema(o.def.name); } + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(authzEngine); const httpServer = kernel.getService('http.server'); baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`; diff --git a/packages/runtime/src/notifications.hono.integration.test.ts b/packages/runtime/src/notifications.hono.integration.test.ts index 574e1a1629a..4acc535550b 100644 --- a/packages/runtime/src/notifications.hono.integration.test.ts +++ b/packages/runtime/src/notifications.hono.integration.test.ts @@ -13,6 +13,7 @@ import { DriverPlugin } from './driver-plugin.js'; import { captureExpectedReadRefusals } from './expected-read-refusal-noise.js'; import type { IHttpServer } from '@objectstack/spec/contracts'; import type { ServiceObject } from '@objectstack/spec/data'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** * End-to-end regression for framework #3362 (`#3354 not effective on hono`). @@ -242,6 +243,9 @@ describe('in-app notifications over a real hono server (integration, #3362)', () authzEngine.registerObject(o.def, o.owner); await authzEngine.syncObjectSchema(o.def.name); } + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(authzEngine); const httpServer = kernel.getService('http.server'); baseUrl = `http://127.0.0.1:${httpServer.getPort!()}`; diff --git a/packages/runtime/src/package-uninstall-shared-object.integration.test.ts b/packages/runtime/src/package-uninstall-shared-object.integration.test.ts index 4fb5969734c..1cc6aa72585 100644 --- a/packages/runtime/src/package-uninstall-shared-object.integration.test.ts +++ b/packages/runtime/src/package-uninstall-shared-object.integration.test.ts @@ -201,7 +201,8 @@ async function boot() { async records(name: string) { return ((await knex(name).select('title')) as Array<{ title: string }>).map((r) => r.title); }, - insertRecord: (name: string, title: string) => engine.insert(name, { title }, { context: { isSystem: true } } as any), + // Written in an organization: a fresh tenant column is NOT NULL (ADR-0131 D1). + insertRecord: (name: string, title: string) => engine.insert(name, { title }, { context: { isSystem: true, tenantId: 'org_a' } } as any), }; } diff --git a/packages/runtime/src/pre-18-tenant-column.testkit.ts b/packages/runtime/src/pre-18-tenant-column.testkit.ts new file mode 100644 index 00000000000..e7648974357 --- /dev/null +++ b/packages/runtime/src/pre-18-tenant-column.testkit.ts @@ -0,0 +1,27 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Return driver-created tables to the shape a store built BEFORE 18.x has: + * the tenant column nullable. + * + * ADR-0131 D1: since 18.x the SQL driver creates a table's tenant column NOT + * NULL. A store an earlier release built still holds the nullable column and + * its organization-less legacy rows (until the organization-ownership + * ceremony attributes them, or reports them). A suite whose subject is those + * rows — a backfill over them, a sweep that must still reach them — builds + * its tables, then calls this before seeding them. Done through the driver's + * own drift apply door (`relax_not_null`), never a hand `ALTER`. + */ +export async function relaxTenantColumns( + driver: { applyMigrationEntries(entries: never[]): Promise<{ applied: unknown[] }> }, + tables: readonly string[], + column = 'organization_id', +): Promise { + for (const table of tables) { + const op = { type: 'relax_not_null', table, column }; + const { applied } = await driver.applyMigrationEntries([ + { kind: 'nullability_mismatch', remoteName: table, table, column, category: 'needs_confirm', op } as never, + ]); + if (applied.length !== 1) throw new Error(`relaxTenantColumns: ${table}.${column} was not relaxed`); + } +} 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 6dd3470bef2..2ca6d791802 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 @@ -42,6 +42,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { SeedLoaderRequestSchema, type EngineQueryOptions } from '@objectstack/spec/data'; import type { PluginContext } from '@objectstack/core'; import { AppPlugin } from './app-plugin'; +import { relaxTenantColumns } from './pre-18-tenant-column.testkit.js'; const ORG = 'org_twdefaultk3x9qfa2'; const OTHER_ORG = 'org_twotherorgp4w8m2'; @@ -125,6 +126,9 @@ async function boot() { await engine.init(); for (const o of OBJECTS) engine.registry.registerObject(o as any, 'com.objectstack.test.24301'); await raw.initObjects(OBJECTS.map((o) => engine.registry.getObject(o.name) ?? o) as any); + // The 17.x residue this file plants lands only on a store built before 18.x, + // whose tenant columns are nullable (ADR-0131 D1 makes a fresh one NOT NULL). + await relaxTenantColumns(raw as any, OBJECTS.filter((o) => (engine.registry.getObject(o.name) as any)?.fields?.organization_id).map((o) => o.name)); cleanups.push(() => engine.destroy()); const systemRead: EngineQueryOptions = { context: { isSystem: true } }; diff --git a/packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts b/packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts index 0442ee042cd..72810653a84 100644 --- a/packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts +++ b/packages/runtime/src/seed-tenancy-autonumber-split.integration.test.ts @@ -60,6 +60,7 @@ import { resolveSeedTenancySeam, GLOBAL_TENANT, } from '@objectstack/metadata-protocol'; +import { relaxTenantColumns } from './pre-18-tenant-column.testkit.js'; const ORG_ID = 'org_mssymr19xzd645gv'; const SEEDED_ROWS = 38; @@ -113,6 +114,9 @@ async function bootInstall() { engine.registry.registerObject(CASE_OBJECT, '#8686'); engine.registry.registerObject(ORG_OBJECT, '#8686'); await driver.initObjects([CASE_OBJECT, ORG_OBJECT]); + // The pre-ADR-0131 residue lands only on a store built before 18.x, whose + // tenant column is nullable (D1 makes a fresh one NOT NULL): this is that store. + await relaxTenantColumns(driver as any, [CASE_OBJECT.name]); return { driver, engine }; } @@ -482,6 +486,9 @@ describe('#8686 seed/API tenancy split — autonumber scope', () => { engine.registry.registerObject(ticketObject, '#12394'); engine.registry.registerObject(ORG_OBJECT, '#12394'); await driver.initObjects([ticketObject, ORG_OBJECT]); + // The pre-ADR-0131 residue lands only on a store built before 18.x, whose + // tenant column is nullable (D1 makes a fresh one NOT NULL): this is that store. + await relaxTenantColumns(driver as any, [ticketObject.name]); // Seed-shaped writes: untenanted, because no organization existed yet — // the pre-ADR-0131 residue, landed through the driver call the engine made @@ -550,6 +557,9 @@ describe('#8686 seed/API tenancy split — autonumber scope', () => { engine.registry.registerObject(sysObject, '#8686'); engine.registry.registerObject(ORG_OBJECT, '#8686'); await driver.initObjects([sysObject, ORG_OBJECT]); + // The pre-ADR-0131 residue lands only on a store built before 18.x, whose + // tenant column is nullable (D1 makes a fresh one NOT NULL): this is that store. + await relaxTenantColumns(driver as any, [sysObject.name]); // Land untenanted platform rows, then bring an organization into existence. // [C8 stage 8b] Straight through the driver, as `landPreC1SeedResidue` does: diff --git a/packages/runtime/src/standalone-stack-hydrate-metadata.test.ts b/packages/runtime/src/standalone-stack-hydrate-metadata.test.ts index 38a07b897ea..f03700b7dd0 100644 --- a/packages/runtime/src/standalone-stack-hydrate-metadata.test.ts +++ b/packages/runtime/src/standalone-stack-hydrate-metadata.test.ts @@ -167,7 +167,8 @@ describe('[#20071] createStandaloneStack declares sys_metadata hydration instead // is the only path that puts it there — which is what makes it a sharp // second leg below. expect(engine1.registry.getObject(OBJECT)?.name, 'boot 1 registers the object it just saved').toBe(OBJECT); - await engine1.insert(OBJECT, { title: 'survives the restart' }); + // Written in an organization: a fresh tenant column is NOT NULL (ADR-0131 D1). + await engine1.insert(OBJECT, { title: 'survives the restart' }, { context: { tenantId: 'org_a' } } as never); await first.shutdown(); kernels.splice(kernels.indexOf(first), 1); From 6a380353400130fe6de96eed40764f30da0c0735 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:39:42 +0000 Subject: [PATCH 53/81] wip(plugin-approvals): fixtures hold a Default Organization, or model a pre-18 store Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- .../action-page-member.integration.test.ts | 4 ++ .../action-slot-backfill.integration.test.ts | 4 ++ ...actor-lookups-hold-ids.integration.test.ts | 4 ++ ...min-rescue-named-slate.integration.test.ts | 5 ++ ...al-list-display-labels.integration.test.ts | 4 ++ ...oval-notification-body.integration.test.ts | 5 ++ ...requested-notification.integration.test.ts | 4 ++ ...al-token-internal-hash.integration.test.ts | 4 ++ .../src/default-organization.testkit.ts | 45 +++++++++++++++ ...display-label-backfill.integration.test.ts | 4 ++ ...arget-title-reader-fls.integration.test.ts | 4 ++ ...arget-title-row-access.integration.test.ts | 4 ++ .../src/payload-predicate-guard.test.ts | 4 ++ .../src/pre-18-tenant-column.testkit.ts | 56 +++++++++++++++++++ .../record-delete-cancel.integration.test.ts | 7 +++ ...-lock-batch-row-status.integration.test.ts | 4 ++ ...cord-lock-multi-update.integration.test.ts | 4 ++ ...ock-refusal-writer-fls.integration.test.ts | 4 ++ ...cord-lock-schedule-run.integration.test.ts | 4 ++ .../record-lock-user-facing-message.test.ts | 4 ++ .../record-reader-opt-in.integration.test.ts | 4 ++ ...er-action-link-at-rest.integration.test.ts | 4 ++ ...equest-child-read-gate.integration.test.ts | 4 ++ .../src/request-read-gate.integration.test.ts | 4 ++ ...bject-title-reader-fls.integration.test.ts | 4 ++ ...-approval-delegation-display-title.test.ts | 4 ++ .../src/sys-approval-display-title.test.ts | 4 ++ ...approval-request-title.integration.test.ts | 4 ++ ...pproval-request-viewer.conformance.test.ts | 4 ++ ...val-token-generic-door.integration.test.ts | 4 ++ 30 files changed, 218 insertions(+) create mode 100644 packages/plugins/plugin-approvals/src/default-organization.testkit.ts create mode 100644 packages/plugins/plugin-approvals/src/pre-18-tenant-column.testkit.ts diff --git a/packages/plugins/plugin-approvals/src/action-page-member.integration.test.ts b/packages/plugins/plugin-approvals/src/action-page-member.integration.test.ts index 5bdfbdadf75..9862dd726b4 100644 --- a/packages/plugins/plugin-approvals/src/action-page-member.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/action-page-member.integration.test.ts @@ -36,6 +36,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { ApprovalsServicePlugin } from './approvals-plugin.js'; import type { ApprovalService } from './approval-service.js'; import { renderResultPage } from './action-link-pages.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; const SUBMITTER = { userId: 'u_submitter', positions: [], permissions: [] } as any; @@ -128,6 +129,9 @@ async function bootStack(options: { rawApp: boolean }) { (objectql as any).registerDriver(driver, true); objectql.registry.registerObject(leaveRequest as any, 'approvals-test', 'approvals-test'); await (objectql as any).syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(objectql as any); const svc = kernel.getService('approvals'); let n = 0; diff --git a/packages/plugins/plugin-approvals/src/action-slot-backfill.integration.test.ts b/packages/plugins/plugin-approvals/src/action-slot-backfill.integration.test.ts index 1a5e27bbea4..8569bee1717 100644 --- a/packages/plugins/plugin-approvals/src/action-slot-backfill.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/action-slot-backfill.integration.test.ts @@ -36,6 +36,7 @@ import { SysApprovalRequest } from './sys-approval-request.object.js'; import { SysApprovalAction } from './sys-approval-action.object.js'; import { SysApprovalApprover } from './sys-approval-approver.object.js'; import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; const SUBMITTER = { userId: 'u_submitter', positions: [], permissions: [] } as any; @@ -85,6 +86,9 @@ describe('the boot-time action-slot backfill (#21411)', () => { // Real DDL — including the `acted_as` column, so a write of it the schema // does not carry fails here rather than passing against a permissive fake. await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); svc = new ApprovalService({ engine: engine as any, logger: { warn: () => {} } as any }); await engine.insert('crm_deal', { id: 'D1', title: 'Deal' }, { context: SYSTEM } as any); }); diff --git a/packages/plugins/plugin-approvals/src/actor-lookups-hold-ids.integration.test.ts b/packages/plugins/plugin-approvals/src/actor-lookups-hold-ids.integration.test.ts index edf4be063ce..47e86114010 100644 --- a/packages/plugins/plugin-approvals/src/actor-lookups-hold-ids.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/actor-lookups-hold-ids.integration.test.ts @@ -46,6 +46,7 @@ import { APPROVAL_REVISE_NODE_TYPE } from '@objectstack/spec/automation'; import type { EngineQueryOptions } from '@objectstack/spec/data'; import { ApprovalService } from './approval-service.js'; import { ApprovalsServicePlugin } from './approvals-plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; const asUser = (userId: string, positions: string[] = []) => @@ -156,6 +157,9 @@ describe('ADR-0118 D1 — every sys_user lookup approvals writes holds an id or engine.registry.registerObject(def as any, 'approvals-test', 'approvals-test'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const u of USERS) await engine.insert('sys_user', u, { context: SYSTEM } as any); svc = new ApprovalService({ diff --git a/packages/plugins/plugin-approvals/src/admin-rescue-named-slate.integration.test.ts b/packages/plugins/plugin-approvals/src/admin-rescue-named-slate.integration.test.ts index b5faf949ce2..5beefef10c5 100644 --- a/packages/plugins/plugin-approvals/src/admin-rescue-named-slate.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/admin-rescue-named-slate.integration.test.ts @@ -45,6 +45,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { bindSecurityCatalogReader, type SecurityCatalogEntry, type SecurityCatalogReader } from '@objectstack/core'; import { ApprovalService } from './approval-service.js'; import { ApprovalsServicePlugin } from './approvals-plugin.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; @@ -185,6 +186,10 @@ describe('an empty admin_rescue slate opens on the organization\'s administrator ]; for (const def of defs) engine.registry.registerObject(def as any, 'approvals-test', 'approvals-test'); await engine.syncSchemas(); + // The organization-less rows below (positions, holdings) exist only on a + // store built before 18.x (ADR-0131 D1 makes a fresh tenant column NOT + // NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); bindSecurityCatalogReader(engine, catalogServing([ 'organization_admin', 'organization_admin_no_bypass', 'admin_full_access', ])); diff --git a/packages/plugins/plugin-approvals/src/approval-list-display-labels.integration.test.ts b/packages/plugins/plugin-approvals/src/approval-list-display-labels.integration.test.ts index 7057baeeb5b..e715bdf7254 100644 --- a/packages/plugins/plugin-approvals/src/approval-list-display-labels.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-list-display-labels.integration.test.ts @@ -54,6 +54,7 @@ import { ApprovalService } from './approval-service.js'; import { ApprovalsServicePlugin } from './approvals-plugin.js'; import { SysApprovalRequest } from './sys-approval-request.object.js'; import { SysApprovalAction } from './sys-approval-action.object.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; const asUser = (userId: string, positions: string[] = []) => @@ -136,6 +137,9 @@ describe('#23889 — the approval lists read engine-written display columns', () engine.registry.registerObject(def as any, 'approvals-test', 'approvals-test'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const u of USERS) await engine.insert('sys_user', u, { context: SYSTEM } as any); svc = new ApprovalService({ diff --git a/packages/plugins/plugin-approvals/src/approval-notification-body.integration.test.ts b/packages/plugins/plugin-approvals/src/approval-notification-body.integration.test.ts index da7f391f657..b7d9c0d0c0e 100644 --- a/packages/plugins/plugin-approvals/src/approval-notification-body.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-notification-body.integration.test.ts @@ -76,6 +76,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { APPROVAL_REVISE_NODE_TYPE } from '@objectstack/spec/automation'; import { ApprovalService } from './approval-service.js'; import { ApprovalsServicePlugin } from './approvals-plugin.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; const asUser = (userId: string, positions: string[] = []) => @@ -211,6 +212,10 @@ describe('every approval notification carries its text in body', () => { engine.registry.registerObject(def as any, 'approvals-test', 'approvals-test'); } await engine.syncSchemas(); + // The organization-less rows below (positions, holdings) exist only on a + // store built before 18.x (ADR-0131 D1 makes a fresh tenant column NOT + // NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); for (const u of USERS) await engine.insert('sys_user', u, { context: SYSTEM } as any); svc = new ApprovalService({ diff --git a/packages/plugins/plugin-approvals/src/approval-requested-notification.integration.test.ts b/packages/plugins/plugin-approvals/src/approval-requested-notification.integration.test.ts index bbdc0222b7a..497df05668a 100644 --- a/packages/plugins/plugin-approvals/src/approval-requested-notification.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-requested-notification.integration.test.ts @@ -47,6 +47,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ApprovalService } from './approval-service.js'; import { ApprovalsServicePlugin } from './approvals-plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; const asUser = (userId: string) => ({ userId, positions: [], permissions: [] }) as any; @@ -161,6 +162,9 @@ describe('opening an approval step tells each resolved approver, once (approval. engine.registry.registerObject(def as any, 'approvals-test', 'approvals-test'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const u of USERS) await engine.insert('sys_user', u, { context: SYSTEM } as any); // `legal` is staffed by two people; `treasury` by nobody. for (const [id, user] of [['up_1', 'u_carol'], ['up_2', 'u_dan']]) { diff --git a/packages/plugins/plugin-approvals/src/approval-token-internal-hash.integration.test.ts b/packages/plugins/plugin-approvals/src/approval-token-internal-hash.integration.test.ts index 0e8d2cf4961..6480b289735 100644 --- a/packages/plugins/plugin-approvals/src/approval-token-internal-hash.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-token-internal-hash.integration.test.ts @@ -22,6 +22,7 @@ import { SysApprovalAction } from './sys-approval-action.object.js'; import { SysApprovalApprover } from './sys-approval-approver.object.js'; import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; import { SysApprovalToken } from './sys-approval-token.object.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; const SUBMITTER = { userId: 'submitter', positions: [], permissions: [] } as any; @@ -51,6 +52,9 @@ describe('[#21197] the approval token digest is internal, and the actionable lin engine.registry.registerObject(def as any, 'approvals-test', 'approvals-test'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); svc = new ApprovalService({ engine: engine as any, automation: { diff --git a/packages/plugins/plugin-approvals/src/default-organization.testkit.ts b/packages/plugins/plugin-approvals/src/default-organization.testkit.ts new file mode 100644 index 00000000000..fc514f66ebe --- /dev/null +++ b/packages/plugins/plugin-approvals/src/default-organization.testkit.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Give a lean test engine what every real `single` install has: an + * organization object holding its Default Organization. + * + * ADR-0131 D1: a table the SQL driver creates carries its tenant column NOT + * NULL. The engine stamps an organization-less system write with the derived + * organization under `single` (D9) — but only where the composition registers + * the organization object; a bare `ObjectQL` + `SqlDriver` registers none, so + * the write reaches the database with no organization and is refused there. + * A suite whose subject is not tenancy (claims, acks, idle cost, at-rest + * shapes) calls this once its own objects are registered, so its system + * writes land the way they do on a real install. + * + * The object mirrors the platform's: managed by better-auth, so it carries no + * tenant column of its own. + */ +export const DEFAULT_ORGANIZATION_ID = 'org_default'; + +export async function installDefaultOrganization(engine: { + registry: { registerObject(def: unknown, owner: string): unknown }; + getSchema(object: string): unknown; + syncSchemas(): Promise; + getDriverForObject(object: string): + | { + create(object: string, data: Record): Promise; + findOne(object: string, query: unknown): Promise; + } + | undefined; +}): Promise { + // Idempotent: a suite that syncs one engine more than once may call it each time. + if (!engine.getSchema('sys_organization')) { + engine.registry.registerObject( + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, + '@objectstack/plugin-approvals', + ); + } + await engine.syncSchemas(); + // Seeded through the driver: the organization is data at rest, not a write + // the suite under test should observe (an audit or activity mirror would). + const driver = engine.getDriverForObject('sys_organization')!; + if (await driver.findOne('sys_organization', { where: { id: DEFAULT_ORGANIZATION_ID } })) return; + await driver.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); +} diff --git a/packages/plugins/plugin-approvals/src/display-label-backfill.integration.test.ts b/packages/plugins/plugin-approvals/src/display-label-backfill.integration.test.ts index 3ebd47128db..9cb8daca10c 100644 --- a/packages/plugins/plugin-approvals/src/display-label-backfill.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/display-label-backfill.integration.test.ts @@ -47,6 +47,7 @@ import { SysApprovalRequest } from './sys-approval-request.object.js'; import { SysApprovalAction } from './sys-approval-action.object.js'; import { SysApprovalApprover } from './sys-approval-approver.object.js'; import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; const STAMP = '2026-09-01T09:00:00.000Z'; @@ -128,6 +129,9 @@ describe('#23889 — the boot-time display-label backfill', () => { engine.registry.registerObject(def as any, 'approvals-test', 'approvals-test'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const u of [ { id: 'u_sub', name: 'Sam Submitter', email: 'sam@example.com' }, { id: 'u_alice', name: 'Alice', email: 'alice@example.com' }, diff --git a/packages/plugins/plugin-approvals/src/lookup-target-title-reader-fls.integration.test.ts b/packages/plugins/plugin-approvals/src/lookup-target-title-reader-fls.integration.test.ts index 41bb909ee4e..ab144bfab6c 100644 --- a/packages/plugins/plugin-approvals/src/lookup-target-title-reader-fls.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/lookup-target-title-reader-fls.integration.test.ts @@ -47,6 +47,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { ApprovalService } from './approval-service.js'; import { ApprovalsServicePlugin } from './approvals-plugin.js'; import type { FieldVisibilitySource } from './payload-redaction.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; const asUser = (userId: string) => ({ userId, positions: [], permissions: [] }) as any; @@ -200,6 +201,9 @@ describe('[#23618] a lookup target\'s title is served from what its reader may r engine.registry.registerObject(def as any, 'approvals-test', 'approvals-test'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const id of [RESTRICTED, MASKED, READER, SUBMITTER]) { await engine.insert('sys_user', { id, name: id, email: `${id}@example.com` }, { context: SYSTEM } as any); } diff --git a/packages/plugins/plugin-approvals/src/lookup-target-title-row-access.integration.test.ts b/packages/plugins/plugin-approvals/src/lookup-target-title-row-access.integration.test.ts index ed64b4ffe79..c7446cac029 100644 --- a/packages/plugins/plugin-approvals/src/lookup-target-title-row-access.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/lookup-target-title-row-access.integration.test.ts @@ -65,6 +65,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { ApprovalService, type ObjectReadAdmission } from './approval-service.js'; import { ApprovalsServicePlugin } from './approvals-plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; const asUser = (userId: string) => ({ userId, positions: [], permissions: [] }) as any; @@ -190,6 +191,9 @@ async function bootEngine(reads: CallerRead[]): Promise { engine.registry.registerObject(def as any, 'approvals-test', 'approvals-test'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); guardReads(engine, reads); for (const id of [RESTRICTED, READER, SUBMITTER]) { await engine.insert('sys_user', { id, name: id, email: `${id}@example.com` }, { context: SYSTEM } as any); diff --git a/packages/plugins/plugin-approvals/src/payload-predicate-guard.test.ts b/packages/plugins/plugin-approvals/src/payload-predicate-guard.test.ts index 0d6e260408a..451d2a4c04b 100644 --- a/packages/plugins/plugin-approvals/src/payload-predicate-guard.test.ts +++ b/packages/plugins/plugin-approvals/src/payload-predicate-guard.test.ts @@ -33,6 +33,7 @@ import { SysApprovalRequest } from './sys-approval-request.object.js'; import { SysApprovalApprover } from './sys-approval-approver.object.js'; import { SysApprovalAction } from './sys-approval-action.object.js'; import { namedSnapshotColumn, pinnedSubjectObject } from './payload-predicate-guard.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const REQUEST_OBJECT = 'sys_approval_request'; const SUBJECT = 'ppg_subject'; @@ -138,6 +139,9 @@ describe('[#21154] a query over the approval snapshot by a reader withheld a sub engine.registry.registerObject(o as any, HARNESS_PACKAGE); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const snapshot = (subject: string, row: Record) => ({ object_name: subject, record_id: String(row.id), status: 'pending', diff --git a/packages/plugins/plugin-approvals/src/pre-18-tenant-column.testkit.ts b/packages/plugins/plugin-approvals/src/pre-18-tenant-column.testkit.ts new file mode 100644 index 00000000000..b0cbb5efa38 --- /dev/null +++ b/packages/plugins/plugin-approvals/src/pre-18-tenant-column.testkit.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Return engine-synced tables to the shape a store built BEFORE 18.x has: the + * tenant column nullable. + * + * ADR-0131 D1: since 18.x the SQL driver creates a table's tenant column NOT + * NULL. A store an earlier release built still holds the nullable column and + * its organization-less rows — the holdings, grants and seeds this package's + * NULL readers still answer for until the organization-ownership ceremony has + * attributed them. A suite whose subject is those rows calls this after + * `syncSchemas()` and before seeding them. Done through each table's driver + * and its own drift apply door (`relax_not_null`), never a hand `ALTER`. + */ +export async function relaxTenantColumns( + engine: { getDriverForObject(object: string): unknown }, + tables: readonly string[], + column = 'organization_id', +): Promise { + for (const table of tables) { + const driver = engine.getDriverForObject(table) as { + applyMigrationEntries(entries: never[]): Promise<{ applied: unknown[] }>; + }; + const op = { type: 'relax_not_null', table, column }; + const { applied } = await driver.applyMigrationEntries([ + { kind: 'nullability_mismatch', remoteName: table, table, column, category: 'needs_confirm', op } as never, + ]); + if (applied.length !== 1) throw new Error(`relaxTenantColumns: ${table}.${column} was not relaxed`); + } +} + +/** + * {@link relaxTenantColumns} over every object of an app that carries a tenant + * column as the engine registered it — the shape a whole pre-18 store has. + */ +export async function relaxEveryTenantColumn( + engine: { getDriverForObject(object: string): unknown; getSchema(object: string): unknown }, + objects: readonly { name: string }[], +): Promise { + const tenant = objects + .map((o) => o.name) + .filter((name) => { + const schema = engine.getSchema(name) as { fields?: Record; tenancy?: { enabled?: boolean } } | undefined; + return !!schema?.fields?.organization_id && schema.tenancy?.enabled !== false; + }); + await relaxTenantColumns(engine, tenant); +} + +/** {@link relaxEveryTenantColumn} over every object the engine's registry holds. */ +export async function relaxEveryRegisteredTenantColumn(engine: { + getDriverForObject(object: string): unknown; + getSchema(object: string): unknown; + registry: { getAllObjects(): readonly { name: string }[] }; +}): Promise { + await relaxEveryTenantColumn(engine, engine.registry.getAllObjects()); +} diff --git a/packages/plugins/plugin-approvals/src/record-delete-cancel.integration.test.ts b/packages/plugins/plugin-approvals/src/record-delete-cancel.integration.test.ts index b0d33c626a6..71890400f8f 100644 --- a/packages/plugins/plugin-approvals/src/record-delete-cancel.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/record-delete-cancel.integration.test.ts @@ -58,6 +58,7 @@ import { SysApprovalRequest } from './sys-approval-request.object.js'; import { SysApprovalAction } from './sys-approval-action.object.js'; import { SysApprovalApprover } from './sys-approval-approver.object.js'; import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; const SUBMITTER = { userId: 'submitter', positions: [], permissions: [] } as any; @@ -157,6 +158,9 @@ describe('a deleted record auto-cancels its pending approvals (#13568)', () => { // this card adds, so a write of it that the schema does not carry fails // here rather than passing against a permissive fake. await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); warnings = []; automation = makeAutomationSpy(); @@ -349,6 +353,9 @@ describe('#13568 control — without the linkage the same delete strands the req engine.registry.registerObject(def as any, 'approvals-test', 'approvals-test'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); svc = new ApprovalService({ engine: engine as any }); await engine.insert('crm_leave_request', { id: 'LR6', title: 'Leave' }, { context: SYSTEM } as any); // ⛔ deliberately NOT bound — that is the whole case. diff --git a/packages/plugins/plugin-approvals/src/record-lock-batch-row-status.integration.test.ts b/packages/plugins/plugin-approvals/src/record-lock-batch-row-status.integration.test.ts index b4d795dd5c0..68d75b5b38c 100644 --- a/packages/plugins/plugin-approvals/src/record-lock-batch-row-status.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/record-lock-batch-row-status.integration.test.ts @@ -50,6 +50,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { ObjectStackProtocolImplementation } from '@objectstack/metadata-protocol'; import { resolveThrownHttpError } from '@objectstack/types'; import { bindApprovalLockHook } from './lifecycle-hooks.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const opportunity = { name: 'opportunity', @@ -105,6 +106,9 @@ describe('[#8570] a locked record\'s batch row carries the 409 the hook declared } // Real DDL through the real path. await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); lockedId = String((await engine.insert('opportunity', { name: 'Deal', amount: 100 })).id); freeId = String((await engine.insert('opportunity', { name: 'Other', amount: 100 })).id); diff --git a/packages/plugins/plugin-approvals/src/record-lock-multi-update.integration.test.ts b/packages/plugins/plugin-approvals/src/record-lock-multi-update.integration.test.ts index 19241ee3767..e6779521dba 100644 --- a/packages/plugins/plugin-approvals/src/record-lock-multi-update.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/record-lock-multi-update.integration.test.ts @@ -34,6 +34,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { bindApprovalLockHook } from './lifecycle-hooks.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const opportunity = { name: 'opportunity', @@ -112,6 +113,9 @@ describe('approvals record lock — predicate (multi) updates (#4778)', () => { // Real DDL through the real path — the tables every write below hits are // created by the driver, not conjured by a store on first write. await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); lockedId = String((await engine.insert('opportunity', { name: 'Deal', amount: 100 })).id); freeId = String((await engine.insert('opportunity', { name: 'Other', amount: 100 })).id); diff --git a/packages/plugins/plugin-approvals/src/record-lock-refusal-writer-fls.integration.test.ts b/packages/plugins/plugin-approvals/src/record-lock-refusal-writer-fls.integration.test.ts index f158a652a8b..cb071aaceaf 100644 --- a/packages/plugins/plugin-approvals/src/record-lock-refusal-writer-fls.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/record-lock-refusal-writer-fls.integration.test.ts @@ -41,6 +41,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { bindApprovalLockHook } from './lifecycle-hooks.js'; import type { FieldVisibilitySource } from './payload-redaction.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; /** The writer's envelope carries `permissions`, which the hook session does not. */ @@ -132,6 +133,9 @@ describe('[#23618] the RECORD_LOCKED refusal names the record from what its writ engine.registry.registerObject(o as any, 'com.objectstack.test.23618'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); lockedId = String((await engine.insert('opportunity', { name: TITLE, amount: 100 }, { context: SYSTEM } as any)).id); freeId = String((await engine.insert('opportunity', { name: 'Unlocked deal', amount: 5 }, { context: SYSTEM } as any)).id); diff --git a/packages/plugins/plugin-approvals/src/record-lock-schedule-run.integration.test.ts b/packages/plugins/plugin-approvals/src/record-lock-schedule-run.integration.test.ts index 75dbf2446ec..54b193594f8 100644 --- a/packages/plugins/plugin-approvals/src/record-lock-schedule-run.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/record-lock-schedule-run.integration.test.ts @@ -35,6 +35,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { resolveRunDataContext } from '@objectstack/service-automation'; import { bindApprovalLockHook } from './lifecycle-hooks.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const opportunity = { name: 'opportunity', @@ -99,6 +100,9 @@ describe('an owning run and the approvals record lock (#3703 / #3712 / #3760)', await engine.init(); for (const o of [opportunity, approvalRequest]) engine.registry.registerObject(o as any); await engine.syncSchemas(); // real DDL for both tables + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const opp = await engine.insert('opportunity', { name: 'Deal', amount: 100 }); oppId = String(opp.id); diff --git a/packages/plugins/plugin-approvals/src/record-lock-user-facing-message.test.ts b/packages/plugins/plugin-approvals/src/record-lock-user-facing-message.test.ts index 4aa7062c87e..8a6bc2bb900 100644 --- a/packages/plugins/plugin-approvals/src/record-lock-user-facing-message.test.ts +++ b/packages/plugins/plugin-approvals/src/record-lock-user-facing-message.test.ts @@ -43,6 +43,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { bindApprovalLockHook, APPROVALS_HOOK_PACKAGE } from './lifecycle-hooks.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OPPORTUNITY = { name: 'opportunity', @@ -105,6 +106,9 @@ describe('[#18153] the per-row refusal is user-facing copy', () => { engine.registry.registerObject(o as any, 'com.objectstack.test.18153'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); opportunityId = String((await engine.insert('opportunity', { name: 'Acme renewal', amount: 100 })).id); ledgerId = String((await engine.insert('ledger_entry', { amount: 100 })).id); diff --git a/packages/plugins/plugin-approvals/src/record-reader-opt-in.integration.test.ts b/packages/plugins/plugin-approvals/src/record-reader-opt-in.integration.test.ts index 42036862a32..7733ce6aa2b 100644 --- a/packages/plugins/plugin-approvals/src/record-reader-opt-in.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/record-reader-opt-in.integration.test.ts @@ -47,6 +47,7 @@ import { SysApprovalRequest } from './sys-approval-request.object.js'; import { SysApprovalAction } from './sys-approval-action.object.js'; import { SysApprovalApprover } from './sys-approval-approver.object.js'; import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const REQUEST = 'sys_approval_request'; const ACTION = 'sys_approval_action'; @@ -163,6 +164,9 @@ async function boot(options: ApprovalsPluginOptions = {}): Promise { engine.registry.registerObject(def as any, PKG, PKG); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const object of [DECLARED, UNDECLARED]) guardReads(engine, object); const services: Record = { objectql: engine }; diff --git a/packages/plugins/plugin-approvals/src/reminder-action-link-at-rest.integration.test.ts b/packages/plugins/plugin-approvals/src/reminder-action-link-at-rest.integration.test.ts index e4bd6997642..f7571b0c6dd 100644 --- a/packages/plugins/plugin-approvals/src/reminder-action-link-at-rest.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/reminder-action-link-at-rest.integration.test.ts @@ -53,6 +53,7 @@ import { SysApprovalAction } from './sys-approval-action.object.js'; import { SysApprovalApprover } from './sys-approval-approver.object.js'; import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; import { SysApprovalToken } from './sys-approval-token.object.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'linkrest_invoice'; const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; @@ -150,6 +151,9 @@ async function boot(): Promise { engine.registry.registerObject(def as any, 'approvals-link-at-rest-test', 'approvals-link-at-rest-test'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const [id, name] of [[SUBMITTER, 'Sam Submitter'], [APPROVER, 'Ada Approver'], [ORG_ADMIN.userId, 'Olga Admin']]) { await engine.insert('sys_user', { id, name, email: `${id}@example.com` }, { context: SYSTEM } as any); } diff --git a/packages/plugins/plugin-approvals/src/request-child-read-gate.integration.test.ts b/packages/plugins/plugin-approvals/src/request-child-read-gate.integration.test.ts index 0cea7c52325..330bcd71cc2 100644 --- a/packages/plugins/plugin-approvals/src/request-child-read-gate.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/request-child-read-gate.integration.test.ts @@ -56,6 +56,7 @@ import { SysApprovalRequest } from './sys-approval-request.object.js'; import { SysApprovalAction } from './sys-approval-action.object.js'; import { SysApprovalApprover } from './sys-approval-approver.object.js'; import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const ACTION = 'sys_approval_action'; const APPROVER_INDEX = 'sys_approval_approver'; @@ -121,6 +122,9 @@ async function boot( engine.registry.registerObject(def as any, 'approvals-child-read-gate-test', 'approvals-child-read-gate-test'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const recordA = 'sheet_a'; const recordB = 'sheet_b'; diff --git a/packages/plugins/plugin-approvals/src/request-read-gate.integration.test.ts b/packages/plugins/plugin-approvals/src/request-read-gate.integration.test.ts index 08cf2c58924..d9472d43c9a 100644 --- a/packages/plugins/plugin-approvals/src/request-read-gate.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/request-read-gate.integration.test.ts @@ -58,6 +58,7 @@ import { SysApprovalRequest } from './sys-approval-request.object.js'; import { SysApprovalAction } from './sys-approval-action.object.js'; import { SysApprovalApprover } from './sys-approval-approver.object.js'; import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const REQUEST = 'sys_approval_request'; const OBJECT = 'exam_sheet'; @@ -122,6 +123,9 @@ async function boot( engine.registry.registerObject(def as any, 'approvals-read-gate-test', 'approvals-read-gate-test'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); const recordA = 'sheet_a'; const recordB = 'sheet_b'; diff --git a/packages/plugins/plugin-approvals/src/subject-title-reader-fls.integration.test.ts b/packages/plugins/plugin-approvals/src/subject-title-reader-fls.integration.test.ts index 6a2f65312ce..f8eb1ec1a3b 100644 --- a/packages/plugins/plugin-approvals/src/subject-title-reader-fls.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/subject-title-reader-fls.integration.test.ts @@ -50,6 +50,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { ApprovalService } from './approval-service.js'; import { ApprovalsServicePlugin } from './approvals-plugin.js'; import type { FieldVisibilitySource } from './payload-redaction.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; const asUser = (userId: string) => ({ userId, positions: [], permissions: [] }) as any; @@ -181,6 +182,9 @@ describe('[#23594] the subject title is served from what its reader may read', ( engine.registry.registerObject(def as any, 'approvals-test', 'approvals-test'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const id of [RESTRICTED, READER, MASKED, SUBMITTER]) { await engine.insert('sys_user', { id, name: id, email: `${id}@example.com` }, { context: SYSTEM } as any); } diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts index 0c6cfe49cd1..ecc8cad4da6 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation-display-title.test.ts @@ -40,6 +40,7 @@ import { import { SqlDriver } from '@objectstack/driver-sql'; import { resolveDisplayField } from '@objectstack/spec/data'; import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYS = { context: { isSystem: true } } as any; const OBJECT = 'sys_approval_delegation'; @@ -94,6 +95,9 @@ describe('[#20044] sys_approval_delegation resolves a real record title under AD await engine.init(); engine.registry.registerObject(SysApprovalDelegation as any, 'com.objectstack.test.20044'); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); }); afterAll(async () => { diff --git a/packages/plugins/plugin-approvals/src/sys-approval-display-title.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-display-title.test.ts index f762b93c39a..d02051df63e 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-display-title.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-display-title.test.ts @@ -42,6 +42,7 @@ import { resolveDisplayField } from '@objectstack/spec/data'; import { SysApprovalAction } from './sys-approval-action.object.js'; import { SysApprovalApprover } from './sys-approval-approver.object.js'; import { SysApprovalRequest } from './sys-approval-request.object.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYS = { context: { isSystem: true } } as any; @@ -86,6 +87,9 @@ describe('[#20015] approval objects resolve a real record title under ADR-0079 o engine.registry.registerObject(o as any, 'com.objectstack.test.20015'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); }); afterAll(async () => { diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request-title.integration.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-request-title.integration.test.ts index a5ad0f6d2c4..7ade70c0214 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request-title.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request-title.integration.test.ts @@ -38,6 +38,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { ApprovalService } from './approval-service.js'; import { ApprovalsServicePlugin } from './approvals-plugin.js'; import { SysApprovalRequest } from './sys-approval-request.object.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; const asUser = (userId: string) => ({ userId, positions: [], permissions: [] }) as any; @@ -132,6 +133,9 @@ describe('#24665 — the approval request title is the flow label the approvals engine.registry.registerObject(def as any, 'approvals-test', 'approvals-test'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const u of [{ id: 'u_sub', name: 'Sam' }, { id: APPROVER, name: 'Ada' }]) { await engine.insert('sys_user', u, { context: SYSTEM } as any); } diff --git a/packages/plugins/plugin-approvals/src/sys-approval-request-viewer.conformance.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-request-viewer.conformance.test.ts index c3dbd865494..b514e99d669 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-request-viewer.conformance.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-request-viewer.conformance.test.ts @@ -36,6 +36,7 @@ import { ADMIN_FULL_ACCESS } from '@objectstack/spec/identity'; import { ApprovalService } from './approval-service.js'; import { ApprovalsServicePlugin } from './approvals-plugin.js'; import { SysApprovalRequest } from './sys-approval-request.object.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** The value types a read-attachment leaf may declare (the spec's closed set). */ type LeafType = NonNullable[string][string]; @@ -133,6 +134,9 @@ describe('sys_approval_request — the served viewer block conforms to attachedO engine.registry.registerObject(def as any, 'approvals-test', 'approvals-test'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const id of ['u_sub', 'u_app', 'u_root']) { await engine.insert('sys_user', { id, name: id, email: `${id}@example.com` }, { context: SYSTEM } as any); } diff --git a/packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts index 71552a094a9..e9ea358a995 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-token-generic-door.integration.test.ts @@ -66,6 +66,7 @@ import { SysApprovalAction } from './sys-approval-action.object.js'; import { SysApprovalApprover } from './sys-approval-approver.object.js'; import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; import { SysApprovalToken } from './sys-approval-token.object.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const TOKEN = 'sys_approval_token'; const OBJECT = 'tokdoor_sheet'; @@ -143,6 +144,9 @@ async function boot(): Promise { engine.registry.registerObject(def as any, 'approvals-token-door-test', 'approvals-token-door-test'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); await engine.insert(OBJECT, { id: 'sheet_1', title: 'tokdoor ledger sheet' }, { context: SYSTEM } as any); const services: Record = { objectql: engine }; From 0e5e11c66e8921736261f336769a3c2e00bdc0bb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:43:24 +0000 Subject: [PATCH 54/81] wip(client,http-conformance,service-analytics): lean fixture engines hold a Default Organization Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- .../src/client.batch-transaction.test.ts | 4 ++ packages/client/src/client.hono.test.ts | 4 ++ .../src/default-organization.testkit.ts | 45 +++++++++++++++++++ .../src/conformance.integration.test.ts | 4 ++ .../src/default-organization.testkit.ts | 45 +++++++++++++++++++ .../hono-approvals-act.conformance.test.ts | 4 ++ .../src/hono-body-refusal.conformance.test.ts | 4 ++ ...hono-declared-envelope.conformance.test.ts | 4 ++ .../analytics-faces-one-lowering.test.ts | 7 +++ .../src/__tests__/api-exposure-door.test.ts | 4 ++ .../caller-measure-no-field-door.test.ts | 4 ++ .../cube-measure-field-type-door.test.ts | 4 ++ ...ube-measure-relationship-path-type.test.ts | 4 ++ .../__tests__/default-organization.testkit.ts | 45 +++++++++++++++++++ .../json-stored-door-live-drivers.test.ts | 4 ++ .../json-stored-door-undeclared-join.test.ts | 4 ++ ...list-at-scalar-operator-both-faces.test.ts | 4 ++ .../native-sql-aggregate-policies.test.ts | 4 ++ .../native-sql-base-column-qualify.test.ts | 4 ++ .../native-sql-boolean-comparand-door.test.ts | 4 ++ ...ve-sql-measure-number-presentation.test.ts | 10 ++++- .../native-sql-number-comparand-door.test.ts | 4 ++ .../native-sql-offset-only-window.test.ts | 4 ++ .../objectql-echo-bucket-enumeration.test.ts | 4 ++ .../objectql-echo-date-bucket.test.ts | 4 ++ .../objectql-echo-offset-only-window.test.ts | 4 ++ .../objectql-face-order-limit.test.ts | 4 ++ .../src/__tests__/order-key-selected.test.ts | 4 ++ .../picklist-dimension-label-i18n.test.ts | 4 ++ .../read-scope-temporal-coercion.test.ts | 4 ++ 30 files changed, 251 insertions(+), 1 deletion(-) create mode 100644 packages/client/src/default-organization.testkit.ts create mode 100644 packages/qa/http-conformance/src/default-organization.testkit.ts create mode 100644 packages/services/service-analytics/src/__tests__/default-organization.testkit.ts diff --git a/packages/client/src/client.batch-transaction.test.ts b/packages/client/src/client.batch-transaction.test.ts index e72ccbb8cd7..c131568d34f 100644 --- a/packages/client/src/client.batch-transaction.test.ts +++ b/packages/client/src/client.batch-transaction.test.ts @@ -35,6 +35,7 @@ import { createRestApiPlugin } from '@objectstack/runtime'; import { ObjectStackClient } from './index'; import type { IHttpServer } from '@objectstack/spec/contracts'; import type { ServiceObject } from '@objectstack/spec/data'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** * ⚠️ [#18070] The authz objects every authenticated request in this file @@ -210,6 +211,9 @@ describe('data.batchTransaction (live Hono, #1604)', () => { ql.registerObject(o.def, o.owner); await ql.syncObjectSchema(o.def.name); } + // A real `single` install's Default Organization: the writes below derive + // it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(ql); const httpServer = kernel.getService('http.server'); baseUrl = `http://localhost:${httpServer.getPort!()}`; diff --git a/packages/client/src/client.hono.test.ts b/packages/client/src/client.hono.test.ts index 48302233def..29e58ddd028 100644 --- a/packages/client/src/client.hono.test.ts +++ b/packages/client/src/client.hono.test.ts @@ -7,6 +7,7 @@ import { createRestApiPlugin } from '@objectstack/runtime'; import { ObjectStackClient } from './index'; import type { IHttpServer } from '@objectstack/spec/contracts'; import type { ServiceObject } from '@objectstack/spec/data'; +import { installDefaultOrganization } from './default-organization.testkit.js'; /** * ⚠️ [#18070] The authz objects every authenticated request in this file @@ -233,6 +234,9 @@ describe('ObjectStackClient (with Hono Server)', () => { ql.registerObject(o.def, o.owner); await ql.syncObjectSchema(o.def.name); } + // A real `single` install's Default Organization: the writes below derive + // it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(ql); // 5. Get Port from Service const httpServer = kernel.getService('http.server'); diff --git a/packages/client/src/default-organization.testkit.ts b/packages/client/src/default-organization.testkit.ts new file mode 100644 index 00000000000..ad601fd4969 --- /dev/null +++ b/packages/client/src/default-organization.testkit.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Give a lean test engine what every real `single` install has: an + * organization object holding its Default Organization. + * + * ADR-0131 D1: a table the SQL driver creates carries its tenant column NOT + * NULL. The engine stamps an organization-less system write with the derived + * organization under `single` (D9) — but only where the composition registers + * the organization object; a bare `ObjectQL` + `SqlDriver` registers none, so + * the write reaches the database with no organization and is refused there. + * A suite whose subject is not tenancy (claims, acks, idle cost, at-rest + * shapes) calls this once its own objects are registered, so its system + * writes land the way they do on a real install. + * + * The object mirrors the platform's: managed by better-auth, so it carries no + * tenant column of its own. + */ +export const DEFAULT_ORGANIZATION_ID = 'org_default'; + +export async function installDefaultOrganization(engine: { + registry: { registerObject(def: unknown, owner: string): unknown }; + getSchema(object: string): unknown; + syncSchemas(): Promise; + getDriverForObject(object: string): + | { + create(object: string, data: Record): Promise; + findOne(object: string, query: unknown): Promise; + } + | undefined; +}): Promise { + // Idempotent: a suite that syncs one engine more than once may call it each time. + if (!engine.getSchema('sys_organization')) { + engine.registry.registerObject( + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, + '@objectstack/client', + ); + } + await engine.syncSchemas(); + // Seeded through the driver: the organization is data at rest, not a write + // the suite under test should observe (an audit or activity mirror would). + const driver = engine.getDriverForObject('sys_organization')!; + if (await driver.findOne('sys_organization', { where: { id: DEFAULT_ORGANIZATION_ID } })) return; + await driver.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); +} diff --git a/packages/qa/http-conformance/src/conformance.integration.test.ts b/packages/qa/http-conformance/src/conformance.integration.test.ts index ddcbbdb53e3..04eba5237da 100644 --- a/packages/qa/http-conformance/src/conformance.integration.test.ts +++ b/packages/qa/http-conformance/src/conformance.integration.test.ts @@ -23,6 +23,7 @@ import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; import { createDispatcherPlugin, createRestApiPlugin } from '@objectstack/runtime'; import { NodeServerPlugin } from './node-plugin.js'; import type { IHttpServer } from '@objectstack/spec/contracts'; +import { installDefaultOrganization } from './default-organization.testkit.js'; type AdapterCase = { label: 'node' | 'hono'; @@ -174,6 +175,9 @@ async function bootStack(makePlugin: () => any, opts: { withAnalytics?: boolean // a 404 OBJECT_NOT_FOUND — a routing-shaped symptom for a DDL-shaped cause. await ql.syncObjectSchema('task'); for (const o of authzResolverObjects) ql.registry.registerObject(o.def as never, o.owner); + // A real `single` install's Default Organization: the writes below derive + // it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(ql); const httpServer = kernel.getService('http.server'); return { kernel, base: `http://127.0.0.1:${httpServer.getPort!()}` }; diff --git a/packages/qa/http-conformance/src/default-organization.testkit.ts b/packages/qa/http-conformance/src/default-organization.testkit.ts new file mode 100644 index 00000000000..6c7bcdc0855 --- /dev/null +++ b/packages/qa/http-conformance/src/default-organization.testkit.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Give a lean test engine what every real `single` install has: an + * organization object holding its Default Organization. + * + * ADR-0131 D1: a table the SQL driver creates carries its tenant column NOT + * NULL. The engine stamps an organization-less system write with the derived + * organization under `single` (D9) — but only where the composition registers + * the organization object; a bare `ObjectQL` + `SqlDriver` registers none, so + * the write reaches the database with no organization and is refused there. + * A suite whose subject is not tenancy (claims, acks, idle cost, at-rest + * shapes) calls this once its own objects are registered, so its system + * writes land the way they do on a real install. + * + * The object mirrors the platform's: managed by better-auth, so it carries no + * tenant column of its own. + */ +export const DEFAULT_ORGANIZATION_ID = 'org_default'; + +export async function installDefaultOrganization(engine: { + registry: { registerObject(def: unknown, owner: string): unknown }; + getSchema(object: string): unknown; + syncSchemas(): Promise; + getDriverForObject(object: string): + | { + create(object: string, data: Record): Promise; + findOne(object: string, query: unknown): Promise; + } + | undefined; +}): Promise { + // Idempotent: a suite that syncs one engine more than once may call it each time. + if (!engine.getSchema('sys_organization')) { + engine.registry.registerObject( + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, + '@objectstack/http-conformance', + ); + } + await engine.syncSchemas(); + // Seeded through the driver: the organization is data at rest, not a write + // the suite under test should observe (an audit or activity mirror would). + const driver = engine.getDriverForObject('sys_organization')!; + if (await driver.findOne('sys_organization', { where: { id: DEFAULT_ORGANIZATION_ID } })) return; + await driver.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); +} diff --git a/packages/qa/http-conformance/src/hono-approvals-act.conformance.test.ts b/packages/qa/http-conformance/src/hono-approvals-act.conformance.test.ts index 547c4302d30..65e52674e90 100644 --- a/packages/qa/http-conformance/src/hono-approvals-act.conformance.test.ts +++ b/packages/qa/http-conformance/src/hono-approvals-act.conformance.test.ts @@ -40,6 +40,7 @@ import { ApprovalsServicePlugin } from '@objectstack/plugin-approvals'; import type { ApprovalService } from '@objectstack/plugin-approvals'; import { createHonoApp } from '@objectstack/hono'; import type { IHttpServer } from '@objectstack/spec/contracts'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const PREFIX = '/api/v1'; const ACT_PATH = `${PREFIX}/approvals/act`; @@ -103,6 +104,9 @@ async function bootKernel(opts: { selfHosted: boolean; approvals?: boolean }) { (objectql as any).registerDriver(driver, true); objectql.registry.registerObject(leaveRequest as any, 'approvals-act-test', 'approvals-act-test'); await (objectql as any).syncSchemas(); + // A real `single` install's Default Organization: the writes below derive + // it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(objectql as any); let n = 0; /** A pending request with live action tokens for {@link APPROVER}. */ diff --git a/packages/qa/http-conformance/src/hono-body-refusal.conformance.test.ts b/packages/qa/http-conformance/src/hono-body-refusal.conformance.test.ts index fd22a7fe9a0..5e8e5b9907a 100644 --- a/packages/qa/http-conformance/src/hono-body-refusal.conformance.test.ts +++ b/packages/qa/http-conformance/src/hono-body-refusal.conformance.test.ts @@ -40,6 +40,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { HttpDispatcher } from '@objectstack/runtime'; import { HonoHttpServer } from '@objectstack/plugin-hono-server'; import { createHonoApp } from '@objectstack/hono'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const PREFIX = '/api/v1'; const MEMBER = { userId: 'u_member', isSystem: false, systemPermissions: [] as string[] }; @@ -85,6 +86,9 @@ async function boot(auth?: Record) { const ql = kernel.getService('objectql'); ql.registerObject(TASK as any); await ql.syncObjectSchema(TASK.name); + // A real `single` install's Default Organization: the writes below derive + // it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(ql as any); vi.spyOn(HttpDispatcher.prototype as any, 'timedResolveExecutionContext') .mockImplementation(async () => ({ ...MEMBER })); diff --git a/packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts b/packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts index 4f024dbeaad..135dbbd3b9b 100644 --- a/packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts +++ b/packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts @@ -83,6 +83,7 @@ import type { ObjectQL } from '@objectstack/objectql'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { HttpDispatcher } from '@objectstack/runtime'; import { createHonoApp } from '@objectstack/hono'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const PREFIX = '/api/v1'; const MEMBER = { userId: 'u_member', isSystem: false, systemPermissions: [] as string[] }; @@ -172,6 +173,9 @@ async function boot(auth?: Record) { ql.registerObject(object as any); await ql.syncObjectSchema(object.name); } + // A real `single` install's Default Organization: the writes below derive + // it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(ql as any); vi.spyOn(HttpDispatcher.prototype as any, 'timedResolveExecutionContext') .mockImplementation(async () => ({ ...MEMBER })); diff --git a/packages/services/service-analytics/src/__tests__/analytics-faces-one-lowering.test.ts b/packages/services/service-analytics/src/__tests__/analytics-faces-one-lowering.test.ts index 66dd08b68af..7cfbfb4d7ac 100644 --- a/packages/services/service-analytics/src/__tests__/analytics-faces-one-lowering.test.ts +++ b/packages/services/service-analytics/src/__tests__/analytics-faces-one-lowering.test.ts @@ -72,6 +72,7 @@ import { NativeSQLStrategy } from '../strategies/native-sql-strategy.js'; import { ObjectQLStrategy } from '../strategies/objectql-strategy.js'; import { NO_DATETIME_COLUMNS, normalizeAnalyticsFilterTree, type NormalizedFilterNode } from '../strategies/filter-normalizer.js'; import { compileScopedFilterToSql } from '../read-scope-sql.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const HERE = dirname(fileURLToPath(import.meta.url)); const SRC = resolve(HERE, '..'); @@ -378,6 +379,9 @@ for (const cell of DB_CELLS) { await engine.init(); engine.registry.registerObject(LEDGER as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as never); const realExecute = (engine as any).execute.bind(engine); (engine as any).execute = (sql: string, opts?: { object?: string }) => { @@ -588,6 +592,9 @@ describe('[#5930 step 4] the temporal conformance matrix answers on every analyt }, } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const r of TEMPORAL_ROWS) await engine.insert(TEMPORAL, { id: r.id, [COLUMN.at]: r.at, [COLUMN.on]: r.on } as never); faces = await composeFaces(engine, [temporalCube]); }); diff --git a/packages/services/service-analytics/src/__tests__/api-exposure-door.test.ts b/packages/services/service-analytics/src/__tests__/api-exposure-door.test.ts index 447ba7587f9..5cb45adceb9 100644 --- a/packages/services/service-analytics/src/__tests__/api-exposure-door.test.ts +++ b/packages/services/service-analytics/src/__tests__/api-exposure-door.test.ts @@ -41,6 +41,7 @@ import { DatasetSchema } from '@objectstack/spec/ui'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { AnalyticsServicePlugin } from '../plugin.js'; import { AnalyticsService } from '../analytics-service.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const HIDDEN = 'exposure_hidden'; const NO_LIST = 'exposure_nolist'; @@ -246,6 +247,9 @@ describe.each(STRATEGIES)('the analytics door honours the generic-exit declarati engine.registry.registerObject(obj as any); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const [object, rows] of Object.entries(ROWS)) { for (const row of rows) await engine.insert(object, { ...row } as any); } diff --git a/packages/services/service-analytics/src/__tests__/caller-measure-no-field-door.test.ts b/packages/services/service-analytics/src/__tests__/caller-measure-no-field-door.test.ts index dd5b1d09030..6556168a672 100644 --- a/packages/services/service-analytics/src/__tests__/caller-measure-no-field-door.test.ts +++ b/packages/services/service-analytics/src/__tests__/caller-measure-no-field-door.test.ts @@ -56,6 +56,7 @@ import { inferredCallerMeasureSql, } from '../analytics-service.js'; import { AnalyticsServicePlugin } from '../plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'os21437_deal'; @@ -149,6 +150,9 @@ describe('[#21437] a caller-named measure whose source names no field — at the await engine.init(); engine.registry.registerObject(DEAL as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); const realExecute = (engine as any).execute.bind(engine); diff --git a/packages/services/service-analytics/src/__tests__/cube-measure-field-type-door.test.ts b/packages/services/service-analytics/src/__tests__/cube-measure-field-type-door.test.ts index 43325d23aa9..48065670d2e 100644 --- a/packages/services/service-analytics/src/__tests__/cube-measure-field-type-door.test.ts +++ b/packages/services/service-analytics/src/__tests__/cube-measure-field-type-door.test.ts @@ -53,6 +53,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import type { Cube } from '@objectstack/spec/data'; import { AnalyticsService } from '../analytics-service.js'; import { AnalyticsServicePlugin } from '../plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'os21044_cube_measure_ledger'; @@ -180,6 +181,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); const realExecute = (engine as any).execute.bind(engine); diff --git a/packages/services/service-analytics/src/__tests__/cube-measure-relationship-path-type.test.ts b/packages/services/service-analytics/src/__tests__/cube-measure-relationship-path-type.test.ts index 4e4dc88a71e..7df90c6d198 100644 --- a/packages/services/service-analytics/src/__tests__/cube-measure-relationship-path-type.test.ts +++ b/packages/services/service-analytics/src/__tests__/cube-measure-relationship-path-type.test.ts @@ -62,6 +62,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import type { Cube } from '@objectstack/spec/data'; import { AnalyticsService } from '../analytics-service.js'; import { AnalyticsServicePlugin } from '../plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const ACCOUNT = 'os21129_account'; const PERSON = 'os21129_person'; @@ -226,6 +227,9 @@ for (const cell of CELLS) { await engine.init(); for (const object of [ACCOUNT_OBJECT, PERSON_OBJECT, DEAL_OBJECT]) engine.registry.registerObject(object as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ACCOUNTS) await engine.insert(ACCOUNT, { ...row } as any); for (const row of PEOPLE) await engine.insert(PERSON, { ...row } as any); for (const row of DEALS) await engine.insert(DEAL, { ...row } as any); diff --git a/packages/services/service-analytics/src/__tests__/default-organization.testkit.ts b/packages/services/service-analytics/src/__tests__/default-organization.testkit.ts new file mode 100644 index 00000000000..34d061fd492 --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/default-organization.testkit.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Give a lean test engine what every real `single` install has: an + * organization object holding its Default Organization. + * + * ADR-0131 D1: a table the SQL driver creates carries its tenant column NOT + * NULL. The engine stamps an organization-less system write with the derived + * organization under `single` (D9) — but only where the composition registers + * the organization object; a bare `ObjectQL` + `SqlDriver` registers none, so + * the write reaches the database with no organization and is refused there. + * A suite whose subject is not tenancy (claims, acks, idle cost, at-rest + * shapes) calls this once its own objects are registered, so its system + * writes land the way they do on a real install. + * + * The object mirrors the platform's: managed by better-auth, so it carries no + * tenant column of its own. + */ +export const DEFAULT_ORGANIZATION_ID = 'org_default'; + +export async function installDefaultOrganization(engine: { + registry: { registerObject(def: unknown, owner: string): unknown }; + getSchema(object: string): unknown; + syncSchemas(): Promise; + getDriverForObject(object: string): + | { + create(object: string, data: Record): Promise; + findOne(object: string, query: unknown): Promise; + } + | undefined; +}): Promise { + // Idempotent: a suite that syncs one engine more than once may call it each time. + if (!engine.getSchema('sys_organization')) { + engine.registry.registerObject( + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, + '@objectstack/service-analytics', + ); + } + await engine.syncSchemas(); + // Seeded through the driver: the organization is data at rest, not a write + // the suite under test should observe (an audit or activity mirror would). + const driver = engine.getDriverForObject('sys_organization')!; + if (await driver.findOne('sys_organization', { where: { id: DEFAULT_ORGANIZATION_ID } })) return; + await driver.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); +} diff --git a/packages/services/service-analytics/src/__tests__/json-stored-door-live-drivers.test.ts b/packages/services/service-analytics/src/__tests__/json-stored-door-live-drivers.test.ts index 8be4827860c..66242bbf4b2 100644 --- a/packages/services/service-analytics/src/__tests__/json-stored-door-live-drivers.test.ts +++ b/packages/services/service-analytics/src/__tests__/json-stored-door-live-drivers.test.ts @@ -41,6 +41,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { DatasetSchema } from '@objectstack/spec/ui'; import { AnalyticsServicePlugin } from '../plugin.js'; import type { AnalyticsService } from '../analytics-service.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'analytics_json_stored_door_ledger'; const OPTIONS = [{ label: 'A', value: 'a' }, { label: 'B', value: 'b' }]; @@ -141,6 +142,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); const realExecute = (engine as any).execute.bind(engine); diff --git a/packages/services/service-analytics/src/__tests__/json-stored-door-undeclared-join.test.ts b/packages/services/service-analytics/src/__tests__/json-stored-door-undeclared-join.test.ts index c225f754ae2..532e5b4d301 100644 --- a/packages/services/service-analytics/src/__tests__/json-stored-door-undeclared-join.test.ts +++ b/packages/services/service-analytics/src/__tests__/json-stored-door-undeclared-join.test.ts @@ -59,6 +59,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import type { Cube } from '@objectstack/spec/data'; import type { AnalyticsService } from '../analytics-service.js'; import { AnalyticsServicePlugin } from '../plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const PERSON = 'os21232_person'; const ACCOUNT = 'os21232_account'; @@ -225,6 +226,9 @@ for (const cell of CELLS) { await engine.init(); for (const object of [PERSON_OBJECT, ACCOUNT_OBJECT, DEAL_OBJECT]) engine.registry.registerObject(object as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of PEOPLE) await engine.insert(PERSON, { ...row } as any); for (const row of ACCOUNTS) await engine.insert(ACCOUNT, { ...row } as any); for (const row of DEALS) await engine.insert(DEAL, { ...row } as any); diff --git a/packages/services/service-analytics/src/__tests__/list-at-scalar-operator-both-faces.test.ts b/packages/services/service-analytics/src/__tests__/list-at-scalar-operator-both-faces.test.ts index 53acbfc92c4..4b6b7957b68 100644 --- a/packages/services/service-analytics/src/__tests__/list-at-scalar-operator-both-faces.test.ts +++ b/packages/services/service-analytics/src/__tests__/list-at-scalar-operator-both-faces.test.ts @@ -52,6 +52,7 @@ import type { Cube } from '@objectstack/spec/data'; import { DatasetSchema } from '@objectstack/spec/ui'; import type { AnalyticsService } from '../analytics-service.js'; import { AnalyticsServicePlugin } from '../plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'os21448_list_ledger'; @@ -239,6 +240,9 @@ for (const cell of DRIVER_CELLS) { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); const realExecute = (engine as any).execute.bind(engine); diff --git a/packages/services/service-analytics/src/__tests__/native-sql-aggregate-policies.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-aggregate-policies.test.ts index 2180f4f86fd..21fa74e42f1 100644 --- a/packages/services/service-analytics/src/__tests__/native-sql-aggregate-policies.test.ts +++ b/packages/services/service-analytics/src/__tests__/native-sql-aggregate-policies.test.ts @@ -60,6 +60,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import type { AnalyticsService } from '../analytics-service.js'; import { AnalyticsServicePlugin } from '../plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'os21042_policy_ledger'; @@ -237,6 +238,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); const realExecute = (engine as any).execute.bind(engine); diff --git a/packages/services/service-analytics/src/__tests__/native-sql-base-column-qualify.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-base-column-qualify.test.ts index 830d9a3cb98..d05e364673c 100644 --- a/packages/services/service-analytics/src/__tests__/native-sql-base-column-qualify.test.ts +++ b/packages/services/service-analytics/src/__tests__/native-sql-base-column-qualify.test.ts @@ -55,6 +55,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import type { Cube } from '@objectstack/spec/data'; import type { AnalyticsService } from '../analytics-service.js'; import { AnalyticsServicePlugin } from '../plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const PERSON = 'os21249_person'; const DEAL = 'os21249_deal'; @@ -204,6 +205,9 @@ for (const cell of CELLS) { await engine.init(); for (const object of [PERSON_OBJECT, DEAL_OBJECT]) engine.registry.registerObject(object as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of PEOPLE) await engine.insert(PERSON, { ...row } as any); for (const row of DEALS) await engine.insert(DEAL, { ...row } as any); diff --git a/packages/services/service-analytics/src/__tests__/native-sql-boolean-comparand-door.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-boolean-comparand-door.test.ts index 2afd67cafbe..e627bd3b100 100644 --- a/packages/services/service-analytics/src/__tests__/native-sql-boolean-comparand-door.test.ts +++ b/packages/services/service-analytics/src/__tests__/native-sql-boolean-comparand-door.test.ts @@ -51,6 +51,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import type { Cube } from '@objectstack/spec/data'; import type { AnalyticsService } from '../analytics-service.js'; import { AnalyticsServicePlugin } from '../plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'os21376_flag_ledger'; @@ -216,6 +217,9 @@ for (const cell of DRIVER_CELLS) { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); const realExecute = (engine as any).execute.bind(engine); diff --git a/packages/services/service-analytics/src/__tests__/native-sql-measure-number-presentation.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-measure-number-presentation.test.ts index 155088944a0..193d07e2952 100644 --- a/packages/services/service-analytics/src/__tests__/native-sql-measure-number-presentation.test.ts +++ b/packages/services/service-analytics/src/__tests__/native-sql-measure-number-presentation.test.ts @@ -71,6 +71,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import type { Cube } from '@objectstack/spec/data'; import type { AnalyticsService } from '../analytics-service.js'; import { AnalyticsServicePlugin } from '../plugin.js'; +import { DEFAULT_ORGANIZATION_ID, installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'os20889_measure_ledger'; @@ -243,6 +244,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); const realExecute = (engine as any).execute.bind(engine); @@ -357,7 +361,11 @@ for (const cell of CELLS) { // exact-decimal column stores them exactly (SQLite's column rounds on write). const EXACT = ['9007199254740993', '12345678901234567.123456789']; for (const [i, literal] of EXACT.entries()) { - await driver.execute(`insert into ${OBJECT} (id, category, amount) values ('p${i}', 'p${i}', ${literal})`); + // The raw row names the install's organization, as the engine's own + // writes do: a fresh tenant column is NOT NULL (ADR-0131 D1). + await driver.execute( + `insert into ${OBJECT} (id, category, amount, organization_id) values ('p${i}', 'p${i}', ${literal}, '${DEFAULT_ORGANIZATION_ID}')`, + ); } const native = byCategory((await cubeRead('native', ['sum_amount', 'avg_amount', 'min_amount'], ['category'])).rows); const objectql = byCategory((await cubeRead('objectql', ['sum_amount', 'avg_amount', 'min_amount'], ['category'])).rows); diff --git a/packages/services/service-analytics/src/__tests__/native-sql-number-comparand-door.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-number-comparand-door.test.ts index 819107f0f76..fb69f2c986e 100644 --- a/packages/services/service-analytics/src/__tests__/native-sql-number-comparand-door.test.ts +++ b/packages/services/service-analytics/src/__tests__/native-sql-number-comparand-door.test.ts @@ -73,6 +73,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import type { Cube } from '@objectstack/spec/data'; import type { AnalyticsService } from '../analytics-service.js'; import { AnalyticsServicePlugin } from '../plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'os21426_amount_ledger'; const ACCOUNT = 'os21426_amount_account'; @@ -337,6 +338,9 @@ for (const cell of DRIVER_CELLS) { engine.registry.registerObject(ACCOUNT_DEF as any); engine.registry.registerObject(LEDGER as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ACCOUNTS) await engine.insert(ACCOUNT, { ...row } as any); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as any); diff --git a/packages/services/service-analytics/src/__tests__/native-sql-offset-only-window.test.ts b/packages/services/service-analytics/src/__tests__/native-sql-offset-only-window.test.ts index 8877b7eec8d..8d15d0a37dc 100644 --- a/packages/services/service-analytics/src/__tests__/native-sql-offset-only-window.test.ts +++ b/packages/services/service-analytics/src/__tests__/native-sql-offset-only-window.test.ts @@ -48,6 +48,7 @@ import type { AnalyticsService } from '../analytics-service.js'; import { AnalyticsServicePlugin } from '../plugin.js'; import { NativeSQLStrategy, windowClauseSql } from '../strategies/native-sql-strategy.js'; import type { DatasetScopedStrategyContext } from '../strategies/types.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const DEAL = 'os21365_deal'; @@ -181,6 +182,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(DEAL_OBJECT as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of DEALS) await engine.insert(DEAL, { ...row } as any); const realExecute = (engine as any).execute.bind(engine); diff --git a/packages/services/service-analytics/src/__tests__/objectql-echo-bucket-enumeration.test.ts b/packages/services/service-analytics/src/__tests__/objectql-echo-bucket-enumeration.test.ts index 9c7149174d1..a4b9efb9a8a 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-echo-bucket-enumeration.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-echo-bucket-enumeration.test.ts @@ -118,6 +118,7 @@ import { BUILTIN_DRIVER_IDS, TimeUpdateInterval, type BuiltinDriverId, type Cube import { declaredRefusalMessage } from '@objectstack/types'; import type { AnalyticsService } from '../analytics-service.js'; import { AnalyticsServicePlugin } from '../plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const DEAL = 'os21647_bucket_deal'; const DEAL_OBJECT = { @@ -327,6 +328,9 @@ async function serve(face: DriverFace) { engine.registry.registerObject(DEAL_OBJECT as any); if (seeded) { await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const deal of EXCEPTION_DEALS) await engine.insert(DEAL, { ...deal } as any); } diff --git a/packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts b/packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts index c6439755325..9aa387b39b4 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-echo-date-bucket.test.ts @@ -64,6 +64,7 @@ import { AnalyticsServicePlugin } from '../plugin.js'; import { ObjectQLStrategy } from '../strategies/objectql-strategy.js'; import type { StrategyContext } from '../strategies/types.js'; import { declaredRefusalMessage } from '@objectstack/types'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const DEAL = 'os21441_bucket_deal'; @@ -209,6 +210,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(DEAL_OBJECT as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of DEALS) await engine.insert(DEAL, { ...row } as any); driver.knex.on('query', (q: { sql: string }) => { driverRan.push(q.sql); }); diff --git a/packages/services/service-analytics/src/__tests__/objectql-echo-offset-only-window.test.ts b/packages/services/service-analytics/src/__tests__/objectql-echo-offset-only-window.test.ts index 74f046e9f99..211a6021265 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-echo-offset-only-window.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-echo-offset-only-window.test.ts @@ -50,6 +50,7 @@ import type { AnalyticsService } from '../analytics-service.js'; import { AnalyticsServicePlugin } from '../plugin.js'; import { ObjectQLStrategy } from '../strategies/objectql-strategy.js'; import type { StrategyContext } from '../strategies/types.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const DEAL = 'os21365_echo_deal'; @@ -163,6 +164,9 @@ for (const cell of CELLS) { await engine.init(); engine.registry.registerObject(DEAL_OBJECT as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of DEALS) await engine.insert(DEAL, { ...row } as any); const realExecute = (engine as any).execute.bind(engine); diff --git a/packages/services/service-analytics/src/__tests__/objectql-face-order-limit.test.ts b/packages/services/service-analytics/src/__tests__/objectql-face-order-limit.test.ts index ed8e64547b1..010850e8f66 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-face-order-limit.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-face-order-limit.test.ts @@ -51,6 +51,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import type { Cube } from '@objectstack/spec/data'; import type { AnalyticsService } from '../analytics-service.js'; import { AnalyticsServicePlugin } from '../plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const PERSON = 'os21316_person'; const DEAL = 'os21316_deal'; @@ -192,6 +193,9 @@ for (const cell of CELLS) { await engine.init(); for (const object of [PERSON_OBJECT, DEAL_OBJECT]) engine.registry.registerObject(object as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of PEOPLE) await engine.insert(PERSON, { ...row } as any); for (const row of DEALS) await engine.insert(DEAL, { ...row } as any); diff --git a/packages/services/service-analytics/src/__tests__/order-key-selected.test.ts b/packages/services/service-analytics/src/__tests__/order-key-selected.test.ts index 940b0014ae7..a43ecf14af6 100644 --- a/packages/services/service-analytics/src/__tests__/order-key-selected.test.ts +++ b/packages/services/service-analytics/src/__tests__/order-key-selected.test.ts @@ -39,6 +39,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import type { Cube } from '@objectstack/spec/data'; import type { AnalyticsService } from '../analytics-service.js'; import { AnalyticsServicePlugin } from '../plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const PERSON = 'os21267_person'; const DEAL = 'os21267_deal'; @@ -203,6 +204,9 @@ for (const cell of CELLS) { await engine.init(); for (const object of [PERSON_OBJECT, DEAL_OBJECT]) engine.registry.registerObject(object as any); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of PEOPLE) await engine.insert(PERSON, { ...row } as any); for (const row of DEALS) await engine.insert(DEAL, { ...row } as any); diff --git a/packages/services/service-analytics/src/__tests__/picklist-dimension-label-i18n.test.ts b/packages/services/service-analytics/src/__tests__/picklist-dimension-label-i18n.test.ts index c92c227fb24..13016c2f08a 100644 --- a/packages/services/service-analytics/src/__tests__/picklist-dimension-label-i18n.test.ts +++ b/packages/services/service-analytics/src/__tests__/picklist-dimension-label-i18n.test.ts @@ -54,6 +54,7 @@ import { DatasetSchema } from '@objectstack/spec/ui'; import { translateObject, type TranslationBundle } from '@objectstack/spec/system'; import type { AnalyticsService } from '../analytics-service.js'; import { AnalyticsServicePlugin } from '../plugin.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'os22178_account'; const PICKLIST = 'os22178_industry'; @@ -213,6 +214,9 @@ describe('[#22178] a picklist-bound select dimension renders its labels in the r await engine.init(); engine.registerApp({ id: 'com.test.os22178', name: 'os22178', picklists: [INDUSTRY], objects: [ACCOUNT] } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as never); for (const [face, caps] of [ diff --git a/packages/services/service-analytics/src/__tests__/read-scope-temporal-coercion.test.ts b/packages/services/service-analytics/src/__tests__/read-scope-temporal-coercion.test.ts index 94821df1885..bfba3de67bf 100644 --- a/packages/services/service-analytics/src/__tests__/read-scope-temporal-coercion.test.ts +++ b/packages/services/service-analytics/src/__tests__/read-scope-temporal-coercion.test.ts @@ -35,6 +35,7 @@ import { DatasetSchema } from '@objectstack/spec/ui'; import { AnalyticsService } from '../analytics-service.js'; import { AnalyticsServicePlugin } from '../plugin.js'; import { compileScopedFilterToSql, type ReadScopeCompileOptions } from '../read-scope-sql.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; // ── Where the pair applies: after the lowering, on the value comparisons ───── @@ -199,6 +200,9 @@ for (const cell of DB_CELLS) { await engine.init(); engine.registry.registerObject(LEDGER as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const row of ROWS) await engine.insert(OBJECT, { ...row } as never); const realExecute = (engine as any).execute.bind(engine); (engine as any).execute = (sql: string, opts?: { object?: string }) => { From e36e85502f0acf893cde08dd0988c28e1b226f72 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 08:50:45 +0000 Subject: [PATCH 55/81] wip(dogfood): fixtures name their organization, hold a Default Organization, or model a pre-18 store Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- ...achments-permission-matrix.dogfood.test.ts | 4 ++ .../test/default-organization.testkit.ts | 45 +++++++++++++++ .../test/email-template-boot-sweep.test.ts | 5 ++ .../test/engine-where-shape-refusal.test.ts | 4 ++ ...-write-refusal-not-visible.dogfood.test.ts | 4 ++ .../test/pre-18-tenant-column.testkit.ts | 56 +++++++++++++++++++ ...ite-unreadable-not-matched.dogfood.test.ts | 4 ++ ...ing-rule-criteria-required.dogfood.test.ts | 4 ++ ...aring-rule-org-less-caller.dogfood.test.ts | 4 ++ .../test/storage-growth.dogfood.test.ts | 11 +++- .../test/undeclared-key-conformance.test.ts | 4 ++ ...or-unreadable-is-not-found.dogfood.test.ts | 4 ++ 12 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 packages/qa/dogfood/test/default-organization.testkit.ts create mode 100644 packages/qa/dogfood/test/pre-18-tenant-column.testkit.ts diff --git a/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts b/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts index 1d072ec2a10..c30eff7de6f 100644 --- a/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts +++ b/packages/qa/dogfood/test/attachments-permission-matrix.dogfood.test.ts @@ -569,11 +569,15 @@ describe('attachments permission matrix (#2755)', () => { // Hook-bypass re-reference: a DIRECT DRIVER insert (no engine hooks // fire, so afterInsert cannot un-tombstone) + a backdated tombstone. const driver = ql.getDriverForObject('sys_attachment'); + // The row carries its parent's organization, as the engine would stamp + // it: a fresh tenant column is NOT NULL (ADR-0131 D1). + const parent = await ql.findOne('att_case', { where: { id: caseAId }, context: SYS }); await driver.create('sys_attachment', { id: `bypass-${fileId}`, parent_object: 'att_case', parent_id: caseAId, file_id: fileId, + organization_id: parent.organization_id, created_at: new Date(), }); await ql.update( diff --git a/packages/qa/dogfood/test/default-organization.testkit.ts b/packages/qa/dogfood/test/default-organization.testkit.ts new file mode 100644 index 00000000000..6767dfb63bf --- /dev/null +++ b/packages/qa/dogfood/test/default-organization.testkit.ts @@ -0,0 +1,45 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Give a lean test engine what every real `single` install has: an + * organization object holding its Default Organization. + * + * ADR-0131 D1: a table the SQL driver creates carries its tenant column NOT + * NULL. The engine stamps an organization-less system write with the derived + * organization under `single` (D9) — but only where the composition registers + * the organization object; a bare `ObjectQL` + `SqlDriver` registers none, so + * the write reaches the database with no organization and is refused there. + * A suite whose subject is not tenancy (claims, acks, idle cost, at-rest + * shapes) calls this once its own objects are registered, so its system + * writes land the way they do on a real install. + * + * The object mirrors the platform's: managed by better-auth, so it carries no + * tenant column of its own. + */ +export const DEFAULT_ORGANIZATION_ID = 'org_default'; + +export async function installDefaultOrganization(engine: { + registry: { registerObject(def: unknown, owner: string): unknown }; + getSchema(object: string): unknown; + syncSchemas(): Promise; + getDriverForObject(object: string): + | { + create(object: string, data: Record): Promise; + findOne(object: string, query: unknown): Promise; + } + | undefined; +}): Promise { + // Idempotent: a suite that syncs one engine more than once may call it each time. + if (!engine.getSchema('sys_organization')) { + engine.registry.registerObject( + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, + '@objectstack/dogfood', + ); + } + await engine.syncSchemas(); + // Seeded through the driver: the organization is data at rest, not a write + // the suite under test should observe (an audit or activity mirror would). + const driver = engine.getDriverForObject('sys_organization')!; + if (await driver.findOne('sys_organization', { where: { id: DEFAULT_ORGANIZATION_ID } })) return; + await driver.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); +} diff --git a/packages/qa/dogfood/test/email-template-boot-sweep.test.ts b/packages/qa/dogfood/test/email-template-boot-sweep.test.ts index 2d507ae5b35..f6fc749255e 100644 --- a/packages/qa/dogfood/test/email-template-boot-sweep.test.ts +++ b/packages/qa/dogfood/test/email-template-boot-sweep.test.ts @@ -32,6 +32,7 @@ import { describe, it, expect, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { EmailServicePlugin } from '@objectstack/plugin-email'; +import { relaxTenantColumns } from './pre-18-tenant-column.testkit.js'; const SYS = { isSystem: true, positions: [], permissions: [] }; const TABLE = 'sys_email_template'; @@ -100,6 +101,10 @@ const onTable = (statements: string[]) => statements.filter((s) => s.includes(`\ * every row carrying stale wording a send must never render. */ async function seedSharedSlot(engine: ObjectQL): Promise { + // `etpl_z` belongs to no organization, which only a store built before 18.x + // can hold (ADR-0131 D1 makes a fresh tenant column NOT NULL): the table is + // that store's. + await relaxTenantColumns(engine as any, [TABLE]); const rows: Array<[string, string | null, string]> = [ ['etpl_m', 'org_b', 'en-US'], ['etpl_z', null, 'en-US'], diff --git a/packages/qa/dogfood/test/engine-where-shape-refusal.test.ts b/packages/qa/dogfood/test/engine-where-shape-refusal.test.ts index a7b7b4892f3..83cce916c2d 100644 --- a/packages/qa/dogfood/test/engine-where-shape-refusal.test.ts +++ b/packages/qa/dogfood/test/engine-where-shape-refusal.test.ts @@ -22,6 +22,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYS = { context: { isSystem: true, userId: 'usr_system' } }; @@ -67,6 +68,9 @@ beforeEach(async () => { objects: [PROBE_ORDER], } as never); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const [id, name, amount] of [['o1', 'a', 50], ['o2', 'b', 150], ['o3', 'c', 50], ['o4', 'd', 150]] as const) { await engine.insert('probe_order', { id, name, amount }, SYS as never); } diff --git a/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts b/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts index d900b833f2e..d3231df9bc8 100644 --- a/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts +++ b/packages/qa/dogfood/test/parent-derived-write-refusal-not-visible.dogfood.test.ts @@ -59,6 +59,7 @@ import { buildAttSecret, buildAttReadonly, attFixtureBaselineSet, attachmentMana import { buildCmtPrivate, buildCmtReadonly, commentManagerSet } from './fixtures/comments-fixture.js'; import { armedWhen, assertArmed, leaveOrganization, principalArmed, resolveAuthzFor } from './armed.js'; import { catalogPermissionSet } from './catalog-permission-set.js'; +import { relaxTenantColumns } from './pre-18-tenant-column.testkit.js'; const SYS = { isSystem: true } as const; @@ -139,6 +140,9 @@ async function boot(inside: boolean, email: string): Promise { // organization-less system insert with the Default Organization (ADR-0131 D9), // so the global row is landed beneath the engine, as the residue an older // release left. The readers still apply it until C8's NULL-reader stages. + // That residue lives only on a store built before 18.x, whose tenant column is + // nullable (ADR-0131 D1 makes a fresh one NOT NULL): the table is that store's. + await relaxTenantColumns(ql as any, ['sys_user_permission_set']); await ql.getDriver('sys_user_permission_set').create('sys_user_permission_set', { id: `ups_${userId}_${set.name}`, user_id: userId, permission_set: set.name, organization_id: null, }); diff --git a/packages/qa/dogfood/test/pre-18-tenant-column.testkit.ts b/packages/qa/dogfood/test/pre-18-tenant-column.testkit.ts new file mode 100644 index 00000000000..b0cbb5efa38 --- /dev/null +++ b/packages/qa/dogfood/test/pre-18-tenant-column.testkit.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Return engine-synced tables to the shape a store built BEFORE 18.x has: the + * tenant column nullable. + * + * ADR-0131 D1: since 18.x the SQL driver creates a table's tenant column NOT + * NULL. A store an earlier release built still holds the nullable column and + * its organization-less rows — the holdings, grants and seeds this package's + * NULL readers still answer for until the organization-ownership ceremony has + * attributed them. A suite whose subject is those rows calls this after + * `syncSchemas()` and before seeding them. Done through each table's driver + * and its own drift apply door (`relax_not_null`), never a hand `ALTER`. + */ +export async function relaxTenantColumns( + engine: { getDriverForObject(object: string): unknown }, + tables: readonly string[], + column = 'organization_id', +): Promise { + for (const table of tables) { + const driver = engine.getDriverForObject(table) as { + applyMigrationEntries(entries: never[]): Promise<{ applied: unknown[] }>; + }; + const op = { type: 'relax_not_null', table, column }; + const { applied } = await driver.applyMigrationEntries([ + { kind: 'nullability_mismatch', remoteName: table, table, column, category: 'needs_confirm', op } as never, + ]); + if (applied.length !== 1) throw new Error(`relaxTenantColumns: ${table}.${column} was not relaxed`); + } +} + +/** + * {@link relaxTenantColumns} over every object of an app that carries a tenant + * column as the engine registered it — the shape a whole pre-18 store has. + */ +export async function relaxEveryTenantColumn( + engine: { getDriverForObject(object: string): unknown; getSchema(object: string): unknown }, + objects: readonly { name: string }[], +): Promise { + const tenant = objects + .map((o) => o.name) + .filter((name) => { + const schema = engine.getSchema(name) as { fields?: Record; tenancy?: { enabled?: boolean } } | undefined; + return !!schema?.fields?.organization_id && schema.tenancy?.enabled !== false; + }); + await relaxTenantColumns(engine, tenant); +} + +/** {@link relaxEveryTenantColumn} over every object the engine's registry holds. */ +export async function relaxEveryRegisteredTenantColumn(engine: { + getDriverForObject(object: string): unknown; + getSchema(object: string): unknown; + registry: { getAllObjects(): readonly { name: string }[] }; +}): Promise { + await relaxEveryTenantColumn(engine, engine.registry.getAllObjects()); +} diff --git a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts index 1c30c648be9..787d86d56a2 100644 --- a/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts +++ b/packages/qa/dogfood/test/predicate-write-unreadable-not-matched.dogfood.test.ts @@ -58,6 +58,7 @@ import { import { buildCmtOpen, buildCmtPrivate, buildCmtReadonly, commentManagerSet } from './fixtures/comments-fixture.js'; import { armedWhen, assertArmed, leaveOrganization, principalArmed, resolveAuthzFor } from './armed.js'; import { catalogPermissionSet } from './catalog-permission-set.js'; +import { relaxTenantColumns } from './pre-18-tenant-column.testkit.js'; const SYS = { isSystem: true } as const; @@ -162,6 +163,9 @@ async function boot(inside: boolean, email: string): Promise { // organization-less system insert with the Default Organization (ADR-0131 D9), // so the global row is landed beneath the engine, as the residue an older // release left. The readers still apply it until C8's NULL-reader stages. + // That residue lives only on a store built before 18.x, whose tenant column is + // nullable (ADR-0131 D1 makes a fresh one NOT NULL): the table is that store's. + await relaxTenantColumns(ql as any, ['sys_user_permission_set']); await ql.getDriver('sys_user_permission_set').create('sys_user_permission_set', { id: `ups_${userId}_${set.name}`, user_id: userId, permission_set: set.name, organization_id: null, }); diff --git a/packages/qa/dogfood/test/sharing-rule-criteria-required.dogfood.test.ts b/packages/qa/dogfood/test/sharing-rule-criteria-required.dogfood.test.ts index 6ec135ef64f..3aef7e10d8e 100644 --- a/packages/qa/dogfood/test/sharing-rule-criteria-required.dogfood.test.ts +++ b/packages/qa/dogfood/test/sharing-rule-criteria-required.dogfood.test.ts @@ -25,6 +25,7 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; +import { relaxTenantColumns } from './pre-18-tenant-column.testkit.js'; // Sharing-rule routes are anchored on the API base, not under `/data/:object` // (they administer rules tenant-wide) — `/api/v1/sharing/rules`. @@ -137,6 +138,9 @@ describe('#3896 — a sharing rule POSTed without criteria is rejected, never ma const id = 'srule_3896_legacy'; const driver = (ql as any).getDriverForObject('sys_sharing_rule'); expect(driver, 'sys_sharing_rule driver must be resolvable').toBeTruthy(); + // That residue lives only on a store built before 18.x, whose tenant column is + // nullable (ADR-0131 D1 makes a fresh one NOT NULL): the table is that store's. + await relaxTenantColumns(ql as any, ['sys_sharing_rule']); await driver.create('sys_sharing_rule', { id, name: 'legacy_match_all_3896', diff --git a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts index 18fc74ea469..c390129ed79 100644 --- a/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts +++ b/packages/qa/dogfood/test/sharing-rule-org-less-caller.dogfood.test.ts @@ -65,6 +65,7 @@ import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; import { leaveOrganization } from './armed.js'; import { catalogPermissionSet } from './catalog-permission-set.js'; +import { relaxTenantColumns } from './pre-18-tenant-column.testkit.js'; const RULES = '/sharing/rules'; const SYS = { isSystem: true } as const; @@ -167,6 +168,9 @@ describe('#8158 — a manage_sharing holder with NO active organization cannot r // NULL-reader stages. const globalGrant = (row: Record) => ql.getDriver('sys_user_permission_set').create('sys_user_permission_set', { ...row, organization_id: null }); + // That residue lives only on a store built before 18.x, whose tenant column is + // nullable (ADR-0131 D1 makes a fresh one NOT NULL): the table is that store's. + await relaxTenantColumns(ql as any, ['sys_user_permission_set']); await globalGrant({ id: 'ups_8158_org_less', user_id: orgLessUserId, permission_set: 'sharing_admin_8158' }); // The #8158 grant as filed — scoped to tenant A — for the control (which // has tenant A active) and for the org-scoped persona (which has none). diff --git a/packages/qa/dogfood/test/storage-growth.dogfood.test.ts b/packages/qa/dogfood/test/storage-growth.dogfood.test.ts index 97eccd4e820..e6f138b3703 100644 --- a/packages/qa/dogfood/test/storage-growth.dogfood.test.ts +++ b/packages/qa/dogfood/test/storage-growth.dogfood.test.ts @@ -65,6 +65,7 @@ describe('objectstack verify LIFECYCLE (ADR-0057): declared policies bound growt let engine: EngineLike; let lifecycle: LifecycleLike; let driver: DriverLike; + let organizationId: string; // Platform storage convention: `created_at` is registry-injected as a // `datetime` field, stored as epoch-ms INTEGER on SQLite (a JS Date through @@ -76,6 +77,11 @@ describe('objectstack verify LIFECYCLE (ADR-0057): declared policies bound growt beforeAll(async () => { stack = await bootShowcase(); engine = stack.kernel.getService('objectql') as unknown as EngineLike; + // The runaway writer's rows belong to the install's organization, as the + // engine would stamp them: a fresh tenant column is NOT NULL (ADR-0131 D1). + organizationId = String( + ((await (engine as any).find('sys_organization', { context: { isSystem: true } })) as Array<{ id: string }>)[0].id, + ); lifecycle = stack.kernel.getService('lifecycle') as unknown as LifecycleLike; expect(lifecycle?.sweep, 'the ObjectQLPlugin must register the ADR-0057 lifecycle service').toBeTruthy(); @@ -148,12 +154,14 @@ describe('objectstack verify LIFECYCLE (ADR-0057): declared policies bound growt // any window (must never be touched). for (let i = 0; i < 40; i++) { await driver.create('growth_probe_event', { + organization_id: organizationId, payload: `tick-${i}`, created_at: backdated(5 + i * 5), // 5d … 200d }); } for (let i = 0; i < 5; i++) { await driver.create('growth_probe_record', { + organization_id: organizationId, payload: `business-${i}`, created_at: backdated(400), }); @@ -215,7 +223,7 @@ describe('objectstack verify LIFECYCLE (ADR-0057): declared policies bound growt // Round-trip: write through the driver, read through the view, and a // sweep applies the 'rotation' policy without touching the rows inside // the window. - await streamDriver.create('growth_probe_stream', { payload: 'tick' }); + await streamDriver.create('growth_probe_stream', { organization_id: organizationId, payload: 'tick' }); expect(await streamDriver.count('growth_probe_stream', {})).toBe(1); const report = await lifecycle.sweep(); @@ -227,6 +235,7 @@ describe('objectstack verify LIFECYCLE (ADR-0057): declared policies bound growt it('ARCHIVE SAFETY: an audit ledger with a declared archive is never hot-deleted unarchived', async () => { for (let i = 0; i < 3; i++) { await driver.create('growth_probe_ledger', { + organization_id: organizationId, payload: `ledger-${i}`, created_at: backdated(365), // far past the 90d hot window }); diff --git a/packages/qa/dogfood/test/undeclared-key-conformance.test.ts b/packages/qa/dogfood/test/undeclared-key-conformance.test.ts index 878a1b97a7c..22459778948 100644 --- a/packages/qa/dogfood/test/undeclared-key-conformance.test.ts +++ b/packages/qa/dogfood/test/undeclared-key-conformance.test.ts @@ -41,6 +41,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const DRIVERS = [ { @@ -139,6 +140,9 @@ describe.each(DRIVERS)('undeclared-key conformance: $name', ({ make }) => { await engine.init(); engine.registerApp(APP); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); }); afterEach(async () => { diff --git a/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts b/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts index 69a8d1d01fe..a0c6d495b9b 100644 --- a/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts +++ b/packages/qa/dogfood/test/write-door-unreadable-is-not-found.dogfood.test.ts @@ -53,6 +53,7 @@ import { import { buildCmtOpen, buildCmtPrivate, buildCmtReadonly, commentManagerSet } from './fixtures/comments-fixture.js'; import { armedWhen, assertArmed, leaveOrganization, principalArmed, resolveAuthzFor } from './armed.js'; import { catalogPermissionSet } from './catalog-permission-set.js'; +import { relaxTenantColumns } from './pre-18-tenant-column.testkit.js'; const SYS = { isSystem: true } as const; const RECORD_SENTENCE = BUILTIN_OPERATION_MESSAGES.en.record_access_denied!; @@ -131,6 +132,9 @@ async function boot(inside: boolean, email: string): Promise { // organization-less system insert with the Default Organization (ADR-0131 D9), // so the global row is landed beneath the engine, as the residue an older // release left. The readers still apply it until C8's NULL-reader stages. + // That residue lives only on a store built before 18.x, whose tenant column is + // nullable (ADR-0131 D1 makes a fresh one NOT NULL): the table is that store's. + await relaxTenantColumns(ql as any, ['sys_user_permission_set']); await ql.getDriver('sys_user_permission_set').create('sys_user_permission_set', { id: `ups_${userId}_${set.name}`, user_id: userId, permission_set: set.name, organization_id: null, }); From 3bb0ddb429033df1e18681781a17dab04e42a8fc Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 09:01:22 +0000 Subject: [PATCH 56/81] wip(plugin-auth): fixtures model a pre-18 store where they hold organization-less rows Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- .../src/account-identity-preflight.test.ts | 5 ++ .../driver-fault-auth-log-carriers.test.ts | 5 ++ .../src/grant-readers-by-name.golden.test.ts | 5 ++ .../src/grant-readers-unnamed-grant.test.ts | 5 ++ ...last-admin-guard.activation-ledger.test.ts | 5 ++ .../last-admin-guard.config-anchor.test.ts | 5 ++ .../src/last-admin-guard.re-pricing.test.ts | 5 ++ .../plugin-auth/src/last-admin-guard.test.ts | 5 ++ .../src/pre-18-tenant-column.testkit.ts | 56 +++++++++++++++++++ .../scim-deactivation-reconcile-user.test.ts | 5 ++ .../signup-existing-address-refusal.test.ts | 5 ++ .../sso-register-platform-admin-gate.test.ts | 5 ++ 12 files changed, 111 insertions(+) create mode 100644 packages/plugins/plugin-auth/src/pre-18-tenant-column.testkit.ts diff --git a/packages/plugins/plugin-auth/src/account-identity-preflight.test.ts b/packages/plugins/plugin-auth/src/account-identity-preflight.test.ts index c37085455a5..2624c7eec6d 100644 --- a/packages/plugins/plugin-auth/src/account-identity-preflight.test.ts +++ b/packages/plugins/plugin-auth/src/account-identity-preflight.test.ts @@ -46,6 +46,7 @@ import { SYS_ACCOUNT_OBJECT, } from './account-identity-preflight.js'; import { authIdentityObjects } from './manifest.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const SYSTEM = { context: { isSystem: true } } as never; @@ -105,6 +106,10 @@ async function bootEngine(objects: unknown[]): Promise { engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); } await engine.syncSchemas(); + // The organization-less rows below (global sets, grants, identities) exist + // only on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column + // NOT NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); return engine; } diff --git a/packages/plugins/plugin-auth/src/driver-fault-auth-log-carriers.test.ts b/packages/plugins/plugin-auth/src/driver-fault-auth-log-carriers.test.ts index 5c8d48ce2b5..8706c212e60 100644 --- a/packages/plugins/plugin-auth/src/driver-fault-auth-log-carriers.test.ts +++ b/packages/plugins/plugin-auth/src/driver-fault-auth-log-carriers.test.ts @@ -47,6 +47,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { SqlDriver } from '@objectstack/driver-sql'; import { AuthManager } from './auth-manager.js'; import { authIdentityObjects } from './manifest.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; /** The caller's value. Synthetic; asserted ABSENT from every carrier. */ const S = 'SENTINEL-21274-BOUND-VALUE'; @@ -252,6 +253,10 @@ for (const cell of CELLS) { engine.registry.registerObject(sysPermissionSet as never, '@objectstack/plugin-security'); engine.registry.registerObject(sysUserPermissionSet as never, '@objectstack/plugin-security'); await engine.syncSchemas(); + // The organization-less rows below (global sets, grants, identities) exist + // only on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column + // NOT NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); // The seeded holder of the sentinel, then the constraint the two writes // below will meet in the DATABASE — not in any check of ours. diff --git a/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts index d14080f2608..3e9e5c3d9f8 100644 --- a/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts +++ b/packages/plugins/plugin-auth/src/grant-readers-by-name.golden.test.ts @@ -42,6 +42,7 @@ import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin- import { ensureDefaultOrganization } from './ensure-default-organization.js'; import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.js'; import { withLegacyPermissionSetTable } from './__tests__/legacy-catalog-objects.testkit.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const SYS = { isSystem: true } as const; const POSTURE_ENV = 'OS_TENANCY_POSTURE'; @@ -134,6 +135,10 @@ async function standingByReader(posture: Posture): Promise undefined); registerLastAdminGuard(engine as unknown as LastAdminGuardEngine, { diff --git a/packages/plugins/plugin-auth/src/grant-readers-unnamed-grant.test.ts b/packages/plugins/plugin-auth/src/grant-readers-unnamed-grant.test.ts index e2384898862..1e3ce369050 100644 --- a/packages/plugins/plugin-auth/src/grant-readers-unnamed-grant.test.ts +++ b/packages/plugins/plugin-auth/src/grant-readers-unnamed-grant.test.ts @@ -36,6 +36,7 @@ import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin- import { ensureDefaultOrganization } from './ensure-default-organization.js'; import { seedLegacyPlatformBucket } from './__tests__/legacy-catalog-rows.testkit.js'; import { withLegacyPermissionSetTable } from './__tests__/legacy-catalog-objects.testkit.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const SYS = { context: { isSystem: true } } as any; const GUARD_PACKAGE = 'test.grant-readers-unnamed'; @@ -113,6 +114,10 @@ async function soleGrantAnchoredAdmin(): Promise { ]), } as any); await engine.syncSchemas(); + // The organization-less rows below (global sets, grants, identities) exist + // only on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column + // NOT NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); // [ADR-0131 D3/D5] The Default Organization a `single` boot creates before // `kernel:ready` (AuthPlugin's invariant); it owns the promotion's grant. await engine.insert('sys_organization', { id: 'org_default', name: 'Default Organization', slug: 'default' }, SYS); diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.activation-ledger.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.activation-ledger.test.ts index 46210f01378..e5e87ffeebd 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.activation-ledger.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.activation-ledger.test.ts @@ -21,6 +21,7 @@ import { resetPlatformAdminEmailMemo } from '@objectstack/core'; import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin-guard.js'; import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const SYSTEM = { context: { isSystem: true } } as const; const text = { type: 'text' as const }; @@ -116,6 +117,10 @@ async function boot(opts: Boot = { permissions: [ADMIN_FULL_ACCESS] }): Promise< await engine.init(); for (const o of objects) engine.registry.registerObject(o as never); await engine.syncSchemas(); + // The organization-less rows below (global sets, grants, identities) exist + // only on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column + // NOT NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); const guardEngine = opts.readThrough?.(engine) ?? (engine as unknown as LastAdminGuardEngine); if (opts.permissions) { const catalog = { permissions: opts.permissions.map((name) => ({ name })) }; diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts index 6c8c990da5c..b849e651846 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.config-anchor.test.ts @@ -52,6 +52,7 @@ import { resetPlatformAdminEmailMemo } from '@objectstack/core'; import { registerLastAdminGuard, USER_STANDING_KEYS, type LastAdminGuardEngine } from './last-admin-guard.js'; import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const ENV = 'OS_PLATFORM_OWNER_EMAIL'; /** @@ -205,6 +206,10 @@ async function boot(opts: { unguarded?: boolean } = {}): Promise { engine.registry.registerObject(o as never); } await engine.syncSchemas(); + // The organization-less rows below (global sets, grants, identities) exist + // only on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column + // NOT NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); // [ADR-0131 D3/D4] The set a grant names is the catalog's definition. bindTestSecurityCatalog(engine, { permissions: [{ name: ADMIN_FULL_ACCESS }] }); if (!opts.unguarded) { diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.re-pricing.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.re-pricing.test.ts index f79c3f47a70..4fa2036206f 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.re-pricing.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.re-pricing.test.ts @@ -40,6 +40,7 @@ import { resetPlatformAdminEmailMemo } from '@objectstack/core'; import { registerLastAdminGuard, type LastAdminGuardEngine } from './last-admin-guard.js'; import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; /** [ADR-0131 D3, ADR-0126 §4] The activation ledger a permission set is switched off in. */ const sysMetadataActivation = { @@ -172,6 +173,10 @@ async function boot(): Promise { engine.registry.registerObject(o as never); } await engine.syncSchemas(); + // The organization-less rows below (global sets, grants, identities) exist + // only on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column + // NOT NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); // [ADR-0131 D3/D4] The set a grant names is the catalog's definition. bindTestSecurityCatalog(engine, { permissions: [{ name: ADMIN_FULL_ACCESS }] }); registerLastAdminGuard(engine as unknown as LastAdminGuardEngine, { packageId: 'test.last-admin-guard-re-pricing' }); diff --git a/packages/plugins/plugin-auth/src/last-admin-guard.test.ts b/packages/plugins/plugin-auth/src/last-admin-guard.test.ts index 283db61973f..14789fee1fb 100644 --- a/packages/plugins/plugin-auth/src/last-admin-guard.test.ts +++ b/packages/plugins/plugin-auth/src/last-admin-guard.test.ts @@ -66,6 +66,7 @@ import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js import { sqliteEnginePool, type BootedSqliteEngine } from './__tests__/sqlite-engine-pool.testkit.js'; import { admin } from 'better-auth/plugins/admin'; import { organization } from 'better-auth/plugins/organization'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; // --------------------------------------------------------------------------- // Fixtures @@ -290,6 +291,10 @@ async function bootEngine(opts: BootOptions, track?: ObjectQL[]): Promise(); catalogs.set(engine, names); const catalog = () => ({ permissions: [...names].map((name) => ({ name })) }); diff --git a/packages/plugins/plugin-auth/src/pre-18-tenant-column.testkit.ts b/packages/plugins/plugin-auth/src/pre-18-tenant-column.testkit.ts new file mode 100644 index 00000000000..b0cbb5efa38 --- /dev/null +++ b/packages/plugins/plugin-auth/src/pre-18-tenant-column.testkit.ts @@ -0,0 +1,56 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Return engine-synced tables to the shape a store built BEFORE 18.x has: the + * tenant column nullable. + * + * ADR-0131 D1: since 18.x the SQL driver creates a table's tenant column NOT + * NULL. A store an earlier release built still holds the nullable column and + * its organization-less rows — the holdings, grants and seeds this package's + * NULL readers still answer for until the organization-ownership ceremony has + * attributed them. A suite whose subject is those rows calls this after + * `syncSchemas()` and before seeding them. Done through each table's driver + * and its own drift apply door (`relax_not_null`), never a hand `ALTER`. + */ +export async function relaxTenantColumns( + engine: { getDriverForObject(object: string): unknown }, + tables: readonly string[], + column = 'organization_id', +): Promise { + for (const table of tables) { + const driver = engine.getDriverForObject(table) as { + applyMigrationEntries(entries: never[]): Promise<{ applied: unknown[] }>; + }; + const op = { type: 'relax_not_null', table, column }; + const { applied } = await driver.applyMigrationEntries([ + { kind: 'nullability_mismatch', remoteName: table, table, column, category: 'needs_confirm', op } as never, + ]); + if (applied.length !== 1) throw new Error(`relaxTenantColumns: ${table}.${column} was not relaxed`); + } +} + +/** + * {@link relaxTenantColumns} over every object of an app that carries a tenant + * column as the engine registered it — the shape a whole pre-18 store has. + */ +export async function relaxEveryTenantColumn( + engine: { getDriverForObject(object: string): unknown; getSchema(object: string): unknown }, + objects: readonly { name: string }[], +): Promise { + const tenant = objects + .map((o) => o.name) + .filter((name) => { + const schema = engine.getSchema(name) as { fields?: Record; tenancy?: { enabled?: boolean } } | undefined; + return !!schema?.fields?.organization_id && schema.tenancy?.enabled !== false; + }); + await relaxTenantColumns(engine, tenant); +} + +/** {@link relaxEveryTenantColumn} over every object the engine's registry holds. */ +export async function relaxEveryRegisteredTenantColumn(engine: { + getDriverForObject(object: string): unknown; + getSchema(object: string): unknown; + registry: { getAllObjects(): readonly { name: string }[] }; +}): Promise { + await relaxEveryTenantColumn(engine, engine.registry.getAllObjects()); +} diff --git a/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts b/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts index 2c163359999..4f09ab5bb2a 100644 --- a/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts +++ b/packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts @@ -99,6 +99,7 @@ import '@better-auth/core/context'; import '@better-auth/core/error'; import '@better-auth/oauth-provider'; import '@better-auth/scim'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; /** * [#23184] Every sign-in below verifies the password the case attached, which @@ -241,6 +242,10 @@ async function bootEngine(): Promise { engine.registry.registerObject(object as never, '@objectstack/plugin-auth'); } await engine.syncSchemas(); + // The organization-less rows below (global sets, grants, identities) exist + // only on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column + // NOT NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); // [ADR-0131 D3/D4] `admin_full_access` is a catalog definition — what the // break-glass guard, like the resolver, resolves a grant's set name to. bindTestSecurityCatalog(engine, { permissions: [{ name: ADMIN_FULL_ACCESS }] }); diff --git a/packages/plugins/plugin-auth/src/signup-existing-address-refusal.test.ts b/packages/plugins/plugin-auth/src/signup-existing-address-refusal.test.ts index 514040ce296..58a891f704f 100644 --- a/packages/plugins/plugin-auth/src/signup-existing-address-refusal.test.ts +++ b/packages/plugins/plugin-auth/src/signup-existing-address-refusal.test.ts @@ -69,6 +69,7 @@ import { AuthManager } from './auth-manager.js'; import { authIdentityObjects } from './manifest.js'; import { SELF_REGISTRATION_CLOSED } from './audience-posture.js'; import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const BASE = 'http://localhost:3000'; const AUTH = `${BASE}/api/v1/auth`; @@ -155,6 +156,10 @@ async function bootEngine(): Promise { engine.registry.registerObject(sysPermissionSet as never, '@objectstack/plugin-security'); engine.registry.registerObject(sysUserPermissionSet as never, '@objectstack/plugin-security'); await engine.syncSchemas(); + // The organization-less rows below (global sets, grants, identities) exist + // only on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column + // NOT NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); // [ADR-0131 D3/D4] The declared self-registration set is a catalog definition // — what the admission asks before it admits. return bindTestSecurityCatalog(engine, { permissions: [{ name: 'member_default' }] }); diff --git a/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts b/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts index 46b4de6031e..ba908336465 100644 --- a/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts +++ b/packages/plugins/plugin-auth/src/sso-register-platform-admin-gate.test.ts @@ -88,6 +88,7 @@ import type { PluginContext } from '@objectstack/core'; import { inviteForAudienceGate } from './audience-gate-test-support'; import { bindTestSecurityCatalog } from './__tests__/security-catalog.testkit'; import { ADMIN_FULL_ACCESS_CAPABILITIES } from '@objectstack/spec'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const BASE = 'http://localhost:3000'; const AUTH = `${BASE}/api/v1/auth`; @@ -187,6 +188,10 @@ async function bootEngine(): Promise { engine.registry.registerObject(sysPosition as never, '@objectstack/plugin-auth'); engine.registry.registerObject(sysUserPosition as never, '@objectstack/plugin-auth'); await engine.syncSchemas(); + // The organization-less rows below (global sets, grants, identities) exist + // only on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column + // NOT NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); // [ADR-0131 D3/D4] The platform's `admin_full_access` definition, which an // org-less grant of it resolves through (no security plugin binds one here). return bindTestSecurityCatalog(engine, { permissions: [{ name: ADMIN_FULL_ACCESS, ...ADMIN_FULL_ACCESS_CAPABILITIES }] }); From 1920c54d4c321dd2e61ab6e7e53c4edb2785bd06 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 09:40:47 +0000 Subject: [PATCH 57/81] wip(cli): served test boots hold a Default Organization ahead of the app's seed Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- .../preview-read-only.integration.test.ts | 5 +- .../default-organization-plugin.testkit.ts | 52 +++++++++++++++++++ ...igrate.one-shot-family.integration.test.ts | 9 +++- 3 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/utils/default-organization-plugin.testkit.ts diff --git a/packages/cli/src/commands/migrate/preview-read-only.integration.test.ts b/packages/cli/src/commands/migrate/preview-read-only.integration.test.ts index e96e99c6b44..06cc13b813d 100644 --- a/packages/cli/src/commands/migrate/preview-read-only.integration.test.ts +++ b/packages/cli/src/commands/migrate/preview-read-only.integration.test.ts @@ -63,6 +63,7 @@ import { isExitSignal } from '../../utils/format.js'; // MODULE LOAD: the fixture's served boot reaches it through a dynamic // `import()` inside a clocked hook (`scripts/check-test-source-alias.mjs`). import '@objectstack/runtime'; +import { withDefaultOrganization } from '../../utils/default-organization-plugin.testkit.js'; const HERE = dirname(fileURLToPath(import.meta.url)); const CLI_ROOT = resolve(HERE, '..', '..', '..'); @@ -249,7 +250,9 @@ async function createFixture(cell: DialectCell): Promise { const served = await createStandaloneStack({ projectRoot: dir, databaseUrl }); const runtime = new Runtime({ cluster: false }); const kernel = runtime.getKernel(); - for (const plugin of served.plugins) await kernel.use(plugin as any); + // A real served boot composes authentication, whose Default Organization + // stamps the seed rows (ADR-0131 D9); the fresh tenant column is NOT NULL (D1). + for (const plugin of withDefaultOrganization(served.plugins)) await kernel.use(plugin as any); for (const plugin of await buildDataMigrationPlugins({ automation: true, audit: true })) await kernel.use(plugin as any); await runtime.start(); try { diff --git a/packages/cli/src/utils/default-organization-plugin.testkit.ts b/packages/cli/src/utils/default-organization-plugin.testkit.ts new file mode 100644 index 00000000000..a5c49c3f376 --- /dev/null +++ b/packages/cli/src/utils/default-organization-plugin.testkit.ts @@ -0,0 +1,52 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * A fixture plugin that gives a served test boot what every real `single` + * install has before its inline seed runs: an organization object holding the + * Default Organization. + * + * ADR-0131 D1: a table the SQL driver creates carries its tenant column NOT + * NULL. A real `os dev` / `os serve` composes authentication, which provisions + * the Default Organization, and the engine stamps every organization-less seed + * row with it (D9). `createStandaloneStack` alone composes no authentication, + * so a suite that serves an artifact's seeds through it would have them refused + * at the database. Spliced into the plugin list ahead of the app plugin (see + * {@link withDefaultOrganization}), it starts after the default datasource and + * before the app's `start()` seeds. + * + * The object mirrors the platform's: managed by better-auth, so it carries no + * tenant column of its own; its row is seeded through the driver, as data at + * rest. + */ +export const DEFAULT_ORGANIZATION_ID = 'org_default'; + +export function defaultOrganizationPlugin() { + return { + name: 'test.default-organization', + version: '0.0.0', + type: 'standard', + dependencies: ['com.objectstack.runtime.default-datasource'], + async init() {}, + async start(ctx: { getService(name: string): any }) { + const ql = ctx.getService('objectql'); + if (!ql.getSchema('sys_organization')) { + ql.registry.registerObject( + { name: 'sys_organization', label: 'Organization', managedBy: 'better-auth', fields: { name: { type: 'text', label: 'Name' } } }, + 'test.default-organization', + ); + } + await ql.syncObjectSchema('sys_organization'); + const driver = ql.getDriverForObject('sys_organization'); + if (await driver.findOne('sys_organization', { where: { id: DEFAULT_ORGANIZATION_ID } })) return; + await driver.create('sys_organization', { id: DEFAULT_ORGANIZATION_ID, name: 'Default' }); + }, + }; +} + +/** The served plugin list with {@link defaultOrganizationPlugin} ahead of every app plugin. */ +export function withDefaultOrganization(plugins: readonly T[]): T[] { + const out = [...plugins]; + const datasource = out.findIndex((p: any) => p?.name === 'com.objectstack.runtime.default-datasource'); + out.splice(datasource + 1, 0, defaultOrganizationPlugin() as T); + return out; +} 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 b63ede0b718..815ac265fc7 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 @@ -86,6 +86,7 @@ import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; import { SettingsServicePlugin } from '@objectstack/service-settings'; import '@objectstack/service-storage'; import '@objectstack/plugin-audit'; +import { withDefaultOrganization } from './default-organization-plugin.testkit.js'; const HERE = dirname(fileURLToPath(import.meta.url)); const SRC = resolve(HERE, '..'); @@ -406,7 +407,9 @@ async function bootServedStack(dbFile: string): Promise { const stack = await createStandaloneStack({ projectRoot: dir, databaseUrl: `file:${dbFile}` }); const runtime = new Runtime({ cluster: false }); const kernel = runtime.getKernel(); - for (const plugin of stack.plugins) await kernel.use(plugin as any); + // A real served boot composes authentication, whose Default Organization + // stamps the seed rows (ADR-0131 D9); the fresh tenant column is NOT NULL (D1). + for (const plugin of withDefaultOrganization(stack.plugins)) await kernel.use(plugin as any); for (const plugin of await buildDataMigrationPlugins({ storage: true, automation: true, audit: true })) { await kernel.use(plugin as any); } @@ -613,7 +616,9 @@ describe('[#21391] a one-shot boot arms no lifecycle sweep', () => { const stack = await createStandaloneStack({ projectRoot: dir, databaseUrl: `file:${c.dbFile}`, skipSeedData: true }); const runtime = new Runtime({ cluster: false }); const kernel = runtime.getKernel(); - for (const plugin of stack.plugins) await kernel.use(plugin as any); + // A real served boot composes authentication, whose Default Organization + // stamps the seed rows (ADR-0131 D9); the fresh tenant column is NOT NULL (D1). + for (const plugin of withDefaultOrganization(stack.plugins)) await kernel.use(plugin as any); await runtime.start(); try { expect(armed(kernel)).toBe(true); From f99c220679dc1c1b512c536d6eaa21ef1eda9b97 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 10:06:09 +0000 Subject: [PATCH 58/81] chore(spec): regenerate the reference docs the main merge deferred Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- content/docs/references/system/migration.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/system/migration.mdx b/content/docs/references/system/migration.mdx index 04854497121..0f7cb83dd63 100644 --- a/content/docs/references/system/migration.mdx +++ b/content/docs/references/system/migration.mdx @@ -77,7 +77,7 @@ Add a new field to an existing object | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | | **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty `options`; with neither it is refused at parse. | -| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. | +| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. Never a stored-metadata table (`sys_metadata`, `sys_metadata_history`): their rows are served only through the metadata API, so the engine never hydrates a reference into one, and `os validate`, `os build` and the object save door refuse it. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **referenceDeletedWhen** | `{ field: string; value: string }` | optional | Marks the record a `referenceVia` pointer names as PLATFORM-DELETED on rows where a sibling `select` field holds one declared option — e.g. `record_id` with `{ field: 'cancel_reason', value: 'record_deleted' }`. A renderer then draws a tombstone from that option's own label, reads nothing and links nothing. Valid only beside `referenceVia`; `field` must be a single-value `select` on the same object declaring inline `options`, and `value` one of its option values — any other shape is refused at parse. | | **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | @@ -506,7 +506,7 @@ Add a new field to an existing object | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | | **picklist** | `string` | optional | Name of a shared `picklist` whose options this field offers — instead of `options`, never with it. Option types only (select, radio, multiselect, checkboxes, tags). A select / radio declares this or a non-empty `options`; with neither it is refused at parse. | -| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. | +| **reference** | `string` | optional | Target object name (snake_case) for lookup/master_detail fields. Required for relationship types. Used by $expand to resolve foreign key IDs into full objects. Never a stored-metadata table (`sys_metadata`, `sys_metadata_history`): their rows are served only through the metadata API, so the engine never hydrates a reference into one, and `os validate`, `os build` and the object save door refuse it. On a `tree` field it is optional and, if given, must be the declaring object's own name — the object schema refuses any other target, and writes the own name when it is left out. | | **referenceVia** | `string` | optional | Declares this text field as the id half of a polymorphic pointer pair (ADR-0052 §5 ActivityPointer): the value is a record id of the object named by the SIBLING FIELD this key names — e.g. `record_id` with `referenceVia: 'object_name'`. The sibling must be a declared field on the same object holding an object machine name. Text fields only; mutually exclusive with `reference` (a static and a per-record target contradict). Enforced today at seed load: the value resolves as a natural key against the object the sibling column names, and an unresolvable pointer is refused loudly instead of stored verbatim. Adds no referential integrity or $expand behavior. | | **referenceDeletedWhen** | `{ field: string; value: string }` | optional | Marks the record a `referenceVia` pointer names as PLATFORM-DELETED on rows where a sibling `select` field holds one declared option — e.g. `record_id` with `{ field: 'cancel_reason', value: 'record_deleted' }`. A renderer then draws a tombstone from that option's own label, reads nothing and links nothing. Valid only beside `referenceVia`; `field` must be a single-value `select` on the same object declaring inline `options`, and `value` one of its option values — any other shape is refused at parse. | | **mailAssociation** | `boolean` | optional | Inbound mail association (`email` fields only): TRUE marks this field as identifying a correspondent. When a mailbox service ingests a message, it associates the message with every record of this object whose value in this field equals one of the message's participant addresses (From, Sender, Reply-To, To and Cc, plus those of a forwarded message it carries; ASCII case-insensitive; the receiving mailbox's own addresses excluded; only within the mailbox's organization). A message no record matches waits in the mailbox's unassociated pool. Refused on any other type. Takes effect only where a mailbox service is composed. | From 582989ec34b20b0ddadc092f25c9363624c5e1aa Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 10:46:43 +0000 Subject: [PATCH 59/81] test(plugin-security): main's delegated-write fixture models a pre-18 store Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- .../src/delegated-write-never-exceeds-delegator.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/plugins/plugin-security/src/delegated-write-never-exceeds-delegator.test.ts b/packages/plugins/plugin-security/src/delegated-write-never-exceeds-delegator.test.ts index d87113708fc..4a9330a9643 100644 --- a/packages/plugins/plugin-security/src/delegated-write-never-exceeds-delegator.test.ts +++ b/packages/plugins/plugin-security/src/delegated-write-never-exceeds-delegator.test.ts @@ -45,6 +45,7 @@ import { SysUserPosition } from './objects/sys-user-position.object.js'; import { SysUserPermissionSet } from './objects/sys-user-permission-set.object.js'; import { SecurityPlugin } from './security-plugin.js'; import { defaultPermissionSets } from './objects/default-permission-sets.js'; +import { relaxEveryRegisteredTenantColumn } from './pre-18-tenant-column.testkit.js'; const SYS = { context: { isSystem: true } } as never; const ALPHA = 'org_alpha'; @@ -124,6 +125,10 @@ async function boot(posture: TenancyPosture) { ], } as never); await engine.syncSchemas(); + // The organization-less rows below (global sets, positions, grants) exist only + // on a store built before 18.x (ADR-0131 D1 makes a fresh tenant column NOT + // NULL): this is that store. + await relaxEveryRegisteredTenantColumn(engine); const e = engine as unknown as { insert: (o: string, row: Record, opts: never) => Promise; From 1d01986cee216e643fa937fd9f721ed271de827e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:13:23 +0000 Subject: [PATCH 60/81] wip(driver-sql, driver-turso, driver-memory, objectql, cli, metadata-protocol): plain organization key part in new DDL; the differ reads COALESCE as plain on a certified table Claude-Session: https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd Co-authored-by: Claude --- packages/cli/src/commands/generate.ts | 69 +++------- .../src/memory-unique-constraint.ts | 54 +++++--- .../drivers/driver-sql/src/schema-drift.ts | 119 ++++++++++------- packages/drivers/driver-sql/src/sql-driver.ts | 121 ++++++++++++++++-- .../drivers/driver-turso/src/turso-driver.ts | 6 +- .../migrations/sys-setting-identity-index.ts | 37 +++--- packages/objectql/src/engine.ts | 59 +++++++++ 7 files changed, 322 insertions(+), 143 deletions(-) diff --git a/packages/cli/src/commands/generate.ts b/packages/cli/src/commands/generate.ts index bd9ccfc23f2..41bbfb23ab6 100644 --- a/packages/cli/src/commands/generate.ts +++ b/packages/cli/src/commands/generate.ts @@ -3226,17 +3226,6 @@ function indexKeyColumns(obj: Record): ReadonlySet { * is what keeps the transcription honest — it recomputes every name from the * driver's own exported `uniqueIndexesFromFields` and compares. */ -/** - * `driver-sql`'s `GLOBAL_TENANT` — the sentinel the ADR-0120 D3 NULL-safe - * organization key part folds a NULL organization onto. - * - * Transcribed for the same #5726 reason as the rest of this block, and it - * reaches only a COMMENT in the generated file: this format emits no expression - * key part, so the sentinel is here to NAME the index that was not emitted, not - * to build one. The pin compares it against the driver's own export. - */ -const GLOBAL_TENANT_KEY = '__global__'; - const INDEX_NAME_MAX = 60; /** Chars kept from `_
` before the `_` suffix of a truncated name. */ const INDEX_NAME_HEAD = INDEX_NAME_MAX - 9; @@ -3253,15 +3242,13 @@ function buildIndexName(table: string, columns: string[], unique: boolean): stri interface MirroredUniqueIndex { /** {@link buildIndexName}'s answer — the identifier the driver would use. */ name: string; - /** The key parts, in the driver's order (tenant column first when scoped). */ - columns: string[]; /** - * The tenant column whose key part materializes as the ADR-0120 D3 NULL-safe - * expression `COALESCE(, '__global__')` rather than as a bare column, - * or `null` for a plain single-column unique. An expression key part is what - * neither format emits — see {@link uniqueIndexesForObject}. + * The key parts, in the driver's order (tenant column first when scoped). + * Every part is a plain column: since ADR-0131 D1 the driver keys the + * NOT NULL tenant column itself, so an organization composite is a column + * list both formats can emit. */ - nullSafeColumn: string | null; + columns: string[]; } /** @@ -3276,10 +3263,10 @@ interface MirroredUniqueIndex { * {@link isUniqueScopeDeclared}, {@link isOrganizationScopedUnique} and * {@link tenantFieldOf} — so they cannot disagree about which fields are keyed. * - * Scoping rule, transcribed from the driver (ADR-0120 D1/D3): + * Scoping rule, transcribed from the driver (ADR-0120 D1, ADR-0131 D1): * - `unique: 'global'` → single-column `(field)`, platform-wide. * - `unique: true` / `'organization'` on a tenant-scoped table → composite - * `(COALESCE(tenantField, '__global__'), field)`, tenant column FIRST. + * `(tenantField, field)`, tenant column FIRST. * - `unique: true` / `'organization'` with no tenant column → `(field)`. * - a unique declaration ON the tenant column itself stays single-column — * `(organization_id, organization_id)` is not a constraint. @@ -3301,11 +3288,7 @@ function uniqueIndexesForObject(obj: Record): MirroredUniqueIndex[] const scoped = isOrganizationScopedUnique(field.unique) && tenantField != null && tenantField !== name; const columns = scoped ? [tenantField as string, name] : [name]; - out.push({ - name: buildIndexName(table, columns, true), - columns, - nullSafeColumn: scoped ? (tenantField as string) : null, - }); + out.push({ name: buildIndexName(table, columns, true), columns }); } return out; } @@ -3314,24 +3297,19 @@ function uniqueIndexesForObject(obj: Record): MirroredUniqueIndex[] * The subset of {@link uniqueIndexesForObject} a format may actually emit, and * a line for every one it may not. * - * Two exclusions, and BOTH are the driver's own behaviour rather than a - * convenience here: + * One exclusion, and it is the driver's own behaviour rather than a + * convenience here: a key part with no column. `syncDeclaredIndexes` skips a + * declared index whose columns are not in `physicalColumns` and warns; the + * generator's equivalent of "not materialized" is a field this file emits no + * column for — a VIRTUAL `formula` (commit 08706f0e0). Emitting the index + * anyway produces DDL that refuses to run at all. * - * 1. A key part with no column. `syncDeclaredIndexes` skips a declared index - * whose columns are not in `physicalColumns` and warns; the generator's - * equivalent of "not materialized" is a field this file emits no column - * for — a VIRTUAL `formula` (commit 08706f0e0). Emitting the index anyway produces - * DDL that refuses to run at all. - * 2. An EXPRESSION key part. `COALESCE(, '__global__')` is what the - * driver builds through raw DDL precisely because knex's schema builder - * cannot express it, and it is NOT interchangeable with the bare composite: - * under SQL's NULL-distinct UNIQUE a bare `(organization_id, field)` - * enforces NOTHING on rows without an organization, which on a - * single-tenant stack is every row (#5030). So emitting the bare composite - * here would ADVERTISE a constraint the table does not carry — worse than - * emitting nothing, and the failure mode Prime Directive #10 names. + * The organization composite is emitted: since ADR-0131 D1 the driver keys the + * plain tenant column, so the bare `(organization_id, field)` IS the platform's + * constraint, no longer the ADR-0120 D3 `COALESCE` expression neither format + * could spell. * - * ⛔ Neither exclusion is silent. A skipped index is named in the generated file + * ⛔ The exclusion is not silent. A skipped index is named in the generated file * itself, with what it would have keyed, because the operator reading that file * is the only person who can act on it — "Absence must be loud". */ @@ -3347,15 +3325,6 @@ function partitionUniqueIndexes( skipped.push({ index, why: `no column is generated for ${missing.join(', ')}` }); continue; } - if (index.nullSafeColumn !== null) { - skipped.push({ - index, - why: - `its organization key part is COALESCE("${index.nullSafeColumn}", '${GLOBAL_TENANT_KEY}'), ` + - 'an expression key part this format does not emit; the platform creates it at boot', - }); - continue; - } emit.push(index); } return { emit, skipped }; diff --git a/packages/drivers/driver-memory/src/memory-unique-constraint.ts b/packages/drivers/driver-memory/src/memory-unique-constraint.ts index eb0eb11358e..102b36b10a4 100644 --- a/packages/drivers/driver-memory/src/memory-unique-constraint.ts +++ b/packages/drivers/driver-memory/src/memory-unique-constraint.ts @@ -39,7 +39,7 @@ * | declaration | tenant column | driver-sql index | here | * |:---|:---|:---|:---| * | `unique: 'global'` | any | `(field)` | scope `null` | - * | `unique: true` / `'organization'` | present, ≠ field | `(COALESCE(tenant,'__global__'), field)` | scope = tenant column | + * | `unique: true` / `'organization'` | present, ≠ field | `(tenant, field)` | scope = tenant column | * | `unique: true` / `'organization'` | absent | `(field)` | scope `null` | * | `unique: true` / `'organization'` | IS the field | `(field)` | scope `null` | * | absent / `false` | any | no index | not a constraint | @@ -61,8 +61,8 @@ * | `fields` empty / absent / no non-empty strings | any | `null` — unusable | no constraint | * | `unique` absent / `false` | any | a PLAIN index | not a constraint | * | `unique: true` / `'global'` | any | the listed columns VERBATIM | `columns` = listed, no NULL-safe part | - * | `unique: 'organization'`, tenant column NOT listed | present | `(COALESCE(tenant,'__global__'), …listed)` | `columns` = `[tenant, …listed]`, `nullSafeColumns` = `[tenant]` | - * | `unique: 'organization'`, tenant column ALREADY listed | present | listed verbatim, the tenant's own key part goes NULL-safe | `columns` = listed (order kept), `nullSafeColumns` = `[tenant]` | + * | `unique: 'organization'`, tenant column NOT listed | present | `(tenant, …listed)` | `columns` = `[tenant, …listed]`, `scopeColumns` = `[tenant]` | + * | `unique: 'organization'`, tenant column ALREADY listed | present | listed verbatim | `columns` = listed (order kept), `scopeColumns` = `[tenant]` | * | `unique: 'organization'` | absent | degrades to the listed columns alone | `columns` = listed, no NULL-safe part | * * ⚠️ **bare `true` inverts between the two surfaces.** On a declared index it @@ -105,9 +105,16 @@ * NULL key part exempts the row (below), so such an index constrains * nothing. The field surface has the same exposure and the same answer. * - * ### Why the NULL-organization fold needs no `'__global__'` token here + * ### The organization key part is plain since ADR-0131 D1 * - * ADR-0120 D3 materializes the organization key part as + * The tenant column is NOT NULL (ADR-0131 D1), so `driver-sql` keys the plain + * column and a row whose organization is NULL is exempt under its + * NULL-distinct UNIQUE, like a NULL in any other key part. This module answers + * the same: the organization key part is an ordinary one. The fold the next + * paragraphs describe is what ADR-0120 D3 did before, kept for the record of + * why `nullSafeColumns` exists on the constraint shape. + * + * ADR-0120 D3 materialized the organization key part as * `COALESCE(organization_id, '__global__')` because SQL `UNIQUE` is * NULL-DISTINCT: a raw `(organization_id, field)` composite enforces NOTHING on * rows whose organization is NULL, which on a single-organization stack is @@ -148,9 +155,9 @@ * * So there is exactly ONE rule, and {@link uniqueKeyOf} is the one place it * lives: **a NULL in any key part EXEMPTS the row, except in a NULL-SAFE part, - * where it folds onto the shared `null` bucket.** The organization key part is - * the only NULL-safe one, on either surface, because on the SQL side it is the - * only one materialized as an expression that can never be NULL. + * where it folds onto the shared `null` bucket.** Since ADR-0131 D1 no + * organization key part is NULL-safe; the one NULL-safe part left is the + * constant platform scope of a field-level constraint with no tenant column. * * ## Which column conflicted: this driver answers `undefined`, always * @@ -244,10 +251,15 @@ export interface MemoryDeclaredIndexConstraint { readonly columns: readonly string[]; /** * The subset of {@link columns} whose NULL FOLDS onto one shared bucket - * instead of exempting the row (ADR-0120 D3). In practice the organization - * key part, and only on the `'organization'` spelling. + * instead of exempting the row (ADR-0120 D3). Empty from every declaration + * since ADR-0131 D1, which keys the plain organization column. */ readonly nullSafeColumns: readonly string[]; + /** + * The organization key part the `'organization'` spelling prepended or + * named — what the refusal states as the scope. Absent otherwise. + */ + readonly scopeColumns?: readonly string[]; } /** @@ -459,11 +471,11 @@ export function uniqueConstraintsFromDeclaredIndexes( if (!isUniqueDeclared(idx?.unique)) continue; if (isOrganizationUnique(idx?.unique) && tenantField) { - // A listed column that IS the tenant column is not prepended again — its - // own key part becomes the NULL-safe one instead (the hand-written S6 - // spelling, opted in), and the author's column order is kept. + // A listed column that IS the tenant column is not prepended again (the + // hand-written S6 spelling), and the author's column order is kept. The + // organization key part is plain (ADR-0131 D1). const columns = listed.includes(tenantField) ? listed : [tenantField, ...listed]; - out.push({ columns, nullSafeColumns: [tenantField] }); + out.push({ columns, nullSafeColumns: [], scopeColumns: [tenantField] }); continue; } // `'global'`, bare `true`, or `'organization'` on an object with no tenant @@ -491,8 +503,8 @@ export function uniqueKeyOf( const parts: unknown[] = []; for (const part of keyPartsOf(constraint)) { // A NULL-SAFE part folds: `null` IS its bucket, and the row stays - // constrained. That is the ADR-0120 D3 organization key part, and the only - // kind of part that behaves this way on either surface. + // constrained: the constant platform scope of a field-level constraint. + // The organization key part is plain (ADR-0131 D1). if (part.nullSafe) { parts.push(part.column === null ? null : (record[part.column] ?? null)); continue; @@ -530,7 +542,9 @@ function keyPartsOf(constraint: MemoryUniqueEnforcement): readonly UniqueKeyPart })); } return [ - { column: constraint.scopeField, nullSafe: true }, + // The constant platform scope (`null`) folds; an organization column is a + // plain key part (ADR-0131 D1), so a row with no organization is exempt. + { column: constraint.scopeField, nullSafe: constraint.scopeField === null }, { column: constraint.field, nullSafe: false }, ]; } @@ -574,10 +588,10 @@ export function declaredIndexViolationError( constraint: MemoryDeclaredIndexConstraint, record: Record, ): Error & { code: string; status: number } { - const scope = constraint.nullSafeColumns; + const scope = constraint.scopeColumns ?? constraint.nullSafeColumns; const keyed = constraint.columns.filter((c) => !scope.includes(c)); - // A key whose ONLY part is the NULL-safe organization ("one row per - // organization") has nothing left to list, so it reports its own column. + // A key whose ONLY part is the organization ("one row per organization") + // has nothing left to list, so it reports its own column. const reported = keyed.length > 0 ? keyed : constraint.columns; const scoped = keyed.length > 0 && scope.length > 0 diff --git a/packages/drivers/driver-sql/src/schema-drift.ts b/packages/drivers/driver-sql/src/schema-drift.ts index 7aed35b83a2..5d3ceeb7bb7 100644 --- a/packages/drivers/driver-sql/src/schema-drift.ts +++ b/packages/drivers/driver-sql/src/schema-drift.ts @@ -63,6 +63,12 @@ import type { SchemaDiffEntry } from '@objectstack/spec/shared'; * `(__global__, a@b.com)` says "platform bucket", not "corrupt data". * - **The token is reserved**: an organization id may never equal it * (guarded at the organization-creation seam in plugin-auth). + * + * Since ADR-0131 D1 the tenant column is NOT NULL, so new DDL keys the plain + * column and no longer emits this form. The COALESCE form stays READ: + * every database an earlier release built keeps those indexes, and the + * differ takes them as equivalent to the plain key on a certified database + * ({@link diffManagedIndexes}'s `tenantKeyCertified`). */ export const GLOBAL_TENANT = '__global__'; @@ -113,9 +119,12 @@ export function isOrganizationScopedUnique(unique: unknown): boolean { } /** - * The organization key part of an organization-scoped unique index, spelled - * once (ADR-0120 D3). Display/signature form — DDL emission quotes the - * identifier per dialect in `SqlDriver.syncDeclaredIndexes`. + * The NULL-safe organization key part, spelled once (ADR-0120 D3). Display and + * signature form — DDL emission quotes the identifier per dialect. + * + * Read vocabulary only since ADR-0131 D1: no generator marks the tenant key + * part NULL-safe any more, so this renders an index an earlier release built, + * or an op a caller hands in already normalized. */ export function organizationKeyPartSql(column: string): string { return `COALESCE(${column}, '${GLOBAL_TENANT}')`; @@ -1829,13 +1838,14 @@ export function isRuntimeManagedIndex( * Scoping rule (ADR-0120 D1/D3): * - `unique: 'global'` → single-column `(field)`, platform-wide. * - `unique: true` / `unique: 'organization'` on a tenant-scoped table → - * composite `(COALESCE(tenantField, '__global__'), field)`: unique - * *within* the organization, matching the per-tenant autonumber sequence, - * the RLS read predicate and the write-path tenant stamp. The organization - * key part is NULL-safe: rows without an organization form one platform - * bucket, unique among themselves — a bare `(tenantField, field)` under - * SQL's NULL-distinct UNIQUE enforced NOTHING on those rows, which on a - * single-tenant stack is every row (#5030). + * composite `(tenantField, field)`: unique *within* the organization, + * matching the per-tenant autonumber sequence, the RLS read predicate and + * the write-path tenant stamp. The organization key part is the plain + * column (ADR-0131 D1: the tenant column is NOT NULL, so no row escapes + * the NULL-distinct UNIQUE). ADR-0120 D3's `COALESCE(tenantField, + * '__global__')` form, which folded NULL-organization rows into one bucket + * (#5030), is what earlier releases built; the differ reads it as + * equivalent on a certified database. * - `unique: true` / `'organization'` with no tenant column → single-column * `(field)`. * @@ -1856,12 +1866,7 @@ export function uniqueIndexesFromFields( const scoped = isOrganizationScopedUnique(field.unique) && tenantField != null && tenantField !== name; const columns = scoped ? [tenantField, name] : [name]; - out.push({ - name: buildIndexName(table, columns, true), - columns, - unique: true, - ...(scoped ? { nullSafeColumns: [tenantField] } : {}), - }); + out.push({ name: buildIndexName(table, columns, true), columns, unique: true }); } return out; } @@ -1883,13 +1888,14 @@ export interface DeclaredIndexInput { * - `unique: true` / `'global'` → the listed columns, VERBATIM — the #3696 * contract, now the `'global'` arm of the explicit vocabulary. * - `unique: 'organization'` → the organization key part is PREPENDED to the - * listed columns in its NULL-safe form (`COALESCE(tenantField, - * '__global__')`), resolved against the table's tenant column at - * registration — the one place tenancy is knowable. With no tenant column - * the index degrades to the listed columns alone, mirroring field-level - * behavior (S11). A listed column that IS the tenant column is not - * prepended again — its own key part becomes the NULL-safe form instead - * (the hand-written S6 spelling, opted in). + * listed columns as the plain tenant column (ADR-0131 D1), resolved + * against the table's tenant column at registration — the one place + * tenancy is knowable. With no tenant column the index degrades to the + * listed columns alone, mirroring field-level behavior (S11). A listed + * column that IS the tenant column is not prepended again (the + * hand-written S6 spelling). + * - An input already carrying `nullSafeColumns` (an op normalized before + * ADR-0131 D1) is honoured verbatim. */ export function normalizeDeclaredIndex( table: string, @@ -1910,7 +1916,6 @@ export function normalizeDeclaredIndex( if (nullSafeColumns.length === 0) nullSafeColumns = undefined; } else if (idx?.unique === 'organization' && tenantField) { columns = listed.includes(tenantField) ? listed : [tenantField, ...listed]; - nullSafeColumns = [tenantField]; } const name = @@ -2196,16 +2201,11 @@ export function legacyUniqueReplacements(args: { column: name, legacyColumns: [name], legacyNames, - // NULL-safe organization key part (ADR-0120 D3). Still a pure - // relaxation to create from under the legacy GLOBAL single-column - // unique: any two rows colliding in the new key already collided in the - // old one, so the replacement can neither fail nor lose data. - replacement: { - name: buildIndexName(table, columns, true), - columns, - unique: true, - nullSafeColumns: [tenantField], - }, + // A pure relaxation to create from under the legacy GLOBAL + // single-column unique: any two rows colliding in the new key already + // collided in the old one, so the replacement can neither fail nor lose + // data. + replacement: { name: buildIndexName(table, columns, true), columns, unique: true }, }); } @@ -2214,8 +2214,8 @@ export function legacyUniqueReplacements(args: { // #8323: the same retirement, one level up. A declared index's bare // `unique: true` is the positional spelling of `'global'` — the listed // columns VERBATIM — so respelling it `'organization'` changes the - // materialized shape from `(…listed)` to `(COALESCE(tenant,'__global__'), - // …listed)`, and with it the generated NAME. On a deployed database that + // materialized shape from `(…listed)` to `(tenant, …listed)`, and with it + // the generated NAME. On a deployed database that // reads as two unrelated findings: the composite is missing (create, safe) // and the old global index is an orphan (drop, DESTRUCTIVE, opt-in). An // operator who applies only the safe half keeps the global index — and the @@ -2248,8 +2248,7 @@ export function legacyUniqueReplacements(args: { const legacyName = buildIndexName(table, listed, true); // The S6 hand-written composite already lists the tenant column, so // `normalizeDeclaredIndex` prepends nothing and the "legacy" name IS the - // current name. Nothing was superseded; the D4 NULL-safe tightening path - // owns that transition. + // current name. Nothing was superseded. if (legacyName === replacement.name) continue; // An index the CURRENT metadata declares is by definition not legacy // (#3955) — the same guard the field-level arm applies. @@ -2356,6 +2355,19 @@ export function canonicalIndexKey(columns: string[], nullSafeColumns?: ReadonlyA return columns.map((c) => (ns.has(c) ? `coalesce:${c}` : c)).join(','); } +/** + * [ADR-0131 D1] Does any physical index key the tenant column through the + * NULL-safe `COALESCE` form — directly, or through a #11627 hash shadow's + * resolved key? Those are the indexes an earlier release built, and the only + * ones whose verdict {@link diffManagedIndexes}'s `tenantKeyCertified` can + * change, so a caller asks the (costly) certification question only when this + * answers yes. + */ +export function carriesNullSafeTenantKeyPart(physical: PhysicalIndex[], tenantField?: string | null): boolean { + if (!tenantField) return false; + return physical.some((p) => (enforcedIndexKey(p).nullSafeColumns ?? []).includes(tenantField)); +} + /** * Diff a table's expected index set against the physical one. * @@ -2381,8 +2393,29 @@ export function diffManagedIndexes(args: { * (ADR-0120 D3) — see {@link isSyncReproducibleIndex}. */ tenantField?: string | null; + /** + * [ADR-0131 D1] Does the tenant column hold no NULL on this table — because + * the column is physically NOT NULL, or because the database is certified + * (a birth marker or an organization-ownership ceremony marker of the + * current shape generation)? Then `COALESCE(, …)` and the plain + * tenant column enforce the same constraint, and a key part in either form + * matches the other, in both directions: an index an earlier release built + * in the NULL-safe form is never proposed for a rebuild into the plain form + * new DDL emits. Absent or false on a database that may still hold + * NULL-organization rows, where the two forms are different constraints. + */ + tenantKeyCertified?: boolean; }): ManagedDriftEntry[] { - const { table, expected, legacy, physical, runtimeCreated, tenantField } = args; + const { table, expected, legacy, physical, runtimeCreated, tenantField, tenantKeyCertified } = args; + // The comparison identity of a key: the NULL-safe tenant key part reads as + // the plain column where the two are the same constraint (ADR-0131 D1). + const comparisonKey = (columns: string[], nullSafeColumns?: ReadonlyArray | null): string => + canonicalIndexKey( + columns, + tenantKeyCertified === true && tenantField + ? (nullSafeColumns ?? []).filter((c) => c !== tenantField) + : nullSafeColumns, + ); const out: ManagedDriftEntry[] = []; const byName = new Map(physical.map((p) => [p.name, p])); /** Physical index names accounted for — either declared, or already reported. */ @@ -2475,12 +2508,10 @@ export function diffManagedIndexes(args: { // Same normalization on BOTH sides (#4884, ADR-0120 D3): column identity // AND key-part form, literal-agnostic on the COALESCE literal — asked of // the key the index ENFORCES, which for a #11627 shadow-carried UNIQUE is - // not the column it stores (commit cd1348802). + // not the column it stores (commit cd1348802). On a certified table the + // tenant key part's form drops out of the identity (ADR-0131 D1). const pk = enforcedIndexKey(p); - if ( - p.unique === e.unique && - canonicalIndexKey(pk.columns, pk.nullSafeColumns) === canonicalIndexKey(e.columns, e.nullSafeColumns) - ) { + if (p.unique === e.unique && comparisonKey(pk.columns, pk.nullSafeColumns) === comparisonKey(e.columns, e.nullSafeColumns)) { continue; } // The framework's own runtime migrations own some declared names — ADR-0048 diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 56246c6991f..9fe1fd86938 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -119,6 +119,7 @@ import { fieldHasColumn, GLOBAL_TENANT, canonicalIndexKey, + carriesNullSafeTenantKeyPart, hashShadowColumnFor, isHashShadowCarrier, parseHashShadowKeyParts, @@ -191,8 +192,9 @@ import { isComposedReadableIds, type ComposedReadableIds } from '@objectstack/sp * no columns. So the refusal is the ordinary state of a new database, and not * a `DATABASE_ERROR` to put in front of an operator. * - * Only one question reads through this scope, and only - * {@link SqlDriver.backendStatementFault} consults it. ⛔ It demotes one class, + * Two questions read through this scope — that one, and the ADR-0131 D1 + * certification {@link SqlDriver.isTenantKeyCertified} asks of the same + * ledger — and only {@link SqlDriver.backendStatementFault} consults it. ⛔ It demotes one class, * a missing table, recognised by the shared `isMissingTableError`. Any other * refusal inside the scope still warns, and so does a missing table outside it. * @@ -202,6 +204,21 @@ import { isComposedReadableIds, type ComposedReadableIds } from '@objectstack/sp */ const PRE_DDL_QUESTION_SCOPE = new AsyncLocalStorage(); +/** + * [ADR-0131 D1] The `WHERE` of a unique key's duplicate probe: every plain key + * part NOT NULL, so the probe groups only the rows the index constrains (SQL + * UNIQUE is NULL-distinct). Empty when every part is NULL-safe. The Turso + * remote face spells the same clause with its own identifier quoting. + */ +function uniqueProbeNotNullClause( + columns: readonly string[], + nullSafe: ReadonlySet, + quote: (identifier: string) => string, +): string { + const plain = columns.filter((c) => !nullSafe.has(c)); + return plain.length === 0 ? '' : ` WHERE ${plain.map((c) => `${quote(c)} IS NOT NULL`).join(' AND ')}`; +} + /** * Default ID length for auto-generated IDs. */ @@ -13818,7 +13835,19 @@ export class SqlDriver implements IDataDriver { // NOT NULL is the organization-ownership ceremony's, not drift. ceremonyOwnedNullability: this.resolveTenantField(tableName), }); - out.push(...(await this.detectTableIndexDrift(tableName, fields, declaredIndexes, new Set(cols.map((c) => c.name))))); + // [ADR-0131 D1] A physically NOT NULL tenant column is its own proof that + // the NULL-safe and the plain tenant key part are one constraint here. + const tenantField = this.resolveTenantField(tableName); + const tenantColumnNotNull = !!tenantField && cols.some((c) => c.name === tenantField && c.nullable === false); + out.push( + ...(await this.detectTableIndexDrift( + tableName, + fields, + declaredIndexes, + new Set(cols.map((c) => c.name)), + tenantColumnNotNull, + )), + ); return out; } @@ -13829,27 +13858,39 @@ export class SqlDriver implements IDataDriver { * introspection round-trip (indexes, not columns) and two inputs the column * differ has no use for: the object's declared `indexes[]` and its tenant * column. + * + * [ADR-0131 D1] `tenantColumnNotNull` and {@link isTenantKeyCertified} are + * the two witnesses that the tenant column holds no NULL here, which is what + * lets the differ read an earlier release's `COALESCE(, …)` key part + * as the plain column new DDL keys. The certification is asked only when a + * physical index carries that form and the column is not NOT NULL already. */ protected async detectTableIndexDrift( tableName: string, fields: Record, declaredIndexes: any[] | undefined, physicalColumns: Set, + tenantColumnNotNull = false, ): Promise { const tenantField = this.resolveTenantField(tableName); + const physical = await this.introspectIndexes(tableName); + const tenantKeyCertified = + tenantColumnNotNull || + (carriesNullSafeTenantKeyPart(physical, tenantField) && (await this.isTenantKeyCertified())); const entries = diffManagedIndexes({ table: tableName, expected: expectedIndexes({ table: tableName, fields, tenantField, declaredIndexes, physicalColumns }), // `declaredIndexes` goes to BOTH: it is what the table should have, and // therefore also what must never be mistaken for legacy debt (#3955). legacy: legacyUniqueReplacements({ table: tableName, fields, tenantField, physicalColumns, declaredIndexes }), - physical: await this.introspectIndexes(tableName), + physical, // Indexes the framework built through raw DDL on this boot are its own to // manage — never this differ's to propose dropping (#4884). runtimeCreated: this.runtimeCreatedIndexes.get(tableName), // Lets the differ recognise the NULL-safe organization key part as the // sync's own vocabulary (ADR-0120 D3). tenantField, + tenantKeyCertified, }); // ADR-0120 D4: the duplicate pre-flight probe decides each NULL-safe // unique op's fate — clean data upgrades the pure tightening to `safe` @@ -13968,10 +14009,17 @@ export class SqlDriver implements IDataDriver { } /** - * Find duplicate groups under a NULL-safe organization unique key: GROUP BY - * the exact key the index will enforce — `COALESCE(, '__global__')` for - * the NULL-safe parts, the bare column otherwise — HAVING COUNT(*) > 1. - * Returns one entry per conflicting group, `key` naming columns and values. + * Find duplicate groups under a unique key: GROUP BY the exact key the index + * will enforce — `COALESCE(, '__global__')` for the NULL-safe parts, + * the bare column otherwise — HAVING COUNT(*) > 1. Returns one entry per + * conflicting group, `key` naming columns and values. + * + * [ADR-0131 D1] A row with a NULL in a plain key part conflicts with nothing + * under SQL's NULL-distinct UNIQUE, while GROUP BY puts NULLs together, so + * such rows are left out ({@link uniqueProbeNotNullClause}). Since new DDL + * keys the plain tenant column, that is every organization-less row a + * nullable tenant column still holds — a group of them is no duplicate the + * index will refuse. */ protected async probeNullSafeUniqueDuplicates( table: string, @@ -13986,7 +14034,8 @@ export class SqlDriver implements IDataDriver { const selectList = parts.map((p, i) => `${p} AS k${i}`).join(', '); const groupList = parts.join(', '); const sql = - `SELECT ${selectList}, COUNT(*) AS n FROM ${q(table)} ` + + `SELECT ${selectList}, COUNT(*) AS n FROM ${q(table)}` + + `${uniqueProbeNotNullClause(columns, ns, q)} ` + `GROUP BY ${groupList} HAVING COUNT(*) > 1 ORDER BY n DESC LIMIT 20`; const res: any = await this.knex.raw(sql); const rows: any[] = Array.isArray(res) ? (Array.isArray(res[0]) ? res[0] : res) : (res?.rows ?? []); @@ -18712,6 +18761,14 @@ export class SqlDriver implements IDataDriver { * the CONCAT-NULL semantics above: an any-NULL tuple still conflicts with * nothing, matching MySQL's own composite-UNIQUE behaviour. * + * Since ADR-0131 D1 no generator marks the tenant key part NULL-safe, so a + * NEW shadow hashes the plain tenant column, which is what a direct index + * would key. An existing shadow is never rebuilt for it: the additive sync + * skips its index by name, and the differ reads its resolved COALESCE key + * as the plain one on a certified table. Only a shadow whose index is + * already gone (a `recreate_index`, a manual drop) meets the new key here, + * as `stale`, and its column is derived, so regenerating it moves no data. + * * Returns `true` when the shadow index now exists. */ protected async createHashShadowUniqueIndex( @@ -20290,6 +20347,52 @@ export class SqlDriver implements IDataDriver { /** Has {@link resolveFileColumnsMoved} already run to completion? */ private fileColumnsMovedAsked = false; + /** + * [ADR-0131 D1] The kernel→driver supply seam for "is this database + * certified": a birth marker, or an organization-ownership ceremony marker + * of the current shape generation. `ObjectQL.registerDriver` hands over a + * closure over the engine's `isOrganizationOwnershipCertified()`, which reads + * the ceremony's `sys_migration` row — a row in a table this driver serves, + * read through the registry and the pipeline the engine owns, so the driver + * receives the question, never the ledger's vocabulary. + * + * The answer decides one thing only: whether the differ reads an earlier + * release's `COALESCE(, …)` key part as the plain tenant column new + * DDL keys ({@link detectTableIndexDrift}). Without a resolver, or when it + * throws, the answer is "not certified" — the two forms then stay distinct + * constraints, which is the reading that never proposes a rebuild on a + * guess. + */ + setTenantKeyCertifiedResolver(resolve: () => boolean | Promise): void { + this.tenantKeyCertifiedResolver = resolve; + this.tenantKeyCertified = undefined; + } + + private tenantKeyCertifiedResolver?: () => boolean | Promise; + /** Set once the resolver has answered yes — a certificate is never withdrawn. */ + private tenantKeyCertified?: true; + + /** + * Asked lazily — only a table carrying the NULL-safe tenant key part needs + * it. A yes is kept; a no is asked again next time, so an answer taken + * before the ledger could be read never outlives that moment. + */ + protected async isTenantKeyCertified(): Promise { + if (this.tenantKeyCertified) return true; + const resolver = this.tenantKeyCertifiedResolver; + if (!resolver) return false; + try { + // Asked inside the pre-DDL scope: a store whose ledger does not exist + // yet answers with a refused read, which is not a fault (see + // {@link PRE_DDL_QUESTION_SCOPE}). + if ((await PRE_DDL_QUESTION_SCOPE.run(true, resolver)) !== true) return false; + } catch { + return false; + } + this.tenantKeyCertified = true; + return true; + } + protected async resolveFileColumnsMoved(): Promise { const resolver = this.fileColumnsMovedResolver; if (!resolver) { diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index 88f0db0e362..458bb512481 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -4076,8 +4076,12 @@ export class TursoDriver extends SqlDriver { const parts = columns.map((c) => nullSafe.has(c) ? `COALESCE(${quoteRemoteIdentifier(c)}, '${GLOBAL_TENANT}')` : quoteRemoteIdentifier(c), ); + // [ADR-0131 D1] A row with a NULL plain key part conflicts with nothing + // under the NULL-distinct UNIQUE; the local face leaves it out the same way. + const notNull = columns.filter((c) => !nullSafe.has(c)).map((c) => `${quoteRemoteIdentifier(c)} IS NOT NULL`); const sql = - `SELECT ${parts.map((p, i) => `${p} AS k${i}`).join(', ')}, COUNT(*) AS n FROM ${quoteRemoteIdentifier(table)} ` + + `SELECT ${parts.map((p, i) => `${p} AS k${i}`).join(', ')}, COUNT(*) AS n FROM ${quoteRemoteIdentifier(table)}` + + `${notNull.length > 0 ? ` WHERE ${notNull.join(' AND ')}` : ''} ` + `GROUP BY ${parts.join(', ')} HAVING COUNT(*) > 1 ORDER BY n DESC LIMIT 20`; return (await this.remoteCatalogRead(sql)).map((r) => ({ key: columns.map((c, i) => `${c}=${JSON.stringify(r[`k${i}`])}`).join(', '), diff --git a/packages/metadata-protocol/src/migrations/sys-setting-identity-index.ts b/packages/metadata-protocol/src/migrations/sys-setting-identity-index.ts index 971b734fe1b..5c3aeb585db 100644 --- a/packages/metadata-protocol/src/migrations/sys-setting-identity-index.ts +++ b/packages/metadata-protocol/src/migrations/sys-setting-identity-index.ts @@ -86,16 +86,11 @@ * * ## The KEY, and why each sentinel is copied rather than chosen * - * Both spellings already exist in this repo; neither is invented here. - * - * - `organization_id` → `'__global__'`. ADR-0120 D3's exact form for a tenant - * column, and the literal `SqlDriver`'s `GLOBAL_TENANT` / - * `organizationKeyPartSql` materializes. It is reproduced (not imported) - * because this package must not depend on a driver — so the literal is pinned - * by the sibling test, and a silent edit here cannot re-partition the index. - * ⚠️ Reproducing it is also what keeps this a pure tightening of the SHIPPED - * index rather than a re-keying of it: change this literal and the platform - * bucket moves. + * The spelling already exists in this repo; it is not invented here. + * + * - `organization_id` → none since ADR-0131 D1: the tenant column is NOT NULL, + * so its key part is the plain column, exactly as the driver now keys it. + * Before that it was `'__global__'`, ADR-0120 D3's form for a tenant column. * - `user_id` → `''`. The `ensureOverlayIndex` precedent for a NON-tenant * nullable discriminator (`COALESCE(package_id, '')`), whose comment states * this same NULL-distinct reason. A user id is never the empty string, so the @@ -208,14 +203,19 @@ export const SYS_SETTING_IDENTITY_INDEX_COLUMNS = [ * * A column listed here is materialized as `COALESCE(, '')` so * its NULL rows form ONE bucket that is unique among itself, instead of being - * mutually DISTINCT and therefore unconstrained. Both spellings are copied from - * an existing in-repo precedent — see the module header for which, and why - * neither may be edited casually. - * - * `namespace`, `key` and `scope` are required and take no sentinel. + * mutually DISTINCT and therefore unconstrained. The spelling is copied from + * an existing in-repo precedent — see the module header for which, and why it + * may not be edited casually. + * + * `namespace`, `key` and `scope` are required and take no sentinel, and since + * ADR-0131 D1 neither does `organization_id`: the tenant column is NOT NULL + * (the organization-ownership ceremony moves the `scope = 'global'` rows out + * to `sys_platform_setting`), so the driver keys it plain and so does this + * index. An index an earlier release built with `COALESCE(organization_id, + * '__global__')` is rebuilt to this key by the next boot's run, which rebuilds + * the definition on every boot anyway. */ export const SYS_SETTING_NULL_SENTINELS: Readonly> = { - organization_id: '__global__', user_id: '', }; @@ -326,9 +326,8 @@ export function buildSysSettingPresenceSql(): string { * exactly TWO dialects that can build the index this query explains, so it must * be legal on both, not on the lenient one. * - * Folding costs the operator nothing: neither sentinel can occur in real data, - * so `organization_id_key = '__global__'` reads as "organization_id IS NULL" and - * `user_id_key = ''` as "user_id IS NULL". + * Folding costs the operator nothing: the sentinel cannot occur in real data, + * so `user_id_key = ''` reads as "user_id IS NULL". */ export function buildSysSettingDuplicateProbeSql(): string { return duplicateProbeSqlIn(BARE); diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index b857b0a3d3e..4e044b47bd9 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -91,6 +91,9 @@ import { DATA_MIGRATION_FLAG_OBJECT, FILE_REFERENCES_MIGRATION_ID, VALUE_SHAPES_MIGRATION_ID, + ORGANIZATION_OWNERSHIP_MIGRATION_ID, + ORGANIZATION_OWNERSHIP_SHAPE_GENERATION, + organizationOwnershipShapeGenerationOf, isDataMigrationFlagVerified, hasMovedFileColumns, renderOperationMessage, @@ -9043,6 +9046,7 @@ export class ObjectQL implements IObjectQLEngine { this.drivers.set(driver.name, driver); this.supplyFileColumnsMovedResolver(driver); + this.supplyTenantKeyCertifiedResolver(driver); this.logger.info('Registered driver', { driverName: driver.name, version: driver.version @@ -12187,6 +12191,61 @@ export class ObjectQL implements IObjectQLEngine { return (await this.readFileReferencesFlagRow()).verified; } + /** + * [ADR-0131 D1] Hand a freshly-registered driver the question "is this + * database certified", the same closure-not-value seam as + * {@link supplyFileColumnsMovedResolver} and for the same reason: the answer + * is a `sys_migration` row on a store this driver has not connected to yet. + * A SQL driver asks it only when a table carries an index an earlier release + * built with the NULL-safe `COALESCE(, …)` key part, to decide + * whether that index already is the plain key new DDL emits. A driver with + * no such seam is left alone. + */ + private supplyTenantKeyCertifiedResolver(driver: IDataDriver): void { + const sink = driver as unknown as { + setTenantKeyCertifiedResolver?: (resolve: () => Promise) => void; + }; + if (typeof sink.setTenantKeyCertifiedResolver !== 'function') return; + try { + sink.setTenantKeyCertifiedResolver(() => this.isOrganizationOwnershipCertified()); + } catch (e: any) { + // A driver that did not take the resolver answers "not certified", the + // reading that never proposes a rebuild on a guess. + this.logger.debug('Driver declined the ADR-0131 certification resolver', { + driverName: driver.name, + error: e?.message ?? String(e), + }); + } + } + + /** + * [ADR-0131 D1] Is this database certified for the organization-ownership + * shape — a verified `sys_migration` row for the ceremony's id (the birth + * marker a store created from empty receives, or the ceremony's completion + * marker) at this release's shape generation or above? The same reading the + * runtime's boot guard boots on. Every way of not knowing (no ledger object, + * no row, an unreadable read, an unverified or older row) answers `false`. + */ + async isOrganizationOwnershipCertified(): Promise { + if (!this._registry.getObject(DATA_MIGRATION_FLAG_OBJECT)) return false; + try { + const row: any = await this.findOne(DATA_MIGRATION_FLAG_OBJECT, { + where: { id: ORGANIZATION_OWNERSHIP_MIGRATION_ID }, + context: { isSystem: true } as ExecutionContext, + }); + if (!row || row.id !== ORGANIZATION_OWNERSHIP_MIGRATION_ID) return false; + const verified = isDataMigrationFlagVerified({ + id: ORGANIZATION_OWNERSHIP_MIGRATION_ID, + last_run_at: String(row.last_run_at ?? ''), + verified_at: row.verified_at == null ? null : String(row.verified_at), + blocking: typeof row.blocking === 'number' ? row.blocking : Number(row.blocking ?? Number.NaN), + }); + return verified && organizationOwnershipShapeGenerationOf(row.details) >= ORGANIZATION_OWNERSHIP_SHAPE_GENERATION; + } catch { + return false; + } + } + /** * The one memoized read both file-flag questions come off (#15989). * From 6fa53ac3f71dd2f00b73cd3151a009609a0a1f9a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:19:56 +0000 Subject: [PATCH 61/81] wip(driver-turso): the remote-face table answers setTenantKeyCertifiedResolver Claude-Session: https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd Co-authored-by: Claude --- packages/drivers/driver-turso/src/turso-driver.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index 458bb512481..8b0ded12cf8 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -1092,6 +1092,11 @@ export const REMOTE_FACE_ANSWERS = { // `false`, the method's own "not taken" answer: only the Knex `initObjects` // asks the resolver, so on this face it would never be asked. setFileColumnsMovedResolver: 'remote', + // [ADR-0131 D1] In-memory bookkeeping with no Knex: the resolver is asked by + // the shared index differ, which the remote drift detector runs, so the + // remote face reads an earlier release's COALESCE tenant key part as the + // plain one on a certified database exactly as the local face does. + setTenantKeyCertifiedResolver: 'inherited', // `false`: remote mode opens no in-memory database. sqliteOpenedEmptyInMemory: 'inherited', // `'sqlite'`: libSQL speaks SQLite, and `execute()` sends what a caller From 86940d83040bd5356ac4d9847265042d55f51bc5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:30:21 +0000 Subject: [PATCH 62/81] test(driver-sql, dogfood): pin the tenant key equivalence and the plain DDL, on the differ and on the ceremonied 18.0 fixture Claude-Session: https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd Co-authored-by: Claude --- .../sql-driver-tenant-key-equivalence.test.ts | 188 ++++++++++++++++ ...rade-18x-index-equivalence.dogfood.test.ts | 202 ++++++++++++++++++ 2 files changed, 390 insertions(+) create mode 100644 packages/drivers/driver-sql/src/sql-driver-tenant-key-equivalence.test.ts create mode 100644 packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.ts diff --git a/packages/drivers/driver-sql/src/sql-driver-tenant-key-equivalence.test.ts b/packages/drivers/driver-sql/src/sql-driver-tenant-key-equivalence.test.ts new file mode 100644 index 00000000000..f7a57e218f5 --- /dev/null +++ b/packages/drivers/driver-sql/src/sql-driver-tenant-key-equivalence.test.ts @@ -0,0 +1,188 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { SqlDriver } from './sql-driver.js'; +import { + carriesNullSafeTenantKeyPart, + diffManagedIndexes, + hashShadowColumnFor, + normalizeDeclaredIndex, + uniqueIndexesFromFields, + type ExpectedIndex, + type PhysicalIndex, +} from './schema-drift.js'; +import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; + +/** + * [18.x step 7b-1 + 7b-2] The organization key part of a unique index. + * + * 18.0 keyed every organization-scoped unique through ADR-0120 D3's + * `COALESCE(organization_id, '__global__')`. Since ADR-0131 D1 the tenant + * column is NOT NULL, so new DDL keys the plain column (7b-2), and the differ + * reads the old form as the same constraint wherever the tenant column holds + * no NULL: physically NOT NULL, or a certified database — a birth marker or an + * organization-ownership ceremony marker of the current shape generation + * (7b-1). Elsewhere the two forms are different constraints, and that + * reading stays: the uncertified, nullable legs below are each case's control. + */ + +const TABLE = 'eqv_account'; +const ORG = 'organization_id'; +const NAME = `uniq_${TABLE}_${ORG}_code`; + +/** What 18.0 built: the NULL-safe organization key part, attributed by introspection. */ +const coalesced: PhysicalIndex = { + name: NAME, + columns: [ORG, 'code'], + unique: true, + expressions: [`COALESCE("${ORG}", '__global__')`], + nullSafeColumns: [ORG], +}; +/** What HEAD builds: the plain composite. */ +const plain: PhysicalIndex = { name: NAME, columns: [ORG, 'code'], unique: true }; +const expectedPlain: ExpectedIndex = { name: NAME, columns: [ORG, 'code'], unique: true }; +const expectedNullSafe: ExpectedIndex = { ...expectedPlain, nullSafeColumns: [ORG] }; + +const diff = (expected: ExpectedIndex, physical: PhysicalIndex, tenantKeyCertified?: boolean, tenantField: string | null = ORG) => + diffManagedIndexes({ table: TABLE, expected: [expected], legacy: [], physical: [physical], tenantField, tenantKeyCertified }); + +describe('7b-2 — the generators key the plain organization column', () => { + it('a field-level organization unique is the plain composite, tenant column first', () => { + expect(uniqueIndexesFromFields(TABLE, { code: { type: 'text', unique: true } }, ORG)).toEqual([expectedPlain]); + }); + + it("a declared `unique: 'organization'` prepends the plain tenant column", () => { + expect(normalizeDeclaredIndex(TABLE, { fields: ['code'], unique: 'organization' }, ORG)).toEqual(expectedPlain); + }); + + it('an input already normalized with a NULL-safe part is honoured verbatim (the read vocabulary stays)', () => { + expect(normalizeDeclaredIndex(TABLE, { name: NAME, fields: [ORG, 'code'], unique: true, nullSafeColumns: [ORG] }, ORG)).toEqual( + expectedNullSafe, + ); + }); +}); + +describe('7b-1 — the differ: COALESCE(tenant) ≡ tenant where the tenant column holds no NULL', () => { + it('an 18.0 COALESCE index against the plain expectation: no drift when certified', () => { + expect(diff(expectedPlain, coalesced, true)).toEqual([]); + }); + + it('control — the same pair, uncertified: a destructive recreate_index (the two are different constraints)', () => { + const [entry, ...rest] = diff(expectedPlain, coalesced, false); + expect(rest).toEqual([]); + expect(entry).toMatchObject({ category: 'destructive', op: { type: 'recreate_index', indexName: NAME } }); + }); + + it('the other direction: a plain physical index against a NULL-safe expectation, no drift when certified', () => { + expect(diff(expectedNullSafe, plain, true)).toEqual([]); + expect(diff(expectedNullSafe, plain, false).map((d) => d.op.type)).toEqual(['recreate_index']); + }); + + it('the equivalence is the TENANT column\'s: with no tenant column named it does not apply', () => { + expect(diff(expectedPlain, coalesced, true, null).map((d) => d.op.type)).not.toEqual([]); + }); + + it('it changes the form of the key part only — a different column set stays drift, certified or not', () => { + const other: ExpectedIndex = { name: NAME, columns: [ORG, 'code', 'region'], unique: true }; + expect(diff(other, coalesced, true).map((d) => d.op.type)).toEqual(['recreate_index']); + }); + + it('a MySQL hash shadow carrying the COALESCE key reads as the plain key when certified', () => { + const carrier: PhysicalIndex = { + name: NAME, + columns: [hashShadowColumnFor(NAME)], + unique: true, + shadowKey: [ + { column: ORG, nullSafe: true }, + { column: 'code', nullSafe: false }, + ], + }; + expect(diff(expectedPlain, carrier, true)).toEqual([]); + expect(diff(expectedPlain, carrier, false).map((d) => d.op.type)).toEqual(['recreate_index']); + }); + + it('carriesNullSafeTenantKeyPart answers for direct and shadow-carried keys, and only for the tenant column', () => { + expect(carriesNullSafeTenantKeyPart([coalesced], ORG)).toBe(true); + expect(carriesNullSafeTenantKeyPart([plain], ORG)).toBe(false); + expect(carriesNullSafeTenantKeyPart([coalesced], null)).toBe(false); + expect(carriesNullSafeTenantKeyPart([{ ...coalesced, nullSafeColumns: ['package_id'] }], ORG)).toBe(false); + }); +}); + +describe('the driver, on real SQLite', () => { + let driver: SqlDriver; + const object = { name: TABLE, fields: { code: { type: 'text', unique: true }, [ORG]: { type: 'text' } } }; + + beforeEach(async () => { + driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }); + await driver.initObjects([object]); + }); + + afterEach(async () => { + await driver.disconnect(); + }); + + const knex = () => (driver as unknown as { knex: { raw(sql: string, b?: unknown[]): Promise } }).knex; + const indexSql = async (): Promise => { + const rows: any[] = await knex().raw(`SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?`, [NAME]); + return String(rows[0]?.sql ?? ''); + }; + /** The table as 18.0 left it: a nullable tenant column and the COALESCE index under the declared name. */ + const as18Point0 = async (): Promise => { + await asPre18TenantColumn(driver, TABLE); + await knex().raw(`DROP INDEX "${NAME}"`); + await knex().raw(`CREATE UNIQUE INDEX "${NAME}" ON "${TABLE}" (COALESCE("${ORG}", '__global__'), "code")`); + }; + const indexOps = async () => (await driver.detectManagedDrift()).filter((d) => d.op.type.endsWith('_index')); + + it('a fresh table gets the plain composite — no COALESCE — and no index drift', async () => { + const sql = await indexSql(); + expect(sql).toMatch(/\(\s*[`"]?organization_id[`"]?\s*,\s*[`"]?code[`"]?\s*\)/); + expect(sql).not.toMatch(/COALESCE/i); + expect(await indexOps()).toEqual([]); + }); + + it('an 18.0 table on a certified database: no drift, and the certification is asked', async () => { + await as18Point0(); + let asked = 0; + driver.setTenantKeyCertifiedResolver(() => { + asked++; + return true; + }); + expect(await indexOps()).toEqual([]); + expect(asked).toBeGreaterThan(0); + expect(await indexSql()).toMatch(/COALESCE/); + }); + + it('control — the same table with no certification: the destructive recreate_index the equivalence exists to remove', async () => { + await as18Point0(); + expect((await indexOps()).map((d) => [d.op.type, d.category])).toEqual([['recreate_index', 'destructive']]); + }); + + it('a resolver that answers no, or throws, is "not certified"', async () => { + await as18Point0(); + driver.setTenantKeyCertifiedResolver(() => false); + expect((await indexOps()).map((d) => d.op.type)).toEqual(['recreate_index']); + driver.setTenantKeyCertifiedResolver(() => { + throw new Error('ledger unreadable'); + }); + expect((await indexOps()).map((d) => d.op.type)).toEqual(['recreate_index']); + }); + + it('a physically NOT NULL tenant column is its own witness: no drift without any certification', async () => { + await knex().raw(`DROP INDEX "${NAME}"`); + await knex().raw(`CREATE UNIQUE INDEX "${NAME}" ON "${TABLE}" (COALESCE("${ORG}", '__global__'), "code")`); + expect(await indexOps()).toEqual([]); + }); + + it('a table carrying no COALESCE tenant key part never asks the question', async () => { + let asked = 0; + driver.setTenantKeyCertifiedResolver(() => { + asked++; + return true; + }); + await asPre18TenantColumn(driver, TABLE); + expect(await indexOps()).toEqual([]); + expect(asked).toBe(0); + }); +}); diff --git a/packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.ts b/packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.ts new file mode 100644 index 00000000000..56fac8b8509 --- /dev/null +++ b/packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.ts @@ -0,0 +1,202 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [18.x step 7b-1 + 7b-2] The organization key part of a unique index, across +// the upgrade. +// +// An 18.0 database keys every organization-scoped unique index through the +// ADR-0120 D3 form `COALESCE(organization_id, '__global__')`. HEAD's new DDL +// keys the plain column (ADR-0131 D1: the tenant column is NOT NULL), and its +// differ reads the old form as equivalent where the tenant column holds no +// NULL: physically NOT NULL, or a certified database — a birth marker or the +// ownership ceremony's marker. Without that equivalence every one of those +// indexes reads as "same name, different definition", a `recreate_index`: a +// DROP of a live unique constraint, proposed at every boot and by +// `os migrate plan` to every upgraded deployment. +// +// What the ceremony itself does to them is measured, not assumed: on SQLite its +// NOT NULL step rebuilds each table through the driver's drift apply door, and +// the rebuild re-keys the table's indexes with this release's DDL — plain. A +// table the ceremony leaves nullable (`sys_email_template` here) keeps its +// 18.0 index, and only the certification makes it equivalent. PostgreSQL and +// MySQL take NOT NULL by `ALTER COLUMN` and a remote Turso store not at all, so +// there every 18.0 index survives the ceremony; a SQLite store an earlier 18.x +// ceremony certified holds them too. The second block models that store by +// restoring the 13 indexes exactly as 18.0 wrote them. +import { copyFileSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DatabaseSync } from 'node:sqlite'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { VerifyStack } from '@objectstack/verify'; +import { ceremoniedFixture } from './upgrade-harness/ceremony.js'; +import { bootFixture } from './upgrade-harness/populate-fixture.js'; + +const fixtureSql = readFileSync(new URL('./upgrade-harness/fixture-18.0/database.sql', import.meta.url), 'utf8'); + +/** One unique index of the file, as SQLite recorded its DDL. */ +interface UniqueIndex { + name: string; + table: string; + sql: string; +} + +/** A tenant key part in the ADR-0120 D3 form, quoted or not. */ +const COALESCE_ORG = /COALESCE\(\s*[`"]?organization_id[`"]?\s*,/i; + +/** The 18.0 fixture's own `CREATE UNIQUE INDEX` statements over the COALESCE organization key part. */ +const COALESCE_INDEXES_18_0: Array<{ name: string; sql: string }> = fixtureSql + .split('\n') + .filter((line) => /^CREATE UNIQUE INDEX/i.test(line) && COALESCE_ORG.test(line)) + .map((line) => ({ + name: /^CREATE UNIQUE INDEX\s+[`"]?([A-Za-z0-9_]+)/i.exec(line)![1]!, + sql: line.replace(/;\s*$/, ''), + })); + +/** Every UNIQUE index of `file` whose DDL keys the organization column. */ +function organizationUniqueIndexes(file: string): UniqueIndex[] { + const db = new DatabaseSync(file, { readOnly: true }); + try { + return ( + db + .prepare("SELECT name, tbl_name AS \"table\", sql FROM sqlite_master WHERE type = 'index' AND sql IS NOT NULL ORDER BY name") + .all() as unknown as UniqueIndex[] + ).filter((i) => /^CREATE UNIQUE INDEX/i.test(i.sql) && /organization_id/.test(i.sql)); + } finally { + db.close(); + } +} + +/** Whether `table`'s organization column is physically NOT NULL in `file`. */ +function organizationNotNull(file: string, table: string): boolean { + const db = new DatabaseSync(file, { readOnly: true }); + try { + const row = db.prepare("SELECT \"notnull\" AS nn FROM pragma_table_info(?) WHERE name = 'organization_id'").get(table) as + | { nn: number } + | undefined; + return row?.nn === 1; + } finally { + db.close(); + } +} + +type DriftEntry = { table: string; category: string; op: { type: string } }; + +/** The schema differ's verdict on the booted store, over every object it synced. */ +async function managedDrift(stack: VerifyStack): Promise { + const ql = (await stack.kernel.getServiceAsync('objectql')) as { + getDriverForObject(object: string): { detectManagedDrift?: () => Promise }; + }; + const driver = ql.getDriverForObject('sys_migration'); + expect(typeof driver.detectManagedDrift, 'the store serving the ledger is the SQL driver').toBe('function'); + return driver.detectManagedDrift!(); +} + +/** No index op the differ would propose to DROP anything: no recreate, nothing destructive. */ +function expectNoDestructiveIndexDrift(drift: DriftEntry[]): void { + const recreate = drift.filter((d) => d.op.type === 'recreate_index'); + expect(recreate, JSON.stringify(recreate, null, 2)).toEqual([]); + const destructive = drift.filter((d) => d.category === 'destructive'); + expect(destructive, JSON.stringify(destructive, null, 2)).toEqual([]); +} + +describe('[18.x step 7b] the organization key part across the 18.0 → HEAD upgrade', () => { + const dir = mkdtempSync(join(tmpdir(), 'dogfood-index-equivalence-')); + let ceremonied = ''; + + beforeAll(async () => { + ceremonied = (await ceremoniedFixture(dir, fixtureSql, 'equivalence')).file; + }, 900_000); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + it('the 18.0 fixture carries 13 COALESCE organization unique indexes (a vacuous fixture is no pin)', () => { + expect(COALESCE_INDEXES_18_0).toHaveLength(13); + }); + + describe('the ceremonied database as the ceremony left it', () => { + let stack: VerifyStack | undefined; + let before: UniqueIndex[] = []; + + beforeAll(async () => { + before = organizationUniqueIndexes(ceremonied); + stack = await bootFixture(ceremonied); + }, 300_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it("sys_email_template stays nullable and keeps its 18.0 COALESCE index: the certification is what carries it", () => { + expect(organizationNotNull(ceremonied, 'sys_email_template')).toBe(false); + const kept = before.filter((i) => COALESCE_ORG.test(i.sql)).map((i) => i.name); + expect(kept).toContain('uniq_sys_email_template_organization_id_name_locale'); + }); + + it("HEAD's differ reports zero recreate_index and zero destructive drift", async () => { + expectNoDestructiveIndexDrift(await managedDrift(stack!)); + }); + }); + + describe('the ceremonied database holding all 13 indexes exactly as 18.0 wrote them', () => { + const restored = join(dir, 'restored.db'); + let stack: VerifyStack | undefined; + + beforeAll(async () => { + copyFileSync(ceremonied, restored); + const db = new DatabaseSync(restored); + try { + for (const index of COALESCE_INDEXES_18_0) { + db.exec(`DROP INDEX IF EXISTS "${index.name}"`); + db.exec(index.sql); + } + } finally { + db.close(); + } + stack = await bootFixture(restored); + }, 300_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it("HEAD's differ reports zero recreate_index and zero destructive drift", async () => { + expectNoDestructiveIndexDrift(await managedDrift(stack!)); + }); + + it('the 12 declared organization composites survive the HEAD boot byte for byte', () => { + // `sys_setting`'s identity index is the thirteenth: the metadata + // protocol's own runtime migration rebuilds it on every boot, now over + // the plain organization key. + const declared = COALESCE_INDEXES_18_0.filter((i) => !i.name.startsWith('uniq_sys_setting_')); + expect(declared).toHaveLength(12); + const after = new Map(organizationUniqueIndexes(restored).map((i) => [i.name, i.sql])); + for (const index of declared) expect(after.get(index.name), index.name).toBe(index.sql); + }); + }); + + describe('a store HEAD creates from empty', () => { + const fresh = join(dir, 'fresh.db'); + let stack: VerifyStack | undefined; + + beforeAll(async () => { + stack = await bootFixture(fresh); + }, 300_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('keys every organization unique index on the plain column: no COALESCE organization key part anywhere', () => { + const indexes = organizationUniqueIndexes(fresh); + expect(indexes.length, 'the fresh store holds organization unique indexes').toBeGreaterThanOrEqual(13); + expect(indexes.filter((i) => COALESCE_ORG.test(i.sql)).map((i) => i.name)).toEqual([]); + }); + + it('and its differ reports no index drift', async () => { + const drift = await managedDrift(stack!); + expect(drift.filter((d) => d.op.type.endsWith('_index'))).toEqual([]); + }); + }); +}); From 0ad6c5b613ce39b2585987e2a1663faeec315dfb Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:53:04 +0000 Subject: [PATCH 63/81] test(driver-sql, objectql): re-pin the tenant-scoped declared uniques to the plain organization key part; pin the engine's certification supply Claude-Session: https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd Co-authored-by: Claude --- .../24583-plain-organization-key-part.md | 19 +++ ...8577-tenant-scoped-declared-unique.test.ts | 69 +++++--- ...iver-tenant-scoped-declared-unique.test.ts | 60 ++++--- ...dr0131-tenant-key-certified-supply.test.ts | 153 ++++++++++++++++++ 4 files changed, 257 insertions(+), 44 deletions(-) create mode 100644 .changeset/24583-plain-organization-key-part.md create mode 100644 packages/objectql/src/adr0131-tenant-key-certified-supply.test.ts diff --git a/.changeset/24583-plain-organization-key-part.md b/.changeset/24583-plain-organization-key-part.md new file mode 100644 index 00000000000..354c24e73cf --- /dev/null +++ b/.changeset/24583-plain-organization-key-part.md @@ -0,0 +1,19 @@ +--- +'@objectstack/driver-sql': minor +'@objectstack/driver-turso': minor +'@objectstack/driver-memory': minor +'@objectstack/objectql': minor +'@objectstack/metadata-protocol': minor +'@objectstack/cli': minor +--- + +feat(driver-sql,driver-turso,driver-memory,objectql,metadata-protocol,cli): an organization-scoped unique index keys the plain organization column, and an index an earlier release built with `COALESCE(organization_id, '__global__')` is read as the same index on a certified database + +Clause-②: yes (widening) + +18.x step 7b-1 + 7b-2 (ADR-0131 D1: the tenant column is NOT NULL, so the NULL-organization bucket of ADR-0120 D3 has no rows left to hold). + +- **New DDL is plain.** A field-level `unique: true` / `'organization'` on a tenant-scoped object, and a declared index with `unique: 'organization'`, now build `UNIQUE (organization_id, …)` instead of `UNIQUE (COALESCE(organization_id, '__global__'), …)`. Index names do not change. The same holds for the Turso remote face, the MySQL hash shadow (its generated column now hashes the plain column), the in-memory driver's unique enforcement, `os generate migration` (which now emits the organization composite it used to list as NOT EMITTED), and the `sys_setting` row-identity index (`COALESCE(user_id, '')` stays). +- **Existing indexes stay as they are.** Nothing is renamed, dropped or rebuilt by this release. The schema differ reads `COALESCE(, …)` and the plain tenant column as one key part, in both directions, where the tenant column holds no NULL: the column is physically `NOT NULL`, or the database is certified (its organization-ownership birth marker, or the ownership ceremony's marker at the current shape generation). So `os migrate plan` and the boot report no `recreate_index` for those indexes. On an uncertified database with a nullable tenant column the two forms stay different constraints, as before. +- **What widens.** Where a tenant column is still nullable (an uncertified 17.x / 18.0 database, a rotation shard, a Turso remote table), a unique index created from now on does not constrain rows whose organization is NULL, exactly like a NULL in any other key part; in the in-memory driver a record with no organization is exempt from an organization-scoped unique. The unique duplicate pre-flight no longer reports groups of rows with a NULL key part, since the index never refuses them. +- **Who sees it.** A certified database or a fresh install sees nothing: every row carries its organization there. Nothing to do. diff --git a/packages/drivers/driver-sql/src/sql-driver-8577-tenant-scoped-declared-unique.test.ts b/packages/drivers/driver-sql/src/sql-driver-8577-tenant-scoped-declared-unique.test.ts index 449ce0fcc1a..9e6878d7e80 100644 --- a/packages/drivers/driver-sql/src/sql-driver-8577-tenant-scoped-declared-unique.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-8577-tenant-scoped-declared-unique.test.ts @@ -74,6 +74,16 @@ import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; * the safe half keeps the defect while the plan reads as applied. Section 3 is * therefore the load-bearing part of this file: it builds installations that * ALREADY HAVE the old index and real rows, then migrates them. + * + * ## The organization key part is PLAIN (ADR-0131 D1) + * + * The replacement once keyed `COALESCE(organization_id, '')` — ADR-0120 D3's + * NULL-safe part, which made the organization-less rows one more bucket that + * refused duplicates among themselves. Since 18.x a fresh tenant column is NOT + * NULL and the driver keys the PLAIN column: `(organization_id, …listed)`. On a + * pre-18 database whose tenant column is still nullable, rows with no + * organization are not constrained at all — SQL UNIQUE is NULL-distinct — and + * this file pins that exemption rather than the retired bucket. */ /** The wire shape a duplicate insert must produce, per `rest-server.ts`. */ @@ -291,12 +301,14 @@ describe('#8577 — two more tenant-scoped declared unique indexes become per-or // 1. Materialized shape — both spellings, kept side by side // ─────────────────────────────────────────────────────────────────── - it('the fixed declaration keys on the NULL-safe organization part', async () => { + it('the fixed declaration keys on the PLAIN organization part (ADR-0131 D1)', async () => { + // No COALESCE: a fresh tenant column is NOT NULL, so the plain column + // is already a total key part. const d = makeDriver(); await d.initObjects(app(sub, 'fixed') as any); expect(await uniqueKeyParts(sub.table)).toEqual({ - [sub.replacementName]: ['COALESCE(organization_id)', ...sub.keyColumns], + [sub.replacementName]: ['organization_id', ...sub.keyColumns], }); }); @@ -363,16 +375,18 @@ describe('#8577 — two more tenant-scoped declared unique indexes become per-or expect(await d.count(sub.table, {})).toBe(1); }); - it('AFTER: rows with no organization stay unique among THEMSELVES (ADR-0120 D3)', async () => { - // Platform/seed rows carry no organization. A bare - // `(organization_id, …)` composite would be NULL-DISTINCT under SQL, so - // every seeded row could be duplicated at will; the NULL-safe key part - // is what prevents that, and seed upsert-by-name relies on it. + it('AFTER: on a pre-18 nullable column, rows with no organization are NOT constrained (ADR-0131 D1)', async () => { + // Platform/seed rows carry no organization. ADR-0120 D3's NULL-safe + // key part once made them a bucket that refused duplicates among + // themselves; ADR-0131 D1 retires it. The key is the PLAIN + // `(organization_id, …)` composite, NULL-DISTINCT under SQL, so on a + // database that still holds such rows they are exempt from the key. // - // ⚠️ On `sys_audience_binding_suggestion` this is not a hypothetical - // bucket: the shipped `syncAudienceBindingSuggestions` writes with a - // hardcoded `{ isSystem: true }` context carrying no tenant, so every - // row it creates today lands organization-less. See + // ⚠️ On `sys_audience_binding_suggestion` such rows are not + // hypothetical on a pre-18 database: the shipped + // `syncAudienceBindingSuggestions` wrote with a hardcoded + // `{ isSystem: true }` context carrying no tenant, so the rows it + // created landed organization-less. See // `suggested-audience-bindings-install-path.test.ts` §3. const d = makeDriver(); await d.initObjects(app(sub, 'fixed') as any); @@ -381,13 +395,15 @@ describe('#8577 — two more tenant-scoped declared unique indexes become per-or await asPre18TenantColumn(d, sub.table); expect((await createAsApi(d, sub.table, row(sub, 'seed1', undefined, sub.key))).status).toBe(201); - expect(await createAsApi(d, sub.table, row(sub, 'seed2', undefined, sub.key))).toMatchObject( - CONFLICT_ENVELOPE, - ); + expect((await createAsApi(d, sub.table, row(sub, 'seed2', undefined, sub.key))).status).toBe(201); - // A tenant may still hold its OWN row of the same key — the platform - // bucket and the organization buckets are separate namespaces. + // A tenant may still hold its OWN row of the same key, and the + // organization's key stays enforced on the very same table — the + // exemption is the NULL organization's alone, not a dropped constraint. expect((await createAsApi(d, sub.table, row(sub, 'own1', 'org_jia', sub.key))).status).toBe(201); + expect(await createAsApi(d, sub.table, row(sub, 'own2', 'org_jia', sub.key))).toMatchObject( + CONFLICT_ENVELOPE, + ); }); it('the key is the whole COMPOSITE — varying a trailing column was always accepted', async () => { @@ -469,8 +485,9 @@ describe('#8577 — two more tenant-scoped declared unique indexes become per-or dropIndexNames: [sub.legacyName], createIndexName: sub.replacementName, createColumns: ['organization_id', ...sub.keyColumns], - nullSafeColumns: ['organization_id'], }); + // ADR-0131 D1: the replacement keys the PLAIN organization column. + expect((entry!.op as any).nullSafeColumns).toBeUndefined(); // ⛔ The old index must NOT ALSO surface as an orphan. An orphan drop // is `destructive`, so an operator applying only the safe half would @@ -493,8 +510,10 @@ describe('#8577 — two more tenant-scoped declared unique indexes become per-or expect(applied.some((e) => e.op.type === 'replace_unique_index')).toBe(true); expect(skipped).toHaveLength(0); + // The PLAIN organization part (ADR-0131 D1), even on this pre-18 + // nullable column. expect(await uniqueKeyParts(sub.table)).toEqual({ - [sub.replacementName]: ['COALESCE(organization_id)', ...sub.keyColumns], + [sub.replacementName]: ['organization_id', ...sub.keyColumns], }); expect(await d.count(sub.table, {})).toBe(3); @@ -517,10 +536,11 @@ describe('#8577 — two more tenant-scoped declared unique indexes become per-or CONFLICT_ENVELOPE, ); - // …and the organization-less bucket survived the migration intact: - // the seeded platform row still blocks a duplicate of ITSELF. - expect(await createAsApi(d, sub.table, row(sub, 'z3', undefined, sub.platformKey))).toMatchObject( - CONFLICT_ENVELOPE, + // …and the seeded organization-less row is exempt from the PLAIN key + // (ADR-0131 D1): SQL UNIQUE is NULL-distinct, so a duplicate of it is + // accepted — the retired ADR-0120 D3 bucket did not survive. + expect((await createAsApi(d, sub.table, row(sub, 'z3', undefined, sub.platformKey))).status).toBe( + 201, ); }); @@ -592,9 +612,10 @@ describe('#8577 — two more tenant-scoped declared unique indexes become per-or name: sub.replacementName, columns: ['organization_id', ...sub.keyColumns], unique: true, - nullSafeColumns: ['organization_id'], }, }); + // ADR-0131 D1: no NULL-safe part — the organization column is keyed plain. + expect(entry!.replacement.nullSafeColumns).toBeUndefined(); }); it('claims nothing for an EXPLICITLY NAMED index — that transition is a recreate', () => { @@ -654,7 +675,7 @@ describe('#8577 — two more tenant-scoped declared unique indexes become per-or const d = makeDriver(); await d.initObjects(app(sub, 'fixed') as any); expect(await uniqueKeyParts(sub.table), `${sub.table} posture=${posture}`).toEqual({ - [sub.replacementName]: ['COALESCE(organization_id)', ...sub.keyColumns], + [sub.replacementName]: ['organization_id', ...sub.keyColumns], }); await d.disconnect(); driver = undefined; diff --git a/packages/drivers/driver-sql/src/sql-driver-tenant-scoped-declared-unique.test.ts b/packages/drivers/driver-sql/src/sql-driver-tenant-scoped-declared-unique.test.ts index 6242d96eff2..d77e131a8e0 100644 --- a/packages/drivers/driver-sql/src/sql-driver-tenant-scoped-declared-unique.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-tenant-scoped-declared-unique.test.ts @@ -66,6 +66,16 @@ import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; * half keeps the defect while the plan reads as applied. Section 3 is therefore * the load-bearing part of this file: it builds installations that ALREADY HAVE * the old index and real rows, then migrates them. + * + * ## The organization key part is PLAIN (ADR-0131 D1) + * + * The replacement once keyed `COALESCE(organization_id, '')` — ADR-0120 D3's + * NULL-safe part, which made the organization-less rows one more bucket that + * refused duplicates among themselves. Since 18.x a fresh tenant column is NOT + * NULL and the driver keys the PLAIN column: `(organization_id, …listed)`. On a + * pre-18 database whose tenant column is still nullable, rows with no + * organization are not constrained at all — SQL UNIQUE is NULL-distinct — and + * this file pins that exemption rather than the retired bucket. */ /** The wire shape a duplicate insert must produce, per `rest-server.ts`. */ @@ -338,12 +348,14 @@ describe('#8554 — five tenant-scoped declared unique indexes become per-organi // 1. Materialized shape — both spellings, kept side by side // ─────────────────────────────────────────────────────────────────── - it('the fixed declaration keys on the NULL-safe organization part', async () => { + it('the fixed declaration keys on the PLAIN organization part (ADR-0131 D1)', async () => { + // No COALESCE: a fresh tenant column is NOT NULL, so the plain column + // is already a total key part. const d = makeDriver(); await d.initObjects(app(sub, 'fixed') as any); expect(await uniqueKeyParts(sub.table)).toEqual({ - [sub.replacementName]: ['COALESCE(organization_id)', ...sub.keyColumns], + [sub.replacementName]: ['organization_id', ...sub.keyColumns], }); }); @@ -410,11 +422,12 @@ describe('#8554 — five tenant-scoped declared unique indexes become per-organi expect(await d.count(sub.table, {})).toBe(1); }); - it('AFTER: rows with no organization stay unique among THEMSELVES (ADR-0120 D3)', async () => { - // Platform/seed rows carry no organization. A bare - // `(organization_id, …)` composite would be NULL-DISTINCT under SQL, so - // every seeded row could be duplicated at will; the NULL-safe key part - // is what prevents that, and seed upsert-by-name relies on it. + it('AFTER: on a pre-18 nullable column, rows with no organization are NOT constrained (ADR-0131 D1)', async () => { + // Platform/seed rows carry no organization. ADR-0120 D3's NULL-safe + // key part once made them a bucket that refused duplicates among + // themselves; ADR-0131 D1 retires it. The key is the PLAIN + // `(organization_id, …)` composite, NULL-DISTINCT under SQL, so on a + // database that still holds such rows they are exempt from the key. const d = makeDriver(); await d.initObjects(app(sub, 'fixed') as any); // Rows with no organization exist only on a database built before 18.x @@ -422,13 +435,15 @@ describe('#8554 — five tenant-scoped declared unique indexes become per-organi await asPre18TenantColumn(d, sub.table); expect((await createAsApi(d, sub.table, row(sub, 'seed1', undefined, sub.key))).status).toBe(201); - expect(await createAsApi(d, sub.table, row(sub, 'seed2', undefined, sub.key))).toMatchObject( - CONFLICT_ENVELOPE, - ); + expect((await createAsApi(d, sub.table, row(sub, 'seed2', undefined, sub.key))).status).toBe(201); - // A tenant may still hold its OWN row of the same key — the platform - // bucket and the organization buckets are separate namespaces. + // A tenant may still hold its OWN row of the same key, and the + // organization's key stays enforced on the very same table — the + // exemption is the NULL organization's alone, not a dropped constraint. expect((await createAsApi(d, sub.table, row(sub, 'own1', 'org_jia', sub.key))).status).toBe(201); + expect(await createAsApi(d, sub.table, row(sub, 'own2', 'org_jia', sub.key))).toMatchObject( + CONFLICT_ENVELOPE, + ); }); if (sub.trailingControl) { @@ -497,8 +512,9 @@ describe('#8554 — five tenant-scoped declared unique indexes become per-organi dropIndexNames: [sub.legacyName], createIndexName: sub.replacementName, createColumns: ['organization_id', ...sub.keyColumns], - nullSafeColumns: ['organization_id'], }); + // ADR-0131 D1: the replacement keys the PLAIN organization column. + expect((entry!.op as any).nullSafeColumns).toBeUndefined(); // ⛔ The old index must NOT ALSO surface as an orphan. An orphan drop // is `destructive`, so an operator applying only the safe half would @@ -521,8 +537,10 @@ describe('#8554 — five tenant-scoped declared unique indexes become per-organi expect(applied.some((e) => e.op.type === 'replace_unique_index')).toBe(true); expect(skipped).toHaveLength(0); + // The PLAIN organization part (ADR-0131 D1), even on this pre-18 + // nullable column. expect(await uniqueKeyParts(sub.table)).toEqual({ - [sub.replacementName]: ['COALESCE(organization_id)', ...sub.keyColumns], + [sub.replacementName]: ['organization_id', ...sub.keyColumns], }); expect(await d.count(sub.table, {})).toBe(3); @@ -545,10 +563,11 @@ describe('#8554 — five tenant-scoped declared unique indexes become per-organi CONFLICT_ENVELOPE, ); - // …and the organization-less bucket survived the migration intact: - // the seeded platform row still blocks a duplicate of ITSELF. - expect(await createAsApi(d, sub.table, row(sub, 'z3', undefined, sub.platformKey))).toMatchObject( - CONFLICT_ENVELOPE, + // …and the seeded organization-less row is exempt from the PLAIN key + // (ADR-0131 D1): SQL UNIQUE is NULL-distinct, so a duplicate of it is + // accepted — the retired ADR-0120 D3 bucket did not survive. + expect((await createAsApi(d, sub.table, row(sub, 'z3', undefined, sub.platformKey))).status).toBe( + 201, ); }); @@ -619,9 +638,10 @@ describe('#8554 — five tenant-scoped declared unique indexes become per-organi name: sub.replacementName, columns: ['organization_id', ...sub.keyColumns], unique: true, - nullSafeColumns: ['organization_id'], }, }); + // ADR-0131 D1: no NULL-safe part — the organization column is keyed plain. + expect(entry!.replacement.nullSafeColumns).toBeUndefined(); }); it('claims nothing for an EXPLICITLY NAMED index — that transition is a recreate', () => { @@ -681,7 +701,7 @@ describe('#8554 — five tenant-scoped declared unique indexes become per-organi const d = makeDriver(); await d.initObjects(app(sub, 'fixed') as any); expect(await uniqueKeyParts(sub.table), `${sub.table} posture=${posture}`).toEqual({ - [sub.replacementName]: ['COALESCE(organization_id)', ...sub.keyColumns], + [sub.replacementName]: ['organization_id', ...sub.keyColumns], }); await d.disconnect(); driver = undefined; diff --git a/packages/objectql/src/adr0131-tenant-key-certified-supply.test.ts b/packages/objectql/src/adr0131-tenant-key-certified-supply.test.ts new file mode 100644 index 00000000000..1bb08dbcdf0 --- /dev/null +++ b/packages/objectql/src/adr0131-tenant-key-certified-supply.test.ts @@ -0,0 +1,153 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [18.x step 7b-1] The kernel→driver supply seam for "is this database + * certified", from the ENGINE's side (ADR-0131 D1). + * + * A SQL driver reads an earlier release's `COALESCE(, '__global__')` + * key part as the plain tenant column new DDL keys only where the tenant + * column holds no NULL. A ceremony-upgraded store whose column stayed nullable + * (a rotation shard, a Turso remote table, a reported table) proves that only + * by its certificate: the ownership row in `sys_migration`, verified, at this + * release's shape generation — a birth marker or the ceremony's own marker. + * The engine reads it; the driver is handed the question. The driver's half + * is pinned in `@objectstack/driver-sql` + * (`sql-driver-tenant-key-equivalence.test.ts`). + * + * Every way of not knowing answers `false`, the reading under which the differ + * keeps the two forms distinct and never proposes a rebuild on a guess. + */ + +import { describe, it, expect } from 'vitest'; +import { ObjectQL } from './engine'; +import { + ORGANIZATION_OWNERSHIP_MIGRATION_ID, + ORGANIZATION_OWNERSHIP_SHAPE_GENERATION, +} from '@objectstack/spec/system'; +import type { IDataDriver } from '@objectstack/spec/contracts'; + +type Rows = Array>; + +/** Equality only; a combinator read as a field name would answer a silent "no rows". */ +function matches(row: Record, where: any): boolean { + if (!where || typeof where !== 'object') return true; + return Object.entries(where).every(([k, v]) => { + if (k.startsWith('$')) throw new Error(`fake driver: unsupported combinator ${k}`); + return row[k] === v; + }); +} + +interface Sink { + resolver: (() => Promise) | null; +} + +function boot(rows: Rows, opts: { sink?: Sink; readThrows?: boolean; withFlagObject?: boolean } = {}): ObjectQL { + const driver: Record = { + name: 'default', + version: '1.0.0', + async connect() {}, + async disconnect() {}, + async find(_object: string, ast: any) { + const found = rows.filter((r) => matches(r, ast?.where)); + return typeof ast?.limit === 'number' ? found.slice(0, ast.limit) : found; + }, + async findOne(_object: string, ast: any) { + if (opts.readThrows) throw new Error('relation "sys_migration" does not exist'); + return rows.find((r) => matches(r, ast?.where)) ?? null; + }, + async count() { + return rows.length; + }, + async create(_object: string, data: any) { + rows.push({ ...data }); + return data; + }, + async update() { + return null; + }, + async delete() { + return true; + }, + async syncSchema() {}, + async dropTable() {}, + }; + if (opts.sink) { + const sink = opts.sink; + driver.setTenantKeyCertifiedResolver = (resolve: () => Promise) => { + sink.resolver = resolve; + }; + } + const engine = new ObjectQL(); + engine.registerDriver(driver as unknown as IDataDriver, true); + const flagObject = { + name: 'sys_migration', + fields: { + id: { type: 'text' }, + last_run_at: { type: 'datetime' }, + verified_at: { type: 'datetime' }, + blocking: { type: 'number' }, + details: { type: 'textarea' }, + }, + }; + const task = { name: 'showcase_task', fields: { id: { type: 'text' }, title: { type: 'text' } } }; + engine.registerApp({ + id: 'showcase_pkg', + name: 'Showcase', + objects: opts.withFlagObject === false ? [task] : [task, flagObject], + } as any); + return engine; +} + +const NOW = '2026-10-20T04:00:00.000Z'; +const certified = (over: Record = {}): Record => ({ + id: ORGANIZATION_OWNERSHIP_MIGRATION_ID, + last_run_at: NOW, + verified_at: NOW, + blocking: 0, + details: JSON.stringify({ checkpoint: { shapeGeneration: ORGANIZATION_OWNERSHIP_SHAPE_GENERATION } }), + ...over, +}); + +describe('[ADR-0131 D1] isOrganizationOwnershipCertified()', () => { + it("the ceremony's verified marker at the current shape generation certifies", async () => { + expect(await boot([certified()]).isOrganizationOwnershipCertified()).toBe(true); + }); + + it('so does a birth marker', async () => { + const birth = certified({ + details: JSON.stringify({ birth: { shapeGeneration: ORGANIZATION_OWNERSHIP_SHAPE_GENERATION } }), + }); + expect(await boot([birth]).isOrganizationOwnershipCertified()).toBe(true); + }); + + it('every way of not knowing answers false', async () => { + expect(await boot([]).isOrganizationOwnershipCertified(), 'no row').toBe(false); + expect(await boot([certified({ verified_at: null })]).isOrganizationOwnershipCertified(), 'unverified').toBe(false); + expect(await boot([certified({ blocking: 3 })]).isOrganizationOwnershipCertified(), 'blocking findings').toBe(false); + expect( + await boot([certified({ details: JSON.stringify({ checkpoint: { shapeGeneration: ORGANIZATION_OWNERSHIP_SHAPE_GENERATION - 1 } }) })]) + .isOrganizationOwnershipCertified(), + 'an older shape generation', + ).toBe(false); + expect(await boot([certified({ details: null })]).isOrganizationOwnershipCertified(), 'no generation named').toBe(false); + expect(await boot([certified()], { readThrows: true }).isOrganizationOwnershipCertified(), 'unreadable').toBe(false); + expect(await boot([certified()], { withFlagObject: false }).isOrganizationOwnershipCertified(), 'no ledger object').toBe( + false, + ); + }); + + it('registerDriver hands a driver with the seam a closure over the same reading', async () => { + const rows: Rows = []; + const sink: Sink = { resolver: null }; + boot(rows, { sink }); + expect(sink.resolver).toBeTypeOf('function'); + expect(await sink.resolver!()).toBe(false); + // A question, not a value: the answer follows the ledger at the moment it is asked. + rows.push(certified()); + expect(await sink.resolver!()).toBe(true); + }); + + it('a driver without the seam is left alone', () => { + expect(() => boot([])).not.toThrow(); + }); +}); From e4c6ffedbe42fe00547b58e99b419a7d80f8dc25 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 11:58:13 +0000 Subject: [PATCH 64/81] test(driver-sql): re-pin the organization unique suites to the plain key part (ADR-0131 D1) Claude-Session: https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd Co-authored-by: Claude --- .../adr0120-three-posture-conformance.test.ts | 60 +++-- ...2-plain-unique-duplicate-preflight.test.ts | 46 ++-- .../src/sql-driver-autonumber-resync.test.ts | 47 ++-- ...ared-index-organization-respelling.test.ts | 66 +++--- .../src/sql-driver-index-drift.test.ts | 24 +- ...acy-tenant-composite-null-distinct.test.ts | 41 ++-- ...r-sys-position-organization-unique.test.ts | 62 +++-- ...er-sys-setting-organization-unique.test.ts | 79 ++++--- .../src/sql-driver-unique-tenancy.test.ts | 215 ++++++++---------- 9 files changed, 362 insertions(+), 278 deletions(-) diff --git a/packages/drivers/driver-sql/src/adr0120-three-posture-conformance.test.ts b/packages/drivers/driver-sql/src/adr0120-three-posture-conformance.test.ts index 152ad99f753..d4772c26148 100644 --- a/packages/drivers/driver-sql/src/adr0120-three-posture-conformance.test.ts +++ b/packages/drivers/driver-sql/src/adr0120-three-posture-conformance.test.ts @@ -69,7 +69,8 @@ const FIXTURE_APP = [ { // S1 — per-organization unique field. Two organizations may each hold // `a@b.com`; a duplicate WITHIN one may not. S8's mixed population rides - // along: NULL-organization rows form one platform bucket (D3). + // along: NULL-organization rows (pre-18 databases only) are exempt from + // the plain organization key part (ADR-0131 D1). name: 'crm_contact', fields: { organization_id: { type: 'string' }, @@ -152,22 +153,23 @@ const FIXTURE_APP = [ /** * The expected unique key parts, per table — the SAME map for every posture. - * `COALESCE(col)` is the literal-agnostic reading of the NULL-safe organization - * key part, matching the identity the drift differ compares on. + * The organization key part is the PLAIN column (ADR-0131 D1): a legacy + * NULL-safe part would read here as `COALESCE(col)` (literal elided, the + * differ's identity) and fail the map, so no posture can bring one back. */ const EXPECTED_UNIQUE_KEY_PARTS: Record> = { - crm_contact: { uniq_crm_contact_organization_id_email: ['COALESCE(organization_id)', 'email'] }, + crm_contact: { uniq_crm_contact_organization_id_email: ['organization_id', 'email'] }, crm_device: { uniq_crm_device_serial: ['serial'] }, // Note the index NAME: the deterministic name is derived from the RESOLVED // columns, so a declared `'organization'` index carries the organization // column in its name even though the author never listed it. That is the // differ's identity too, which is why declaring and detecting cannot drift. crm_case: { - uniq_crm_case_organization_id_department_code: ['COALESCE(organization_id)', 'department', 'code'], + uniq_crm_case_organization_id_department_code: ['organization_id', 'department', 'code'], }, crm_delivery: { uniq_crm_delivery_source_dedup_key: ['source', 'dedup_key'] }, crm_team: { uniq_crm_team_name_organization_id: ['name', 'organization_id'] }, - crm_product: { uniq_crm_product_organization_id_code: ['COALESCE(organization_id)', 'code'] }, + crm_product: { uniq_crm_product_organization_id_code: ['organization_id', 'code'] }, crm_registry: { uniq_crm_registry_slug: ['slug'] }, }; @@ -279,21 +281,29 @@ describe('ADR-0120 — one app package, three tenancy postures (S13/S14 acceptan .rejects.toThrow(REJECTED); }); - it('S7/S8 — the NULL-organization bucket is a real bucket, in every posture', async () => { - // THE #5030 headline, and the reason `'organization'` could ship at all: - // before D3 this pair inserted twice on a single-organization stack - // (SQL UNIQUE is NULL-distinct), so every field-level unique was a - // silent no-op there. It must now be enforced under `single` exactly as - // under `isolated` — the posture is not what makes a constraint real. - // The NULL bucket exists only on a database built before 18.x (ADR-0131 - // D1 makes a fresh tenant column NOT NULL). + it('S7/S8 — NULL organization: refused on a fresh table, exempt on a pre-18 one, in every posture', async () => { + // THE #5030 headline: before ADR-0120 D3 a NULL-organization pair + // inserted twice on a single-organization stack (SQL UNIQUE is + // NULL-distinct), so every field-level unique was a silent no-op there. + // ADR-0131 D1 closes that at the COLUMN, not the index: a fresh tenant + // column is NOT NULL, so the row cannot be written at all — under + // `single` exactly as under `isolated`; the posture is not what makes a + // constraint real. + await expect(driver.create('crm_contact', { email: 'platform@b.com' })) + .rejects.toThrow(/NOT NULL constraint failed|null value in column/); + // A database built before 18.x keeps its nullable column; there the + // plain key part is NULL-distinct, so NULL-organization rows are exempt + // (the D3 platform bucket is retired, not rebuilt)… await asPre18TenantColumn(driver, 'crm_contact'); await driver.create('crm_contact', { email: 'platform@b.com' }); await expect(driver.create('crm_contact', { email: 'platform@b.com' })) - .rejects.toThrow(REJECTED); - // …while an organization-owned row with the same value still fits (S8). + .resolves.toBeTruthy(); + // …while an organization-owned row with the same value still fits (S8), + // once — the organization stays constrained. await expect(driver.create('crm_contact', { organization_id: 'org_a', email: 'platform@b.com' })) .resolves.toBeTruthy(); + await expect(driver.create('crm_contact', { organization_id: 'org_a', email: 'platform@b.com' })) + .rejects.toThrow(REJECTED); }); it('S2 — a platform-wide unique field crosses organizations, in every posture', async () => { @@ -308,12 +318,15 @@ describe('ADR-0120 — one app package, three tenancy postures (S13/S14 acceptan .resolves.toBeTruthy(); await expect(driver.create('crm_case', { organization_id: 'org_a', department: 'ops', code: 'C1' })) .rejects.toThrow(REJECTED); - // …and the NULL bucket is constrained too (D3 reaches declared indexes). - // The NULL bucket exists only on a database built before 18.x (ADR-0131 - // D1 makes a fresh tenant column NOT NULL). + // …and NULL-organization rows, which exist only on a database built + // before 18.x, are exempt from the declared composite exactly as from + // the field-level one: its organization key part is plain (ADR-0131 D1). await asPre18TenantColumn(driver, 'crm_case'); await driver.create('crm_case', { department: 'ops', code: 'C9' }); await expect(driver.create('crm_case', { department: 'ops', code: 'C9' })) + .resolves.toBeTruthy(); + // The relaxation left the composite in force for organizations. + await expect(driver.create('crm_case', { organization_id: 'org_a', department: 'ops', code: 'C1' })) .rejects.toThrow(REJECTED); }); @@ -340,10 +353,11 @@ describe('ADR-0120 — one app package, three tenancy postures (S13/S14 acceptan .rejects.toThrow(REJECTED); await expect(driver.create('crm_team', { organization_id: 'org_b', name: 'Core' })) .resolves.toBeTruthy(); - // The un-closed NULL hole, pinned as the honest status quo (D5c nudges - // toward the respelling that closes it; nothing forces it). - // The NULL bucket exists only on a database built before 18.x (ADR-0131 - // D1 makes a fresh tenant column NOT NULL). + // NULL-organization rows are exempt, pinned as the honest status quo. + // Since ADR-0131 D1 the respelling D5c nudges toward is plain too, so + // this is no longer a hole one spelling has and the other closes: such + // rows exist only on a database built before 18.x (a fresh tenant + // column is NOT NULL). await asPre18TenantColumn(driver, 'crm_team'); await driver.create('crm_team', { name: 'Platform' }); await expect(driver.create('crm_team', { name: 'Platform' })).resolves.toBeTruthy(); diff --git a/packages/drivers/driver-sql/src/sql-driver-14902-plain-unique-duplicate-preflight.test.ts b/packages/drivers/driver-sql/src/sql-driver-14902-plain-unique-duplicate-preflight.test.ts index 925f3451a03..ab78a9f0a67 100644 --- a/packages/drivers/driver-sql/src/sql-driver-14902-plain-unique-duplicate-preflight.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-14902-plain-unique-duplicate-preflight.test.ts @@ -38,12 +38,14 @@ import { SqlDriver } from '../src/index.js'; * * ## What this suite pins * - * Parity, in both halves, plus path A as the CONTROL. A change that quietly - * moved path A while making path B loud would otherwise read as success, so the - * last block asserts path A's message and classification are still their own — - * the `#5030` framing, which is precisely what the plain path must NOT claim - * (nothing ever admitted these rows; the constraint is simply newly declared - * over data that does not satisfy it). + * Parity, in both halves, plus the tenanted composite as the CONTROL. Path A + * as measured no longer exists: since ADR-0131 D1 the organization key part is + * PLAIN (no COALESCE), so a tenanted `unique: true` is path B's shape with the + * organization column in front. The last block pins that it reaches path B's + * disposition — loud, `destructive`, the conflicting groups named — and that + * it does NOT carry path A's retired `#5030` framing: no NULL-safe story, no + * `'__global__'` bucket, and NULL-organization rows (which never conflict under + * a plain UNIQUE) are not reported as duplicates. * * ⛔ Nothing here pins the DATABASE's raw error text: the `catch` shape is * dialect-independent, that text is not. Assertions are on our own messages and @@ -272,8 +274,8 @@ describe('#14902 plain unique index over duplicate rows', () => { }); }); - describe('CONTROL — path A is untouched', () => { - it('still logs its own NULL-safe #5030 message and still reports destructive with the key groups', async () => { + describe('CONTROL — the tenanted composite, plain since ADR-0131 D1', () => { + it('reaches the plain disposition with the organization key part, and never reports NULL-organization rows', async () => { await seed(true, [ { id: 'r1', organization_id: null, quote_number: 'QUO-00009' }, { id: 'r2', organization_id: null, quote_number: 'QUO-00009' }, @@ -282,7 +284,7 @@ describe('#14902 plain unique index over duplicate rows', () => { ]); // `unique: true` on a tenanted object == `unique: 'organization'` — the - // #13894 default, and the shape this card must not have disturbed. + // #13894 default. Its key is `(organization_id, quote_number)`, plain. await expect( driver.initObjects([ { @@ -297,20 +299,28 @@ describe('#14902 plain unique index over duplicate rows', () => { const durability = logs.filter((l) => l.level === 'error'); expect(durability).toHaveLength(1); - expect(durability[0].msg).toContain('NULL-safe unique index'); - expect(durability[0].msg).toContain('NULL-distinct'); - expect(durability[0].msg).toContain('ADR-0120 D4'); - expect(durability[0].msg).toContain("'organization_id, quote_number'"); + const msg = durability[0].msg; + expect(msg).toContain("'uniq_crm_quote_organization_id_quote_number'"); + expect(msg).toContain("'organization_id, quote_number'"); + expect(msg).toMatch(/Conflicting group\(s\)/); + expect(msg).toContain('(organization_id="org_x", quote_number="QUO-00010") × 2 rows'); + expect(msg).toMatch(/NOT enforced/); + expect(msg).toContain('os migrate plan'); + // The two NULL-organization rows never conflict under a plain UNIQUE + // (NULL-distinct), so the pre-flight does not report them — and the + // retired ADR-0120 D3/D4 framing is gone with the COALESCE key. + expect(msg).not.toContain('QUO-00009'); + expect(msg).not.toContain('NULL-safe'); + expect(msg).not.toContain('__global__'); const entry = (await driver.detectManagedDrift()).find((d) => d.op.type === 'create_index')!; expect(entry.category).toBe('destructive'); expect(entry.severity).toBe('error'); - expect(entry.message).toContain('NULL-distinct'); - expect(entry.message).toContain('__global__'); - // Both key groups, with counts — the NULL-organization bucket and a real - // organization, which is what makes the COALESCE key self-describing. - expect(entry.message).toContain('(organization_id="__global__", quote_number="QUO-00009")'); + expect(entry.message).toMatch(/BLOCKED/); expect(entry.message).toContain('(organization_id="org_x", quote_number="QUO-00010")'); + expect(entry.message).not.toContain('QUO-00009'); + expect(entry.message).not.toContain('__global__'); + expect(entry.message).not.toContain('NULL-safe'); }); }); }); diff --git a/packages/drivers/driver-sql/src/sql-driver-autonumber-resync.test.ts b/packages/drivers/driver-sql/src/sql-driver-autonumber-resync.test.ts index e50b4533a93..86ab3498b5f 100644 --- a/packages/drivers/driver-sql/src/sql-driver-autonumber-resync.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-autonumber-resync.test.ts @@ -40,11 +40,13 @@ * * - the filing's own message is a **composite** — * `UNIQUE constraint failed: crm_case.organization_id, crm_case.case_number` - * — and `uniqueViolationColumn` refuses composites by contract; - * - what this repo builds **today** is narrower still: ADR-0120 D3 makes the - * tenanted unique index `(COALESCE(organization_id,'__global__'), field)`, - * an *expression* index, on which SQLite reports - * `UNIQUE constraint failed: index 'uniq_…'` and names no column at all. + * — and `uniqueViolationColumn` refuses composites by contract. Since + * ADR-0131 D1 that is again exactly what this repo builds **today**: the + * tenanted unique index is the plain `(organization_id, field)`; + * - a database still carrying the ADR-0120 D3 index is narrower still: its + * `(COALESCE(organization_id,'__global__'), field)` is an *expression* + * index, on which SQLite reports `UNIQUE constraint failed: index 'uniq_…'` + * and names no column at all. * * So the "column not determinable" limb is not an edge case on this path — it * is the only limb that ever runs for a tenanted autonumber. The discriminator @@ -210,13 +212,16 @@ describe('[#5495] autonumber sequence re-seed on a provable collision', () => { it('pins WHY the column-name predicate cannot carry this decision', async () => { await driver.initObjects([CRM_CASE]); - // 1. What this repo builds today: an expression composite, on which SQLite - // reports the INDEX and no column. + // 1. What this repo builds today (ADR-0131 D1): the PLAIN tenant composite, + // on which SQLite reports both columns — a composite, which + // `uniqueViolationColumn` refuses by contract. This is the filing's own + // message, byte for byte. const indexes: any[] = await knex().raw( `select name, sql from sqlite_master where type='index' and tbl_name='crm_case'`, ); const declared = indexes.find((r) => r.name === 'uniq_crm_case_organization_id_case_number'); - expect(declared?.sql).toContain(`COALESCE(\`organization_id\`, '__global__')`); + expect(declared?.sql).toContain('(`organization_id`, `case_number`)'); + expect(declared?.sql).not.toContain('COALESCE'); await knex()('crm_case').insert({ id: 'd1', organization_id: 'orgA', case_number: 'CASE-00001', title: 'a' }); const caught = await knex()('crm_case') @@ -226,14 +231,28 @@ describe('[#5495] autonumber sequence re-seed on a provable collision', () => { (e: unknown) => e, ); expect(isUniqueViolationError(caught)).toBe(true); - expect((caught as Error).message).toMatch(/UNIQUE constraint failed: index '/); + expect((caught as Error).message).toContain( + 'UNIQUE constraint failed: crm_case.organization_id, crm_case.case_number', + ); expect(uniqueViolationColumn(caught)).toBeUndefined(); - // 2. And the message the filing itself reported — the plain composite that - // predates ADR-0120 D3 — is refused for the other reason. - expect( - uniqueViolationColumn('UNIQUE constraint failed: crm_case.organization_id, crm_case.case_number'), - ).toBeUndefined(); + // 2. And a database still carrying the ADR-0120 D3 expression composite — + // built before ADR-0131 D1, honoured as it stands — is refused for the + // other reason: SQLite reports the INDEX and no column. + await knex().raw('create table legacy_case (id text primary key, organization_id text, case_number text)'); + await knex().raw( + `create unique index uniq_legacy_case_organization_id_case_number on legacy_case (COALESCE(organization_id, '__global__'), case_number)`, + ); + await knex()('legacy_case').insert({ id: 'l1', organization_id: 'orgA', case_number: 'CASE-00001' }); + const caughtLegacy = await knex()('legacy_case') + .insert({ id: 'l2', organization_id: 'orgA', case_number: 'CASE-00001' }) + .then( + () => undefined, + (e: unknown) => e, + ); + expect(isUniqueViolationError(caughtLegacy)).toBe(true); + expect((caughtLegacy as Error).message).toMatch(/UNIQUE constraint failed: index '/); + expect(uniqueViolationColumn(caughtLegacy)).toBeUndefined(); }); /** diff --git a/packages/drivers/driver-sql/src/sql-driver-declared-index-organization-respelling.test.ts b/packages/drivers/driver-sql/src/sql-driver-declared-index-organization-respelling.test.ts index 1b5f6878fa5..6e6d1a82436 100644 --- a/packages/drivers/driver-sql/src/sql-driver-declared-index-organization-respelling.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-declared-index-organization-respelling.test.ts @@ -38,7 +38,9 @@ import { * pinned in `spellings` below. Field-level `unique: true` has meant "per * organization" since #3696; a DECLARED index's `unique: true` is the positional * spelling of `'global'` and takes the listed columns VERBATIM. Only the - * explicit `'organization'` prepends the NULL-safe organization key part. + * explicit `'organization'` prepends the organization key part — since + * ADR-0131 D1 the PLAIN tenant column (it was ADR-0120 D3's NULL-safe + * `COALESCE` part before 18.x). * `packages/lint/src/data-model-rules.ts` calls this "the #4986 trap" and warns * on it (`unique/unscoped-declared-index`); `sys_user_preference` and * `sys_capability` were two instances of it in the platform's own metadata. @@ -182,17 +184,18 @@ describe('#8323 — declared unique indexes on the platform’s tenant-scoped ob expect(bare).toEqual(explicit); }); - it('only the explicit `"organization"` spelling prepends the NULL-safe organization key part', () => { + it('only the explicit `"organization"` spelling prepends the PLAIN organization key part (ADR-0131 D1)', () => { const norm = normalizeDeclaredIndex( 'sys_user_preference', { fields: ['user_id', 'key'], unique: 'organization' }, 'organization_id', ); + // ADR-0131 D1: the tenant column is prepended PLAIN — no + // `nullSafeColumns`, so no COALESCE in the DDL it materializes. expect(norm).toEqual({ name: 'uniq_sys_user_preference_organization_id_user_id_key', columns: ['organization_id', 'user_id', 'key'], unique: true, - nullSafeColumns: ['organization_id'], }); }); @@ -206,11 +209,11 @@ describe('#8323 — declared unique indexes on the platform’s tenant-scoped ob { key: { type: 'string', unique: true } }, 'organization_id', ); + // Keyed on the PLAIN organization column since ADR-0131 D1. expect(fieldLevel).toEqual({ name: 'uniq_sys_user_preference_organization_id_key', columns: ['organization_id', 'key'], unique: true, - nullSafeColumns: ['organization_id'], }); }); }); @@ -277,19 +280,17 @@ describe('#8323 — declared unique indexes on the platform’s tenant-scoped ob ] as const; describe('materialized shape', () => { - it('the fixed declarations key on the NULL-safe organization part', async () => { + it('the fixed declarations key on the PLAIN organization column (ADR-0131 D1)', async () => { + // No COALESCE in new DDL: a fresh table's tenant column is NOT NULL, so + // the plain column is the whole key part. const d = makeDriver(); await d.initObjects(FIXED_APP as any); expect(await uniqueKeyParts('sys_user_preference')).toEqual({ - uniq_sys_user_preference_organization_id_user_id_key: [ - 'COALESCE(organization_id)', - 'user_id', - 'key', - ], + uniq_sys_user_preference_organization_id_user_id_key: ['organization_id', 'user_id', 'key'], }); expect(await uniqueKeyParts('sys_capability')).toEqual({ - uniq_sys_capability_organization_id_name: ['COALESCE(organization_id)', 'name'], + uniq_sys_capability_organization_id_name: ['organization_id', 'name'], }); }); @@ -376,10 +377,13 @@ describe('#8323 — declared unique indexes on the platform’s tenant-scoped ob expect(await d.count('sys_capability', {})).toBe(1); }); - it('AFTER: platform-seeded rows carry no organization and stay unique among THEMSELVES (D3)', async () => { - // The NULL-safe key part is what makes this hold: a bare - // `(organization_id, name)` composite would be NULL-distinct under SQL, - // so every platform-seeded capability could be duplicated at will. + it('AFTER: on a pre-18 nullable column, rows with no organization are NOT constrained by the plain key (ADR-0131 D1)', async () => { + // ADR-0120 D3's NULL-safe key part once kept a NULL-organization + // "bucket" unique among itself. ADR-0131 D1 retires it: the key is the + // PLAIN `(organization_id, name)` composite, and SQL UNIQUE is + // NULL-distinct, so two NULL-organization rows of one name do not + // conflict. A fresh table cannot hold such rows (its tenant column is + // NOT NULL); this pins the plain key on the shape that still can. const d = makeDriver(); await d.initObjects(FIXED_APP as any); // Rows with no organization exist only on a database built before 18.x @@ -387,16 +391,20 @@ describe('#8323 — declared unique indexes on the platform’s tenant-scoped ob await asPre18TenantColumn(d, 'sys_capability'); expect((await createAsApi(d, 'sys_capability', { id: 'p1', name: 'manage_metadata' })).status).toBe(201); - const duplicateSeed = await createAsApi(d, 'sys_capability', { id: 'p2', name: 'manage_metadata' }); - expect(duplicateSeed).toMatchObject(CONFLICT_ENVELOPE); + const nullOrgRepeat = await createAsApi(d, 'sys_capability', { id: 'p2', name: 'manage_metadata' }); + expect(nullOrgRepeat.status).toBe(201); + expect(await d.count('sys_capability', {})).toBe(2); - // An organization may still define its own row of the same name — that - // is the ADR-0066 "admins EXTEND the registry" case, and the reason the - // platform bucket and the organization buckets are separate. + // An organization may still define its own row of the same name — the + // ADR-0066 "admins EXTEND the registry" case — and the plain key still + // refuses that organization's second one: the exemption is the NULL. expect( (await createAsApi(d, 'sys_capability', { id: 'o1', organization_id: 'org_jia', name: 'manage_metadata' })) .status, ).toBe(201); + expect( + await createAsApi(d, 'sys_capability', { id: 'o2', organization_id: 'org_jia', name: 'manage_metadata' }), + ).toMatchObject(CONFLICT_ENVELOPE); }); }); @@ -529,8 +537,9 @@ describe('#8323 — declared unique indexes on the platform’s tenant-scoped ob dropIndexNames: ['uniq_sys_user_preference_user_id_key'], createIndexName: 'uniq_sys_user_preference_organization_id_user_id_key', createColumns: ['organization_id', 'user_id', 'key'], - nullSafeColumns: ['organization_id'], }); + // ADR-0131 D1: the replacement keys the PLAIN organization column. + expect(entry!.op).not.toHaveProperty('nullSafeColumns'); // The old index must NOT be reported as an orphan as well. An orphan drop // is `destructive`, so an operator applying only the safe half would keep @@ -555,12 +564,9 @@ describe('#8323 — declared unique indexes on the platform’s tenant-scoped ob expect(applied.some((e) => e.op.type === 'replace_unique_index')).toBe(true); expect(skipped).toHaveLength(0); + // The plain composite (ADR-0131 D1). expect(await uniqueKeyParts('sys_user_preference')).toEqual({ - uniq_sys_user_preference_organization_id_user_id_key: [ - 'COALESCE(organization_id)', - 'user_id', - 'key', - ], + uniq_sys_user_preference_organization_id_user_id_key: ['organization_id', 'user_id', 'key'], }); expect(await d.count('sys_user_preference', {})).toBe(2); @@ -793,13 +799,9 @@ describe('#8323 — declared unique indexes on the platform’s tenant-scoped ob driver = undefined; } // Positively asserted, not merely "all three agree" — three empty maps - // would satisfy sameness alone. + // would satisfy sameness alone. The plain key part is ADR-0131 D1's. expect(shapes.single).toEqual({ - uniq_sys_user_preference_organization_id_user_id_key: [ - 'COALESCE(organization_id)', - 'user_id', - 'key', - ], + uniq_sys_user_preference_organization_id_user_id_key: ['organization_id', 'user_id', 'key'], }); expect(shapes.group).toEqual(shapes.single); expect(shapes.isolated).toEqual(shapes.single); diff --git a/packages/drivers/driver-sql/src/sql-driver-index-drift.test.ts b/packages/drivers/driver-sql/src/sql-driver-index-drift.test.ts index 6d72cd14c6d..4d2e99c41ec 100644 --- a/packages/drivers/driver-sql/src/sql-driver-index-drift.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-index-drift.test.ts @@ -74,9 +74,10 @@ describe('SqlDriver index drift (#3728)', () => { /** * Unique index name → canonical key parts, read from the index DDL so * expression keys are visible (`PRAGMA index_info` reports a NULL name for - * them). A plain column reads as its name; the NULL-safe organization key - * part (ADR-0120 D3) reads as `COALESCE()` — literal elided, the - * same literal-agnostic identity the drift differ compares on. + * them). A plain column reads as its name; a legacy NULL-safe organization + * key part (ADR-0120 D3) reads as `COALESCE()` — literal elided, the + * same literal-agnostic identity the drift differ compares on. New DDL keys + * the plain organization column (ADR-0131 D1), so the pins below expect none. */ const uniqueIndexColumns = async (table: string): Promise> => { const list: any = await knexInstance.raw(`PRAGMA index_list(${table})`); @@ -212,10 +213,11 @@ describe('SqlDriver index drift (#3728)', () => { const driver = makeDriver(); await driver.initObjects(contactMeta); - // Both are current intent: the tenant composite from the field-level - // `unique: true`, and the verbatim declared global unique. + // Both are current intent: the plain tenant composite from the + // field-level `unique: true` (ADR-0131 D1), and the verbatim declared + // global unique. const uniques = await uniqueIndexColumns('hp_contact'); - expect(uniques['uniq_hp_contact_organization_id_email']).toEqual(['COALESCE(organization_id)', 'email']); + expect(uniques['uniq_hp_contact_organization_id_email']).toEqual(['organization_id', 'email']); expect(uniques['uniq_hp_contact_email']).toEqual(['email']); // Before the fix this reported `replace_unique_index` — proposing to drop @@ -290,7 +292,8 @@ describe('SqlDriver index drift (#3728)', () => { const uniques = await uniqueIndexColumns('product'); expect(uniques['product_code_unique']).toBeUndefined(); - expect(Object.values(uniques)).toContainEqual(['COALESCE(organization_id)', 'code']); + // The replacement is the plain composite (ADR-0131 D1). + expect(Object.values(uniques)).toContainEqual(['organization_id', 'code']); // Existing rows survived, and the cross-tenant insert the issue is about works. expect(await driver.count('product', {})).toBe(2); @@ -310,10 +313,7 @@ describe('SqlDriver index drift (#3728)', () => { await driver.applyMigrationEntries(drift, { allowDestructive: false }); const again = await driver.applyMigrationEntries(drift, { allowDestructive: false }); expect(again.skipped).toHaveLength(0); - expect(Object.values(await uniqueIndexColumns('product'))).toContainEqual([ - 'COALESCE(organization_id)', - 'code', - ]); + expect(Object.values(await uniqueIndexColumns('product'))).toContainEqual(['organization_id', 'code']); }); }); @@ -328,7 +328,7 @@ describe('SqlDriver index drift (#3728)', () => { const uniques = await uniqueIndexColumns('product'); expect(uniques['product_code_unique']).toBeUndefined(); - expect(Object.values(uniques)).toContainEqual(['COALESCE(organization_id)', 'code']); + expect(Object.values(uniques)).toContainEqual(['organization_id', 'code']); expect(await driver.detectManagedDrift()).toHaveLength(0); const b = await driver.create('product', { organization_id: 'org_b', code: 'PROD-00001' }); diff --git a/packages/drivers/driver-sql/src/sql-driver-legacy-tenant-composite-null-distinct.test.ts b/packages/drivers/driver-sql/src/sql-driver-legacy-tenant-composite-null-distinct.test.ts index b609f3f6ae1..b0bd828ebbe 100644 --- a/packages/drivers/driver-sql/src/sql-driver-legacy-tenant-composite-null-distinct.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-legacy-tenant-composite-null-distinct.test.ts @@ -8,9 +8,13 @@ import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; /** * #23603 row 7 — the OLD hand-written table composite * `indexes: [{ fields: ['organization_id', X], unique }]` constrains NOTHING on - * untenanted rows, because SQL UNIQUE is NULL-distinct; field-level - * `unique: true` does, because its organization key part is NULL-safe - * (ADR-0120 D3: `COALESCE(organization_id, '__global__')`). + * untenanted rows, because SQL UNIQUE is NULL-distinct. Field-level + * `unique: true` once differed — its organization key part was NULL-safe + * (ADR-0120 D3: `COALESCE(organization_id, '__global__')`). Since ADR-0131 D1 + * it is the SAME plain composite: the untenanted hole is closed at the column + * instead (a fresh tenant column is NOT NULL, so no untenanted row can be + * written), and on a database built before 18.x — whose column is still + * nullable — both spellings admit untenanted duplicates alike. * * Replaces the driver half of objectstack-ai/hotcrm * `test/case-number-tenant-scope.test.ts` (the R residue in #15953's @@ -27,14 +31,14 @@ import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; * were prevented only by the accident of a single sequence, never by the * database. The old index still refuses a duplicate WITHIN one organization, * which is exactly what hid the hole; both halves are pinned, against the - * field-level spelling on the same driver. + * field-level spelling on the same driver — which now measures the same. * * A declared index's `unique: true` is the positional spelling of `'global'` * (verbatim columns; ADR-0120 D2 converts stored rows to `'global'` and the * spec refuses bare `true` since protocol 18). Both spellings are measured, * because stored metadata older than the conversion reaches the driver bare. */ -describe('row 7 — the old table composite is NULL-distinct, field-level unique is NULL-safe (hotcrm case-number-tenant-scope)', () => { +describe('row 7 — the old table composite and field-level unique are one plain composite; the untenanted hole closes at the column (hotcrm case-number-tenant-scope)', () => { let driver: SqlDriver; beforeEach(() => { @@ -44,7 +48,7 @@ describe('row 7 — the old table composite is NULL-distinct, field-level unique await driver.disconnect(); }); - /** Unique index name → key parts; the NULL-safe part reads `COALESCE(col)`. */ + /** Unique index name → key parts; a legacy NULL-safe part would read `COALESCE(col)`. */ async function uniqueKeyParts(table: string): Promise> { const k = (driver as any).knex; const master: any = await k.raw(`SELECT name, sql FROM sqlite_master WHERE type = 'index' AND tbl_name = ?`, [table]); @@ -63,16 +67,16 @@ describe('row 7 — the old table composite is NULL-distinct, field-level unique const refusal = (p: Promise) => p.then(() => undefined, (e: unknown) => e); - describe('field-level unique: true (the fix)', () => { + describe('field-level unique: true', () => { beforeEach(async () => { await driver.initObjects([ { name: 'kase', fields: { organization_id: { type: 'string' }, case_number: { type: 'string', unique: true } } }, ] as any); }); - it('materializes the per-organization unique with a NULL-safe organization key part', async () => { + it('materializes the per-organization unique with a PLAIN organization key part — the old composite, byte for byte (ADR-0131 D1)', async () => { expect(await uniqueKeyParts('kase')).toEqual({ - uniq_kase_organization_id_case_number: ['COALESCE(organization_id)', 'case_number'], + uniq_kase_organization_id_case_number: ['organization_id', 'case_number'], }); }); @@ -83,13 +87,20 @@ describe('row 7 — the old table composite is NULL-distinct, field-level unique expect(isUniqueViolationError(err)).toBe(true); }); - it('refuses a duplicate on an UNTENANTED install too', async () => { - // Rows with no organization exist only on a database built before 18.x - // (ADR-0131 D1 makes a fresh tenant column NOT NULL). - await asPre18TenantColumn(driver, 'kase'); - await driver.create('kase', { case_number: 'CASE-2' }); + it('closes the UNTENANTED hole at the column: a fresh install refuses the row, a pre-18 one is NULL-distinct', async () => { + // A fresh tenant column is NOT NULL (ADR-0131 D1): the untenanted row the + // hole needed cannot be written at all — the refusal is the column's, + // not the unique index's. const err = await refusal(driver.create('kase', { case_number: 'CASE-2' })); - expect(isUniqueViolationError(err)).toBe(true); + expect(isUniqueViolationError(err)).toBe(false); + expect(String((err as Error)?.message)).toMatch(/NOT NULL constraint failed|null value in column/); + + // Rows with no organization exist only on a database built before 18.x; + // there the plain key part is NULL-distinct, like the old composite. + await asPre18TenantColumn(driver, 'kase'); + const first = await driver.create('kase', { case_number: 'CASE-2' }); + const second = await driver.create('kase', { case_number: 'CASE-2' }); + expect(second.id).not.toBe(first.id); }); }); diff --git a/packages/drivers/driver-sql/src/sql-driver-sys-position-organization-unique.test.ts b/packages/drivers/driver-sql/src/sql-driver-sys-position-organization-unique.test.ts index cdb04dc46ff..4387645ccd0 100644 --- a/packages/drivers/driver-sql/src/sql-driver-sys-position-organization-unique.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-sys-position-organization-unique.test.ts @@ -204,12 +204,15 @@ describe('#8468 — sys_position.name is unique per organization, not per instal // ─────────────────────────────────────────────────────────────────────────── describe('materialized shape', () => { - it('the fixed declaration keys on the NULL-safe organization part', async () => { + it('the fixed declaration keys on the PLAIN organization column (ADR-0131 D1)', async () => { + // ADR-0131 D1: new DDL keys the plain tenant column — no COALESCE. A + // fresh table's tenant column is NOT NULL, so the NULL-safe spelling + // ADR-0120 D3 needed would guard a value that can no longer occur. const d = makeDriver(); await d.initObjects(FIXED_APP as any); expect(await uniqueKeyParts('sys_position')).toEqual({ - uniq_sys_position_organization_id_name: ['COALESCE(organization_id)', 'name'], + uniq_sys_position_organization_id_name: ['organization_id', 'name'], }); }); @@ -307,14 +310,17 @@ describe('#8468 — sys_position.name is unique per organization, not per instal expect(await d.count('sys_position', {})).toBe(1); }); - it('AFTER: platform-seeded rows carry no organization and stay unique among THEMSELVES (D3)', async () => { + it('AFTER: on a pre-18 nullable column, rows with no organization are NOT constrained by the plain key (ADR-0131 D1)', async () => { // `bootstrapBuiltinRoles` seeds the framework-reserved identity positions // (platform_admin / org_*) and the ADR-0090 D9 audience anchors - // (everyone / guest) with `managed_by: 'platform'` and no organization. - // A bare `(organization_id, name)` composite would be NULL-DISTINCT under - // SQL, so every seeded position could be duplicated at will; the NULL-safe - // key part is what prevents that, and the bootstrap upsert-by-name relies - // on it. + // (everyone / guest) with `managed_by: 'platform'`. ADR-0120 D3 once keyed + // a NULL-safe organization part so a NULL-organization "bucket" stayed + // unique among itself. ADR-0131 D1 retires that bucket: new DDL keys the + // PLAIN `(organization_id, name)` composite, and SQL UNIQUE is + // NULL-distinct, so on a nullable column two NULL-organization rows of + // the same name do not conflict. A fresh table cannot hold such rows at + // all (its tenant column is NOT NULL); this pins what the plain key does + // on the only shape that still can. const d = makeDriver(); await d.initObjects(FIXED_APP as any); // Rows with no organization exist only on a database built before 18.x @@ -326,18 +332,27 @@ describe('#8468 — sys_position.name is unique per organization, not per instal id: 'seed1', name: 'platform_admin', label: 'Platform Admin', managed_by: 'platform', })).status, ).toBe(201); - const duplicateSeed = await createAsApi(d, 'sys_position', { + const nullOrgRepeat = await createAsApi(d, 'sys_position', { id: 'seed2', name: 'platform_admin', label: 'Platform Admin', managed_by: 'platform', }); - expect(duplicateSeed).toMatchObject(CONFLICT_ENVELOPE); + expect(nullOrgRepeat.status).toBe(201); + expect( + (await d.find('sys_position', {})).filter((r: any) => r.name === 'platform_admin').length, + ).toBe(2); - // A tenant may still author its OWN position of the same name — the - // platform bucket and the organization buckets are separate namespaces. + // A tenant may still author its OWN position of the same name, and the + // plain key still refuses that organization's second one — the exemption + // is the NULL, not the column. expect( (await createAsApi(d, 'sys_position', { id: 'own1', organization_id: 'org_jia', name: 'platform_admin', label: 'Local', managed_by: 'admin', })).status, ).toBe(201); + expect( + await createAsApi(d, 'sys_position', { + id: 'own2', organization_id: 'org_jia', name: 'platform_admin', label: 'Local again', managed_by: 'admin', + }), + ).toMatchObject(CONFLICT_ENVELOPE); }); }); @@ -400,8 +415,9 @@ describe('#8468 — sys_position.name is unique per organization, not per instal dropIndexNames: ['uniq_sys_position_name'], createIndexName: 'uniq_sys_position_organization_id_name', createColumns: ['organization_id', 'name'], - nullSafeColumns: ['organization_id'], }); + // ADR-0131 D1: the replacement keys the PLAIN organization column. + expect(entry!.op).not.toHaveProperty('nullSafeColumns'); // ⛔ The old index must NOT ALSO surface as an orphan. An orphan drop is // `destructive`, so an operator applying only the safe half would keep the @@ -425,8 +441,10 @@ describe('#8468 — sys_position.name is unique per organization, not per instal expect(applied.some((e) => e.op.type === 'replace_unique_index')).toBe(true); expect(skipped).toHaveLength(0); + // The plain composite (ADR-0131 D1) — and it converges on the pre-18 + // nullable column too, since the expectation is plain as well. expect(await uniqueKeyParts('sys_position')).toEqual({ - uniq_sys_position_organization_id_name: ['COALESCE(organization_id)', 'name'], + uniq_sys_position_organization_id_name: ['organization_id', 'name'], }); expect(await d.count('sys_position', {})).toBe(3); @@ -455,12 +473,15 @@ describe('#8468 — sys_position.name is unique per organization, not per instal }), ).toMatchObject(CONFLICT_ENVELOPE); - // …and the platform seed bucket survived the migration intact. + // …and the NULL-organization row the deployment already held is exempt + // from the migrated PLAIN key (ADR-0131 D1: SQL UNIQUE is NULL-distinct; + // the ADR-0120 D3 bucket is gone). Before the migration the global index + // refused this; afterwards only organization-keyed rows are constrained. expect( - await createAsApi(d, 'sys_position', { + (await createAsApi(d, 'sys_position', { id: 'seed2', name: 'platform_admin', label: 'Platform Admin', managed_by: 'platform', - }), - ).toMatchObject(CONFLICT_ENVELOPE); + })).status, + ).toBe(201); }); it('boot creates the replacement ADDITIVELY, so the defect is STILL LIVE until the plan runs', async () => { @@ -532,9 +553,10 @@ describe('#8468 — sys_position.name is unique per organization, not per instal name: 'uniq_sys_position_organization_id_name', columns: ['organization_id', 'name'], unique: true, - nullSafeColumns: ['organization_id'], }, }); + // ADR-0131 D1: the replacement is the PLAIN composite — no NULL-safe part. + expect(entry!.replacement).not.toHaveProperty('nullSafeColumns'); }); it('claims nothing for an EXPLICITLY NAMED index — that transition is a recreate', () => { @@ -588,7 +610,7 @@ describe('#8468 — sys_position.name is unique per organization, not per instal const d = makeDriver(); await d.initObjects(FIXED_APP as any); expect(await uniqueKeyParts('sys_position'), `posture=${posture}`).toEqual({ - uniq_sys_position_organization_id_name: ['COALESCE(organization_id)', 'name'], + uniq_sys_position_organization_id_name: ['organization_id', 'name'], }); await d.disconnect(); driver = undefined; diff --git a/packages/drivers/driver-sql/src/sql-driver-sys-setting-organization-unique.test.ts b/packages/drivers/driver-sql/src/sql-driver-sys-setting-organization-unique.test.ts index 742e1bd11d5..70d42fa8cc7 100644 --- a/packages/drivers/driver-sql/src/sql-driver-sys-setting-organization-unique.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-sys-setting-organization-unique.test.ts @@ -235,12 +235,15 @@ describe('#8555 — sys_setting: the declared unique index becomes per-organizat // 1. Materialized shape — both spellings, kept side by side // ───────────────────────────────────────────────────────────────────────── - it('the fixed declaration keys on the NULL-safe organization part', async () => { + it('the fixed declaration keys on the PLAIN organization column (ADR-0131 D1)', async () => { + // ADR-0131 D1: new DDL keys the plain tenant column — no COALESCE. A fresh + // table's tenant column is NOT NULL, so ADR-0120 D3's NULL-safe spelling + // would guard a value that can no longer occur. const d = makeDriver(); await d.initObjects(app('fixed') as any); expect(await uniqueKeyParts()).toEqual({ - [REPLACEMENT_NAME]: ['COALESCE(organization_id)', ...KEY_COLUMNS], + [REPLACEMENT_NAME]: ['organization_id', ...KEY_COLUMNS], }); }); @@ -306,14 +309,15 @@ describe('#8555 — sys_setting: the declared unique index becomes per-organizat expect(await d.count(TABLE, {})).toBe(1); }); - it('AFTER: rows with no organization stay unique among THEMSELVES (ADR-0120 D3)', async () => { - // This is the arm that answers the card's "argument for keeping it global". - // The `scope='global'` LAYER — the platform default every organization - // resolves against at rung 2 — is preserved by the NULL-safe key part, not - // by making the whole index installation-wide: platform rows carry no - // organization, so they share the one `__global__` bucket and remain unique - // within it, while an organization may still hold its own row of the same - // key at its own layer. + it('AFTER: on a pre-18 nullable column, rows with no organization are NOT constrained by the plain key (ADR-0131 D1)', async () => { + // This arm once answered the card's "argument for keeping it global" with + // ADR-0120 D3's NULL-safe key part: organization-less platform rows shared + // one `COALESCE` bucket and stayed unique within it. ADR-0131 D1 retires + // that bucket. New DDL keys the PLAIN `organization_id`, and SQL UNIQUE is + // NULL-distinct, so on a nullable column two organization-less rows of the + // same key do not conflict. A fresh table cannot hold such rows (its + // tenant column is NOT NULL; the ownership ceremony moves the platform + // layer's rows out); this pins the plain key on the shape that still can. const d = makeDriver(); await d.initObjects(app('fixed') as any); // Rows with no organization exist only on a database built before 18.x @@ -322,11 +326,14 @@ describe('#8555 — sys_setting: the declared unique index becomes per-organizat const platform = { scope: 'global', namespace: 'branding', key: 'logo_url' }; expect((await createAsApi(d, row('seed1', undefined, platform))).status).toBe(201); - expect(await createAsApi(d, row('seed2', undefined, platform))).toMatchObject( - CONFLICT_ENVELOPE, - ); + expect((await createAsApi(d, row('seed2', undefined, platform))).status).toBe(201); + expect(await d.count(TABLE, {})).toBe(2); + // An organization may still hold its own row of the same key, and the + // plain key still refuses that organization's second one — the exemption + // is the NULL, not the column. expect((await createAsApi(d, row('own1', 'org_jia', platform))).status).toBe(201); + expect(await createAsApi(d, row('own2', 'org_jia', platform))).toMatchObject(CONFLICT_ENVELOPE); }); it('the key is the whole COMPOSITE — varying only the trailing column was always accepted', async () => { @@ -404,8 +411,9 @@ describe('#8555 — sys_setting: the declared unique index becomes per-organizat dropIndexNames: [LEGACY_NAME], createIndexName: REPLACEMENT_NAME, createColumns: ['organization_id', ...KEY_COLUMNS], - nullSafeColumns: ['organization_id'], }); + // ADR-0131 D1: the replacement keys the PLAIN organization column. + expect(entry!.op).not.toHaveProperty('nullSafeColumns'); // ⛔ The old index must NOT ALSO surface as an orphan. An orphan drop is // `destructive`, so an operator applying only the safe half would keep the @@ -428,8 +436,10 @@ describe('#8555 — sys_setting: the declared unique index becomes per-organizat expect(applied.some((e) => e.op.type === 'replace_unique_index')).toBe(true); expect(skipped).toHaveLength(0); + // The plain composite (ADR-0131 D1) — and it converges on the pre-18 + // nullable column too, since the expectation is plain as well. expect(await uniqueKeyParts()).toEqual({ - [REPLACEMENT_NAME]: ['COALESCE(organization_id)', ...KEY_COLUMNS], + [REPLACEMENT_NAME]: ['organization_id', ...KEY_COLUMNS], }); // Every row survives — including the two pre-existing duplicates the old // index allowed. A migration that silently dropped them would be data loss @@ -453,11 +463,12 @@ describe('#8555 — sys_setting: the declared unique index becomes per-organizat // The anti-vacuity arm, on the SAME migrated index. expect(await createAsApi(d, row('z2', 'org_jia', KEY))).toMatchObject(CONFLICT_ENVELOPE); - // …and the organization-less bucket survived the migration intact: the - // seeded platform row still blocks a duplicate of ITSELF. + // …and the organization-less row the deployment already held is exempt + // from the migrated PLAIN key (ADR-0131 D1: SQL UNIQUE is NULL-distinct; + // the ADR-0120 D3 bucket is gone), so a second one of it is accepted. expect( - await createAsApi(d, row('z3', undefined, { scope: 'global', key: 'platform_default' })), - ).toMatchObject(CONFLICT_ENVELOPE); + (await createAsApi(d, row('z3', undefined, { scope: 'global', key: 'platform_default' }))).status, + ).toBe(201); }); it('boot creates the replacement ADDITIVELY, so the defect is STILL LIVE until the plan runs', async () => { @@ -540,9 +551,11 @@ describe('#8555 — sys_setting: the declared unique index becomes per-organizat * without weakening anything else, survives a later boot's additive sync, * and is not reported as drift the reconciler would undo. */ + // Since ADR-0131 D1 the builder keys `organization_id` PLAIN, exactly as + // the driver does; only `user_id` keeps its NULL-safe `COALESCE`. const NULL_SAFE_IDENTITY_DDL = `CREATE UNIQUE INDEX IF NOT EXISTS ${REPLACEMENT_NAME} ON ${TABLE} ` + - "(COALESCE(organization_id, '__global__'), namespace, key, scope, COALESCE(user_id, ''))"; + "(organization_id, namespace, key, scope, COALESCE(user_id, ''))"; /** The migration's probe-first order, through the driver's raw seam. */ const runIdentityMigration = async (d: SqlDriver): Promise => { @@ -590,7 +603,13 @@ describe('#8555 — sys_setting: the declared unique index becomes per-organizat expect(await d.count(TABLE, {})).toBe(1); }); - it('AFTER the migration: two platform defaults on the `scope=global` layer are refused', async () => { + it('AFTER the migration: a `scope=global` duplicate is refused within an organization; organization-less rows are exempt (ADR-0131 D1)', async () => { + // The `user_id` fold closes the hole on the `global` layer exactly as on + // the `tenant` one. What it no longer does is fold the ORGANIZATION: + // ADR-0131 D1 keys `organization_id` plain, so on a pre-18 nullable + // column two organization-less platform defaults are NULL-distinct and + // both accepted. On 18.x those rows do not live here at all (the tenant + // column is NOT NULL; the ownership ceremony moves them out). const d = makeDriver(); await d.initObjects(app('fixed') as any); // Rows with no organization exist only on a database built before 18.x @@ -598,8 +617,11 @@ describe('#8555 — sys_setting: the declared unique index becomes per-organizat await asPre18TenantColumn(d, TABLE); await runIdentityMigration(d); - expect((await createAsApi(d, row('a', undefined, globalRow))).status).toBe(201); - expect(await createAsApi(d, row('b', undefined, globalRow))).toMatchObject(CONFLICT_ENVELOPE); + expect((await createAsApi(d, row('a', 'org_jia', globalRow))).status).toBe(201); + expect(await createAsApi(d, row('b', 'org_jia', globalRow))).toMatchObject(CONFLICT_ENVELOPE); + + expect((await createAsApi(d, row('c', undefined, globalRow))).status).toBe(201); + expect((await createAsApi(d, row('d', undefined, globalRow))).status).toBe(201); }); it('AFTER the migration (anti-vacuity): the key is still PER-ORGANIZATION', async () => { @@ -648,7 +670,7 @@ describe('#8555 — sys_setting: the declared unique index becomes per-organizat await d.initObjects(app('fixed') as any); expect(await uniqueKeyParts()).toEqual({ - [REPLACEMENT_NAME]: ['COALESCE(organization_id)', 'namespace', 'key', 'scope', 'COALESCE(user_id)'], + [REPLACEMENT_NAME]: ['organization_id', 'namespace', 'key', 'scope', 'COALESCE(user_id)'], }); expect(await createAsApi(d, row('a', 'org_jia', tenantRow))).toMatchObject({ status: 201 }); expect(await createAsApi(d, row('b', 'org_jia', tenantRow))).toMatchObject(CONFLICT_ENVELOPE); @@ -692,8 +714,10 @@ describe('#8555 — sys_setting: the declared unique index becomes per-organizat expect(isUniqueViolationError(refusal)).toBe(true); expect(await d.count(TABLE, {})).toBe(2); expect(Object.keys(await uniqueKeyParts())).toEqual([REPLACEMENT_NAME]); + // The previous index — the driver's declared one, plain-keyed since + // ADR-0131 D1 — is still the one on the table. expect(await uniqueKeyParts()).toEqual({ - [REPLACEMENT_NAME]: ['COALESCE(organization_id)', ...KEY_COLUMNS], + [REPLACEMENT_NAME]: ['organization_id', ...KEY_COLUMNS], }); }); }); @@ -724,9 +748,10 @@ describe('#8555 — sys_setting: the declared unique index becomes per-organizat name: REPLACEMENT_NAME, columns: ['organization_id', ...KEY_COLUMNS], unique: true, - nullSafeColumns: ['organization_id'], }, }); + // ADR-0131 D1: the replacement is the PLAIN composite — no NULL-safe part. + expect(entry!.replacement).not.toHaveProperty('nullSafeColumns'); }); it('claims nothing for an EXPLICITLY NAMED index — that transition is a recreate', () => { @@ -781,7 +806,7 @@ describe('#8555 — sys_setting: the declared unique index becomes per-organizat const d = makeDriver(); await d.initObjects(app('fixed') as any); expect(await uniqueKeyParts(), `posture=${posture}`).toEqual({ - [REPLACEMENT_NAME]: ['COALESCE(organization_id)', ...KEY_COLUMNS], + [REPLACEMENT_NAME]: ['organization_id', ...KEY_COLUMNS], }); await d.disconnect(); driver = undefined; diff --git a/packages/drivers/driver-sql/src/sql-driver-unique-tenancy.test.ts b/packages/drivers/driver-sql/src/sql-driver-unique-tenancy.test.ts index e43351665a5..8d5baca97b5 100644 --- a/packages/drivers/driver-sql/src/sql-driver-unique-tenancy.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-unique-tenancy.test.ts @@ -7,7 +7,7 @@ import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; /** * Unique-scope materialization: tenancy composites (#3696) + the explicit - * scope vocabulary and NULL-safe organization key part (ADR-0120, #5030). + * scope vocabulary (ADR-0120) + the plain organization key part (ADR-0131 D1). * * The defect #3696 locked down: `unique: true` used to become a single-column * global index that ignored `tenancy` entirely, while the autonumber sequence @@ -18,15 +18,20 @@ import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; * The defect #5030 measured on top of it: SQL UNIQUE is NULL-distinct, so the * bare `(organization_id, field)` composite enforced NOTHING on rows whose * organization is NULL — which on a single-tenant stack (where the kernel - * injects the column and never fills it) is EVERY row. ADR-0120 D3 makes the - * organization key part NULL-safe: `COALESCE(organization_id, '__global__')`. + * injects the column and never fills it) is EVERY row. ADR-0120 D3 answered it + * with a NULL-safe key part, `COALESCE(organization_id, '__global__')`. + * ADR-0131 D1 closes the hole at the column instead: a fresh table's tenant + * column is NOT NULL, so new DDL keys the PLAIN organization column and the + * NULL-organization "platform bucket" no longer exists. Only a database built + * before 18.x still holds a nullable tenant column (`asPre18TenantColumn`), + * and there NULL-organization rows are exempt from the plain index — SQL + * UNIQUE is NULL-distinct. * - * The contract now (ADR-0120 D1/D3; scope is a vocabulary, not a position): + * The contract now (ADR-0120 D1 vocabulary; ADR-0131 D1 key shape): * * field `unique: true` / `'organization'` + tenant column - * → composite `(COALESCE(tenantField, '__global__'), field)` — unique per - * organization; NULL-organization rows form one platform bucket, unique - * among themselves (#5030). + * → composite `(tenantField, field)` — unique per organization, the + * tenant key part plain (no COALESCE). * field `unique: true` / `'organization'`, no tenant column * → single-column `(field)`. * field `unique: 'global'` @@ -40,16 +45,17 @@ import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; * The old "`'global'` is a synonym of `true`" pin retired with the bare * spelling. * declared index `unique: 'organization'` - * → NULL-safe organization key part PREPENDED to the listed columns; with + * → the plain organization column PREPENDED to the listed columns; with * no tenant column it degrades to the listed columns (S11); a listed - * tenant column is made NULL-safe in place instead (S6 respelling). + * tenant column is kept in place, never prepended twice (S6 respelling). * * Retiring the legacy global index is no longer inline DDL at boot: since * #3728 it is a `replace_unique_index` drift entry, auto-applied on restart * only under the dev `autoMigrate: 'safe'` policy and otherwise left to - * `os migrate`. The #5030 tightening (bare composite → NULL-safe composite) - * goes through the same ceremony as `recreate_index`, gated by the ADR-0120 D4 - * duplicate pre-flight probe. The migration tests below opt into that policy. + * `os migrate`. The ADR-0120 D4 tightening (bare composite → NULL-safe + * composite) is retired with the COALESCE expectation: a bare composite IS the + * expected shape now, so no op is proposed (ADR-0131 D1). The migration tests + * below opt into that policy. */ describe('SqlDriver unique × tenancy (#3696)', () => { let driver: SqlDriver; @@ -86,9 +92,10 @@ describe('SqlDriver unique × tenancy (#3696)', () => { /** * Unique index name → canonical key parts, read from the index DDL so * expression keys are visible (`PRAGMA index_info` reports a NULL name for - * them). A plain column reads as its name; the NULL-safe organization key - * part (ADR-0120 D3) reads as `COALESCE()` — literal elided, the - * same literal-agnostic identity the drift differ compares on. + * them). A plain column reads as its name; a legacy NULL-safe organization + * key part (ADR-0120 D3) would read as `COALESCE()` — literal + * elided, the same literal-agnostic identity the drift differ compares on — + * which is how the pins below prove new DDL carries none (ADR-0131 D1). */ async function uniqueIndexColumns(table: string): Promise> { const k = (driver as any).knex; @@ -182,7 +189,7 @@ describe('SqlDriver unique × tenancy (#3696)', () => { // ── Physical shape ──────────────────────────────────────────────────────── - it('materializes a COMPOSITE (tenant, field) unique index, NULL-safe tenant key part first', async () => { + it('materializes a COMPOSITE (tenant, field) unique index, PLAIN tenant key part first (ADR-0131 D1)', async () => { await driver.initObjects([ { name: 'product', @@ -194,21 +201,21 @@ describe('SqlDriver unique × tenancy (#3696)', () => { ]); const uniques = await uniqueIndexColumns('product'); - expect(Object.values(uniques)).toContainEqual(['COALESCE(organization_id)', 'code']); - // No leftover single-column global unique on `code`, and no bare - // (NULL-distinct — i.e. void on NULL-org rows, #5030) composite. + expect(Object.values(uniques)).toContainEqual(['organization_id', 'code']); + // No leftover single-column global unique on `code`, and no retired + // NULL-safe (ADR-0120 D3) composite. expect(Object.values(uniques)).not.toContainEqual(['code']); - expect(Object.values(uniques)).not.toContainEqual(['organization_id', 'code']); + expect(Object.values(uniques)).not.toContainEqual(['COALESCE(organization_id)', 'code']); - // The literal is pinned too (ADR-0120 D3, maintainer-resolved): the - // constraint-violation error must read as "the platform bucket collided" - // — `('__global__', …)` — and the word must match the autonumber sequence - // table's GLOBAL_TENANT, not a bare ''. + // The DDL itself carries no expression key (ADR-0131 D1): the tenant + // column is NOT NULL on a fresh table, so the plain column is the whole + // per-organization key — no '__global__' platform bucket is built. const k = (driver as any).knex; const master: any = await k.raw( `SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'uniq_product_organization_id_code'`, ); - expect(String(master[0]?.sql)).toMatch(/COALESCE\([`"]?organization_id[`"]?, '__global__'\)/i); + expect(String(master[0]?.sql)).toMatch(/\(\s*[`"]?organization_id[`"]?\s*,\s*[`"]?code[`"]?\s*\)/i); + expect(String(master[0]?.sql)).not.toMatch(/COALESCE|__global__/i); }); it('materializes a SINGLE-column unique when the object has no tenant column', async () => { @@ -275,7 +282,7 @@ describe('SqlDriver unique × tenancy (#3696)', () => { ]); const uniques = await uniqueIndexColumns('env_setting'); - expect(Object.values(uniques)).toContainEqual(['COALESCE(environment_id)', 'key']); + expect(Object.values(uniques)).toContainEqual(['environment_id', 'key']); await driver.create('env_setting', { environment_id: 'env_a', key: 'k' }); const other = await driver.create('env_setting', { environment_id: 'env_b', key: 'k' }); @@ -447,7 +454,8 @@ describe('SqlDriver unique × tenancy (#3696)', () => { const uniques = await uniqueIndexColumns('product'); expect(uniques['product_code_unique']).toBeUndefined(); - expect(Object.values(uniques)).toContainEqual(['COALESCE(organization_id)', 'code']); + // The replacement is the plain composite (ADR-0131 D1). + expect(Object.values(uniques)).toContainEqual(['organization_id', 'code']); // Existing data survived, and the cross-tenant insert now works. expect(await driver.count('product', {})).toBe(2); @@ -477,7 +485,7 @@ describe('SqlDriver unique × tenancy (#3696)', () => { const uniques = await uniqueIndexColumns('product'); expect(uniques['uniq_product_code']).toBeUndefined(); - expect(Object.values(uniques)).toContainEqual(['COALESCE(organization_id)', 'code']); + expect(Object.values(uniques)).toContainEqual(['organization_id', 'code']); }); it("does NOT retire the single-column index of a unique: 'global' field", async () => { @@ -548,9 +556,10 @@ describe('SqlDriver unique × tenancy (#3696)', () => { ]); const uniques = await uniqueIndexColumns('product'); - expect(Object.values(uniques)).toContainEqual(['COALESCE(organization_id)', 'code']); + // The rebuild lands on the same plain composite as `initObjects` (ADR-0131 D1). + expect(Object.values(uniques)).toContainEqual(['organization_id', 'code']); expect(Object.values(uniques)).not.toContainEqual(['code']); - expect(Object.values(uniques)).not.toContainEqual(['organization_id', 'code']); + expect(Object.values(uniques)).not.toContainEqual(['COALESCE(organization_id)', 'code']); // Data preserved, and cross-tenant reuse still works post-rebuild. expect(await driver.count('product', {})).toBe(1); @@ -558,14 +567,14 @@ describe('SqlDriver unique × tenancy (#3696)', () => { expect(b.code).toBe('C1'); }); - // ── ADR-0120 D3: the NULL-organization bucket is constrained (#5030) ────── + // ── ADR-0131 D1: the NULL-organization bucket is retired (#5030) ────────── - describe('NULL-safe organization uniqueness (ADR-0120 D3, #5030)', () => { - it('#5030 regression: unique IS enforced when organization_id exists but is NULL on every row', async () => { - // The measured defect shape, verbatim from the issue: the kernel injects - // `organization_id` on single-tenant stacks too, every row leaves it - // NULL, and the bare NULL-distinct composite admitted any number of - // duplicates — field-level `unique: true` enforced NOTHING. + describe('NULL organization under the plain key part (ADR-0131 D1, #5030)', () => { + it('#5030 closed at the column: a fresh tenant column refuses a NULL organization outright', async () => { + // The measured #5030 shape — the kernel injects `organization_id` and a + // row leaves it NULL, slipping past a NULL-distinct composite — cannot + // be written on a fresh table: the tenant column is NOT NULL (ADR-0131 + // D1), so the plain composite has no NULL rows to be void on. await driver.initObjects([ { name: 'account', @@ -576,17 +585,13 @@ describe('SqlDriver unique × tenancy (#3696)', () => { }, ]); - // Rows with no organization exist only on a database built before 18.x - // (ADR-0131 D1 makes a fresh tenant column NOT NULL). - await asPre18TenantColumn(driver, 'account'); - const first = await driver.create('account', { email: 'dup@example.com' }); - expect(first.organization_id ?? null).toBeNull(); await expect(driver.create('account', { email: 'dup@example.com' })).rejects.toThrow( - /UNIQUE constraint failed|duplicate key value/, + /NOT NULL constraint failed|null value in column/, ); + expect(await driver.count('account', {})).toBe(0); }); - it('the NULL bucket and organizations coexist: one platform holder, plus one per organization (S8)', async () => { + it('on a pre-18 nullable column, NULL-organization rows are exempt; organizations stay unique (S8)', async () => { await driver.initObjects([ { name: 'template', @@ -597,7 +602,6 @@ describe('SqlDriver unique × tenancy (#3696)', () => { }, ]); - // Platform (NULL-organization) row takes the value… // Rows with no organization exist only on a database built before 18.x // (ADR-0131 D1 makes a fresh tenant column NOT NULL). await asPre18TenantColumn(driver, 'template'); @@ -605,9 +609,14 @@ describe('SqlDriver unique × tenancy (#3696)', () => { // …an organization may still hold the same value (per-org scope)… const org = await driver.create('template', { organization_id: 'org_a', key: 'welcome' }); expect(org.key).toBe('welcome'); - // …but a SECOND platform row may not: NULL rows are one bucket, unique - // among themselves. - await expect(driver.create('template', { key: 'welcome' })).rejects.toThrow( + // …a SECOND NULL-organization row is admitted too: the plain key part + // is NULL-distinct, and ADR-0131 D1 retired the ADR-0120 D3 platform + // bucket rather than rebuilding it… + const second = await driver.create('template', { key: 'welcome' }); + expect(second.key).toBe('welcome'); + expect(await driver.count('template', { where: { key: 'welcome' } } as any)).toBe(3); + // …while the organization itself is still held to one row per value. + await expect(driver.create('template', { organization_id: 'org_a', key: 'welcome' })).rejects.toThrow( /UNIQUE constraint failed|duplicate key value/, ); }); @@ -623,8 +632,9 @@ describe('SqlDriver unique × tenancy (#3696)', () => { } as any, ]); + // Byte-for-byte the `unique: true` shape: the plain composite (ADR-0131 D1). const uniques = await uniqueIndexColumns('contact'); - expect(Object.values(uniques)).toContainEqual(['COALESCE(organization_id)', 'email']); + expect(Object.values(uniques)).toContainEqual(['organization_id', 'email']); await driver.create('contact', { organization_id: 'org_a', email: 'a@x.test' }); const other = await driver.create('contact', { organization_id: 'org_b', email: 'a@x.test' }); @@ -638,7 +648,7 @@ describe('SqlDriver unique × tenancy (#3696)', () => { // ── ADR-0120 D1: declared-index `unique: 'organization'` ────────────────── describe("declared-index unique: 'organization' (ADR-0120 D1)", () => { - it('prepends the NULL-safe organization key part to the listed columns', async () => { + it('prepends the PLAIN organization column to the listed columns (ADR-0131 D1)', async () => { await driver.initObjects([ { name: 'case_record', @@ -653,13 +663,14 @@ describe('SqlDriver unique × tenancy (#3696)', () => { const uniques = await uniqueIndexColumns('case_record'); expect(uniques['uniq_case_record_organization_id_department_code']).toEqual([ - 'COALESCE(organization_id)', + 'organization_id', 'department', 'code', ]); // Per-organization semantics: two orgs may hold the same (department, - // code); the same org may not; the NULL bucket is constrained too. + // code); the same org may not; NULL-organization rows (pre-18 only) + // are exempt — the plain key part is NULL-distinct (ADR-0131 D1). await driver.create('case_record', { organization_id: 'org_a', department: 'sales', code: 'C-1' }); await driver.create('case_record', { organization_id: 'org_b', department: 'sales', code: 'C-1' }); await expect( @@ -669,8 +680,11 @@ describe('SqlDriver unique × tenancy (#3696)', () => { // (ADR-0131 D1 makes a fresh tenant column NOT NULL). await asPre18TenantColumn(driver, 'case_record'); await driver.create('case_record', { department: 'sales', code: 'C-2' }); + const nullOrgTwin = await driver.create('case_record', { department: 'sales', code: 'C-2' }); + expect(nullOrgTwin.code).toBe('C-2'); + // The relaxation kept the index exactly as built: org_a is still refused. await expect( - driver.create('case_record', { department: 'sales', code: 'C-2' }), + driver.create('case_record', { organization_id: 'org_a', department: 'sales', code: 'C-1' }), ).rejects.toThrow(/UNIQUE constraint failed|duplicate key value/); }); @@ -687,11 +701,11 @@ describe('SqlDriver unique × tenancy (#3696)', () => { expect(uniques['uniq_plain_catalog_department_code']).toEqual(['department', 'code']); }); - it('makes a LISTED tenant column NULL-safe in place — the S6 respelling, not double-prepended', async () => { + it('keeps a LISTED tenant column plain in place — the S6 respelling, not double-prepended', async () => { // The hand-written legacy shape, opted into the explicit vocabulary: // the author already listed the tenant column, so the org key part is - // not prepended again — the listed column's own key part becomes the - // NULL-safe form. + // not prepended again — and since ADR-0131 D1 the listed column stays + // the plain column, no longer rewritten to the NULL-safe form. await driver.initObjects([ { name: 'team', @@ -701,24 +715,26 @@ describe('SqlDriver unique × tenancy (#3696)', () => { ]); const uniques = await uniqueIndexColumns('team'); - expect(uniques['uniq_team_organization_id_name']).toEqual([ - 'COALESCE(organization_id)', - 'name', - ]); + expect(uniques['uniq_team_organization_id_name']).toEqual(['organization_id', 'name']); + + await driver.create('team', { organization_id: 'org_a', name: 'Core' }); + await expect(driver.create('team', { organization_id: 'org_a', name: 'Core' })).rejects.toThrow( + /UNIQUE constraint failed|duplicate key value/, + ); // Rows with no organization exist only on a database built before 18.x - // (ADR-0131 D1 makes a fresh tenant column NOT NULL). + // (ADR-0131 D1 makes a fresh tenant column NOT NULL); there they are + // exempt from the plain key part. await asPre18TenantColumn(driver, 'team'); await driver.create('team', { name: 'Core' }); - await expect(driver.create('team', { name: 'Core' })).rejects.toThrow( - /UNIQUE constraint failed|duplicate key value/, - ); + const twin = await driver.create('team', { name: 'Core' }); + expect(twin.name).toBe('Core'); }); }); - // ── ADR-0120 D4: the tightening goes through the ceremony ───────────────── + // ── ADR-0120 D4 retired: a bare composite IS the expectation (ADR-0131 D1) ─ - describe('bare-composite tightening + duplicate pre-flight (ADR-0120 D4)', () => { + describe('bare composite from a pre-18 database — no tightening proposed (ADR-0131 D1)', () => { /** A pre-ADR-0120 database: the bare NULL-distinct composite, in place. */ const seedBareComposite = async () => { const k = (driver as any).knex; @@ -745,11 +761,13 @@ describe('SqlDriver unique × tenancy (#3696)', () => { }, ]; - it("auto-tightens at boot under autoMigrate: 'safe' when the probe is clean", async () => { + it("boots under autoMigrate: 'safe' with the bare composite untouched and ZERO drift", async () => { + // ADR-0120 D4 used to propose `recreate_index` (bare → COALESCE) here. + // The expectation is the plain composite now, which this index already + // is — so there is nothing to tighten. await driver.disconnect(); driver = makeDriver({ autoMigrate: 'safe' }); const k = await seedBareComposite(); - // Clean data: distinct codes, including one NULL-organization row. await k('product').insert([ { id: 'r1', organization_id: 'org_a', code: 'C1' }, { id: 'r2', organization_id: null, code: 'C1' }, @@ -758,75 +776,38 @@ describe('SqlDriver unique × tenancy (#3696)', () => { await driver.initObjects(productMeta); const uniques = await uniqueIndexColumns('product'); - expect(uniques['uniq_product_organization_id_code']).toEqual([ - 'COALESCE(organization_id)', - 'code', - ]); - // Healthy database: converged, zero drift. + expect(uniques['uniq_product_organization_id_code']).toEqual(['organization_id', 'code']); expect(await driver.detectManagedDrift()).toHaveLength(0); - // The NULL bucket is now enforced: a second platform-bucket C1 is refused. - await expect(driver.create('product', { code: 'C1' })).rejects.toThrow( + // Per organization still enforced; a NULL-organization row is exempt. + await expect(driver.create('product', { organization_id: 'org_a', code: 'C1' })).rejects.toThrow( /UNIQUE constraint failed|duplicate key value/, ); + const nullOrg = await driver.create('product', { code: 'C1' }); + expect(nullOrg.code).toBe('C1'); }); - it('BLOCKS the tightening on duplicate NULL-organization rows — old index kept, rows reported', async () => { + it('duplicate NULL-organization rows are no longer a blocker — no op proposed, rows and index kept', async () => { + // The data that used to BLOCK the D4 tightening: duplicates the + // NULL-distinct index admitted. Under the plain expectation they are + // not a violation of anything the driver asks for. await driver.disconnect(); driver = makeDriver({ autoMigrate: 'safe' }); const k = await seedBareComposite(); - // The #5030 defect made data: duplicates the NULL-distinct index admitted. await k('product').insert([ { id: 'r1', organization_id: null, code: 'DUP' }, { id: 'r2', organization_id: null, code: 'DUP' }, ]); - // Boot survives — the tightening is not applied, the old index stays. await driver.initObjects(productMeta); expect((await uniqueIndexColumns('product'))['uniq_product_organization_id_code']).toEqual([ 'organization_id', 'code', ]); - // The plan reports the op as BLOCKED, with the offending group named — - // and the COALESCE key makes the report self-describing ('__global__' = - // the platform bucket). const drift = await driver.detectManagedDrift(); - const entry = drift.find((d) => d.op.type === 'recreate_index'); - expect(entry).toBeDefined(); - expect(entry!.category).toBe('destructive'); - expect(entry!.severity).toBe('error'); - expect(entry!.message).toMatch(/BLOCKED/); - expect(entry!.message).toMatch(/__global__/); - expect(entry!.message).toMatch(/"DUP"/); - expect(entry!.message).toMatch(/NULL-distinct/); - - // Even `--allow-destructive` cannot force it: apply re-probes and - // refuses — at no point is a constraint dropped without its replacement - // being creatable. - const { applied, skipped } = await driver.applyMigrationEntries([entry!], { - allowDestructive: true, - }); - expect(applied).toHaveLength(0); - expect(skipped).toHaveLength(1); - expect((await uniqueIndexColumns('product'))['uniq_product_organization_id_code']).toEqual([ - 'organization_id', - 'code', - ]); - - // Deduplicating unblocks the op: the probe comes back clean, the entry - // re-grades `safe`, and a plain apply tightens the index. - await k('product').where({ id: 'r2' }).delete(); - const drift2 = await driver.detectManagedDrift(); - const entry2 = drift2.find((d) => d.op.type === 'recreate_index'); - expect(entry2).toBeDefined(); - expect(entry2!.category).toBe('safe'); - const res2 = await driver.applyMigrationEntries([entry2!], { allowDestructive: false }); - expect(res2.applied).toHaveLength(1); - expect((await uniqueIndexColumns('product'))['uniq_product_organization_id_code']).toEqual([ - 'COALESCE(organization_id)', - 'code', - ]); - expect(await driver.detectManagedDrift()).toHaveLength(0); + expect(drift.filter((d) => d.op.type === 'recreate_index')).toEqual([]); + expect(drift).toHaveLength(0); + expect(await driver.count('product', {})).toBe(2); }); }); }); From 31c75dd6af2ed988ba30e604a1bec5e7f4384998 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:00:45 +0000 Subject: [PATCH 65/81] fix(metadata-protocol): the sys_setting duplicate query lists only rows the plain organization key refuses; re-pin turso, memory and sys_setting suites Claude-Session: https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd Co-authored-by: Claude --- .../src/memory-declared-index-unique.test.ts | 69 +++++++------ .../src/memory-unique-constraint.test.ts | 38 +++++-- ...local-remote-declared-index-parity.test.ts | 17 +++- .../src/turso-remote-drift-detection.test.ts | 99 +++++++++++++++---- .../turso-remote-migration-entries.test.ts | 73 ++++++++++++-- .../sys-setting-identity-index.test.ts | 98 ++++++++++++++---- .../migrations/sys-setting-identity-index.ts | 8 +- 7 files changed, 306 insertions(+), 96 deletions(-) diff --git a/packages/drivers/driver-memory/src/memory-declared-index-unique.test.ts b/packages/drivers/driver-memory/src/memory-declared-index-unique.test.ts index 8f3ed786c95..ce711a067c2 100644 --- a/packages/drivers/driver-memory/src/memory-declared-index-unique.test.ts +++ b/packages/drivers/driver-memory/src/memory-declared-index-unique.test.ts @@ -45,15 +45,18 @@ * ('acme', NULL, 'X') then ('acme', NULL, 'X') -> BOTH ACCEPTED (NULL-DISTINCT) * ('acme', 'A2', NULL) then ('acme', 'A2', NULL) -> BOTH ACCEPTED (NULL-DISTINCT) * - * UNIQUE (COALESCE(organization_id,'__global__'), account_id, code) -- 'organization' - * (NULL, 'A1', 'X') then (NULL, 'A1', 'X') -> second REFUSED (the org part FOLDS) - * (NULL, 'A2', NULL) then (NULL, 'A2', NULL) -> BOTH ACCEPTED (NULL-DISTINCT wins) + * UNIQUE (organization_id, account_id, code) -- 'organization' + * (NULL, 'A1', 'X') then (NULL, 'A1', 'X') -> BOTH ACCEPTED (NULL-DISTINCT) + * (NULL, 'A2', NULL) then (NULL, 'A2', NULL) -> BOTH ACCEPTED (NULL-DISTINCT) * ``` * * So the composite rule is the field-level rule with a wider key, and NOT a new - * invention: a NULL in any LISTED key column exempts the row, while a NULL - * ORGANIZATION folds onto one bucket because its key part is an expression that - * is never NULL. Both halves are pinned below. + * invention: a NULL in ANY key part exempts the row — the organization's + * included. Since ADR-0131 D1 the tenant column is NOT NULL and `driver-sql` + * keys it plain; until then ADR-0120 D3 keyed it as + * `COALESCE(organization_id,'__global__')`, which folded NULL organizations onto + * one bucket (the second `(NULL, 'A1', 'X')` above was REFUSED). That fold is + * retired on both surfaces, and the exemption is pinned below. */ import { describe, it, expect, beforeEach } from 'vitest'; @@ -177,9 +180,11 @@ describe('[#13239] the two `unique` surfaces disagree about bare `true`, and thi ); }); - it("only the explicit `'organization'` prepends the NULL-safe organization key part", async () => { + it("only the explicit `'organization'` prepends the organization key part — plain, ADR-0131 D1", async () => { + // `scopeColumns` names it for the refusal's wording; `nullSafeColumns` is + // empty — the organization key part no longer folds NULLs (ADR-0131 D1). expect(uniqueConstraintsFromDeclaredIndexes(ledger('organization'))).toEqual([ - { columns: ['organization_id', 'account_id', 'code'], nullSafeColumns: ['organization_id'] }, + { columns: ['organization_id', 'account_id', 'code'], nullSafeColumns: [], scopeColumns: ['organization_id'] }, ]); const driver = new InMemoryDriver(); @@ -232,16 +237,17 @@ describe('[#13239] the two `unique` surfaces disagree about bare `true`, and thi * ==================================================================== */ describe("[#13239] the arms are `normalizeDeclaredIndex`'s, reproduced without importing driver-sql", () => { - it('a listed column that IS the tenant column is not prepended again — its own key part becomes NULL-safe', () => { + it('a listed column that IS the tenant column is not prepended again — it stays a plain key part, named as the scope', () => { // The hand-written S6 spelling, opted in. Order is preserved: the author's - // column stays where they put it. + // column stays where they put it. Since ADR-0131 D1 its key part is plain, + // like the prepended one. const schema = { name: 'ledger', fields: { id: {}, organization_id: {}, code: {} }, indexes: [{ fields: ['code', 'organization_id'], unique: 'organization' }], }; expect(uniqueConstraintsFromDeclaredIndexes(schema)).toEqual([ - { columns: ['code', 'organization_id'], nullSafeColumns: ['organization_id'] }, + { columns: ['code', 'organization_id'], nullSafeColumns: [], scopeColumns: ['organization_id'] }, ]); }); @@ -276,7 +282,7 @@ describe("[#13239] the arms are `normalizeDeclaredIndex`'s, reproduced without i indexes: [{ fields: ['a'], unique: 'organization' }], }; expect(uniqueConstraintsFromDeclaredIndexes(schema)).toEqual([ - { columns: ['org', 'a'], nullSafeColumns: ['org'] }, + { columns: ['org', 'a'], nullSafeColumns: [], scopeColumns: ['org'] }, ]); }); @@ -337,7 +343,7 @@ describe("[#13239] the arms are `normalizeDeclaredIndex`'s, reproduced without i }; expect(uniqueConstraintsFromDeclaredIndexes(schema)).toEqual([ { columns: ['a'], nullSafeColumns: [] }, - { columns: ['organization_id', 'b', 'c'], nullSafeColumns: ['organization_id'] }, + { columns: ['organization_id', 'b', 'c'], nullSafeColumns: [], scopeColumns: ['organization_id'] }, ]); }); }); @@ -357,43 +363,46 @@ describe('[#13239] NULL handling matches the composite index SQL actually builds expect(await driver.count('ledger')).toBe(4); }); - it('a NULL ORGANIZATION does NOT exempt — the D3 fold, reached without the `__global__` token', async () => { - // SQL folds NULL organizations with COALESCE onto a reserved literal because - // an index EXPRESSION needs a non-NULL one. A JS key holds `null` directly, - // so the same bucket is reached with no token at all. + it('a NULL ORGANIZATION exempts the row, like any NULL key part — the D3 fold is retired (ADR-0131 D1)', async () => { + // The plain `(organization_id, …)` index SQL builds now is NULL-distinct in + // its organization part too, so rows without an organization (which only a + // pre-18 store can still hold) are not constrained among themselves. const driver = new InMemoryDriver(); await driver.syncSchema('ledger', ledger('organization')); await driver.create('ledger', { id: '1', account_id: 'A1', code: 'X' }); - const err = await refusalOf(() => driver.create('ledger', { id: '2', account_id: 'A1', code: 'X' })); + await driver.create('ledger', { id: '2', account_id: 'A1', code: 'X' }); + await driver.create('ledger', { id: '3', organization_id: null, account_id: 'A1', code: 'X' }); + // …while a row that DOES carry an organization is still constrained in it. + await driver.create('ledger', { id: '4', organization_id: 'acme', account_id: 'A1', code: 'X' }); + const err = await refusalOf(() => + driver.create('ledger', { id: '5', organization_id: 'acme', account_id: 'A1', code: 'X' }), + ); expectUniqueViolationEnvelope(err, 'account_id', 'code', 'organization_id'); - // …and a row that DOES carry an organization is untouched by that bucket. - await driver.create('ledger', { id: '3', organization_id: 'acme', account_id: 'A1', code: 'X' }); - expect(await driver.count('ledger')).toBe(2); + expect(await driver.count('ledger')).toBe(4); }); - it('an index whose ONLY key part is the NULL-safe organization is "one row per organization"', async () => { + it('an index whose ONLY key part is the organization is "one row per organization" — NULL-org rows exempt', async () => { // `normalizeDeclaredIndex` has no "a unique ON the tenant column stays // single-column" guard — that guard is the FIELD surface's. Here the listed - // organization column simply becomes the NULL-safe key part, so NULL-org - // rows share one bucket and are unique among themselves. + // organization column is the whole key, and plain since ADR-0131 D1: one + // row per organization, while rows without one are not constrained. const schema = { name: 'settings', fields: { id: {}, organization_id: {} }, indexes: [{ fields: ['organization_id'], unique: 'organization' }], }; expect(uniqueConstraintsFromDeclaredIndexes(schema)).toEqual([ - { columns: ['organization_id'], nullSafeColumns: ['organization_id'] }, + { columns: ['organization_id'], nullSafeColumns: [], scopeColumns: ['organization_id'] }, ]); const driver = new InMemoryDriver(); await driver.syncSchema('settings', schema); await driver.create('settings', { id: '1', organization_id: 'acme' }); - await driver.create('settings', { id: '2' }); // NULL organization: its own bucket + await driver.create('settings', { id: '2' }); // NULL organization: exempt const dupOrg = await refusalOf(() => driver.create('settings', { id: '3', organization_id: 'acme' })); expectUniqueViolationEnvelope(dupOrg, 'organization_id'); - const dupNull = await refusalOf(() => driver.create('settings', { id: '4' })); - expectUniqueViolationEnvelope(dupNull, 'organization_id'); - expect(await driver.count('settings')).toBe(2); + await driver.create('settings', { id: '4' }); // a second NULL-org row lands too + expect(await driver.count('settings')).toBe(3); }); }); @@ -411,7 +420,7 @@ describe('[#13239] the refusal names no single column, because a composite has n ); // Matches what SQLite answers for the same index — measured: the plain // composite prints `UNIQUE constraint failed: t.account_id, t.code` (two - // targets -> undefined) and the NULL-safe form prints + // targets -> undefined) and the 18.0 NULL-safe form printed // `UNIQUE constraint failed: index '…'` (an index name -> undefined). expect(isUniqueViolationError(err)).toBe(true); expect(uniqueViolationColumn(err)).toBeUndefined(); diff --git a/packages/drivers/driver-memory/src/memory-unique-constraint.test.ts b/packages/drivers/driver-memory/src/memory-unique-constraint.test.ts index 243444b8f63..9dd3b67c4fd 100644 --- a/packages/drivers/driver-memory/src/memory-unique-constraint.test.ts +++ b/packages/drivers/driver-memory/src/memory-unique-constraint.test.ts @@ -28,7 +28,9 @@ * platform-wide, bare `true` and `'organization'` are per-organization (bare * `true` is the POSITIONAL spelling of `'organization'` at FIELD level, not of * `'global'` — the #4986 trap), and both degrade to a single-column constraint - * when the object has no tenant column. This package cannot import `driver-sql` + * when the object has no tenant column. Since ADR-0131 D1 the organization key + * part is the PLAIN column there, so a row with no organization is exempt like a + * NULL in any key part. This package cannot import `driver-sql` * (dependency direction), so the arms are reproduced and pinned here against * that rule rather than shared. */ @@ -285,20 +287,36 @@ describe("[#13197] scoping matches driver-sql's ADR-0120 D1/D3 rule", () => { expect(err.message).not.toContain('organization_id'); }); - it('the NULL-organization rows form ONE bucket — the D3 fold, without needing the `__global__` token', async () => { - // SQL UNIQUE is NULL-distinct, so a raw `(organization_id, email)` composite - // enforced NOTHING on NULL-org rows — every row on a single-organization - // stack (#5030). ADR-0120 D3 folds them with COALESCE onto a reserved - // literal because an index EXPRESSION needs one; a JS key holds `null` - // directly, so the same bucket is reached with no token at all. + it('a row with NO organization is exempt — the plain organization key part (ADR-0131 D1), the D3 bucket retired', async () => { + // ADR-0120 D3 once folded NULL-org rows onto one bucket (COALESCE onto + // `__global__` in SQL). The tenant column is NOT NULL since ADR-0131 D1, so + // driver-sql keys `(organization_id, email)` plain and SQL's NULL-distinct + // UNIQUE exempts such rows (which only a pre-18 store can still hold) — + // this driver answers the same. const driver = new InMemoryDriver(); await driver.syncSchema('contact', withOrg(true)); await driver.create('contact', { id: '1', email: 'a@b.com' }); + await driver.create('contact', { id: '2', email: 'a@b.com' }); + await driver.create('contact', { id: '3', organization_id: null, email: 'a@b.com' }); + // …while a row that DOES carry an organization is still constrained in it. + await driver.create('contact', { id: '4', organization_id: 'acme', email: 'a@b.com' }); + const err = await refusalOf(() => + driver.create('contact', { id: '5', organization_id: 'acme', email: 'a@b.com' }), + ); + expectUniqueViolationEnvelope(err, 'email'); + expect(await driver.count('contact')).toBe(4); + }); + + it("a platform-wide constraint's constant scope still constrains rows with no organization", async () => { + // The one NULL-safe part left: `scopeField: null` is a constant, not a column, + // so `'global'` keeps refusing a second NULL-org row (ADR-0131 D1 changes + // only the organization key part). + const driver = new InMemoryDriver(); + await driver.syncSchema('contact', withOrg('global')); + await driver.create('contact', { id: '1', email: 'a@b.com' }); const err = await refusalOf(() => driver.create('contact', { id: '2', email: 'a@b.com' })); expectUniqueViolationEnvelope(err, 'email'); - // …and a row that DOES carry an organization is untouched by that bucket. - await driver.create('contact', { id: '3', organization_id: 'acme', email: 'a@b.com' }); - expect(await driver.count('contact')).toBe(2); + expect(await driver.count('contact')).toBe(1); }); it('with NO tenant column both per-organization spellings degrade to a single-column constraint', () => { diff --git a/packages/drivers/driver-turso/src/turso-local-remote-declared-index-parity.test.ts b/packages/drivers/driver-turso/src/turso-local-remote-declared-index-parity.test.ts index 82c2c69526b..bf20fc638b6 100644 --- a/packages/drivers/driver-turso/src/turso-local-remote-declared-index-parity.test.ts +++ b/packages/drivers/driver-turso/src/turso-local-remote-declared-index-parity.test.ts @@ -23,8 +23,9 @@ * per-face suite cannot fail on the DIFFERENCE between faces, and the * difference is the defect. So each pin here drives one object set through * BOTH faces and compares what physically landed — index names, uniqueness, - * origin and key parts (NULL-safe organization expressions included) — rather - * than asserting either face against a literal. Each parity pin is ANCHORED + * origin and key parts (the organization key part included — PLAIN since + * ADR-0131 D1, so a face that still emitted a COALESCE expression would show + * here) — rather than asserting either face against a literal. Each parity pin is ANCHORED * by names computed through the shared `buildIndexName`, because two faces * that both lost every index would agree perfectly. * @@ -39,7 +40,8 @@ * `indexes` verbatim — because this package does not (and should not) depend * on `service-messaging` or `platform-objects`. `os17609_scoped` covers what * those two do not: a tenant column, a field-level `unique` scoped by it, a - * declared `unique: 'organization'` (NULL-safe key part), an explicit + * declared `unique: 'organization'` (plain organization key part, ADR-0131 + * D1), an explicit * `unique: 'global'`, two author-named indexes — one of them no SQL * identifier at all (a space and a double quote), which both faces must * accept — and an index over a virtual `formula` field that neither face may @@ -374,10 +376,15 @@ describe('[#17609] declared object-level indexes land identically on both TursoD expect(localShapes.map((i) => i.name), name).toEqual(EXPECTED_NAMES[name]); } - // The tenant key part is the NULL-safe expression on both faces, not the bare column. + // ADR-0131 D1: the tenant key part is the PLAIN column on both faces — no + // COALESCE expression — for the declared `unique: 'organization'` and the + // field-level `unique` alike (the remote face's agreement is pinned above). const scoped = await indexShapes(remote.query, SCOPED.name); expect(scoped.find((i) => i.name === buildIndexName(SCOPED.name, ['organization_id', 'slug'], true))).toEqual( - expect.objectContaining({ unique: true, keys: ["COALESCE(organization_id, '__global__')", 'slug'] }), + expect.objectContaining({ unique: true, keys: ['organization_id', 'slug'] }), + ); + expect(scoped.find((i) => i.name === buildIndexName(SCOPED.name, ['organization_id', 'code'], true))).toEqual( + expect.objectContaining({ unique: true, keys: ['organization_id', 'code'] }), ); }); diff --git a/packages/drivers/driver-turso/src/turso-remote-drift-detection.test.ts b/packages/drivers/driver-turso/src/turso-remote-drift-detection.test.ts index 81d10c3c9dc..66645352283 100644 --- a/packages/drivers/driver-turso/src/turso-remote-drift-detection.test.ts +++ b/packages/drivers/driver-turso/src/turso-remote-drift-detection.test.ts @@ -27,9 +27,12 @@ * nowhere, on either face. This is what the `os serve` boot gate reads: a * false finding here would refuse a healthy boot. * 2. Each drifted shape (an extra physical column, a declared index the table - * lacks, a bare organization key a NULL-safe unique must tighten, over - * clean and over duplicate rows) reads alike on both faces, with or without - * explicit objects. + * lacks, an 18.0 `COALESCE` organization key against the plain expectation + * on an uncertified nullable table, and a pre-normalized NULL-safe + * declaration over a bare key, over clean and over duplicate rows) reads + * alike on both faces, with or without explicit objects. On a certified + * database the 18.0 key reads as the plain one: no drift on either face + * (ADR-0131 D1). * 3. The DDL-free `registerObjectMetadata` (a `skipSchemaSync` boot) fills the * registry the no-argument call walks, and sends nothing to the database. * 4. A declared table the database does not hold is skipped, as locally. @@ -120,6 +123,21 @@ const TENANT = { }; const INDEXED = { name: 'indexed_t', fields: { name: { type: 'text' } }, indexes: [{ fields: ['name'] }] }; const ALL: Objects = [T, TENANT, INDEXED]; +const TENANT_UNIQUE = 'uniq_tenant_t_organization_id_code'; +/** What 18.0 built under the declared name: the ADR-0120 D3 NULL-safe key part. */ +const COALESCE_18_0 = `CREATE UNIQUE INDEX "${TENANT_UNIQUE}" ON "tenant_t" (COALESCE("organization_id", '__global__'), "code")`; +/** + * `tenant_t` with its unique declared ALREADY NORMALIZED, carrying the NULL-safe + * key part itself. ADR-0131 D1: the generators key the plain organization column + * now, so this is the one declaration that still yields the ADR-0120 D4 + * tightening (an input carrying `nullSafeColumns` is honoured verbatim). + */ +const TENANT_NULL_SAFE = { + name: 'tenant_t', + fields: { code: { type: 'text' }, label: { type: 'text' }, organization_id: { type: 'text' } }, + indexes: [{ name: TENANT_UNIQUE, fields: ['organization_id', 'code'], unique: true, nullSafeColumns: ['organization_id'] }], +}; +const ALL_NULL_SAFE: Objects = [T, TENANT_NULL_SAFE, INDEXED]; type Door = 'syncSchemasBatch' | 'syncSchema' | 'initObjects'; const SYNC: Record Promise> = { @@ -148,10 +166,12 @@ type Drift = Awaited>; describe('each drifted shape reads alike on the remote and the local face', () => { /** The physical drift each case puts on disk after a clean remote sync, and the finding it must produce. */ - // The fourth element: the case's rows carry no organization, which only a - // store built before 18.x can hold (ADR-0131 D1), so `tenant_t` is built in - // that shape first (`initObjectsPre18`). - const CASES: Array<[string, string[], (drift: Drift) => void, boolean?]> = [ + // The fourth element: the case needs a nullable tenant column — rows that + // carry no organization, or an uncertified table whose column could hold + // them — which only a store built before 18.x has (ADR-0131 D1), so + // `tenant_t` is built in that shape first (`initObjectsPre18`). The fifth: + // the declarations, when not `ALL`. + const CASES: Array<[string, string[], (drift: Drift) => void, boolean?, Objects?]> = [ [ 'an extra physical column the declaration omits', ['ALTER TABLE "t" ADD COLUMN "legacy" TEXT', 'CREATE INDEX "idx_t_legacy" ON "t" ("legacy")'], @@ -167,43 +187,80 @@ describe('each drifted shape reads alike on the remote and the local face', () = })), ], [ - 'a bare organization key a NULL-safe unique tightens, over clean rows', - [ - 'DROP INDEX "uniq_tenant_t_organization_id_code"', - 'CREATE UNIQUE INDEX "uniq_tenant_t_organization_id_code" ON "tenant_t" ("organization_id", "code")', - ], + // ADR-0131 D1: new DDL keys the plain column; on a nullable tenant column + // with no certification the 18.0 COALESCE key is a different constraint, + // so the rebuild is destructive (it is NOT the D4 tightening — the + // expectation carries no NULL-safe part). + 'an 18.0 COALESCE organization key against the plain expectation, on an uncertified nullable table', + [`DROP INDEX "${TENANT_UNIQUE}"`, COALESCE_18_0], + (drift) => { + const entry = drift.find((d) => d.table === 'tenant_t' && d.op.type === 'recreate_index'); + expect(entry).toEqual(expect.objectContaining({ + category: 'destructive', op: expect.objectContaining({ type: 'recreate_index', indexName: TENANT_UNIQUE }), + })); + expect(entry!.op).not.toHaveProperty('nullSafeColumns'); + expect(entry!.op).not.toHaveProperty('tightenNullSafeOnly'); + }, + true, + ], + [ + 'a pre-normalized NULL-safe declaration tightens a bare organization key, over clean rows', + [`DROP INDEX "${TENANT_UNIQUE}"`, `CREATE UNIQUE INDEX "${TENANT_UNIQUE}" ON "tenant_t" ("organization_id", "code")`], (drift) => expect(drift).toContainEqual(expect.objectContaining({ table: 'tenant_t', category: 'safe', op: expect.objectContaining({ type: 'recreate_index', tightenNullSafeOnly: true }), })), + true, + ALL_NULL_SAFE, ], [ 'the same tightening over duplicate rows the bare key admitted', [ - 'DROP INDEX "uniq_tenant_t_organization_id_code"', - 'CREATE UNIQUE INDEX "uniq_tenant_t_organization_id_code" ON "tenant_t" ("organization_id", "code")', + `DROP INDEX "${TENANT_UNIQUE}"`, + `CREATE UNIQUE INDEX "${TENANT_UNIQUE}" ON "tenant_t" ("organization_id", "code")`, `INSERT INTO "tenant_t" ("id", "code", "organization_id") VALUES ('a', 'X', NULL), ('b', 'X', NULL)`, ], (drift) => expect(drift).toContainEqual(expect.objectContaining({ - table: 'tenant_t', category: 'destructive', op: expect.objectContaining({ type: 'recreate_index' }), + table: 'tenant_t', category: 'destructive', op: expect.objectContaining({ type: 'recreate_index', tightenNullSafeOnly: true }), })), true, + ALL_NULL_SAFE, ], ]; - it.each(CASES)('%s', async (_label, statements, finding, pre18Tenant) => { + it.each(CASES)('%s', async (_label, statements, finding, pre18Tenant, objects = ALL) => { const file = nextFile(); const { driver: remote } = await remoteOver(file); - if (pre18Tenant) await initObjectsPre18(remote, TENANT); - await SYNC.syncSchemasBatch(remote, ALL); + if (pre18Tenant) await initObjectsPre18(remote, objects.find((o) => o.name === 'tenant_t')!); + await SYNC.syncSchemasBatch(remote, objects); for (const sql of statements) await onDisk(file, sql); - const local = await localOver(file, ALL); + const local = await localOver(file, objects); - const expected = await local.detectManagedDrift(ALL); + const expected = await local.detectManagedDrift(objects); // Non-vacuous: the local face sees the drift the case put on disk. finding(expected); - expect(await remote.detectManagedDrift(ALL)).toEqual(expected); + expect(await remote.detectManagedDrift(objects)).toEqual(expected); expect(await remote.detectManagedDrift()).toEqual(expected); }); + + it('the same 18.0 COALESCE key on a certified database reads as the plain one: no drift on either face', async () => { + // ADR-0131 D1 — the equivalence leg of the uncertified case above: same + // bytes on disk, the certification resolver answering yes on both faces. + const file = nextFile(); + const { driver: remote } = await remoteOver(file); + await initObjectsPre18(remote, TENANT); + await SYNC.syncSchemasBatch(remote, ALL); + for (const sql of [`DROP INDEX "${TENANT_UNIQUE}"`, COALESCE_18_0]) await onDisk(file, sql); + const local = await localOver(file, ALL); + // Non-vacuous: uncertified, both faces see the destructive rebuild. + expect((await local.detectManagedDrift(ALL)).map((d) => d.op.type)).toEqual(['recreate_index']); + expect((await remote.detectManagedDrift(ALL)).map((d) => d.op.type)).toEqual(['recreate_index']); + + local.setTenantKeyCertifiedResolver(() => true); + remote.setTenantKeyCertifiedResolver(() => true); + expect(await local.detectManagedDrift(ALL)).toEqual([]); + expect(await remote.detectManagedDrift(ALL)).toEqual([]); + expect(await remote.detectManagedDrift()).toEqual([]); + }); }); describe('the registry the no-argument call walks', () => { diff --git a/packages/drivers/driver-turso/src/turso-remote-migration-entries.test.ts b/packages/drivers/driver-turso/src/turso-remote-migration-entries.test.ts index 00d55e68e42..8d329658d7f 100644 --- a/packages/drivers/driver-turso/src/turso-remote-migration-entries.test.ts +++ b/packages/drivers/driver-turso/src/turso-remote-migration-entries.test.ts @@ -27,8 +27,11 @@ * and the indexes the same batch had dropped first are back: the batch is * one transaction. * 3. `drop_index`, `create_index` and `recreate_index` reach their target - * state; the NULL-safe tightening re-probes for duplicates and refuses over - * them, leaving the old index exactly as it was. + * state: an 18.0 `COALESCE` organization key on an uncertified nullable + * table is rebuilt to the plain key new DDL emits (ADR-0131 D1), and on a + * certified database is no entry at all; the NULL-safe tightening (reached + * now only through a pre-normalized declaration) re-probes for duplicates + * and refuses over them, leaving the old index exactly as it was. * 4. A column entry this transport cannot perform (it needs the SQLite table * rebuild) is skipped, not reported applied. * 5. Once the report is applied, the remote face reports no drift, and the @@ -165,6 +168,17 @@ const TENANT = { fields: { code: { type: 'text', unique: true }, organization_id: { type: 'text' } }, }; const TENANT_UNIQUE = 'uniq_tenant_t_organization_id_code'; +/** + * `tenant_t` with its unique declared ALREADY NORMALIZED, carrying the NULL-safe + * key part itself. ADR-0131 D1: the generators key the plain organization column + * now, so only an input carrying `nullSafeColumns` (honoured verbatim) still + * yields the ADR-0120 D4 tightening and its duplicate re-probe. + */ +const TENANT_NULL_SAFE = { + name: 'tenant_t', + fields: { code: { type: 'text' }, organization_id: { type: 'text' } }, + indexes: [{ name: TENANT_UNIQUE, fields: ['organization_id', 'code'], unique: true, nullSafeColumns: ['organization_id'] }], +}; describe('the index entries', () => { it('drop_index: an undeclared index is dropped, and a second run finds the step done', async () => { @@ -193,15 +207,57 @@ describe('the index entries', () => { expect(await indexesOf(file, 'indexed_t')).toEqual(synced); }); - /** A tenant table whose NULL-safe unique was left as the bare composite, optionally over duplicates. */ - async function bareTenantKey(duplicates: boolean) { + /** + * A tenant table as 18.0 left it: a nullable tenant column (built before + * 18.x, ADR-0131 D1) and the ADR-0120 D3 `COALESCE` key under the declared + * name, where HEAD's sync keys the plain column. + */ + async function coalescedTenantKey() { const file = nextFile(); const remote = await remoteOver(file); - // Duplicates the bare key admitted carry no organization, which only a - // store built before 18.x can hold (ADR-0131 D1). - if (duplicates) await initObjectsPre18(remote, TENANT); + await initObjectsPre18(remote, TENANT); await remote.syncSchemasBatch([{ object: 'tenant_t', schema: TENANT }]); const synced = await indexesOf(file, 'tenant_t'); + expect(synced[TENANT_UNIQUE]).not.toMatch(/COALESCE/i); + await onDisk( + file, + `DROP INDEX "${TENANT_UNIQUE}"`, + `CREATE UNIQUE INDEX "${TENANT_UNIQUE}" ON "tenant_t" (COALESCE("organization_id", '__global__'), "code")`, + ); + return { file, remote, synced }; + } + + it('recreate_index: an 18.0 COALESCE key on an uncertified nullable table is rebuilt to the plain key', async () => { + const { file, remote, synced } = await coalescedTenantKey(); + const recreate = (await remote.detectManagedDrift()).find((d) => d.op.type === 'recreate_index')!; + // Uncertified, the two forms are different constraints: a destructive rebuild, not the D4 tightening. + expect(recreate).toEqual(expect.objectContaining({ category: 'destructive' })); + expect(recreate.op).not.toHaveProperty('tightenNullSafeOnly'); + expect(await remote.applyMigrationEntries([recreate])).toEqual({ applied: [], skipped: [recreate] }); + expect(await remote.applyMigrationEntries([recreate], { allowDestructive: true })).toEqual({ applied: [recreate], skipped: [] }); + expect(await indexesOf(file, 'tenant_t')).toEqual(synced); + expect(await remote.detectManagedDrift()).toEqual([]); + }); + + it('a certified database reads the same 18.0 COALESCE key as the plain one: no entry, and nothing moves', async () => { + const { file, remote } = await coalescedTenantKey(); + const coalesced = await indexesOf(file, 'tenant_t'); + remote.setTenantKeyCertifiedResolver(() => true); + expect(await remote.detectManagedDrift()).toEqual([]); + expect(await indexesOf(file, 'tenant_t')).toEqual(coalesced); + }); + + /** A pre-18 tenant table whose pre-normalized NULL-safe unique was left as the bare composite, optionally over duplicates. */ + async function bareTenantKey(duplicates: boolean) { + const file = nextFile(); + const remote = await remoteOver(file); + // A nullable tenant column (a store built before 18.x, ADR-0131 D1): the + // duplicates the bare key admitted carry no organization, and a NOT NULL + // column would read the two key forms as one, proposing nothing. + await initObjectsPre18(remote, TENANT_NULL_SAFE); + await remote.syncSchemasBatch([{ object: 'tenant_t', schema: TENANT_NULL_SAFE }]); + const synced = await indexesOf(file, 'tenant_t'); + expect(synced[TENANT_UNIQUE]).toMatch(/COALESCE/i); await onDisk( file, `DROP INDEX "${TENANT_UNIQUE}"`, @@ -211,10 +267,11 @@ describe('the index entries', () => { const bare = await indexesOf(file, 'tenant_t'); const recreate = (await remote.detectManagedDrift()).find((d) => d.op.type === 'recreate_index')!; expect(recreate).toBeDefined(); + expect(recreate.op).toEqual(expect.objectContaining({ tightenNullSafeOnly: true, nullSafeColumns: ['organization_id'] })); return { file, remote, synced, bare, recreate }; } - it('recreate_index: the bare key tightens to the NULL-safe one over clean rows', async () => { + it('recreate_index: a pre-normalized NULL-safe declaration tightens the bare key over clean rows', async () => { const { file, remote, synced, recreate } = await bareTenantKey(false); expect(await remote.applyMigrationEntries([recreate], { allowDestructive: true })).toEqual({ applied: [recreate], skipped: [] }); expect(await indexesOf(file, 'tenant_t')).toEqual(synced); diff --git a/packages/metadata-protocol/src/migrations/sys-setting-identity-index.test.ts b/packages/metadata-protocol/src/migrations/sys-setting-identity-index.test.ts index 2d7521fa5dc..0d2d4ea691c 100644 --- a/packages/metadata-protocol/src/migrations/sys-setting-identity-index.test.ts +++ b/packages/metadata-protocol/src/migrations/sys-setting-identity-index.test.ts @@ -43,10 +43,19 @@ describe('sys_setting row-identity uniqueness (#8629)', () => { * declaration `{ fields: ['namespace','key','scope','user_id'], unique: * 'organization' }` after #8555 — captured by running the real driver over * the real declaration, the same way the sibling suites source their - * fixtures. It is the NULL-safe-on-organization, NULL-DISTINCT-on-user_id + * fixtures — re-measured at ADR-0131 D1, which keys the organization column + * PLAIN (the tenant column is NOT NULL). It is the NULL-DISTINCT-on-user_id * index this migration exists to replace, and what it must not destroy. */ const DECLARED_INDEX_DDL = + 'CREATE UNIQUE INDEX `uniq_sys_setting_organization_id_namespace_key_scope_user_id` ' + + 'on `sys_setting` (`organization_id`, `namespace`, `key`, `scope`, `user_id`)'; + /** + * What the driver emitted for the same declaration before ADR-0131 D1: + * ADR-0120 D3's NULL-safe organization key part. A store an 18.0 release + * built still carries it under the declared name. + */ + const DECLARED_INDEX_DDL_18_0 = 'CREATE UNIQUE INDEX `uniq_sys_setting_organization_id_namespace_key_scope_user_id` ' + "ON `sys_setting` (COALESCE(`organization_id`, '__global__'), `namespace`, `key`, `scope`, `user_id`)"; @@ -149,11 +158,24 @@ describe('sys_setting row-identity uniqueness (#8629)', () => { expect(dup.error).toContain(SYS_SETTING_IDENTITY_INDEX_NAME); }); - it('AFTER: two platform defaults on the global layer are refused', async () => { + it('AFTER: rows with NO organization are exempt — the organization key part is plain (ADR-0131 D1)', async () => { + // The platform layer lives in `sys_platform_setting` since the + // organization-ownership ceremony (ADR-0131 D7), and the tenant column + // is NOT NULL, so this index keys `organization_id` plain: SQL's + // NULL-distinct UNIQUE leaves the NULL-organization rows a pre-18 store + // may still hold unconstrained, as the driver's own index does. await ensureSysSettingIdentityIndex(exec); expect(insert('g1', { scope: 'global', namespace: 'mail', key: 'smtp_host' }).ok).toBe(true); - expect(insert('g2', { scope: 'global', namespace: 'mail', key: 'smtp_host' }).ok).toBe(false); + expect(insert('g2', { scope: 'global', namespace: 'mail', key: 'smtp_host' }).ok).toBe(true); + expect(insert('n1', { namespace: 'mail', key: 'smtp_host' }).ok).toBe(true); + expect(insert('n2', { namespace: 'mail', key: 'smtp_host' }).ok).toBe(true); + // Control: the same tenant-scope pair, NULL user_id, inside ONE + // organization is refused — the user_id fold is what this index adds. + expect(insert('o1', { organization_id: 'org_jia', namespace: 'mail', key: 'smtp_host' }).ok).toBe(true); + const dup = insert('o2', { organization_id: 'org_jia', namespace: 'mail', key: 'smtp_host' }); + expect(dup.ok).toBe(false); + expect(dup.error).toContain(SYS_SETTING_IDENTITY_INDEX_NAME); }); it('AFTER (anti-vacuity): the key stays PER-ORGANIZATION — two organizations may hold it', async () => { @@ -209,6 +231,21 @@ describe('sys_setting row-identity uniqueness (#8629)', () => { expect(indexNames()).toEqual([SYS_SETTING_IDENTITY_INDEX_NAME]); }); + it('an 18.0 index under the declared name (COALESCE organization) is rebuilt to the plain key', async () => { + // ADR-0131 D1: a store an earlier release built carries the NULL-safe + // organization key part; the run replaces the definition, so the key + // converges on the driver's plain form. + db.exec(`DROP INDEX ${SYS_SETTING_IDENTITY_INDEX_NAME}`); + db.exec(DECLARED_INDEX_DDL_18_0); + + expect(await ensureSysSettingIdentityIndex(exec)).toEqual({ status: 'created' }); + expect(indexDdl(SYS_SETTING_IDENTITY_INDEX_NAME)).toBe( + buildSysSettingIdentityIndexSql(SYS_SETTING_IDENTITY_INDEX_NAME).replace('IF NOT EXISTS ', ''), + ); + expect(indexDdl(SYS_SETTING_IDENTITY_INDEX_NAME)).not.toMatch(/__global__/); + expect(indexNames()).toEqual([SYS_SETTING_IDENTITY_INDEX_NAME]); + }); + it('leaves no probe index behind', async () => { await ensureSysSettingIdentityIndex(exec); expect(indexDdl(SYS_SETTING_IDENTITY_PROBE_INDEX_NAME)).toBeUndefined(); @@ -225,7 +262,7 @@ describe('sys_setting row-identity uniqueness (#8629)', () => { insert('a', { organization_id: 'org_jia' }); await ensureSysSettingIdentityIndex(exec); - // ADR-0120 D3's invariant: only the INDEX folds NULL into a bucket. A + // ADR-0120 D3's invariant, kept for `user_id`: only the INDEX folds NULL into a bucket. A // sentinel that reached storage would be a silent data rewrite. expect(rowCount()).toBe(1); expect( @@ -308,9 +345,11 @@ describe('sys_setting row-identity uniqueness (#8629)', () => { Record >; + // `organization_id` is a plain key part since ADR-0131 D1, so it is + // projected bare under its own name; only `user_id` is folded. expect(rows).toEqual([ { - organization_id_key: 'org_jia', + organization_id: 'org_jia', namespace: 'lifecycle', key: 'retention_overrides', scope: 'tenant', @@ -320,6 +359,17 @@ describe('sys_setting row-identity uniqueness (#8629)', () => { ]); }); + it('does not list organization-less twins: the plain organization key part never refuses them (ADR-0131 D1)', () => { + // A pre-18 table's rows with no organization share a NULL key part, + // so the index exempts them; listing them would send an operator + // after duplicates the constraint does not see. + expect(insert('n1', { organization_id: null, scope: 'tenant' }).ok).toBe(true); + expect(insert('n2', { organization_id: null, scope: 'tenant' }).ok).toBe(true); + const rows = db.prepare(buildSysSettingDuplicateProbeSql()).all() as Array>; + expect(rows.map((r) => r.organization_id)).toEqual(['org_jia']); + db.prepare("DELETE FROM sys_setting WHERE id IN ('n1', 'n2')").run(); + }); + it('keeps refusing until an operator resolves it — and then converges', async () => { expect((await ensureSysSettingIdentityIndex(exec)).status).toBe('conflict'); // The operator's decision, made by the operator. @@ -333,14 +383,15 @@ describe('sys_setting row-identity uniqueness (#8629)', () => { // ───────────────────────────────────────────────────────────────────────── describe('the statements', () => { - it('folds exactly the two nullable key columns, with the precedents’ sentinels', () => { - // Both literals are load-bearing and neither is this module's to - // choose: '__global__' is ADR-0120 D3 / `SqlDriver.GLOBAL_TENANT`, - // and '' is `ensureOverlayIndex`'s spelling for a non-tenant - // nullable discriminator. Editing either re-partitions the index. - expect(SYS_SETTING_NULL_SENTINELS).toEqual({ organization_id: '__global__', user_id: '' }); + it('folds exactly the one nullable non-tenant key column, with the precedent’s sentinel', () => { + // '' is `ensureOverlayIndex`'s spelling for a non-tenant nullable + // discriminator, and not this module's to choose: editing it + // re-partitions the index. `organization_id` takes no sentinel since + // ADR-0131 D1 — the tenant column is NOT NULL and the driver keys it + // plain, so ADR-0120 D3's '__global__' fold is gone from this key. + expect(SYS_SETTING_NULL_SENTINELS).toEqual({ user_id: '' }); expect(sysSettingIdentityKeyParts()).toEqual([ - "COALESCE(organization_id, '__global__')", + 'organization_id', 'namespace', 'key', 'scope', @@ -362,7 +413,7 @@ describe('sys_setting row-identity uniqueness (#8629)', () => { expect(buildSysSettingIdentityIndexSql(SYS_SETTING_IDENTITY_INDEX_NAME)).toBe( 'CREATE UNIQUE INDEX IF NOT EXISTS ' + 'uniq_sys_setting_organization_id_namespace_key_scope_user_id ON sys_setting ' + - "(COALESCE(organization_id, '__global__'), namespace, key, scope, COALESCE(user_id, ''))", + "(organization_id, namespace, key, scope, COALESCE(user_id, ''))", ); expect(buildSysSettingIdentityIndexSql(SYS_SETTING_IDENTITY_INDEX_NAME)).not.toMatch(/where/i); }); @@ -390,10 +441,13 @@ describe('sys_setting row-identity uniqueness (#8629)', () => { // Every folded column is projected through its OWN coalesce, never // bare: PostgreSQL rejects a bare projection of a column that appears // in GROUP BY only inside an expression (#6772), and this query has - // to be legal on both dialects that can build the index. - expect(sql).toContain("COALESCE(organization_id, '__global__') AS organization_id_key"); + // to be legal on both dialects that can build the index. A PLAIN key + // part (`organization_id` since ADR-0131 D1) is grouped bare, so it + // is projected bare — and never through a coalesce it is not keyed by. expect(sql).toContain("COALESCE(user_id, '') AS user_id_key"); - expect(sql).not.toMatch(/SELECT organization_id,/); + expect(sql).not.toMatch(/SELECT user_id,|, user_id,/); + expect(sql).toMatch(/^SELECT organization_id, /); + expect(sql).not.toContain('__global__'); }); // ───────────────────────────────────────────────────────────────────── @@ -410,20 +464,22 @@ describe('sys_setting row-identity uniqueness (#8629)', () => { // ───────────────────────────────────────────────────────────────────── it('the MySQL spelling quotes EVERY identifier, and leaves the bare one untouched', () => { expect(buildSysSettingDuplicateProbeSqlMysql()).toBe( - 'SELECT COALESCE(`organization_id`, \'__global__\') AS `organization_id_key`, ' + + 'SELECT `organization_id`, ' + '`namespace`, `key`, `scope`, COALESCE(`user_id`, \'\') AS `user_id_key`, ' + 'COUNT(*) AS `duplicate_rows` FROM `sys_setting` ' + - 'GROUP BY COALESCE(`organization_id`, \'__global__\'), `namespace`, `key`, ' + + 'WHERE `organization_id` IS NOT NULL AND `namespace` IS NOT NULL AND `key` IS NOT NULL AND `scope` IS NOT NULL ' + + 'GROUP BY `organization_id`, `namespace`, `key`, ' + '`scope`, COALESCE(`user_id`, \'\') HAVING COUNT(*) > 1', ); // The bare builder is the package's existing surface and several // callers execute it on SQLite/PostgreSQL — adding a variant must // not have moved it. expect(buildSysSettingDuplicateProbeSql()).toBe( - "SELECT COALESCE(organization_id, '__global__') AS organization_id_key, " + + 'SELECT organization_id, ' + "namespace, key, scope, COALESCE(user_id, '') AS user_id_key, " + 'COUNT(*) AS duplicate_rows FROM sys_setting ' + - "GROUP BY COALESCE(organization_id, '__global__'), namespace, key, scope, " + + 'WHERE organization_id IS NOT NULL AND namespace IS NOT NULL AND key IS NOT NULL AND scope IS NOT NULL ' + + 'GROUP BY organization_id, namespace, key, scope, ' + "COALESCE(user_id, '') HAVING COUNT(*) > 1", ); // No identifier left bare — not a list of MySQL's reserved words, @@ -458,7 +514,7 @@ describe('sys_setting row-identity uniqueness (#8629)', () => { expect(mysql).toEqual(bare); expect(bare).toEqual([ { - organization_id_key: 'org_jia', + organization_id: 'org_jia', namespace: 'lifecycle', key: 'retention_overrides', scope: 'tenant', diff --git a/packages/metadata-protocol/src/migrations/sys-setting-identity-index.ts b/packages/metadata-protocol/src/migrations/sys-setting-identity-index.ts index 5c3aeb585db..087c4822df6 100644 --- a/packages/metadata-protocol/src/migrations/sys-setting-identity-index.ts +++ b/packages/metadata-protocol/src/migrations/sys-setting-identity-index.ts @@ -388,9 +388,15 @@ function duplicateProbeSqlIn(quote: IdentifierQuote): string { const keyPart = keyParts[i]!; return keyPart === quote(column) ? keyPart : `${keyPart} AS ${quote(`${column}_key`)}`; }); + // [ADR-0131 D1] A row with a NULL in a plain key part conflicts with nothing + // under the NULL-distinct UNIQUE, so it is no duplicate to list: the query + // groups only the rows the index constrains (the driver's own pre-flight + // leaves them out the same way). + const plain = SYS_SETTING_IDENTITY_INDEX_COLUMNS.filter((column) => SYS_SETTING_NULL_SENTINELS[column] === undefined); return ( `SELECT ${projected.join(', ')}, COUNT(*) AS ${quote('duplicate_rows')} ` + `FROM ${quote(SYS_SETTING_TABLE)} ` + + `WHERE ${plain.map((column) => `${quote(column)} IS NOT NULL`).join(' AND ')} ` + `GROUP BY ${keyParts.join(', ')} HAVING COUNT(*) > 1` ); } @@ -590,7 +596,7 @@ function reportDegradation( `'${SYS_SETTING_IDENTITY_INDEX_NAME}' on "${SYS_SETTING_TABLE}" stays NULL-distinct on user_id ` + `over (${columns}). The system keeps looking healthy while the declared row identity is void on ` + `every row that is not scope='user' — user_id is NULL there — so two tenant-scope rows for one ` + - `(namespace, key) in ONE organization, or two platform defaults on the global layer, can coexist ` + + `(namespace, key, scope) in ONE organization can coexist ` + `and SettingsService has no defined answer for which one wins. MySQL/MariaDB before ` + `8.0.13 has no functional key parts, so there is no in-dialect fix: run this platform on ` + `SQLite/PostgreSQL for the guarantee, and meanwhile watch for duplicates with this MySQL ` + From 382851c57fefd1fe989a010312d48b2ae259856e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:05:24 +0000 Subject: [PATCH 66/81] test(cli,plugin-approvals): ceremony and migrate fixtures plant earlier-release rows on a pre-18 store; seeds name their organization Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- ...mmands.absent-database.integration.test.ts | 20 ++++++---- .../migrate/object-scope.integration.test.ts | 4 +- ....declared-rule-mirrors.integration.test.ts | 5 +++ ...ership.no-organization.integration.test.ts | 5 +++ .../unmapped-columns.integration.test.ts | 4 +- .../secret/orphans.driver-contract.test.ts | 3 +- .../secret/rewrap.driver-contract.test.ts | 3 +- .../organization-ownership-not-null.test.ts | 10 +++++ ...orm-global-column-drop.integration.test.ts | 4 ++ .../cli/src/utils/pre-18-store.testkit.ts | 37 +++++++++++++++++++ ...n-outcome-notification.integration.test.ts | 4 ++ ...proval-delegation-delegator-picker.test.ts | 4 ++ 12 files changed, 92 insertions(+), 11 deletions(-) create mode 100644 packages/cli/src/utils/pre-18-store.testkit.ts diff --git a/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts b/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts index 5ee50d83a60..d27f6974378 100644 --- a/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts +++ b/packages/cli/src/commands/migrate/data-commands.absent-database.integration.test.ts @@ -146,7 +146,9 @@ for (const plugin of stack.plugins) await kernel.use(plugin); await kernel.use(new PlatformObjectsPlugin()); await runtime.start(); const ql = kernel.getService('objectql'); -const SYSTEM = { context: { isSystem: true } }; +// The fixture's rows name an organization: a fresh tenant column is NOT NULL +// (ADR-0131 D1), and this stack composes no authentication to derive one. +const SYSTEM = { context: { isSystem: true, tenantId: 'org_fixture' } }; await ql.insert('sys_metadata_history', { id: 'h_21529', event_seq: 1, name: 'os21529_note', type: 'object', version: 1, operation_type: 'create', recorded_by: 'system', recorded_at: new Date().toISOString(), @@ -405,7 +407,9 @@ await kernel.use(new SettingsServicePlugin({ await kernel.use(new StorageServicePlugin({ registerRoutes: false })); await runtime.start(); const ql = kernel.getService('objectql'); -const SYSTEM = { context: { isSystem: true } }; +// The fixture's rows name an organization: a fresh tenant column is NOT NULL +// (ADR-0131 D1), and this stack composes no authentication to derive one. +const SYSTEM = { context: { isSystem: true, tenantId: 'org_fixture' } }; await ql.insert('sys_metadata', { type: 'object', name: 'os21552_note', state: 'active', metadata: JSON.stringify({ name: 'os21552_note', fields: { title: { type: 'text' } } }), @@ -414,6 +418,8 @@ await kernel.shutdown(); const raw = new SqlDriver({ client: 'better-sqlite3', connection: { filename: process.env.FIXTURE_DB }, useNullAsDefault: true }); const k = raw.knex; +// Rows planted below the writer name the fixture's organization: every table +// this release created has a NOT NULL tenant column (ADR-0131 D1). await k.raw('CREATE TABLE sys_account (id text primary key, provider_id text, account_id text, issuer text, user_id text)'); await k('sys_account').insert([ { id: 'acc_1', provider_id: 'github', account_id: 'g-1', issuer: 'github', user_id: 'u1' }, @@ -429,7 +435,7 @@ await k('sys_audit_log').insert({ const shard = (await k.raw("select name from sqlite_master where type = 'table' and name glob 'sys_activity__r*'"))[0]; if (!shard) throw new Error('the control has no sys_activity shard: the rotation did not run'); await k(shard.name).insert({ - id: 'act_21552', timestamp: new Date().toISOString(), type: 'create', summary: 'created', object_name: 'sys_metadata', record_id: 'm_21552', + organization_id: 'org_fixture', id: 'act_21552', timestamp: new Date().toISOString(), type: 'create', summary: 'created', object_name: 'sys_metadata', record_id: 'm_21552', metadata: JSON.stringify({ old: null, new: { @@ -446,23 +452,23 @@ await k('sys_audit_log').insert({ new_value: JSON.stringify({ id: 'em_24693', subject: 's', body_text: 'mail-body-sentinel-24693', body_html: null }), }); await k(shard.name).insert({ - id: 'act_24693', timestamp: new Date().toISOString(), type: 'create', summary: 'created', object_name: 'sys_email', record_id: 'em_24693', + organization_id: 'org_fixture', id: 'act_24693', timestamp: new Date().toISOString(), type: 'create', summary: 'created', object_name: 'sys_email', record_id: 'em_24693', changed_fields: '[]', metadata: JSON.stringify({ old: null, new: { id: 'em_24693', subject: 's', body_text: 'mail-body-sentinel-24693', body_html: null } }), }); await k('sys_comment').insert({ - id: 'cmt_23857', thread_id: 'sys_metadata:m_21552', body: 'planted below the writer', author_id: 'u1', + organization_id: 'org_fixture', id: 'cmt_23857', thread_id: 'sys_metadata:m_21552', body: 'planted below the writer', author_id: 'u1', created_at: new Date().toISOString(), }); await k('sys_secret').insert({ id: 'sec_21552', namespace: 'os21552', key: 'orphan', kms_key_id: 'local', alg: 'aes-256-gcm', version: 1, ciphertext: 'not-a-real-ciphertext', }); await k('sys_file').insert({ - id: 'file_21552', key: 'attachments/os21552.txt', name: 'os21552.txt', size: 12, scope: 'attachments', status: 'committed', + organization_id: 'org_fixture', id: 'file_21552', key: 'attachments/os21552.txt', name: 'os21552.txt', size: 12, scope: 'attachments', status: 'committed', }); // A retired field's column: in the table, in no metadata, holding a value. await k.raw('ALTER TABLE os21529_contact ADD COLUMN legacy_note text'); -await k('os21529_contact').insert({ id: 'con_21573', name: 'Ann', legacy_note: 'kept-21573' }); +await k('os21529_contact').insert({ id: 'con_21573', name: 'Ann', legacy_note: 'kept-21573', organization_id: 'org_fixture' }); await raw.disconnect(); process.stderr.write('[fixture] seeded\\n'); process.exit(0); diff --git a/packages/cli/src/commands/migrate/object-scope.integration.test.ts b/packages/cli/src/commands/migrate/object-scope.integration.test.ts index 57784735318..6bf613f5bb3 100644 --- a/packages/cli/src/commands/migrate/object-scope.integration.test.ts +++ b/packages/cli/src/commands/migrate/object-scope.integration.test.ts @@ -109,7 +109,9 @@ for (const plugin of stack.plugins) await kernel.use(plugin); await kernel.use(new PlatformObjectsPlugin()); await runtime.start(); const ql = kernel.getService('objectql'); -const SYSTEM = { context: { isSystem: true } }; +// The fixture's rows name an organization: a fresh tenant column is NOT NULL +// (ADR-0131 D1), and this stack composes no authentication to derive one. +const SYSTEM = { context: { isSystem: true, tenantId: 'org_fixture' } }; await ql.insert('os21644_account', { id: 'a1', name: 'Acme' }, SYSTEM); await ql.insert('os21644_site', { id: 's1', name: 'HQ', geo: { lat: 1, lng: 2 } }, SYSTEM); await ql.insert('os21644_product', { id: 'p1', name: 'Widget' }, SYSTEM); 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 e5d14c95370..c05cdb363d6 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 @@ -56,6 +56,7 @@ import { DECLARED_SHARING_RULE_SEEDER_RUNS } from '../../utils/organization-owne // through dynamic `import()`s at MODULE LOAD, not inside a clocked case. import '@objectstack/objectql'; import '@objectstack/metadata-protocol'; +import { relaxTenantColumnsOnFile } from '../../utils/pre-18-store.testkit.js'; type Command = (typeof import('./organization-ownership.js'))['default']; let MigrateOrganizationOwnership: Command; @@ -173,6 +174,10 @@ describe('os migrate organization-ownership on a database whose packages declare await stack.stop(); } seeded = sql('SELECT name, managed_by, customized, organization_id FROM sys_sharing_rule ORDER BY name'); + // The rows planted below are an earlier release's, organization-less: its + // tenant columns were nullable, which a table this release creates never is + // (ADR-0131 D1). The file is relaxed to that release's shape first. + await relaxTenantColumnsOnFile(file); // What a 17.x runtime left beside it. const [{ id: organization }] = sql(`SELECT id FROM sys_organization WHERE slug = 'default'`) as Array<{ id: string }>; 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 661896a2e69..251c20fcc14 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 @@ -60,6 +60,7 @@ import { isExitSignal } from '../../utils/format.js'; import '@objectstack/objectql'; import '@objectstack/metadata-protocol'; import '@objectstack/plugin-auth'; +import { relaxTenantColumnsOnFile } from '../../utils/pre-18-store.testkit.js'; type Command = (typeof import('./organization-ownership.js'))['default']; let MigrateOrganizationOwnership: Command; @@ -168,6 +169,10 @@ describe('os migrate organization-ownership on a single-posture database that ho const stack = await bootStack(stackConfig(), { databaseFile: file }); await stack.stop(); + // The rows planted below are an earlier release's, organization-less: its + // tenant columns were nullable, which a table this release creates never is + // (ADR-0131 D1). The file is relaxed to that release's shape first. + await relaxTenantColumnsOnFile(file); expect(sql('SELECT COUNT(*) AS n FROM crm_event')).toEqual([{ n: SUBJECTS.length }]); // What a 17.x install whose seed ran before any organization existed left. diff --git a/packages/cli/src/commands/migrate/unmapped-columns.integration.test.ts b/packages/cli/src/commands/migrate/unmapped-columns.integration.test.ts index e9d465a9cd8..daaeea2a09a 100644 --- a/packages/cli/src/commands/migrate/unmapped-columns.integration.test.ts +++ b/packages/cli/src/commands/migrate/unmapped-columns.integration.test.ts @@ -122,7 +122,9 @@ const kernel = runtime.getKernel(); for (const plugin of stack.plugins) await kernel.use(plugin); await runtime.start(); const ql = kernel.getService('objectql'); -const SYSTEM = { context: { isSystem: true } }; +// The fixture's rows name an organization: a fresh tenant column is NOT NULL +// (ADR-0131 D1), and this stack composes no authentication to derive one. +const SYSTEM = { context: { isSystem: true, tenantId: 'org_fixture' } }; await ql.insert('um_contact', { id: 'c1', name: 'Ann', mailing_street: '1 Retired Way', mailing_city: 'Oldtown' }, SYSTEM); await ql.insert('um_contact', { id: 'c2', name: 'Bob', mailing_street: '2 Retired Way', mailing_city: 'Newtown' }, SYSTEM); await ql.insert('um_contact', { id: 'c3', name: 'Cy' }, SYSTEM); diff --git a/packages/cli/src/commands/secret/orphans.driver-contract.test.ts b/packages/cli/src/commands/secret/orphans.driver-contract.test.ts index c49a23405fe..57f5041d42c 100644 --- a/packages/cli/src/commands/secret/orphans.driver-contract.test.ts +++ b/packages/cli/src/commands/secret/orphans.driver-contract.test.ts @@ -151,7 +151,8 @@ describe('os secret orphans — the concrete driver behind both reads (#14843)', } await secretDriver.create('sys_secret', { ...SEEDED_SECRET }); - await settingDriver.create('sys_setting', { ...SEEDED_SETTING }); + // The row names an organization: a fresh tenant column is NOT NULL (ADR-0131 D1). + await settingDriver.create('sys_setting', { ...SEEDED_SETTING, organization_id: 'org_fixture' }); }, 180_000); afterAll(async () => { diff --git a/packages/cli/src/commands/secret/rewrap.driver-contract.test.ts b/packages/cli/src/commands/secret/rewrap.driver-contract.test.ts index 17b4a1817ea..153389ab76b 100644 --- a/packages/cli/src/commands/secret/rewrap.driver-contract.test.ts +++ b/packages/cli/src/commands/secret/rewrap.driver-contract.test.ts @@ -117,7 +117,8 @@ describe('os secret rewrap — the concrete driver and the command, end to end ( for (const row of [SETTINGS_ROW, DATASOURCE_ROW, ORPHAN_ROW, PROBE_ROW]) { await secretDriver.create('sys_secret', { ...row }); } - await settingDriver.create('sys_setting', { namespace: 'smtp', key: 'password', value_enc: SETTINGS_ROW.id }); + // The row names an organization: a fresh tenant column is NOT NULL (ADR-0131 D1). + await settingDriver.create('sys_setting', { namespace: 'smtp', key: 'password', value_enc: SETTINGS_ROW.id, organization_id: 'org_fixture' }); writeFileSync(declaredFile, JSON.stringify([ { name: 'warehouse', external: { credentialsRef: `sys_secret:${String(DATASOURCE_ROW.id)}` } }, ])); 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 39770a71e11..14d0612313d 100644 --- a/packages/cli/src/utils/organization-ownership-not-null.test.ts +++ b/packages/cli/src/utils/organization-ownership-not-null.test.ts @@ -33,6 +33,16 @@ beforeEach(async () => { await driver.initObjects([ { name: 'crm_deal', fields: { name: { type: 'text' }, organization_id: { type: 'lookup', reference: 'sys_organization', required: false } } }, ] as never); + // The ceremony tightens a table an earlier release built: its tenant column + // is nullable, which a table this release creates never is (ADR-0131 D1). + // Relaxed through the driver's own drift apply door, the shape of that store. + const { applied } = await driver.applyMigrationEntries([ + { + kind: 'nullability_mismatch', remoteName: 'crm_deal', table: 'crm_deal', column: 'organization_id', category: 'needs_confirm', + op: { type: 'relax_not_null', table: 'crm_deal', column: 'organization_id' }, + } as never, + ]); + if (applied.length !== 1) throw new Error('crm_deal was not returned to its pre-18 shape'); await knex('crm_deal').insert([ { id: 'deal_1', name: 'Acme', organization_id: 'org_default' }, { id: 'deal_2', name: 'Globex', organization_id: 'org_default' }, 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 1e8723c05ff..f0fadf2a0e7 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 @@ -73,6 +73,7 @@ import MigrateOrganizationOwnership from '../commands/migrate/organization-owner import '@objectstack/runtime'; import '@objectstack/objectql'; import { PlatformObjectsPlugin } from '@objectstack/platform-objects/plugin'; +import { relaxTenantColumnsOnFile } from './pre-18-store.testkit.js'; const HERE = dirname(fileURLToPath(import.meta.url)); const CLI_ROOT = resolve(HERE, '..', '..'); @@ -368,6 +369,9 @@ beforeAll(async () => { templateDb = join(dir, 'template.db'); await bootV17(templateDb); + // A v17 database's tenant columns are nullable, which a table this release + // creates never is (ADR-0131 D1): the template is relaxed to that shape first. + await relaxTenantColumnsOnFile(templateDb); const db = new Database(templateDb); try { for (const table of HOST_OBJECTS) { diff --git a/packages/cli/src/utils/pre-18-store.testkit.ts b/packages/cli/src/utils/pre-18-store.testkit.ts new file mode 100644 index 00000000000..2c2098a1e46 --- /dev/null +++ b/packages/cli/src/utils/pre-18-store.testkit.ts @@ -0,0 +1,37 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { SqlDriver } from '@objectstack/driver-sql'; + +/** + * Return a SQLite file this release built to the shape an EARLIER release's + * database has: every tenant column (`organization_id`) nullable. + * + * ADR-0131 D1: since 18.x the SQL driver creates a table's tenant column NOT + * NULL. A v17 or 18.0 database still holds the nullable column, and the + * organization-ownership ceremony is what tightens it. A suite that boots this + * release over a file and then plants that earlier release's organization-less + * rows calls this between the two, so the file is the store the ceremony + * meets. Each column is relaxed through the driver's own drift apply door + * (`relax_not_null`, the SQLite table rebuild), never a hand `ALTER`. + */ +export async function relaxTenantColumnsOnFile(file: string, column = 'organization_id'): Promise { + const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: file }, useNullAsDefault: true }); + const relaxed: string[] = []; + try { + const knex = (driver as any).knex; + const tables = (await knex.raw(`SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'`)) as Array<{ name: string }>; + for (const { name } of tables) { + const info = (await knex.raw(`SELECT name, "notnull" AS nn FROM pragma_table_info(?)`, [name])) as Array<{ name: string; nn: number }>; + if (!info.some((c) => c.name === column && Number(c.nn) === 1)) continue; + const op = { type: 'relax_not_null', table: name, column }; + const { applied } = await driver.applyMigrationEntries([ + { kind: 'nullability_mismatch', remoteName: name, table: name, column, category: 'needs_confirm', op } as never, + ]); + if (applied.length !== 1) throw new Error(`relaxTenantColumnsOnFile: ${name}.${column} was not relaxed`); + relaxed.push(name); + } + } finally { + await driver.disconnect(); + } + return relaxed; +} diff --git a/packages/plugins/plugin-approvals/src/approval-decision-outcome-notification.integration.test.ts b/packages/plugins/plugin-approvals/src/approval-decision-outcome-notification.integration.test.ts index ee3523329da..fb365507a43 100644 --- a/packages/plugins/plugin-approvals/src/approval-decision-outcome-notification.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/approval-decision-outcome-notification.integration.test.ts @@ -44,6 +44,7 @@ import type { EngineQueryOptions } from '@objectstack/spec/data'; import { ApprovalService } from './approval-service.js'; import { ApprovalsServicePlugin } from './approvals-plugin.js'; import { registerApprovalNode } from './approval-node.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; const asUser = (userId: string) => ({ userId, positions: [], permissions: [] }) as any; @@ -188,6 +189,9 @@ describe('the decision that ends an approval tells its submitter (approval.appro engine.registry.registerObject(def as any, 'approvals-test', 'approvals-test'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const u of USERS) await engine.insert('sys_user', u, { context: SYSTEM } as any); automation = new AutomationEngine(silent); diff --git a/packages/plugins/plugin-approvals/src/sys-approval-delegation-delegator-picker.test.ts b/packages/plugins/plugin-approvals/src/sys-approval-delegation-delegator-picker.test.ts index 8b295845603..d531e0c86d4 100644 --- a/packages/plugins/plugin-approvals/src/sys-approval-delegation-delegator-picker.test.ts +++ b/packages/plugins/plugin-approvals/src/sys-approval-delegation-delegator-picker.test.ts @@ -33,6 +33,7 @@ import { SqlDriver } from '@objectstack/driver-sql'; import type { EngineCountOptions, EngineQueryOptions } from '@objectstack/spec/data'; import { SysApprovalDelegation } from './sys-approval-delegation.object.js'; import { bindDelegationWriteGuard } from './lifecycle-hooks.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const OBJECT = 'sys_approval_delegation'; const SYSTEM = { context: { isSystem: true } } as any; @@ -96,6 +97,9 @@ describe('sys_approval_delegation — the Delegator picker offers only what the engine.registry.registerObject(sysUser as any, 'com.objectstack.test.delegator-picker'); engine.registry.registerObject(SysApprovalDelegation as any, 'com.objectstack.test.delegator-picker'); await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); bindDelegationWriteGuard(engine as any); for (const u of USERS) await engine.insert('sys_user', u, SYSTEM); }); From 1342d1ddc59b876c5b6d9aef16fb8518c2ebde0f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:12:57 +0000 Subject: [PATCH 67/81] test(cli): the generators emit the plain organization composite; the dev self-heal fence is staged with the legacy global unique Claude-Session: https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd Co-authored-by: Claude --- ...generate-declared-unique-index.pin.test.ts | 176 +++++++++++++----- ...te.dev-self-heal-fence.integration.test.ts | 82 +++++--- 2 files changed, 180 insertions(+), 78 deletions(-) diff --git a/packages/cli/src/commands/generate-declared-unique-index.pin.test.ts b/packages/cli/src/commands/generate-declared-unique-index.pin.test.ts index cee4962373e..cb6feeee89f 100644 --- a/packages/cli/src/commands/generate-declared-unique-index.pin.test.ts +++ b/packages/cli/src/commands/generate-declared-unique-index.pin.test.ts @@ -34,26 +34,31 @@ * constraint. The COLUMN — the #16091 result this change must not spend — read * `character varying(100)` on all three both before and after. * - * ## What this pin does NOT claim, stated so nobody reads it as closed + * ## The organization composite — emitted since ADR-0131 D1 + * + * Until ADR-0131 D1 the ORGANIZATION-SCOPED form was the ADR-0120 D3 + * expression `(COALESCE(, '__global__'), )`, which neither format + * could spell, so it was deliberately unemitted and named in a NOT EMITTED + * line. The driver now keys the PLAIN tenant column — a fresh table's tenant + * column is NOT NULL, so the ADR-0120 D3 NULL-organization bucket the COALESCE + * existed for is gone from new DDL — and the bare `(organization_id, field)` IS + * the platform's constraint. So both formats emit it, and this file pins that + * the generated table carries the SAME composite the driver creates and refuses + * the same in-organization duplicate (block A), and that no NOT EMITTED line is + * printed for it any more (block B). * - * Two declaration shapes are deliberately unemitted, and this file asserts that - * they are unemitted *and named*, never that they are handled: + * ## What this pin does NOT claim, stated so nobody reads it as closed * - * 1. The ADR-0120 D3 ORGANIZATION-SCOPED form — `(COALESCE(, - * '__global__'), )`. Emitting the bare composite instead would be - * worse than emitting nothing: under SQL's NULL-distinct UNIQUE a bare - * `(organization_id, field)` constrains NOTHING on rows with no - * organization, which on a single-tenant stack is every row (#5030) — a - * constraint advertised and not delivered, which is the failure Prime - * Directive #10 names. - * 2. OBJECT-LEVEL `indexes[]`. `normalizeDeclaredIndex` is a second - * normalizer that reads the same `unique: true` token DIFFERENTLY (verbatim - * as global, a maintainer ruling), so it is a second transcription with a - * second pin, not a loop added to this one. + * One declaration shape is deliberately unemitted, and this file asserts that it + * is unemitted *and named*, never that it is handled: OBJECT-LEVEL `indexes[]`. + * `normalizeDeclaredIndex` is a second normalizer that reads the same + * `unique: true` token DIFFERENTLY (verbatim as global, a maintainer ruling), so + * it is a second transcription with a second pin, not a loop added to this one. * - * ⭐ Measured for the record, because "can the TypeScript format express an - * expression key part through knex at all" was the open question that kept the - * scoped form off this change (knex 3.3.0, live PostgreSQL 16.13): + * ⭐ Measured for the record — HISTORICAL since ADR-0131 D1 retired the + * expression key part, kept because it is why the scoped form was once + * unemitted, and stays true for any future expression key part (knex 3.3.0, + * live PostgreSQL 16.13): * * ``` * table.unique([knex.raw("COALESCE(...)"), 'f'], {indexName}) @@ -72,8 +77,9 @@ * knex handle and `db.raw` is exactly the seam `SqlDriver.createNullSafeUniqueIndex` * already uses for this — but knex's SCHEMA BUILDER cannot express it in either * format, so the scoped form costs a raw statement rather than another - * `table.unique(...)` line. That is a fact for whoever takes the scoped half, - * recorded here rather than acted on. + * `table.unique(...)` line. ADR-0131 D1 made that moot for the organization + * composite (its key parts are plain columns now); it is recorded for whoever + * next meets an expression key part. * * ## Why this pin reads the driver instead of asserting the names * @@ -82,7 +88,7 @@ * transcribed `uniq_probe_keyed_unique` would re-create the defect one layer up * and stay green the day the driver's naming moves. Every name and every key-part * ordering here is recomputed from `driver-sql`'s own exported - * `uniqueIndexesFromFields` / `buildIndexName` / `GLOBAL_TENANT`. + * `uniqueIndexesFromFields` / `buildIndexName`. * * ⭐ And the leaves are not the authority. Above the differential sits THE REAL * CHAIN: all three producers driven into ONE in-memory better-sqlite3 database, @@ -109,7 +115,7 @@ * measuring the parser. */ -import { SqlDriver, GLOBAL_TENANT, buildIndexName, uniqueIndexesFromFields } from '@objectstack/driver-sql'; +import { SqlDriver, buildIndexName, uniqueIndexesFromFields } from '@objectstack/driver-sql'; import { afterAll, describe, expect, it } from 'vitest'; import { generateMigrationSql, generateMigrationTs } from './generate.js'; @@ -210,8 +216,12 @@ function loadEmittedTs(ts: string): { up: (db: any) => Promise; down: (db: interface Probe { label: string; object: Record; - /** What the object's declarations mean for the emitters, asserted below. */ - expect: 'emitted' | 'scoped-not-emitted' | 'no-column' | 'none'; + /** + * What the object's declarations mean for the emitters, asserted below. + * `scoped` is the organization composite — emitted like `emitted`, with the + * plain tenant column as its FIRST key part (ADR-0131 D1). + */ + expect: 'emitted' | 'scoped' | 'no-column' | 'none'; } const ORG = { type: 'text', maxLength: 64 } as const; @@ -229,14 +239,14 @@ const CORPUS: Probe[] = [ expect: 'emitted', }, { - label: '`unique: true` beside an organization column — the ADR-0120 D3 scoped composite', + label: '`unique: true` beside an organization column — the plain organization composite (ADR-0131 D1)', object: { name: 'p_scoped_true', fields: { organization_id: ORG, f: { ...KEYED, unique: true } } }, - expect: 'scoped-not-emitted', + expect: 'scoped', }, { label: "explicit `unique: 'organization'` — the same scoped composite", object: { name: 'p_scoped_word', fields: { organization_id: ORG, f: { ...KEYED, unique: 'organization' } } }, - expect: 'scoped-not-emitted', + expect: 'scoped', }, { label: 'unique ON the organization column itself — "one row per tenant" stays single-column', @@ -259,7 +269,7 @@ const CORPUS: Probe[] = [ tenancy: { tenantField: 'org' }, fields: { org: ORG, f: { ...KEYED, unique: true } }, }, - expect: 'scoped-not-emitted', + expect: 'scoped', }, { label: 'a name past the 60-character identifier budget — hash-suffixed', @@ -349,8 +359,9 @@ describe('#16317 — the index each generator emits is the index the driver CREA // ⭐ THE AUTHORITY. Whatever the driver's own table carries, the ts // format's table carries too — by NAME, because both go through knex's - // named-index spelling — except for the two declared exclusions. - const emittable = probe.expect === 'emitted'; + // named-index spelling — except for the declared exclusions. Since + // ADR-0131 D1 the organization composite is one of the emitted shapes. + const emittable = probe.expect === 'emitted' || probe.expect === 'scoped'; if (emittable) { expect(uniqueShapes(built.tsgen)).toEqual(uniqueShapes(built.driver)); expect(uniqueShapes(built.sqlgen)).toEqual(uniqueShapes(built.driver)); @@ -362,22 +373,28 @@ describe('#16317 — the index each generator emits is the index the driver CREA for (const want of wanted) expect(built.sqlText).toContain(`CONSTRAINT "${want.name}" UNIQUE (`); } - if (probe.expect === 'scoped-not-emitted') { - // The driver DOES build it — the expression key part is why we do not. + if (probe.expect === 'scoped') { + // ADR-0131 D1: the driver keys the PLAIN tenant column, so the + // composite it creates is a two-column list with the tenant FIRST and + // no expression key part — the shape both formats can spell, and do. + const tenant = newOracle().tenantFieldFor(probe.object); + expect(tenant).not.toBeNull(); expect(wanted.length).toBeGreaterThan(0); for (const want of wanted) { - expect(want.nullSafeColumns ?? []).not.toHaveLength(0); - // ⛔ Absence must be loud: named in the generated file, both formats. - expect(built.sqlText).toContain(`NOT EMITTED: UNIQUE index "${want.name}"`); - expect(built.tsText).toContain(`NOT EMITTED: UNIQUE index '${want.name}'`); - expect(built.sqlText).toContain(`COALESCE("${want.nullSafeColumns![0]}", '${GLOBAL_TENANT}')`); - // ...and NOT emitted as the bare composite, which would advertise a - // constraint the table does not carry (#5030). - expect(built.sqlText).not.toContain(`CONSTRAINT "${want.name}" UNIQUE (`); - expect(built.tsText).not.toContain(`indexName: '${want.name}'`); + expect(want.nullSafeColumns ?? []).toHaveLength(0); + expect(want.columns).toHaveLength(2); + expect(want.columns[0]).toBe(tenant); } - expect(uniqueShapes(built.tsgen)).toHaveLength(0); - expect(uniqueShapes(built.sqlgen)).toHaveLength(0); + // The catalog agrees: every producer's table carries the composite as + // two PLAIN key parts (SQLite reports `null` for an expression part). + for (const rows of [built.driver, built.sqlgen, built.tsgen]) { + expect(uniqueShapes(rows)).toEqual(wanted.map((w) => w.columns.join(',')).sort()); + } + // ...and nothing is announced as missing, because nothing is. + expect(built.sqlText).not.toContain('NOT EMITTED'); + expect(built.tsText).not.toContain('NOT EMITTED'); + expect(built.sqlText).not.toContain('COALESCE'); + expect(built.tsText).not.toContain('COALESCE'); } if (probe.expect === 'no-column') { @@ -425,6 +442,44 @@ describe('#16317 — the index each generator emits is the index the driver CREA expect(verdicts).toEqual({ driver: 'refused', sqlgen: 'refused', tsgen: 'refused' }); }); + /** + * ⭐ The same consequence for the ORGANIZATION composite (ADR-0131 D1): a + * duplicate inside ONE organization is refused by the driver's table and by + * BOTH generated ones, and the same value in a SECOND organization is accepted + * by all three — uniqueness is per organization, not platform-wide. Rows + * carry an explicit organization: a fresh driver table's tenant column is + * NOT NULL, so a NULL-organization row is not a case the driver's table has. + */ + it('a duplicate inside one organization is refused by BOTH generated tables and the driver; another organization is not', async () => { + const object = { name: 'p_enforce_org', fields: { organization_id: ORG, f: { ...KEYED, unique: true } } }; + const built = await threeTables(object); + const insert = (db: any, id: string, org: string) => + db.raw(`INSERT INTO "p_enforce_org" ("id", "organization_id", "f") VALUES (?, ?, 'dup')`, [id, org]); + const verdicts: Record = {}; + for (const [who, db] of Object.entries(built.dbs)) { + await insert(db, 'a', 'org_1'); + let sameOrg: string; + try { + await insert(db, 'b', 'org_1'); + sameOrg = 'accepted'; + } catch (err) { + // A UNIQUE refusal specifically — not any error the insert could raise. + expect(String((err as Error).message)).toMatch(/UNIQUE constraint failed/); + sameOrg = 'refused'; + } + let otherOrg: string; + try { + await insert(db, 'c', 'org_2'); + otherOrg = 'accepted'; + } catch { + otherOrg = 'refused'; + } + verdicts[who] = { sameOrg, otherOrg }; + } + const perOrg = { sameOrg: 'refused', otherOrg: 'accepted' }; + expect(verdicts).toEqual({ driver: perOrg, sqlgen: perOrg, tsgen: perOrg }); + }); + /** * NON-VACUITY for the whole block. A corpus that built no unique index at all * would satisfy every `toEqual` above by agreeing on emptiness. @@ -436,7 +491,7 @@ describe('#16317 — the index each generator emits is the index the driver CREA for (const probe of CORPUS) { const wanted = driverIndexes(oracle, probe.object); if (probe.expect === 'emitted') counts.emitted += wanted.length; - if (probe.expect === 'scoped-not-emitted') counts.scoped += wanted.length; + if (probe.expect === 'scoped') counts.scoped += wanted.length; if (probe.expect === 'no-column') counts.noColumn += wanted.length; if (probe.expect === 'none') counts.none += wanted.length; for (const w of wanted) if (/_[0-9a-f]{8}$/.test(w.name)) counts.hashed += 1; @@ -477,11 +532,29 @@ describe('#16317 — the transcriptions in generate.ts are the driver\'s own', ( expect(hashed).toBeGreaterThanOrEqual(3); }); - /** The `__global__` sentinel the unemitted-index note names. */ - it('the sentinel the NOT EMITTED note prints is the driver\'s GLOBAL_TENANT', () => { + /** + * The `__global__` sentinel retired with the ADR-0120 D3 expression key part + * (ADR-0131 D1), and with it the NOT EMITTED note that printed it. What is + * left to pin is that the generators stopped announcing the organization + * composite as missing — a stale note would tell the operator to hand-write + * an index the file already creates — and that no COALESCE spelling of it + * survives in either format, comment or code. + */ + it('no NOT EMITTED line is printed for an organization composite, in either format', () => { const object = { name: 'g_probe', fields: { organization_id: ORG, f: { ...KEYED, unique: true } } }; - expect(generateMigrationSql(configFor(object))).toContain(`'${GLOBAL_TENANT}'`); - expect(generateMigrationTs(configFor(object))).toContain(`'${GLOBAL_TENANT}'`); + const [want] = driverIndexes(oracle, object); + expect(want.columns).toEqual(['organization_id', 'f']); + const sql = generateMigrationSql(configFor(object)); + const ts = generateMigrationTs(configFor(object)); + expect(sql).not.toContain('NOT EMITTED'); + expect(ts).not.toContain('NOT EMITTED'); + expect(sql).not.toContain('COALESCE'); + expect(ts).not.toContain('COALESCE'); + expect(sql).not.toContain('__global__'); + expect(ts).not.toContain('__global__'); + // Non-vacuity: the index is in the file — as itself, not as a note. + expect(sql).toContain(`CONSTRAINT "${want.name}" UNIQUE ("organization_id", "f")`); + expect(ts).toContain(`table.unique(['organization_id', 'f'], { indexName: '${want.name}' });`); }); /** @@ -495,16 +568,19 @@ describe('#16317 — the transcriptions in generate.ts are the driver\'s own', ( const ts = generateMigrationTs(configFor(probe.object)); for (const want of driverIndexes(oracle, probe.object)) { checked += 1; - const scoped = (want.nullSafeColumns ?? []).length > 0; - const emitted = probe.expect === 'emitted'; + // ADR-0131 D1: every key part is a plain column, the organization + // composite's included, so every emitted index is a column list. + expect(want.nullSafeColumns ?? []).toHaveLength(0); + const emitted = probe.expect === 'emitted' || probe.expect === 'scoped'; const cols = want.columns.map((c) => `"${c}"`).join(', '); if (emitted) { - expect(scoped).toBe(false); expect(sql).toContain(`CONSTRAINT "${want.name}" UNIQUE (${cols})`); expect(ts).toContain( `table.unique([${want.columns.map((c) => `'${c}'`).join(', ')}], { indexName: '${want.name}' });`, ); } else { + // Only the no-column exclusion is left to announce. + expect(probe.expect).toBe('no-column'); expect(sql).toContain(`NOT EMITTED: UNIQUE index "${want.name}" on (${want.columns.join(', ')})`); expect(ts).toContain(`NOT EMITTED: UNIQUE index '${want.name}' on (${want.columns.join(', ')})`); } diff --git a/packages/cli/src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts b/packages/cli/src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts index 9bd08f8a6fe..f76dc611a9c 100644 --- a/packages/cli/src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts +++ b/packages/cli/src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts @@ -20,14 +20,29 @@ * * ## The fixture * - * The card's reproduction, in-process: one org-scoped `unique` field, whose - * index a plain boot creates NULL-safe (`COALESCE(organization_id, …)`), then - * replaced by the bare `(organization_id, qa_code)` index an older release - * left behind. That is a `recreate_index` drift the duplicate probe finds - * clean, i.e. `safe`: exactly what the self-heal applies. The serving - * declaration (`createStandaloneStack({ dev: true })`, what `os dev` hands it) - * is the positive control — it heals the same staged file, so the fence cases - * cannot be green because the fixture has nothing to heal. + * One org-scoped `unique` field, whose `(organization_id, qa_code)` composite + * a plain boot creates, plus the platform-wide single-column UNIQUE on + * `qa_code` a pre-#3696 release left behind — the state every additive boot + * after such an upgrade produces, since the sync creates the composite by name + * and never drops anything. The differ reports it as `replace_unique_index`, a + * pure relaxation it classes `safe`: exactly what the self-heal applies, and + * what a one-shot boot must leave alone. The serving declaration + * (`createStandaloneStack({ dev: true })`, what `os dev` hands it) is the + * positive control — it heals the same staged file, so the fence cases cannot + * be green because the fixture has nothing to heal. + * + * ⚠️ Why not the drift the card staged. #21733 reproduced with the ADR-0120 D4 + * tightening — a bare composite against the NULL-safe expectation, a + * `recreate_index` the duplicate probe recategorised `safe`. ADR-0131 D1 + * retired it: the expectation is the plain composite now, so a bare composite + * MATCHES and there is no drift at all. Its nearest successor (a COALESCE + * index against the plain expectation on an uncertified table) is a + * DESTRUCTIVE `recreate_index` the self-heal never applies under any + * declaration, so it could not tell the fence from the heal; and a fresh + * table's tenant column is NOT NULL, which certifies it and makes the COALESCE + * form match too. The fence is about the self-heal's `safe` subset reaching a + * one-shot boot, not about any one op, so the fixture carries the `safe` op + * that is still live. * * Every case runs with `NODE_ENV=development` — the input the fence is about. */ @@ -38,7 +53,7 @@ import { randomBytes } from 'node:crypto'; import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { SqlDriver } from '@objectstack/driver-sql'; +import { SqlDriver, legacyUniqueIndexNames } from '@objectstack/driver-sql'; import { isExitSignal } from './format.js'; import { bootSchemaStack } from './schema-migrate.js'; import MigratePlan from '../commands/migrate/plan.js'; @@ -55,7 +70,13 @@ const CLI_ROOT = resolve(HERE, '..', '..'); const CASE_TIMEOUT_MS = 120_000; +/** The organization composite the declaration asks for (ADR-0131 D1: plain key parts). */ const INDEX = 'uniq_scaf_item_organization_id_qa_code'; +/** + * The legacy platform-wide single-column UNIQUE, spelled by the driver's own + * `legacyUniqueIndexNames` — its `uniq_` arm, the one this differ recognises. + */ +const LEGACY = legacyUniqueIndexNames('scaf_item', 'qa_code')[1]; const ARTIFACT = { manifest: { id: 'com.example.scaf', namespace: 'scaf', name: 'scaf', version: '1.0.0', type: 'app' }, @@ -80,11 +101,11 @@ function probe(dbFile: string): SqlDriver { return new SqlDriver({ client: 'better-sqlite3', connection: { filename: dbFile }, useNullAsDefault: true }); } -/** The index's DDL exactly as `sqlite_master` stores it. */ -async function indexSql(dbFile: string): Promise { +/** An index's DDL exactly as `sqlite_master` stores it; `undefined` when absent. */ +async function indexSql(dbFile: string, name: string): Promise { const driver = probe(dbFile); try { - const rows = await (driver as any).knex.raw('SELECT sql FROM sqlite_master WHERE type = ? AND name = ?', ['index', INDEX]); + const rows = await (driver as any).knex.raw('SELECT sql FROM sqlite_master WHERE type = ? AND name = ?', ['index', name]); return (rows as Array<{ sql: string }>)[0]?.sql; } finally { await driver.disconnect(); @@ -153,7 +174,9 @@ async function runPlanJson(dbFile: string): Promise { } } -const isNullSafe = (sql: string | undefined): boolean => /COALESCE\(`?organization_id`?/.test(sql ?? ''); +/** The composite in its ADR-0131 D1 form: both key parts plain, no COALESCE. */ +const isPlainComposite = (sql: string | undefined): boolean => + /\(\s*[`"]?organization_id[`"]?\s*,\s*[`"]?qa_code[`"]?\s*\)/.test(sql ?? '') && !/COALESCE/i.test(sql ?? ''); beforeAll(async () => { for (const key of SAVED_KEYS) savedEnv[key] = process.env[key]; @@ -168,20 +191,20 @@ beforeAll(async () => { process.env.OS_ARTIFACT_PATH = join(dir, 'dist', 'objectstack.json'); writeFileSync(process.env.OS_ARTIFACT_PATH, JSON.stringify(ARTIFACT)); - // A plain boot creates the table and its NULL-safe unique index… + // A plain boot creates the table and its plain organization composite… stagedDb = join(dir, 'data', 'staged.db'); await bootServing(stagedDb, false); - expect(isNullSafe(await indexSql(stagedDb)), 'the fixture boot did not create the NULL-safe index').toBe(true); - // …which the card's step 3 replaces with the bare one an older release left. + const composite = await indexSql(stagedDb, INDEX); + expect(isPlainComposite(composite), `the fixture boot did not create the plain composite: ${composite}`).toBe(true); + expect(await indexSql(stagedDb, LEGACY), 'the fixture boot created the legacy index itself').toBeUndefined(); + // …beside which the staging step plants the global UNIQUE an older release left. const driver = probe(stagedDb); try { - const k = (driver as any).knex; - await k.raw(`DROP INDEX ${INDEX}`); - await k.raw(`CREATE UNIQUE INDEX ${INDEX} ON scaf_item (organization_id, qa_code)`); + await (driver as any).knex.raw(`CREATE UNIQUE INDEX ${LEGACY} ON scaf_item (qa_code)`); } finally { await driver.disconnect(); } - expect(isNullSafe(await indexSql(stagedDb)), 'the staging step did not leave the bare index').toBe(false); + expect(await indexSql(stagedDb, LEGACY), 'the staging step did not leave the legacy index').toBeDefined(); }, CASE_TIMEOUT_MS); afterAll(() => { @@ -194,11 +217,13 @@ afterAll(() => { }); describe('#21733 — the dev self-heal on the standalone stack, and its one-shot fence (NODE_ENV=development)', () => { - it('the serving declaration (`dev: true`, what `os dev` passes) heals the staged index on restart', async () => { + it('the serving declaration (`dev: true`, what `os dev` passes) relaxes the legacy global unique on restart', async () => { const dbFile = stagedCopy('serving-dev'); await bootServing(dbFile, true); - const sql = await indexSql(dbFile); - expect(isNullSafe(sql), `the dev boot left the bare index: ${sql}`).toBe(true); + const legacy = await indexSql(dbFile, LEGACY); + expect(legacy, `the dev boot left the legacy index: ${legacy}`).toBeUndefined(); + // A relaxation, not a removal: the per-organization constraint survives. + expect(isPlainComposite(await indexSql(dbFile, INDEX))).toBe(true); }, CASE_TIMEOUT_MS); it('a non-deferred one-shot boot (`bootSchemaStack`, every write mode) leaves the safe drift in place', async () => { @@ -212,9 +237,10 @@ describe('#21733 — the dev self-heal on the standalone stack, and its one-shot } // Still there, and still the kind the self-heal WOULD apply — so the // boot declined it rather than having nothing to do. - expect(drift.map((d) => `${d.category}:${d.op.type}`)).toContain('safe:recreate_index'); - const sql = await indexSql(dbFile); - expect(isNullSafe(sql), `a one-shot boot auto-applied safe drift: ${sql}`).toBe(false); + expect(drift.map((d) => `${d.category}:${d.op.type}`)).toContain('safe:replace_unique_index'); + const legacy = await indexSql(dbFile, LEGACY); + expect(legacy, 'a one-shot boot auto-applied safe drift').toBeDefined(); + expect(isPlainComposite(await indexSql(dbFile, INDEX))).toBe(true); }, CASE_TIMEOUT_MS); it('`os migrate plan` lists the drift as safe and leaves the staged database byte-identical', async () => { @@ -224,8 +250,8 @@ describe('#21733 — the dev self-heal on the standalone stack, and its one-shot expect( (plan.changes ?? []).map((d: { category: string; op: { type: string } }) => `${d.category}:${d.op.type}`), `plan output: ${JSON.stringify(plan).slice(0, 400)}`, - ).toContain('safe:recreate_index'); + ).toContain('safe:replace_unique_index'); expect(Buffer.compare(readFileSync(dbFile), before), 'os migrate plan wrote to the staged database').toBe(0); - expect(isNullSafe(await indexSql(dbFile))).toBe(false); + expect(await indexSql(dbFile, LEGACY)).toBeDefined(); }, CASE_TIMEOUT_MS); }); From ec4b3e5803bdc82004b4eca5aba37f401a32251e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:18:15 +0000 Subject: [PATCH 68/81] test(driver-sql): the tenant-scoping case of the equivalence pin Claude-Session: https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd Co-authored-by: Claude --- .../driver-sql/src/sql-driver-tenant-key-equivalence.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver-tenant-key-equivalence.test.ts b/packages/drivers/driver-sql/src/sql-driver-tenant-key-equivalence.test.ts index f7a57e218f5..374298351c8 100644 --- a/packages/drivers/driver-sql/src/sql-driver-tenant-key-equivalence.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-tenant-key-equivalence.test.ts @@ -78,8 +78,8 @@ describe('7b-1 — the differ: COALESCE(tenant) ≡ tenant where the tenant colu expect(diff(expectedNullSafe, plain, false).map((d) => d.op.type)).toEqual(['recreate_index']); }); - it('the equivalence is the TENANT column\'s: with no tenant column named it does not apply', () => { - expect(diff(expectedPlain, coalesced, true, null).map((d) => d.op.type)).not.toEqual([]); + it("the equivalence is the TENANT column's: on a table whose tenant column is another one, it does not apply", () => { + expect(diff(expectedNullSafe, plain, true, 'workspace_id').map((d) => d.op.type)).toEqual(['recreate_index']); }); it('it changes the form of the key part only — a different column set stays drift, certified or not', () => { From db78d391f0dd7af8c4623abee65981e6b32b26ab Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 12:46:34 +0000 Subject: [PATCH 69/81] test(cli): the pre-18 store testkit imports the driver lazily, as src/ requires Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- packages/cli/src/utils/pre-18-store.testkit.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/utils/pre-18-store.testkit.ts b/packages/cli/src/utils/pre-18-store.testkit.ts index 2c2098a1e46..4792f6f42a9 100644 --- a/packages/cli/src/utils/pre-18-store.testkit.ts +++ b/packages/cli/src/utils/pre-18-store.testkit.ts @@ -1,7 +1,5 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -import { SqlDriver } from '@objectstack/driver-sql'; - /** * Return a SQLite file this release built to the shape an EARLIER release's * database has: every tenant column (`organization_id`) nullable. @@ -15,6 +13,9 @@ import { SqlDriver } from '@objectstack/driver-sql'; * (`relax_not_null`, the SQLite table rebuild), never a hand `ALTER`. */ export async function relaxTenantColumnsOnFile(file: string, column = 'organization_id'): Promise { + // A dynamic import: this file lives under `src/`, where a static driver import + // is refused (`schema-migrate.lazy-driver-import.test.ts`). + const { SqlDriver } = await import('@objectstack/driver-sql'); const driver = new SqlDriver({ client: 'better-sqlite3', connection: { filename: file }, useNullAsDefault: true }); const relaxed: string[] = []; try { From de01287b9c67d76d87b34c03c2afeb1d7c841224 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:09:52 +0000 Subject: [PATCH 70/81] fix(objectql, driver-sql): an unreadable certification is thrown by the engine and reported once by the driver, never answered silently Claude-Session: https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd Co-authored-by: Claude --- .../src/sql-driver-unique-tenancy.test.ts | 2 +- packages/drivers/driver-sql/src/sql-driver.ts | 15 ++++++++++++++- .../adr0131-tenant-key-certified-supply.test.ts | 15 ++++++++++++--- packages/objectql/src/engine.ts | 12 ++++++++---- 4 files changed, 35 insertions(+), 9 deletions(-) diff --git a/packages/drivers/driver-sql/src/sql-driver-unique-tenancy.test.ts b/packages/drivers/driver-sql/src/sql-driver-unique-tenancy.test.ts index 8d5baca97b5..345d2e71421 100644 --- a/packages/drivers/driver-sql/src/sql-driver-unique-tenancy.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-unique-tenancy.test.ts @@ -614,7 +614,7 @@ describe('SqlDriver unique × tenancy (#3696)', () => { // bucket rather than rebuilding it… const second = await driver.create('template', { key: 'welcome' }); expect(second.key).toBe('welcome'); - expect(await driver.count('template', { where: { key: 'welcome' } } as any)).toBe(3); + expect(await driver.count('template', { where: { key: 'welcome' } })).toBe(3); // …while the organization itself is still held to one row per value. await expect(driver.create('template', { organization_id: 'org_a', key: 'welcome' })).rejects.toThrow( /UNIQUE constraint failed|duplicate key value/, diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 6d5201748a2..85d06471298 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -20414,6 +20414,8 @@ export class SqlDriver implements IDataDriver { private tenantKeyCertifiedResolver?: () => boolean | Promise; /** Set once the resolver has answered yes — a certificate is never withdrawn. */ private tenantKeyCertified?: true; + /** The resolver's failure is reported once per driver. */ + private tenantKeyCertifiedReadFailureReported = false; /** * Asked lazily — only a table carrying the NULL-safe tenant key part needs @@ -20429,7 +20431,18 @@ export class SqlDriver implements IDataDriver { // yet answers with a refused read, which is not a fault (see // {@link PRE_DDL_QUESTION_SCOPE}). if ((await PRE_DDL_QUESTION_SCOPE.run(true, resolver)) !== true) return false; - } catch { + } catch (e: any) { + // Not knowing reads as "not certified" — the reading that never proposes + // a rebuild on a guess — and is said once, so a drift report that then + // names an 18.0 index is not taken for a verdict on a certified store. + if (!this.tenantKeyCertifiedReadFailureReported) { + this.tenantKeyCertifiedReadFailureReported = true; + this.logger.warn( + `[schema-drift] could not read whether this database is certified for the organization-ownership shape ` + + `(${e?.message ?? e}); an index keyed COALESCE(, …) on a nullable tenant column is compared ` + + `as a different constraint from the plain key until it can be read.`, + ); + } return false; } this.tenantKeyCertified = true; diff --git a/packages/objectql/src/adr0131-tenant-key-certified-supply.test.ts b/packages/objectql/src/adr0131-tenant-key-certified-supply.test.ts index 1bb08dbcdf0..4ac9438de2d 100644 --- a/packages/objectql/src/adr0131-tenant-key-certified-supply.test.ts +++ b/packages/objectql/src/adr0131-tenant-key-certified-supply.test.ts @@ -41,7 +41,7 @@ interface Sink { resolver: (() => Promise) | null; } -function boot(rows: Rows, opts: { sink?: Sink; readThrows?: boolean; withFlagObject?: boolean } = {}): ObjectQL { +function boot(rows: Rows, opts: { sink?: Sink; readThrows?: string; withFlagObject?: boolean } = {}): ObjectQL { const driver: Record = { name: 'default', version: '1.0.0', @@ -52,7 +52,7 @@ function boot(rows: Rows, opts: { sink?: Sink; readThrows?: boolean; withFlagObj return typeof ast?.limit === 'number' ? found.slice(0, ast.limit) : found; }, async findOne(_object: string, ast: any) { - if (opts.readThrows) throw new Error('relation "sys_migration" does not exist'); + if (opts.readThrows) throw new Error(opts.readThrows); return rows.find((r) => matches(r, ast?.where)) ?? null; }, async count() { @@ -130,12 +130,21 @@ describe('[ADR-0131 D1] isOrganizationOwnershipCertified()', () => { 'an older shape generation', ).toBe(false); expect(await boot([certified({ details: null })]).isOrganizationOwnershipCertified(), 'no generation named').toBe(false); - expect(await boot([certified()], { readThrows: true }).isOrganizationOwnershipCertified(), 'unreadable').toBe(false); + expect( + await boot([certified()], { readThrows: 'relation "sys_migration" does not exist' }).isOrganizationOwnershipCertified(), + 'no ledger table yet', + ).toBe(false); expect(await boot([certified()], { withFlagObject: false }).isOrganizationOwnershipCertified(), 'no ledger object').toBe( false, ); }); + it('any other read failure is thrown, never answered as "not certified"', async () => { + await expect(boot([certified()], { readThrows: 'connection reset' }).isOrganizationOwnershipCertified()).rejects.toThrow( + 'connection reset', + ); + }); + it('registerDriver hands a driver with the seam a closure over the same reading', async () => { const rows: Rows = []; const sink: Sink = { resolver: null }; diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 4e044b47bd9..9bb90d1b180 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -12223,8 +12223,10 @@ export class ObjectQL implements IObjectQLEngine { * shape — a verified `sys_migration` row for the ceremony's id (the birth * marker a store created from empty receives, or the ceremony's completion * marker) at this release's shape generation or above? The same reading the - * runtime's boot guard boots on. Every way of not knowing (no ledger object, - * no row, an unreadable read, an unverified or older row) answers `false`. + * runtime's boot guard boots on. No ledger object, no ledger table yet, no + * row, or an unverified or older row answers `false`. Any other read failure + * is thrown: "I could not check" is not "not certified", and the driver + * asking reports it (`SqlDriver.isTenantKeyCertified`). */ async isOrganizationOwnershipCertified(): Promise { if (!this._registry.getObject(DATA_MIGRATION_FLAG_OBJECT)) return false; @@ -12241,8 +12243,10 @@ export class ObjectQL implements IObjectQLEngine { blocking: typeof row.blocking === 'number' ? row.blocking : Number(row.blocking ?? Number.NaN), }); return verified && organizationOwnershipShapeGenerationOf(row.details) >= ORGANIZATION_OWNERSHIP_SHAPE_GENERATION; - } catch { - return false; + } catch (error) { + // A store whose ledger table does not exist yet has no certificate. + if (isMissingTableError(error, DATA_MIGRATION_FLAG_OBJECT)) return false; + throw error; } } From b5442cd101a23833bf23d3a0a04cb73d98b3ae8b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:20:47 +0000 Subject: [PATCH 71/81] test(trigger-record-change): the Default Organization testkit takes the suite's narrow engine type Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- .../before-update-flow-payload-reach.test.ts | 2 +- .../src/record-change-integration.test.ts | 18 +++++++++--------- .../src/reentrant-start-condition.test.ts | 2 +- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/triggers/trigger-record-change/src/before-update-flow-payload-reach.test.ts b/packages/triggers/trigger-record-change/src/before-update-flow-payload-reach.test.ts index f89e282947e..e0153dde68b 100644 --- a/packages/triggers/trigger-record-change/src/before-update-flow-payload-reach.test.ts +++ b/packages/triggers/trigger-record-change/src/before-update-flow-payload-reach.test.ts @@ -902,7 +902,7 @@ describe('[#15356/#14744] S5 on the real SQL driver — the mutation reaches no await objectql.syncSchemas(); // A real `single` install's Default Organization: the system writes below // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). - await installDefaultOrganization(objectql); + await installDefaultOrganization(objectql as never); objectql.registerFunction('sq_mutate_nested', async (args: any) => { const tags = (args.automation?.record as any)?.tags; diff --git a/packages/triggers/trigger-record-change/src/record-change-integration.test.ts b/packages/triggers/trigger-record-change/src/record-change-integration.test.ts index 156170426e4..efe3cf3b992 100644 --- a/packages/triggers/trigger-record-change/src/record-change-integration.test.ts +++ b/packages/triggers/trigger-record-change/src/record-change-integration.test.ts @@ -420,7 +420,7 @@ describe('a system write must not fire a record-change flow UNSCOPED (#3760)', ( await objectql.syncSchemas(); // A real `single` install's Default Organization: the system writes below // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). - await installDefaultOrganization(objectql); + await installDefaultOrganization(objectql as never); // No `runAs` — the spec default 'user'. This is the shape an author (very // often an AI) writes without realising it can run without a user. automation.registerFlow('sysw_stamp', stampFlow('sysw_stamp', 'sysw') as any); @@ -468,7 +468,7 @@ describe('a system write must not fire a record-change flow UNSCOPED (#3760)', ( await objectql.syncSchemas(); // A real `single` install's Default Organization: the system writes below // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). - await installDefaultOrganization(objectql); + await installDefaultOrganization(objectql as never); automation.registerFlow('sysw2_stamp', stampFlow('sysw2_stamp', 'sysw2') as any); const created = await data.insert('sysw2', { status: 'new' }, { context: { userId: 'u_trigger' } }); @@ -499,7 +499,7 @@ describe('record-change trigger — end-to-end (#1491)', () => { await objectql.syncSchemas(); // A real `single` install's Default Organization: the system writes below // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). - await installDefaultOrganization(objectql); + await installDefaultOrganization(objectql as never); automation.registerFlow('stamp_flow', stampFlow('stamp_flow', 'wid') as any); // The flow bound to the trigger… @@ -537,7 +537,7 @@ describe('record-change trigger — end-to-end (#1491)', () => { await ql.syncSchemas(); // A real `single` install's Default Organization: the system writes below // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). - await installDefaultOrganization(ql); + await installDefaultOrganization(ql as never); }, }; @@ -581,7 +581,7 @@ describe('record-change trigger — end-to-end (#1491)', () => { await objectql.syncSchemas(); // A real `single` install's Default Organization: the system writes below // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). - await installDefaultOrganization(objectql); + await installDefaultOrganization(objectql as never); automation.registerFlow('mirror_write', mirrorWriteFlow('mirror_write', 'wid3') as any); expect((automation as any).getActiveTriggerBindings()).toContainEqual({ @@ -618,7 +618,7 @@ describe('record-change trigger — end-to-end (#1491)', () => { await objectql.syncSchemas(); // A real `single` install's Default Organization: the system writes below // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). - await installDefaultOrganization(objectql); + await installDefaultOrganization(objectql as never); automation.registerFlow('urgent_alert', urgentAlertFlow('urgent_alert', 'wid5') as any); // Create leg — a brand-new URGENT record: `previous == null` makes the @@ -683,7 +683,7 @@ describe('record-change trigger — end-to-end (#1491)', () => { await objectql.syncSchemas(); // A real `single` install's Default Organization: the system writes below // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). - await installDefaultOrganization(objectql); + await installDefaultOrganization(objectql as never); automation.registerFlow('before_write_probe', { name: 'before_write_probe', label: 'Before-update probe', type: 'record_change', @@ -836,7 +836,7 @@ describe('hydration schema gate — real ObjectQL engine findOne count (#8482)', await objectql.syncSchemas(); // A real `single` install's Default Organization: the system writes below // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). - await installDefaultOrganization(objectql); + await installDefaultOrganization(objectql as never); automation.registerFlow( 'gate_plain_audit_flow', afterUpdateAuditFlow('gate_plain_audit_flow', 'gate_plain', 'gate_plain_audit') as any, @@ -875,7 +875,7 @@ describe('hydration schema gate — real ObjectQL engine findOne count (#8482)', await objectql.syncSchemas(); // A real `single` install's Default Organization: the system writes below // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). - await installDefaultOrganization(objectql); + await installDefaultOrganization(objectql as never); automation.registerFlow( 'gate_calc_audit_flow', afterUpdateAuditFlow('gate_calc_audit_flow', 'gate_calc', 'gate_calc_audit') as any, diff --git a/packages/triggers/trigger-record-change/src/reentrant-start-condition.test.ts b/packages/triggers/trigger-record-change/src/reentrant-start-condition.test.ts index 701d940a774..8f46d9de60c 100644 --- a/packages/triggers/trigger-record-change/src/reentrant-start-condition.test.ts +++ b/packages/triggers/trigger-record-change/src/reentrant-start-condition.test.ts @@ -274,7 +274,7 @@ describe("a record-change flow's start condition is evaluated on its own write-b await objectql.syncSchemas(); // A real `single` install's Default Organization: the system writes below // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). - await installDefaultOrganization(objectql); + await installDefaultOrganization(objectql as never); automation.registerFlow('esc_escalation', escalationFlow('esc_escalation', 'esc_case') as never); // Created OUTSIDE the condition (`priority` is not critical). The flow is From 5a321e30068dd4654cbfcfd0311c52bbbd4fab76 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:53:28 +0000 Subject: [PATCH 72/81] test(driver-sql): main's organization-less fixtures (7a-1 read-back, 7b-3 lean counter) model a pre-18 store Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- .../src/sql-driver-21227-create-answers-stored-row.test.ts | 4 ++++ .../src/sql-driver-organizationless-counter-refusal.test.ts | 6 ++++++ 2 files changed, 10 insertions(+) diff --git a/packages/drivers/driver-sql/src/sql-driver-21227-create-answers-stored-row.test.ts b/packages/drivers/driver-sql/src/sql-driver-21227-create-answers-stored-row.test.ts index 97b23e47ed5..afe6f76bb88 100644 --- a/packages/drivers/driver-sql/src/sql-driver-21227-create-answers-stored-row.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-21227-create-answers-stored-row.test.ts @@ -47,6 +47,7 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { SqlDriver, type SqlDriverConfig } from '../src/index.js'; +import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; import { DIALECT_CELLS, declareDialectCell, type DialectCell } from './live-dialect-matrix.testkit.js'; const TABLE = 'os21227_stored_row'; @@ -207,6 +208,9 @@ function suite(face: Face) { // own union (`IN ('org_b')`) would miss the unstamped row this call just // wrote. The read is scoped by that union only when every row carries a // tenant; here the call's own options (none) decide, and the ids bound it. + // The unstamped row lands only on a table built before 18.x (ADR-0131 D1 + // makes a fresh tenant column NOT NULL): this table is that store's. + await asPre18TenantColumn(driver, TABLE); const batch = await driver.bulkCreate(TABLE, [ { id: 't-m1', title: 'named', organization_id: 'org_b' }, { id: 't-m2', title: 'unstamped' }, diff --git a/packages/drivers/driver-sql/src/sql-driver-organizationless-counter-refusal.test.ts b/packages/drivers/driver-sql/src/sql-driver-organizationless-counter-refusal.test.ts index c68724e0e43..51a71331b35 100644 --- a/packages/drivers/driver-sql/src/sql-driver-organizationless-counter-refusal.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-organizationless-counter-refusal.test.ts @@ -18,6 +18,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { SqlDriver } from './index.js'; +import { asPre18TenantColumn } from './pre-18-tenant-column.testkit.js'; import { ORGANIZATIONLESS_COUNTER_REFUSAL_CODE, ORGANIZATIONLESS_COUNTER_REFUSAL_STATUS, @@ -121,6 +122,11 @@ describe('[ADR-0131 D9, 8d] the counter refuses an organization-less row on a te it('a composition with no organization object (a lean embedding) keeps `__global__` on a tenant table', async () => { const driver = await bootDriver([CASE_OBJECT]); + // The organization-less row itself lands only where the tenant column is + // nullable — a table built before 18.x (ADR-0131 D1 makes a fresh one NOT + // NULL, so a lean embedding's fresh table refuses it at the database). The + // counter's bucket is this case's subject, so the table is that store's. + await asPre18TenantColumn(driver, 'crm_case'); const first = await driver.create('crm_case', { subject: 'lean embedding' }); expect(first.case_number).toBe('CASE-0001'); From 9ef44b50bdf0d0c50167251ccae91ec455056938 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 13:59:01 +0000 Subject: [PATCH 73/81] test(dogfood): pin the equivalence on a store 18.0.0 created from empty (birth-marked, nullable, COALESCE) Claude-Session: https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd Co-authored-by: Claude --- ...rade-18x-index-equivalence.dogfood.test.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.ts b/packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.ts index 56fac8b8509..eb05567cf32 100644 --- a/packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.ts +++ b/packages/qa/dogfood/test/upgrade-18x-index-equivalence.dogfood.test.ts @@ -30,6 +30,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import type { VerifyStack } from '@objectstack/verify'; import { ceremoniedFixture } from './upgrade-harness/ceremony.js'; import { bootFixture } from './upgrade-harness/populate-fixture.js'; +import { relaxTenantColumns } from './pre-18-tenant-column.testkit.js'; const fixtureSql = readFileSync(new URL('./upgrade-harness/fixture-18.0/database.sql', import.meta.url), 'utf8'); @@ -176,6 +177,85 @@ describe('[18.x step 7b] the organization key part across the 18.0 → HEAD upgr }); }); + describe('a store 18.0.0 created from empty: birth-marked, nullable tenant columns, COALESCE organization indexes', () => { + // 18.0.0 (published) births a store with its ownership marker at shape + // generation 1 — the generation HEAD still requires — while it still built + // every tenant column nullable and keyed organization uniques through the + // COALESCE form. Measured on the published 18.0.0 for this part: 34 tenant + // columns, none NOT NULL; 10 of 14 organization uniques in the COALESCE + // form; then HEAD booted it with no refusal and `os migrate plan` read + // 0 changes. This leg models that store from a HEAD birth (same marker), + // the tenant columns relaxed through the drift door and the indexes + // re-spelled exactly as 18.0.0 wrote them, so the CERTIFICATION is the + // only witness: no tenant column here is NOT NULL. + const born = join(dir, 'born-18.0.0.db'); + let stack: VerifyStack | undefined; + let respelled: Array<{ name: string; sql: string }> = []; + + beforeAll(async () => { + const first = await bootFixture(born); + try { + const ql = (await first.kernel.getServiceAsync('objectql')) as { getDriverForObject(object: string): unknown }; + // The tables whose organization unique 18.0.0 keyed through COALESCE — + // the ones whose equivalence is under test. (`sys_setting`'s identity + // index is its runtime migration's, rebuilt every boot.) + const tenantTables = [ + ...new Set(organizationUniqueIndexes(born).map((i) => i.table).filter((t) => t !== 'sys_setting')), + ]; + await relaxTenantColumns(ql, tenantTables); + } finally { + await first.stop(); + } + const db = new DatabaseSync(born); + try { + for (const index of organizationUniqueIndexes(born)) { + // `sys_setting`'s identity index is its runtime migration's, rebuilt every boot. + if (index.table === 'sys_setting') continue; + const sql = index.sql.replace(/\(\s*([`"]?)organization_id\1\s*([,)])/, "(COALESCE($1organization_id$1, '__global__')$2") + .replace(/,\s*([`"]?)organization_id\1\s*([,)])/, ", COALESCE($1organization_id$1, '__global__')$2"); + if (sql === index.sql) continue; + db.exec(`DROP INDEX "${index.name}"`); + db.exec(sql); + respelled.push({ name: index.name, sql }); + } + } finally { + db.close(); + } + stack = await bootFixture(born); + }, 600_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('is the 18.0.0 shape: a verified generation-1 birth marker, no NOT NULL tenant column, COALESCE indexes', () => { + const db = new DatabaseSync(born, { readOnly: true }); + try { + const marker = db.prepare('SELECT verified_at, details FROM sys_migration WHERE id = ?').get( + 'adr-0131-organization-ownership', + ) as { verified_at: unknown; details: string } | undefined; + expect(marker?.verified_at).toBeTruthy(); + expect(JSON.parse(marker!.details).birth?.shapeGeneration).toBe(1); + } finally { + db.close(); + } + expect(respelled.length, 'the store holds COALESCE organization indexes').toBeGreaterThanOrEqual(10); + for (const index of respelled) { + const table = organizationUniqueIndexes(born).find((i) => i.name === index.name)!.table; + expect(organizationNotNull(born, table), `${table}.organization_id`).toBe(false); + } + }); + + it("HEAD boots it, and its differ reports zero recreate_index and zero destructive drift", async () => { + expectNoDestructiveIndexDrift(await managedDrift(stack!)); + }); + + it('every COALESCE index survives the HEAD boot byte for byte', () => { + const after = new Map(organizationUniqueIndexes(born).map((i) => [i.name, i.sql])); + for (const index of respelled) expect(after.get(index.name), index.name).toBe(index.sql); + }); + }); + describe('a store HEAD creates from empty', () => { const fresh = join(dir, 'fresh.db'); let stack: VerifyStack | undefined; From 3bb7ad6b1333cf45f1b20d27a581ec7d017761ee Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:01:12 +0000 Subject: [PATCH 74/81] =?UTF-8?q?fix(driver-sql,=20objectql):=20true=20dec?= =?UTF-8?q?larations=20=E2=80=94=20the=20uncertified=20tenant-key=20findin?= =?UTF-8?q?g=20names=20the=20ownership=20ceremony,=20the=20not-certified?= =?UTF-8?q?=20prose=20says=20what=20it=20reports,=20the=20changeset=20name?= =?UTF-8?q?s=20the=20sys=5Fsetting=20re-key=20and=20the=2018.0.0-born=20wi?= =?UTF-8?q?dening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd Co-authored-by: Claude --- .../24583-plain-organization-key-part.md | 6 ++--- .../drivers/driver-sql/src/schema-drift.ts | 26 ++++++++++++++++++- .../sql-driver-tenant-key-equivalence.test.ts | 14 ++++++++++ packages/drivers/driver-sql/src/sql-driver.ts | 17 +++++++----- ...dr0131-tenant-key-certified-supply.test.ts | 3 ++- packages/objectql/src/engine.ts | 6 +++-- 6 files changed, 59 insertions(+), 13 deletions(-) diff --git a/.changeset/24583-plain-organization-key-part.md b/.changeset/24583-plain-organization-key-part.md index 354c24e73cf..a35ea12e67f 100644 --- a/.changeset/24583-plain-organization-key-part.md +++ b/.changeset/24583-plain-organization-key-part.md @@ -14,6 +14,6 @@ Clause-②: yes (widening) 18.x step 7b-1 + 7b-2 (ADR-0131 D1: the tenant column is NOT NULL, so the NULL-organization bucket of ADR-0120 D3 has no rows left to hold). - **New DDL is plain.** A field-level `unique: true` / `'organization'` on a tenant-scoped object, and a declared index with `unique: 'organization'`, now build `UNIQUE (organization_id, …)` instead of `UNIQUE (COALESCE(organization_id, '__global__'), …)`. Index names do not change. The same holds for the Turso remote face, the MySQL hash shadow (its generated column now hashes the plain column), the in-memory driver's unique enforcement, `os generate migration` (which now emits the organization composite it used to list as NOT EMITTED), and the `sys_setting` row-identity index (`COALESCE(user_id, '')` stays). -- **Existing indexes stay as they are.** Nothing is renamed, dropped or rebuilt by this release. The schema differ reads `COALESCE(, …)` and the plain tenant column as one key part, in both directions, where the tenant column holds no NULL: the column is physically `NOT NULL`, or the database is certified (its organization-ownership birth marker, or the ownership ceremony's marker at the current shape generation). So `os migrate plan` and the boot report no `recreate_index` for those indexes. On an uncertified database with a nullable tenant column the two forms stay different constraints, as before. -- **What widens.** Where a tenant column is still nullable (an uncertified 17.x / 18.0 database, a rotation shard, a Turso remote table), a unique index created from now on does not constrain rows whose organization is NULL, exactly like a NULL in any other key part; in the in-memory driver a record with no organization is exempt from an organization-scoped unique. The unique duplicate pre-flight no longer reports groups of rows with a NULL key part, since the index never refuses them. -- **Who sees it.** A certified database or a fresh install sees nothing: every row carries its organization there. Nothing to do. +- **Existing indexes stay as they are.** Nothing is renamed, and no declared organization composite is dropped or rebuilt. One index is re-keyed: the `sys_setting` row-identity index, which the metadata protocol rebuilds on every boot, keys the plain `organization_id` from the next boot on (probe-first; a pure relaxation, so it cannot fail on existing rows). The schema differ reads `COALESCE(, …)` and the plain tenant column as one key part, in both directions, where the column is physically `NOT NULL` or the database is certified (its organization-ownership birth marker, or the ownership ceremony's marker at the current shape generation). So `os migrate plan` and the boot report no `recreate_index` for those indexes. On an uncertified database with a nullable tenant column the two forms stay different constraints: the finding is reported, and it names `os migrate organization-ownership` as its remedy. +- **What widens.** Where a tenant column is still nullable (every table of a database 18.0.0 created, an uncertified 17.x / 18.0 database, a rotation shard, a Turso remote table, a table the ceremony reported), a unique index created from now on does not constrain rows whose organization is NULL, exactly like a NULL in any other key part; in the in-memory driver a record with no organization is exempt from an organization-scoped unique. The unique duplicate pre-flight no longer reports groups of rows with a NULL key part, since the index never refuses them. +- **Who sees it.** A fresh install and every ceremonied table whose tenant column is NOT NULL see nothing. A database 18.0.0 created from empty is certified by its birth marker yet keeps nullable tenant columns: its existing indexes are kept unchanged, and a new organization unique declared on one of its tables constrains only rows that carry an organization. diff --git a/packages/drivers/driver-sql/src/schema-drift.ts b/packages/drivers/driver-sql/src/schema-drift.ts index 5d3ceeb7bb7..85bfef95b50 100644 --- a/packages/drivers/driver-sql/src/schema-drift.ts +++ b/packages/drivers/driver-sql/src/schema-drift.ts @@ -2557,6 +2557,22 @@ export function diffManagedIndexes(args: { // own (`nullSafeColumns` is only ever recorded alongside an expression key // part); a resolved shadow key can carry NULL-safe parts with no // expressions at all, so the implication no longer holds. + // [ADR-0131 D1] An earlier release's COALESCE tenant key part against the + // plain one new DDL declares — the divergence is ONLY that form, on a + // table where the two are still different constraints (not + // certified, nullable tenant column). The remedy is the ownership ceremony, + // which certifies the database and so retires this entry — never a rebuild: + // `--allow-destructive` would drop and re-key the index over rows that may + // still lack an organization. The op is unchanged (destructive, reported, + // never applied unattended); only what it tells the operator differs. + const uncertifiedTenantKeyOnly = + tenantKeyCertified !== true && + !!tenantField && + p.unique === e.unique && + (pk.nullSafeColumns ?? []).includes(tenantField) && + !(e.nullSafeColumns ?? []).includes(tenantField) && + canonicalIndexKey(pk.columns, (pk.nullSafeColumns ?? []).filter((c) => c !== tenantField)) === + canonicalIndexKey(e.columns, (e.nullSafeColumns ?? []).filter((c) => c !== tenantField)); const tightenNullSafeOnly = e.unique && p.unique && @@ -2584,7 +2600,15 @@ export function diffManagedIndexes(args: { ...(e.nullSafeColumns ? { nullSafeColumns: e.nullSafeColumns } : {}), ...(tightenNullSafeOnly ? { tightenNullSafeOnly: true } : {}), }, - message: tightenNullSafeOnly + message: uncertifiedTenantKeyOnly + ? `${table}: index '${e.name}' is ${indexSignature(pk.columns, p.unique, pk.nullSafeColumns)} and metadata declares ` + + `${indexSignature(e.columns, e.unique, e.nullSafeColumns)}: the same columns, differing only in how the ` + + `'${tenantField}' key part treats a row with no organization. This database is not certified for the ` + + `organization-ownership shape and '${tenantField}' is nullable, so the two are still different constraints. ` + + `Run "os migrate organization-ownership": once it certifies the database the two read as one index and this ` + + `entry is gone. Do not rebuild it with "os migrate apply --allow-destructive", which would drop and re-key ` + + `the index over rows that may still have no organization.` + : tightenNullSafeOnly ? `${table}: index '${e.name}' is ${indexSignature(pk.columns, p.unique, pk.nullSafeColumns)} but metadata declares ` + `${indexSignature(e.columns, e.unique, e.nullSafeColumns)} (ADR-0120 D3: the organization key part is NULL-safe, ` + `so rows without an organization are constrained too). Pure tightening — eligibility is decided by the ` + diff --git a/packages/drivers/driver-sql/src/sql-driver-tenant-key-equivalence.test.ts b/packages/drivers/driver-sql/src/sql-driver-tenant-key-equivalence.test.ts index 374298351c8..6e3c304729d 100644 --- a/packages/drivers/driver-sql/src/sql-driver-tenant-key-equivalence.test.ts +++ b/packages/drivers/driver-sql/src/sql-driver-tenant-key-equivalence.test.ts @@ -73,6 +73,20 @@ describe('7b-1 — the differ: COALESCE(tenant) ≡ tenant where the tenant colu expect(entry).toMatchObject({ category: 'destructive', op: { type: 'recreate_index', indexName: NAME } }); }); + it('the uncertified entry names the ownership ceremony as its remedy, never --allow-destructive', () => { + const [entry] = diff(expectedPlain, coalesced, false); + expect(entry!.message).toContain('"os migrate organization-ownership"'); + expect(entry!.message).toContain('not certified'); + // The rebuild is named only to be ruled out, never prescribed as the fix. + expect(entry!.message).toContain('Do not rebuild it with "os migrate apply --allow-destructive"'); + expect(entry!.message).not.toMatch(/existing duplicates: "os migrate apply --allow-destructive"/); + // Control — a real redefinition keeps the rebuild remedy it always had. + const other: ExpectedIndex = { name: NAME, columns: [ORG, 'code', 'region'], unique: true }; + const [rebuild] = diff(other, coalesced, false); + expect(rebuild!.message).not.toContain('os migrate organization-ownership'); + expect(rebuild!.message).toContain('"os migrate apply --allow-destructive"'); + }); + it('the other direction: a plain physical index against a NULL-safe expectation, no drift when certified', () => { expect(diff(expectedNullSafe, plain, true)).toEqual([]); expect(diff(expectedNullSafe, plain, false).map((d) => d.op.type)).toEqual(['recreate_index']); diff --git a/packages/drivers/driver-sql/src/sql-driver.ts b/packages/drivers/driver-sql/src/sql-driver.ts index 2fbd40cab05..745a7e59ce5 100644 --- a/packages/drivers/driver-sql/src/sql-driver.ts +++ b/packages/drivers/driver-sql/src/sql-driver.ts @@ -20413,9 +20413,12 @@ export class SqlDriver implements IDataDriver { * The answer decides one thing only: whether the differ reads an earlier * release's `COALESCE(, …)` key part as the plain tenant column new * DDL keys ({@link detectTableIndexDrift}). Without a resolver, or when it - * throws, the answer is "not certified" — the two forms then stay distinct - * constraints, which is the reading that never proposes a rebuild on a - * guess. + * throws, the answer is "not certified": the two forms then stay distinct + * constraints, and the differ REPORTS the 18.0 index as a destructive + * `recreate_index`. That report is never applied unattended — the boot + * reconcile applies `safe` entries only, the artifact gate refuses it, and + * `os migrate apply` needs `--allow-destructive` — and its message names the + * ownership ceremony as the remedy. */ setTenantKeyCertifiedResolver(resolve: () => boolean | Promise): void { this.tenantKeyCertifiedResolver = resolve; @@ -20443,9 +20446,11 @@ export class SqlDriver implements IDataDriver { // {@link PRE_DDL_QUESTION_SCOPE}). if ((await PRE_DDL_QUESTION_SCOPE.run(true, resolver)) !== true) return false; } catch (e: any) { - // Not knowing reads as "not certified" — the reading that never proposes - // a rebuild on a guess — and is said once, so a drift report that then - // names an 18.0 index is not taken for a verdict on a certified store. + // Not knowing reads as "not certified": the forms stay distinct and an + // 18.0 index is reported as a destructive recreate_index, which nothing + // applies unattended (safe-only boot reconcile, the artifact gate, + // `--allow-destructive`). Said once, so that report is not taken for a + // verdict on a certified store. if (!this.tenantKeyCertifiedReadFailureReported) { this.tenantKeyCertifiedReadFailureReported = true; this.logger.warn( diff --git a/packages/objectql/src/adr0131-tenant-key-certified-supply.test.ts b/packages/objectql/src/adr0131-tenant-key-certified-supply.test.ts index 4ac9438de2d..121358f36c9 100644 --- a/packages/objectql/src/adr0131-tenant-key-certified-supply.test.ts +++ b/packages/objectql/src/adr0131-tenant-key-certified-supply.test.ts @@ -15,7 +15,8 @@ * (`sql-driver-tenant-key-equivalence.test.ts`). * * Every way of not knowing answers `false`, the reading under which the differ - * keeps the two forms distinct and never proposes a rebuild on a guess. + * keeps the two forms distinct: the differ reports a destructive + * `recreate_index`, which is never applied unattended. */ import { describe, it, expect } from 'vitest'; diff --git a/packages/objectql/src/engine.ts b/packages/objectql/src/engine.ts index 9bb90d1b180..b64b791d974 100644 --- a/packages/objectql/src/engine.ts +++ b/packages/objectql/src/engine.ts @@ -12209,8 +12209,10 @@ export class ObjectQL implements IObjectQLEngine { try { sink.setTenantKeyCertifiedResolver(() => this.isOrganizationOwnershipCertified()); } catch (e: any) { - // A driver that did not take the resolver answers "not certified", the - // reading that never proposes a rebuild on a guess. + // A driver that did not take the resolver answers "not certified": it + // keeps the two forms distinct and REPORTS a destructive recreate_index, + // which is never applied unattended (safe-only boot reconcile, the + // artifact gate, `os migrate apply --allow-destructive`). this.logger.debug('Driver declined the ADR-0131 certification resolver', { driverName: driver.name, error: e?.message ?? String(e), From 97f5296a98cae7761c771901ff6c1ec929dcc29a Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:13:02 +0000 Subject: [PATCH 75/81] test(dogfood): the legacy criteria-less rule belongs to the install's organization, now that 7a-3 reads own-organization rules only Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- .../test/sharing-rule-criteria-required.dogfood.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/qa/dogfood/test/sharing-rule-criteria-required.dogfood.test.ts b/packages/qa/dogfood/test/sharing-rule-criteria-required.dogfood.test.ts index 3aef7e10d8e..13670c3d63e 100644 --- a/packages/qa/dogfood/test/sharing-rule-criteria-required.dogfood.test.ts +++ b/packages/qa/dogfood/test/sharing-rule-criteria-required.dogfood.test.ts @@ -25,7 +25,6 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest'; import { type VerifyStack } from '@objectstack/verify'; import { bootShowcase } from './showcase-boot.js'; -import { relaxTenantColumns } from './pre-18-tenant-column.testkit.js'; // Sharing-rule routes are anchored on the API base, not under `/data/:object` // (they administer rules tenant-wide) — `/api/v1/sharing/rules`. @@ -138,11 +137,13 @@ describe('#3896 — a sharing rule POSTed without criteria is rejected, never ma const id = 'srule_3896_legacy'; const driver = (ql as any).getDriverForObject('sys_sharing_rule'); expect(driver, 'sys_sharing_rule driver must be resolvable').toBeTruthy(); - // That residue lives only on a store built before 18.x, whose tenant column is - // nullable (ADR-0131 D1 makes a fresh one NOT NULL): the table is that store's. - await relaxTenantColumns(ql as any, ['sys_sharing_rule']); + // The legacy row belongs to the install's organization, as every row does + // since the ownership ceremony (ADR-0131 D1): its missing criteria is the + // subject here, not its owner. + const [organization] = (await ql.find('sys_organization', { context: SYS })) as Array<{ id: string }>; await driver.create('sys_sharing_rule', { id, + organization_id: organization.id, name: 'legacy_match_all_3896', label: 'Legacy match-all', object_name: OBJECT, From 5bd35155d1a59c9f80a2f99ed0babca707923bf5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:41:38 +0000 Subject: [PATCH 76/81] test(plugin-approvals): main's two new lean fixture engines hold a Default Organization Claude-Session: https://claude.ai/code/session_018EszgtA3V2KXBX5tf59Hi4 Co-authored-by: Claude --- .../src/later-step-original-submitter.integration.test.ts | 4 ++++ .../src/remind-notified-per-person.integration.test.ts | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/plugins/plugin-approvals/src/later-step-original-submitter.integration.test.ts b/packages/plugins/plugin-approvals/src/later-step-original-submitter.integration.test.ts index 88811cad9cc..d3c8daf8e16 100644 --- a/packages/plugins/plugin-approvals/src/later-step-original-submitter.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/later-step-original-submitter.integration.test.ts @@ -51,6 +51,7 @@ import type { EngineQueryOptions } from '@objectstack/spec/data'; import { ApprovalService } from './approval-service.js'; import { ApprovalsServicePlugin } from './approvals-plugin.js'; import { registerApprovalNode } from './approval-node.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; const asUser = (userId: string) => ({ userId, positions: [], permissions: [] }) as any; @@ -250,6 +251,9 @@ describe('every step of an approval run names the run\'s original submitter (#24 engine.registry.registerObject(def as any, 'approvals-test', 'approvals-test'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const u of USERS) await engine.insert('sys_user', u, { context: SYSTEM } as any); automation = new AutomationEngine(silent); diff --git a/packages/plugins/plugin-approvals/src/remind-notified-per-person.integration.test.ts b/packages/plugins/plugin-approvals/src/remind-notified-per-person.integration.test.ts index 3dc9f2789ce..d9e189edbb7 100644 --- a/packages/plugins/plugin-approvals/src/remind-notified-per-person.integration.test.ts +++ b/packages/plugins/plugin-approvals/src/remind-notified-per-person.integration.test.ts @@ -46,6 +46,7 @@ import type { EngineQueryOptions } from '@objectstack/spec/data'; import { ApprovalService } from './approval-service.js'; import { ApprovalsServicePlugin } from './approvals-plugin.js'; import { registerApprovalNode } from './approval-node.js'; +import { installDefaultOrganization } from './default-organization.testkit.js'; const SYSTEM = { isSystem: true, positions: [], permissions: [] } as any; const asUser = (userId: string) => ({ userId, positions: [], permissions: [] }) as any; @@ -180,6 +181,9 @@ describe('a reminder counts the reminders it hands over, one per distinct approv engine.registry.registerObject(def as any, 'approvals-test', 'approvals-test'); } await engine.syncSchemas(); + // A real `single` install's Default Organization: the system writes below + // derive it (ADR-0131 D9), as a fresh NOT NULL tenant column requires (D1). + await installDefaultOrganization(engine); for (const u of USERS) await engine.insert('sys_user', u, { context: SYSTEM } as any); automation = new AutomationEngine(silent); From c1f00057450c3c3d03e9df204a0ffe9823c36d30 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 15:42:14 +0000 Subject: [PATCH 77/81] test(plugin-security): the grant uniqueness pins re-keyed to the plain organization key part (ADR-0131 D1) Claude-Session: https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd Co-authored-by: Claude --- .../src/grant-permission-set-name.test.ts | 41 +++++++++---------- 1 file changed, 20 insertions(+), 21 deletions(-) diff --git a/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts b/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts index cb0d4a6adfa..df52d0907c3 100644 --- a/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts +++ b/packages/plugins/plugin-security/src/grant-permission-set-name.test.ts @@ -7,9 +7,11 @@ * as an undeclared field; a non-system write naming a set the security * catalog does not hold is refused, and a grant that carries no name is * refused as `required`. One grant per user, set and organization: the - * declared unique index (`unique: 'organization'`, NULL-safe on the - * organization) refuses a second, platform-wide grants included, on every - * write path, so a revoke leaves no grant of that set in force. + * declared unique index (`unique: 'organization'`) refuses a second on every + * write path, so a revoke leaves no grant of that set in force. Its + * organization key part is the plain column since ADR-0131 D1 (the tenant + * column is NOT NULL), so on the pre-18 nullable column this harness models a + * grant with no organization is exempt, like a NULL in any key part. * * Measured on a REAL `ObjectQL` engine over a real SQL driver with the REAL * `SecurityPlugin` started on it the way a kernel composition does, because @@ -309,42 +311,39 @@ describe('one grant per user, set and organization — the declared unique \'org expect(await grantsOfSet(h, 'u_d1', 'qa_alpha')).toEqual([]); }); - it('NEGATIVE — a second platform-wide grant (organization NULL) is refused 409 by the index itself, for every writer; the revoke leaves none in force', async () => { + it('a second grant with no organization is NOT refused by the index on a pre-18 nullable column: the plain organization key part exempts it (ADR-0131 D1)', async () => { const h = await boot(); await h.engine.insert('sys_user_permission_set', { id: 'ups_p1', user_id: 'u_p1', permission_set: 'qa_alpha', organization_id: null }, { context: ADMIN } as any); - for (const context of [ADMIN, SYS]) { - const err = await refusalOf(() => h.engine.insert( - 'sys_user_permission_set', { user_id: 'u_p1', permission_set: 'qa_alpha' }, { context } as any, - )); - expect(duplicateShape(err)).toEqual(DUPLICATE); - } - expect((await grantsOfSet(h, 'u_p1', 'qa_alpha')).map((g) => g.id)).toEqual(['ups_p1']); - - await h.engine.delete('sys_user_permission_set', { where: { id: 'ups_p1' }, context: ADMIN } as any); - expect(await grantsOfSet(h, 'u_p1', 'qa_alpha')).toEqual([]); + await knexOf(h)('sys_user_permission_set').insert({ id: 'ups_p2', user_id: 'u_p1', permission_set: 'qa_alpha', organization_id: null }); + expect((await grantsOfSet(h, 'u_p1', 'qa_alpha')).map((g) => g.id).sort()).toEqual(['ups_p1', 'ups_p2']); }); - it('NEGATIVE — the index refuses a platform-wide duplicate on paths no hook sees: a two-row batch insert stores nothing, and raw SQL beneath the engine is refused', async () => { + it('NEGATIVE — the index refuses an organization duplicate on paths no hook sees: a two-row batch insert stores nothing, and raw SQL beneath the engine is refused', async () => { const h = await boot(); + await h.engine.insert('sys_member', { id: 'mem_b1', user_id: 'u_b1', organization_id: ORG, role: 'member' }, { context: SYS } as any); const err = await refusalOf(() => h.engine.insert( 'sys_user_permission_set', - [{ user_id: 'u_b1', permission_set: 'qa_alpha' }, { user_id: 'u_b1', permission_set: 'qa_alpha' }], + [ + { user_id: 'u_b1', permission_set: 'qa_alpha', organization_id: ORG }, + { user_id: 'u_b1', permission_set: 'qa_alpha', organization_id: ORG }, + ], { context: SYS } as any, )); expect(duplicateShape(err)).toEqual(DUPLICATE); expect(await grantsOf(h, 'u_b1')).toEqual([]); - await knexOf(h)('sys_user_permission_set').insert({ id: 'raw_1', user_id: 'u_r1', permission_set: 'qa_alpha', organization_id: null }); + await knexOf(h)('sys_user_permission_set').insert({ id: 'raw_1', user_id: 'u_r1', permission_set: 'qa_alpha', organization_id: ORG }); await expect( - knexOf(h)('sys_user_permission_set').insert({ id: 'raw_2', user_id: 'u_r1', permission_set: 'qa_alpha', organization_id: null }), + knexOf(h)('sys_user_permission_set').insert({ id: 'raw_2', user_id: 'u_r1', permission_set: 'qa_alpha', organization_id: ORG }), ).rejects.toThrow(/UNIQUE constraint failed/); expect((await grantsOf(h, 'u_r1')).map((g) => g.id)).toEqual(['raw_1']); }); - it('NEGATIVE — an update that makes a platform-wide duplicate (re-naming the set) is refused 409 and the row is untouched', async () => { + it('NEGATIVE — an update that makes an organization duplicate (re-naming the set) is refused 409 and the row is untouched', async () => { const h = await boot(); - await h.engine.insert('sys_user_permission_set', { id: 'ups_u1', user_id: 'u_u1', permission_set: 'qa_alpha' }, { context: ADMIN } as any); - await h.engine.insert('sys_user_permission_set', { id: 'ups_u2', user_id: 'u_u1', permission_set: 'qa_beta' }, { context: ADMIN } as any); + await h.engine.insert('sys_member', { id: 'mem_u1', user_id: 'u_u1', organization_id: ORG, role: 'member' }, { context: SYS } as any); + await h.engine.insert('sys_user_permission_set', { id: 'ups_u1', user_id: 'u_u1', permission_set: 'qa_alpha', organization_id: ORG }, { context: ADMIN } as any); + await h.engine.insert('sys_user_permission_set', { id: 'ups_u2', user_id: 'u_u1', permission_set: 'qa_beta', organization_id: ORG }, { context: ADMIN } as any); const err = await refusalOf(() => h.engine.update( 'sys_user_permission_set', { id: 'ups_u2', permission_set: 'qa_alpha' }, { context: ADMIN } as any, )); From d4e977b874c974d501607bcd7b45621341c4e5c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:11:25 +0000 Subject: [PATCH 78/81] test(cli): the os dev self-heal pin stages the legacy global unique, the safe drift that survives the plain organization key part Claude-Session: https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd Co-authored-by: Claude --- ...v-standalone-self-heal.integration.test.ts | 84 ++++++++++++++----- 1 file changed, 61 insertions(+), 23 deletions(-) diff --git a/packages/cli/test/dev-standalone-self-heal.integration.test.ts b/packages/cli/test/dev-standalone-self-heal.integration.test.ts index 953bc47b48c..5bba0128cf1 100644 --- a/packages/cli/test/dev-standalone-self-heal.integration.test.ts +++ b/packages/cli/test/dev-standalone-self-heal.integration.test.ts @@ -23,14 +23,27 @@ * * ## What each leg pins * - * - boot 1 — a fresh `os dev` creates the NULL-safe index; with - * `OS_TELEMETRY_DB=0` it provisions no telemetry sibling (the opt-out). - * - boot 2 — after the card's step 3 restages the bare index, a plain `os dev` - * restart puts the NULL-safe one back, says `auto-reconciled`, and provisions + * - boot 1 — a fresh `os dev` creates the plain `(organization_id, qa_code)` + * composite (ADR-0131 D1: no COALESCE) and no legacy single-column UNIQUE; + * with `OS_TELEMETRY_DB=0` it provisions no telemetry sibling (the opt-out). + * - boot 2 — after the staging step plants the legacy platform-wide UNIQUE on + * `qa_code` beside the composite, a plain `os dev` restart drops it (a pure + * relaxation, the composite survives), says `auto-reconciled`, and provisions * `A.telemetry.db` (default-on in dev for a file-backed SQLite primary). * - boot 3 — a production boot (`os serve`, `NODE_ENV` unset) on the restaged * file reports the drift and leaves it: production never auto-migrates. * + * ⚠️ Why not the drift the card staged. #21733 reproduced with the ADR-0120 D4 + * tightening — a bare composite against the NULL-safe expectation, a + * `recreate_index` the duplicate probe recategorised `safe` (the table above). + * ADR-0131 D1 retired it: the expectation is the plain composite now, so a bare + * composite MATCHES and there is no drift at all. The defect is about the + * standalone stack never applying the self-heal's `safe` subset, not about any + * one op, so the staging carries the `safe` op that is still live: + * `replace_unique_index`, the legacy global UNIQUE an older release left (the + * same fixture `src/utils/schema-migrate.dev-self-heal-fence.integration.test.ts` + * stages). + * * The host-config path is pinned unchanged where it is decided * (`src/utils/storage-driver.test.ts`), and both hosts are held to one answer * per driver kind by `src/utils/dev-self-heal-host-parity.test.ts`; the @@ -53,6 +66,7 @@ import { tmpdir } from 'node:os'; import { join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import Database from 'better-sqlite3'; +import { legacyUniqueIndexNames } from '@objectstack/driver-sql'; import { childEnv, E2E_SECRET_KEY, @@ -72,7 +86,13 @@ const READY = /Press Ctrl\+C to stop/; const BOOT_TIMEOUT_MS = 180_000; const ALL_BOOTS_TIMEOUT_MS = 3 * (BOOT_TIMEOUT_MS + 30_000); +/** The organization composite the declaration asks for (ADR-0131 D1: plain key parts). */ const INDEX = 'uniq_scaf_item_organization_id_qa_code'; +/** + * The legacy platform-wide single-column UNIQUE, spelled by the driver's own + * `legacyUniqueIndexNames` — its `uniq_` arm, the one the differ recognises. + */ +const LEGACY = legacyUniqueIndexNames('scaf_item', 'qa_code')[1]; /** The card's blank scaffold: one object, an org-scoped unique field, no plugins, no datasources. */ const SCAFFOLD = { @@ -86,8 +106,10 @@ const SCAFFOLD = { }; interface BootRecord { - /** The index DDL `sqlite_master` holds after the boot stopped. */ + /** The composite's DDL `sqlite_master` holds after the boot stopped. */ indexSql: string | undefined; + /** The legacy global UNIQUE's DDL after the boot stopped; `undefined` when absent. */ + legacySql: string | undefined; telemetryExists: boolean; output: string; } @@ -97,10 +119,10 @@ let dir: string; let db: string; const record: Partial> = {}; -function readIndexSql(): string | undefined { +function readIndexSql(name: string): string | undefined { const handle = new Database(db, { readonly: true }); try { - const row = handle.prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?").get(INDEX) as + const row = handle.prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = ?").get(name) as | { sql: string } | undefined; return row?.sql; @@ -109,11 +131,16 @@ function readIndexSql(): string | undefined { } } -/** The card's step 3: replace the NULL-safe unique index with the bare one an older release left. */ -function stageBareIndex(): void { +/** + * The staging step: plant, beside the plain composite, the platform-wide UNIQUE + * on `qa_code` a pre-#3696 release left — the state every additive boot after + * such an upgrade produces, since the sync creates the composite by name and + * never drops anything. Idempotent, so it restages after a heal. + */ +function stageLegacyIndex(): void { const handle = new Database(db); try { - handle.exec(`DROP INDEX ${INDEX}; CREATE UNIQUE INDEX ${INDEX} ON scaf_item (organization_id, qa_code);`); + handle.exec(`CREATE UNIQUE INDEX IF NOT EXISTS ${LEGACY} ON scaf_item (qa_code);`); } finally { handle.close(); } @@ -200,7 +227,12 @@ async function stopGroup(child: ChildProcess): Promise { async function recordBoot(leg: 'fresh' | 'restart' | 'production', boot: Promise): Promise { try { const output = await boot; - record[leg] = { indexSql: readIndexSql(), telemetryExists: existsSync(join(dir, 'A.telemetry.db')), output }; + record[leg] = { + indexSql: readIndexSql(INDEX), + legacySql: readIndexSql(LEGACY), + telemetryExists: existsSync(join(dir, 'A.telemetry.db')), + output, + }; } catch (error) { record[leg] = error instanceof Error ? error : new Error(String(error)); } @@ -213,7 +245,9 @@ function recorded(leg: 'fresh' | 'restart' | 'production'): BootRecord { return r; } -const isNullSafe = (sql: string | undefined): boolean => /COALESCE\(`?organization_id`?/.test(sql ?? ''); +/** The composite in its ADR-0131 D1 form: both key parts plain, no COALESCE. */ +const isPlainComposite = (sql: string | undefined): boolean => + /\(\s*[`"]?organization_id[`"]?\s*,\s*[`"]?qa_code[`"]?\s*\)/.test(sql ?? '') && !/COALESCE/i.test(sql ?? ''); beforeAll(async () => { requireBuiltCli(RUN_JS_RESOLVES_FROM_DIST); @@ -224,12 +258,12 @@ beforeAll(async () => { // Boot 1: fresh, with the telemetry opt-out. await recordBoot('fresh', bootUntilReady(['dev', '--no-watch', '-d', 'file:A.db', '-p', KERNEL_ASSIGNED_PORT], { OS_TELEMETRY_DB: '0' })); - // Boot 2: the card's restart, on the restaged bare index, telemetry default-on. - if (existsSync(db)) stageBareIndex(); + // Boot 2: the card's restart, on the staged legacy index, telemetry default-on. + if (existsSync(db)) stageLegacyIndex(); await recordBoot('restart', bootUntilReady(['dev', '--no-watch', '-d', 'file:A.db', '-p', KERNEL_ASSIGNED_PORT], {})); - // Boot 3: production (`os serve`, NODE_ENV unset) on the restaged bare index. - if (existsSync(db)) stageBareIndex(); + // Boot 3: production (`os serve`, NODE_ENV unset) on the restaged legacy index. + if (existsSync(db)) stageLegacyIndex(); await recordBoot('production', bootUntilReady(['serve', 'objectstack.config.ts', '-p', KERNEL_ASSIGNED_PORT], { OS_DATABASE_URL: 'file:A.db', OS_AUTH_SECRET: 'e2e-21733-production-leg-secret-not-for-real-use', @@ -243,16 +277,19 @@ afterAll(async () => { }); describe('#21733 — a plain `os dev` (standalone stack) self-heals and provisions telemetry', () => { - it('a fresh `os dev` creates the NULL-safe index, and OS_TELEMETRY_DB=0 provisions no sibling', () => { + it('a fresh `os dev` creates the plain organization composite, and OS_TELEMETRY_DB=0 provisions no sibling', () => { const fresh = recorded('fresh'); - expect(isNullSafe(fresh.indexSql), `fresh boot index: ${fresh.indexSql}`).toBe(true); + expect(isPlainComposite(fresh.indexSql), `fresh boot index: ${fresh.indexSql}`).toBe(true); + expect(fresh.legacySql, 'the fresh boot created the legacy global unique itself').toBeUndefined(); expect(fresh.telemetryExists, 'OS_TELEMETRY_DB=0 still created A.telemetry.db').toBe(false); }); - it('a plain `os dev` restart puts the staged bare unique index back NULL-safe', () => { + it('a plain `os dev` restart relaxes the staged legacy global unique to the organization composite', () => { const restart = recorded('restart'); - expect(isNullSafe(restart.indexSql), `the restart left the bare index: ${restart.indexSql}`).toBe(true); - expect(restart.output).toContain('[schema-drift] auto-reconciled recreate_index on scaf_item'); + expect(restart.legacySql, `the restart left the legacy index: ${restart.legacySql}`).toBeUndefined(); + // A relaxation, not a removal: the per-organization constraint survives. + expect(isPlainComposite(restart.indexSql), `the restart lost the composite: ${restart.indexSql}`).toBe(true); + expect(restart.output).toContain('[schema-drift] auto-reconciled replace_unique_index on scaf_item.qa_code'); }); it('the same restart provisions the `telemetry` sibling next to the file-backed primary', () => { @@ -262,8 +299,9 @@ describe('#21733 — a plain `os dev` (standalone stack) self-heals and provisio it('a production boot reports the staged drift and does not auto-migrate it', () => { const production = recorded('production'); - expect(production.output).toContain(`index '${INDEX}' tightens`); + expect(production.output).toContain(`a legacy platform-wide UNIQUE index (${LEGACY})`); expect(production.output).not.toContain('[schema-drift] auto-reconciled'); - expect(isNullSafe(production.indexSql), `production auto-migrated: ${production.indexSql}`).toBe(false); + expect(production.legacySql, 'production auto-migrated the legacy index away').toBeDefined(); + expect(isPlainComposite(production.indexSql)).toBe(true); }); }); From 8cbc5c912253b08795200b26d3bf22bf9e49879f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 16:39:47 +0000 Subject: [PATCH 79/81] =?UTF-8?q?test(dogfood):=20the=20criteria-less=20le?= =?UTF-8?q?gacy=20rule=20takes=20main's=20organization=20stamp=20alone=20?= =?UTF-8?q?=E2=80=94=20the=207b-0=20=C3=97=20main=20textual=20merge=20had?= =?UTF-8?q?=20kept=20both=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude-Session: https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd Co-authored-by: Claude --- .../test/sharing-rule-criteria-required.dogfood.test.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/qa/dogfood/test/sharing-rule-criteria-required.dogfood.test.ts b/packages/qa/dogfood/test/sharing-rule-criteria-required.dogfood.test.ts index 5a5203184c6..c22aa9f438f 100644 --- a/packages/qa/dogfood/test/sharing-rule-criteria-required.dogfood.test.ts +++ b/packages/qa/dogfood/test/sharing-rule-criteria-required.dogfood.test.ts @@ -152,13 +152,8 @@ describe('#3896 — a sharing rule POSTed without criteria is rejected, never ma const id = 'srule_3896_legacy'; const driver = (ql as any).getDriverForObject('sys_sharing_rule'); expect(driver, 'sys_sharing_rule driver must be resolvable').toBeTruthy(); - // The legacy row belongs to the install's organization, as every row does - // since the ownership ceremony (ADR-0131 D1): its missing criteria is the - // subject here, not its owner. - const [organization] = (await ql.find('sys_organization', { context: SYS })) as Array<{ id: string }>; await driver.create('sys_sharing_rule', { id, - organization_id: organization.id, name: 'legacy_match_all_3896', label: 'Legacy match-all', object_name: OBJECT, From 9ee9bf25c6e0f0de0fcb41cb1acf2ae632ffa04c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 16:31:56 +0000 Subject: [PATCH 80/81] test(runtime): take main's Default Organization golden fixture The pre-18 relax a 7b-0 work-in-progress commit added rode into this branch through the earlier main merge; main's version (after #25016) supersedes it and this part does not change that file. Claude-Session: https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd Co-authored-by: Claude --- .../default-organization-statements.golden.test.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/runtime/src/default-organization-statements.golden.test.ts b/packages/runtime/src/default-organization-statements.golden.test.ts index 4c2414821f6..305679807a8 100644 --- a/packages/runtime/src/default-organization-statements.golden.test.ts +++ b/packages/runtime/src/default-organization-statements.golden.test.ts @@ -196,17 +196,6 @@ describe('[#24523] statements per warm request: an organization-owned admin gran describe('[#24523 / #21868] a sys_organization write retires the memoised Default Organization', () => { it('⭐ the Default Organization deleted and recreated: the next request reads the new id', async () => { - // Deleting an organization runs the engine's referential cascade, whose - // `set_null` on every `organization_id` lookup a fresh, NOT NULL tenant - // column refuses (ADR-0131 D1). This test's subject is the memo, not the - // cascade, so the store is a pre-18 one: every tenant table it holds is - // relaxed through its driver's own drift apply door. - for (const o of (engine as any).registry.getAllObjects() as Array<{ name: string; fields?: Record; tenancy?: { enabled?: boolean } }>) { - if (!o.fields?.organization_id || o.tenancy?.enabled === false) continue; - const driver = (engine as any).getDriver(o.name); - if (!(await driver?.knex?.schema.hasTable(o.name))) continue; - await relaxTenantColumns(driver, [o.name]); - } const [old] = await find('sys_organization', { slug: 'default' }); await standing(); // the control above wrote the organization: read it once expect(await standing()).toEqual({ standing: true, organizationReads: 0 }); From 8769400d3f9a5c73d0775afd31e8e32e9e13144c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 21 Oct 2026 16:32:24 +0000 Subject: [PATCH 81/81] chore(changeset): declare Metadata-protocol: semantic and state which rebuild re-keys plain Claude-Session: https://claude.ai/code/session_01NnXVu3vF2TKFLMi5Me4CUd Co-authored-by: Claude --- .changeset/24583-plain-organization-key-part.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/24583-plain-organization-key-part.md b/.changeset/24583-plain-organization-key-part.md index a35ea12e67f..7201687ede9 100644 --- a/.changeset/24583-plain-organization-key-part.md +++ b/.changeset/24583-plain-organization-key-part.md @@ -10,10 +10,11 @@ feat(driver-sql,driver-turso,driver-memory,objectql,metadata-protocol,cli): an organization-scoped unique index keys the plain organization column, and an index an earlier release built with `COALESCE(organization_id, '__global__')` is read as the same index on a certified database Clause-②: yes (widening) +Metadata-protocol: semantic 18.x step 7b-1 + 7b-2 (ADR-0131 D1: the tenant column is NOT NULL, so the NULL-organization bucket of ADR-0120 D3 has no rows left to hold). - **New DDL is plain.** A field-level `unique: true` / `'organization'` on a tenant-scoped object, and a declared index with `unique: 'organization'`, now build `UNIQUE (organization_id, …)` instead of `UNIQUE (COALESCE(organization_id, '__global__'), …)`. Index names do not change. The same holds for the Turso remote face, the MySQL hash shadow (its generated column now hashes the plain column), the in-memory driver's unique enforcement, `os generate migration` (which now emits the organization composite it used to list as NOT EMITTED), and the `sys_setting` row-identity index (`COALESCE(user_id, '')` stays). -- **Existing indexes stay as they are.** Nothing is renamed, and no declared organization composite is dropped or rebuilt. One index is re-keyed: the `sys_setting` row-identity index, which the metadata protocol rebuilds on every boot, keys the plain `organization_id` from the next boot on (probe-first; a pure relaxation, so it cannot fail on existing rows). The schema differ reads `COALESCE(, …)` and the plain tenant column as one key part, in both directions, where the column is physically `NOT NULL` or the database is certified (its organization-ownership birth marker, or the ownership ceremony's marker at the current shape generation). So `os migrate plan` and the boot report no `recreate_index` for those indexes. On an uncertified database with a nullable tenant column the two forms stay different constraints: the finding is reported, and it names `os migrate organization-ownership` as its remedy. +- **Existing indexes stay as they are.** Nothing is renamed, and no declared organization composite is dropped or rebuilt by the boot, `os migrate plan` or `os migrate apply`; on SQLite the ownership ceremony's NOT NULL step rebuilds the tables it tightens, and their composites come back plain, enforcing the same constraint. One index is re-keyed: the `sys_setting` row-identity index, which the metadata protocol rebuilds on every boot, keys the plain `organization_id` from the next boot on (probe-first; a pure relaxation, so it cannot fail on existing rows). The schema differ reads `COALESCE(, …)` and the plain tenant column as one key part, in both directions, where the column is physically `NOT NULL` or the database is certified (its organization-ownership birth marker, or the ownership ceremony's marker at the current shape generation). So `os migrate plan` and the boot report no `recreate_index` for those indexes. On an uncertified database with a nullable tenant column the two forms stay different constraints: the finding is reported, and it names `os migrate organization-ownership` as its remedy. - **What widens.** Where a tenant column is still nullable (every table of a database 18.0.0 created, an uncertified 17.x / 18.0 database, a rotation shard, a Turso remote table, a table the ceremony reported), a unique index created from now on does not constrain rows whose organization is NULL, exactly like a NULL in any other key part; in the in-memory driver a record with no organization is exempt from an organization-scoped unique. The unique duplicate pre-flight no longer reports groups of rows with a NULL key part, since the index never refuses them. - **Who sees it.** A fresh install and every ceremonied table whose tenant column is NOT NULL see nothing. A database 18.0.0 created from empty is certified by its birth marker yet keeps nullable tenant columns: its existing indexes are kept unchanged, and a new organization unique declared on one of its tables constrains only rows that carry an organization.