From 23e3a7f86e71012f909c556265e46b4b9a8b4a08 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 07:05:38 +0000 Subject: [PATCH 01/17] feat(cli,rest,metadata-protocol): the ceremony promotes organization customizations and carries form withdrawals; the form doors' organization read is deleted (ADR-0131 D10, C7b S1) WIP: tests not yet run. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH --- .../migrate/organization-ownership.ts | 33 ++- .../organization-ownership-apply.test.ts | 198 ++++++++++++++++- .../src/utils/organization-ownership-apply.ts | 182 +++++++++++++++- .../src/utils/organization-ownership-plan.ts | 116 ++++++++++ .../src/meta-overlay-cache.test.ts | 24 +-- .../protocol.org-scoped-write-refused.test.ts | 31 +-- .../protocol.reads-environment-only.test.ts | 34 ++- packages/metadata-protocol/src/protocol.ts | 80 ++----- .../view-container-runtime-expansion.test.ts | 46 ++-- ...protocol-org-overlay-registry-gate.test.ts | 60 +++--- ...lic-form-withdrawal-layers.dogfood.test.ts | 28 +-- .../rest/src/public-form-withdrawal.test.ts | 199 +++++++----------- packages/rest/src/rest-server.ts | 89 +++----- 13 files changed, 746 insertions(+), 374 deletions(-) diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts index 3db1862da26..d8471ae3010 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.ts @@ -91,6 +91,7 @@ async function confirm(question: string): Promise { * the precondition (D10 item 2): `--backup-taken`, or a yes at the prompt; a * non-interactive run without the flag is refused before the boot. It then * performs the fates in order — attribution, 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`, * checkpointing each finished step in the deployment ledger (`sys_migration`, @@ -128,7 +129,8 @@ export default class MigrateOrganizationOwnership extends Command { '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 ' + - '(and a backup): attribution, the authored catalog conversion, the template promotion, the settings move, the ' + + '(and a backup): 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.'; @@ -333,6 +335,13 @@ export default class MigrateOrganizationOwnership extends Command { ' differ. --apply blocks on it until one row is chosen with --template-choice ROW_ID.', ); } + for (const conflict of table.metadataConflicts ?? []) { + printWarning( + `Customization conflict ${conflict.type} ${conflict.name}${conflict.packageId ? ` (package ${conflict.packageId})` : ''}: ` + + conflict.candidates.map((c) => `${c.id} (${c.organizationId ? `organization ${c.organizationId}` : 'environment'})`).join(', ') + + ' hold one key. --apply blocks on it until one row is kept and the others are removed.', + ); + } } console.log(''); const { summary } = plan; @@ -416,6 +425,7 @@ export default class MigrateOrganizationOwnership extends Command { const engine = kernel.getService('objectql') as Parameters[0] & { registry?: unknown }; const protocol = kernel.getService('protocol') as { saveMetaItem?: (request: Record) => Promise; + getMetaItems?: (request: { type: string }) => Promise<{ items?: unknown[] } | unknown[]>; getMetaItemsForExecution?: (request: { type: string }) => Promise<{ items?: unknown[] }>; } | undefined; const catalog = createSecurityCatalogReader({ @@ -451,11 +461,28 @@ export default class MigrateOrganizationOwnership extends Command { database: stack.dbLabel, catalog, door: { - save: async (type, name, body) => { + save: async (type, name, body, target) => { if (typeof protocol?.saveMetaItem !== 'function') { throw new Error('no metadata protocol is composed on this stack'); } - await protocol.saveMetaItem({ type, name, item: body, actor: CEREMONY_ACTOR }); + await protocol.saveMetaItem({ + type, + name, + item: body, + actor: CEREMONY_ACTOR, + ...(target ? { packageId: target.packageId } : {}), + }); + }, + }, + views: { + // The env-wide view list, exactly as the anonymous form doors read it. + list: async () => { + if (typeof protocol?.getMetaItems !== 'function') { + throw new Error('no metadata protocol is composed on this stack'); + } + const result = await protocol.getMetaItems({ type: 'view' }); + const items = Array.isArray(result) ? result : (result?.items ?? []); + return items.filter((i): i is Record => !!i && typeof i === 'object'); }, }, templates: { diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts index 388fad9ee3d..7c1624c1426 100644 --- a/packages/cli/src/utils/organization-ownership-apply.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.test.ts @@ -22,6 +22,7 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { permissionSetDefinitionFromRow } from '@objectstack/core'; +import { anonymousFormIntakeCandidates, anonymousFormIntakeWithdrawnIn } from '@objectstack/metadata-core'; import { applyOrganizationOwnership, TemplateChoiceRefusal, @@ -32,6 +33,7 @@ import { type ColumnDropPort, type MetadataDoorPort, type OrganizationOwnershipApplyReport, + type ServedViewsPort, type TemplatePort, } from './organization-ownership-apply.js'; @@ -67,6 +69,8 @@ function executor(opts: { failOnceOn?: RegExp } = {}): ApplyExecutor { function catalog(): CatalogPort & MetadataDoorPort & TemplatePort & { saved: Map>; templates: Map>; + /** Environment `view` saves, in order, with the package each was saved into. */ + viewSaves: Array<{ name: string; body: Record; packageId: string | null }>; boot(): void; } { const declared = new Map>([ @@ -80,9 +84,11 @@ function catalog(): CatalogPort & MetadataDoorPort & TemplatePort & { ['auth.reset_password|en-US', { name: 'auth.reset_password', label: 'Reset', locale: 'en-US', subject: 'Reset', bodyHtml: '

code

' }], ]); let hydrated = new Map>(); + const viewSaves: Array<{ name: string; body: Record; packageId: string | null }> = []; return { saved, templates, + viewSaves, async find(name, locale) { const own = templates.get(`${name}|${locale}`); if (own) return { definition: own, environment: true }; @@ -96,7 +102,11 @@ function catalog(): CatalogPort & MetadataDoorPort & TemplatePort & { const definition = hydrated.get(`${type}|${name}`) ?? declared.get(`${type}|${name}`); return definition ? { definition } : undefined; }, - async save(type, name, body) { + async save(type, name, body, target) { + if (type === 'view') { + viewSaves.push({ name, body, packageId: target?.packageId ?? null }); + return; + } if (type === 'email_template') { if (!/^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*$/.test(name)) throw new Error(`VALIDATION_ERROR: ${name} is not dotted snake_case`); templates.set(`${name}|${String(body.locale)}`, body); @@ -108,6 +118,49 @@ function catalog(): CatalogPort & MetadataDoorPort & TemplatePort & { }; } +/** A form view the code package `pkg_a` ships, open to anonymous intake at `/forms/lead-intake`. */ +const formView = (open: boolean, label = 'Lead intake'): Record => ({ + name: 'crm_lead.intake', + label, + object: 'crm_lead', + viewKind: 'form', + config: { sharing: { enabled: open, allowAnonymous: true, publicLink: '/forms/lead-intake' } }, +}); + +/** + * The env-wide view list as the anonymous doors read it: the code package's + * items, replaced per (name, package) by the active environment rows of + * `sys_metadata` and then by the door's saves this run. + */ +function servedViews(c: ReturnType, code: Array> = [{ ...formView(true), _packageId: 'pkg_a' }]): ServedViewsPort { + return { + async list() { + const slots = new Map>(); + const put = (item: Record, pkg: string | null): void => { + slots.set(`${String(item.name)}|${pkg ?? ''}`, { ...item, ...(pkg ? { _packageId: pkg } : {}) }); + }; + for (const item of code) put(item, (item._packageId as string | undefined) ?? null); + const hasTable = rows(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'sys_metadata'`).length > 0; + if (hasTable) { + for (const row of rows(`SELECT * FROM sys_metadata WHERE type = 'view' AND organization_id IS NULL AND state = 'active'`)) { + put(JSON.parse(String(row.metadata)) as Record, (row.package_id as string | null) ?? null); + } + } + for (const save of c.viewSaves) put(save.body, save.packageId); + return [...slots.values()]; + }, + }; +} + +/** What the anonymous doors serve for a slug, by their own judgement over the env-wide list. */ +async function doorsServe(views: ServedViewsPort, slug: string): Promise { + const list = await views.list(); + return list + .filter((v) => anonymousFormIntakeCandidates(v).some((cand) => cand.slug === slug && !anonymousFormIntakeWithdrawnIn(list, v, cand))) + .map((v) => `${String(v.name)}|${String(v._packageId ?? '')}`) + .sort(); +} + function ledger(): CeremonyLedgerPort & { checkpoint: CeremonyCheckpoint | null; verdicts: Array<{ complete: boolean; blocking: number; advisory: number }> } { const state = { checkpoint: null as CeremonyCheckpoint | null, @@ -285,10 +338,11 @@ async function accessAfter(c: ReturnType): Promise { const apply = ( c: ReturnType, l: ReturnType, - opts: { posture?: string; executor?: ApplyExecutor; templateChoices?: string[] } = {}, + opts: { posture?: string; executor?: ApplyExecutor; templateChoices?: string[]; views?: ServedViewsPort } = {}, ): Promise => { c.boot(); return applyOrganizationOwnership({ + views: opts.views ?? servedViews(c), executor: opts.executor ?? executor(), ...(opts.templateChoices ? { templateChoices: opts.templateChoices } : {}), posture: opts.posture ?? 'single', @@ -455,7 +509,7 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { const again = await apply(c, l); expect(again.resumedFrom).toEqual([ - 'attribution', 'authored-conversion', 'template-promotion', 'settings-move', 'rewrite', 'mirror-deletion', 'column-drops', + 'attribution', 'authored-conversion', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite', 'mirror-deletion', 'column-drops', ]); expect(again.steps.every((s) => s.status === 'checkpointed')).toBe(true); expect(again.complete).toBe(true); @@ -467,14 +521,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', 'template-promotion', 'settings-move', 'rewrite']); + expect(Object.keys(l.checkpoint!.completed)).toEqual(['attribution', 'authored-conversion', 'metadata-promotion', 'template-promotion', 'settings-move', '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', 'template-promotion', 'settings-move', 'rewrite']); - expect(resumed.steps.map((s) => s.status)).toEqual(['checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'done', 'done']); + 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']); 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 }]); @@ -695,7 +749,7 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { it('holds a column whose rows another stage owns, and reports it', async () => { db.exec(` CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, name TEXT, state TEXT, package_id TEXT); - INSERT INTO sys_metadata VALUES ('md_1', 'org_b', 'view', 'deal_list', 'active', NULL); + INSERT INTO sys_metadata VALUES ('md_1', 'org_b', 'object', 'deal', 'active', NULL); `); const report = await apply(catalog(), ledger()); @@ -706,3 +760,133 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { expect(rows(`SELECT name FROM pragma_table_info('sys_job') WHERE name = 'organization_id'`)).toHaveLength(0); }); }); + +describe('ADR-0131 D10 --apply — organization customizations and form withdrawals (rulings A on #22011 and #22008)', () => { + const metadataTable = (values: string): void => { + db.exec(` + CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, name TEXT, state TEXT, + package_id TEXT, bundle_member TEXT, metadata TEXT); + ${values ? `INSERT INTO sys_metadata VALUES ${values};` : ''} + `); + }; + const md = (id: string, org: string | null, type: string, name: string, body: Record, pkg: string | null = null): string => + `('${id}', ${org === null ? 'NULL' : `'${org}'`}, '${type}', '${name}', 'active', ${pkg === null ? 'NULL' : `'${pkg}'`}, NULL, '${JSON.stringify(body)}')`; + const step = (report: OrganizationOwnershipApplyReport) => report.steps.find((s) => s.step === 'metadata-promotion')!; + const owners = (): Array<[unknown, unknown]> => + rows('SELECT id, organization_id FROM sys_metadata ORDER BY id').map((r) => [r.id, r.organization_id]); + + it('under single, an organization customization no other row holds becomes an environment row in place', async () => { + metadataTable([ + md('md_dash', 'org_default', 'dashboard', 'sales_board', { name: 'sales_board', label: 'Org board' }), + md('md_env', null, 'report', 'pipeline', { name: 'pipeline', label: 'Env report' }), + md('md_obj', 'org_default', 'object', 'deal', { name: 'deal' }), + ].join(', ')); + + const report = await apply(catalog(), ledger()); + + expect(step(report)).toMatchObject({ status: 'done', summary: { promoted: 1, conflicts: 0 } }); + // The non-presentational row is reported, never promoted; nothing is deleted. + expect(owners()).toEqual([['md_dash', null], ['md_env', null], ['md_obj', 'org_default']]); + }); + + it('a key held by an organization row and the environment row is a conflict: kept, listed by id, blocking; nothing is written', async () => { + metadataTable([ + md('md_org', 'org_default', 'view', 'deal_list', { name: 'deal_list', label: 'Org list' }), + md('md_env', null, 'view', 'deal_list', { name: 'deal_list', label: 'Env list' }), + ].join(', ')); + const c = catalog(); + + const report = await apply(c, ledger()); + + expect(step(report).status).toBe('blocked'); + expect(step(report).blockers.join('\n')).toMatch(/view deal_list is held by 2 rows .*md_org \(organization org_default, active\), md_env \(environment, active\)/); + expect(writes.filter((w) => /sys_metadata/.test(w))).toEqual([]); + expect(owners()).toEqual([['md_env', null], ['md_org', 'org_default']]); + expect(c.viewSaves).toEqual([]); + + // The operator keeps one row; the re-run promotes nothing more and goes on. + db.exec(`DELETE FROM sys_metadata WHERE id = 'md_org'`); + const again = await apply(c, ledger()); + expect(step(again)).toMatchObject({ status: 'done', summary: { promoted: 0, conflicts: 0 } }); + }); + + it('two organizations holding one key is a conflict too', async () => { + metadataTable([ + md('md_a', 'org_a', 'translation', 'fr', { name: 'fr' }), + md('md_b', 'org_b', 'translation', 'fr', { name: 'fr' }), + ].join(', ')); + const report = await apply(catalog(), ledger()); + expect(step(report).summary.conflicts).toBe(1); + expect(owners()).toEqual([['md_a', 'org_a'], ['md_b', 'org_b']]); + }); + + it('under a wall the rows are reported, never promoted, and they hold the column', async () => { + metadataTable(md('md_dash', 'org_a', 'dashboard', 'sales_board', { name: 'sales_board' })); + const report = await apply(catalog(), ledger(), { posture: 'isolated' }); + expect(step(report)).toMatchObject({ status: 'done', summary: { promoted: 0, reported: 1 } }); + expect(owners()).toEqual([['md_dash', 'org_a']]); + }); + + for (const posture of ['single', 'isolated'] as const) { + it(`⭐ a form an organization withdrew before the upgrade is refused at the anonymous doors after it, across packages (${posture})`, async () => { + // The organization withdrew the package-less copy; package pkg_a serves its own copy, open. + metadataTable(md('md_org', 'org_default', 'view', 'crm_lead.intake', formView(false, 'Org copy'))); + const c = catalog(); + const views = servedViews(c); + expect(await doorsServe(views, 'lead-intake')).toEqual(['crm_lead.intake|pkg_a']); + + const report = await apply(c, ledger(), { posture, views }); + + expect(step(report).status).toBe('done'); + expect(step(report).summary.withdrawalsCarried).toBe(1); + // Carried onto pkg_a's own copy, through the door, into its own package. + expect(c.viewSaves.map((v) => [v.name, v.packageId, (v.body.config as any).sharing.enabled])).toEqual([ + ['crm_lead.intake', 'pkg_a', false], + ]); + expect(await doorsServe(views, 'lead-intake')).toEqual([]); + + // Idempotent: a second run (no checkpoint) carries nothing more. + const again = await apply(c, ledger(), { posture, views }); + expect(step(again).summary.withdrawalsCarried).toBe(0); + expect(c.viewSaves).toHaveLength(1); + }); + } + + it('⭐ under single, a withdrawal the organization holds on the key it alone holds travels with the promoted row', async () => { + metadataTable(md('md_org', 'org_default', 'view', 'crm_lead.intake', formView(false, 'Org copy'), 'pkg_a')); + const c = catalog(); + const views = servedViews(c); + + const report = await apply(c, ledger(), { views }); + + expect(step(report).summary).toMatchObject({ promoted: 1, withdrawalsCarried: 0 }); + expect(c.viewSaves).toEqual([]); + expect(owners()).toEqual([['md_org', null]]); + expect(await doorsServe(views, 'lead-intake')).toEqual([]); + }); + + it('control: an organization row that withdraws nothing carries nothing, and the form stays served', async () => { + metadataTable(md('md_org', 'org_default', 'view', 'crm_lead.intake', formView(true, 'Org copy'))); + const c = catalog(); + const views = servedViews(c); + const report = await apply(c, ledger(), { views }); + expect(step(report).summary.withdrawalsCarried).toBe(0); + expect(c.viewSaves).toEqual([]); + expect(await doorsServe(views, 'lead-intake')).toEqual(['crm_lead.intake|', 'crm_lead.intake|pkg_a']); + }); + + it('a door that refuses the carry blocks the step, naming the form and the remedy', async () => { + metadataTable(md('md_org', 'org_default', 'view', 'crm_lead.intake', formView(false))); + const c = catalog(); + const refusing: ReturnType = { + ...c, + async save(type, name, body, target) { + if (type === 'view') throw new Error('NOT_OVERRIDABLE: the view is sealed'); + return c.save(type, name, body, target); + }, + }; + const report = await apply(refusing, ledger(), { views: servedViews(c) }); + expect(step(report).status).toBe('blocked'); + expect(step(report).blockers.join('\n')).toMatch(/lead-intake of the view crm_lead\.intake \(package pkg_a\).*NOT_OVERRIDABLE.*sharing\.enabled false/); + }); +}); diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index 5805c2d00e2..cb4c183d921 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -5,6 +5,7 @@ import { permissionSetDefinitionFromRow, positionDefinitionFromRow, } from '@objectstack/core'; +import { anonymousFormIntakeCandidates, anonymousFormIntakeWithdrawnIn } from '@objectstack/metadata-core'; import { inventoryEntryFor, type InventoryEntry, @@ -15,6 +16,9 @@ import { columnListSql, definitionKey, groupTemplateRows, + groupMetadataKeyRows, + readMetadataKeyRows, + METADATA_OBJECT, templatePromotionSql, SEEDED_BY, TEMPLATE_OBJECT, @@ -23,6 +27,7 @@ import { predicateSql, Sql, type AttributionSink, + type MetadataKeyRow, type TemplateConflict, type OrganizationOwnershipPlan, type PlanReader, @@ -48,6 +53,20 @@ import { * (`convertPositionBindingRows`). Only under `single` (ADR-0131 D3: a * Setup catalog write is an environment metadata write under `single` and * refused under a wall); under a wall the rows are reported. + * 2a. `metadata-promotion` — rulings A on #22011 and #22008 (records + * 6020163868, 6020151485): first every public-form withdrawal an + * organization saved is carried onto the environment, fail-closed (any + * organization's withdrawal wins), by writing it onto each active + * environment `view` of that name that still serves the form open, + * through the metadata door; the same predicate the anonymous doors + * judge by (`anonymousFormIntakeWithdrawnIn`) decides. Then, under + * `single`, each organization-scoped `sys_metadata` row of the five + * presentational types whose overlay key no other row holds becomes an + * environment row in place (its `organization_id` cleared). A key held by + * more than one row is a conflict: listed, kept, and blocking until the + * operator keeps one row (the ceremony deletes none of them: `sys_metadata` + * is not a fate-2 table). Under a wall the rows are reported, never + * promoted: one organization's view is not every organization's. * 2b. `template-promotion` — ruling C on ADR-0131 §6 Q1: every * `sys_email_template` row an organization customized or authored becomes * an environment `email_template` item through the metadata door (core's @@ -101,6 +120,7 @@ export const ORGANIZATION_OWNERSHIP_MIGRATION_ID = 'adr-0131-organization-owners export const CEREMONY_STEPS = [ 'attribution', 'authored-conversion', + 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite', @@ -121,7 +141,17 @@ export interface CatalogPort { /** The metadata door: an environment metadata save. Throws when the door refuses. */ export interface MetadataDoorPort { - save(type: 'position' | 'permission' | 'email_template', name: string, body: Record): Promise; + save( + type: 'position' | 'permission' | 'email_template' | 'view', + name: string, + body: Record, + target?: { packageId: string | null }, + ): Promise; +} + +/** The env-wide `view` list the anonymous form doors read (every package's item of a name). */ +export interface ServedViewsPort { + list(): Promise>>>; } /** The `email_template` item the registry serves for one `(name, locale)` bundle member. */ @@ -219,6 +249,8 @@ export interface ApplyOptions { catalog: CatalogPort; door: MetadataDoorPort; templates: TemplatePort; + /** The served views, for carrying form withdrawals. */ + views: ServedViewsPort; columnDrops: ColumnDropPort; ledger: CeremonyLedgerPort; inventory?: readonly InventoryEntry[]; @@ -615,6 +647,153 @@ async function runAuthoredConversion(run: Run): Promise { return { step: 'authored-conversion', status: blockers.length ? 'blocked' : 'done', summary, blockers, notes }; } +// ── Step 2a: organization customizations and form withdrawals (rulings A) ── + +const parseBody = (row: Record): Record | null => { + const raw = row.metadata; + try { + const body = typeof raw === 'string' ? JSON.parse(raw) : raw; + if (!body || typeof body !== 'object' || Array.isArray(body)) return null; + // A stored body names its item; the doors judge withdrawals by that name. + return { ...(body as Record), name: (body as Record).name ?? row.name }; + } catch { + return null; + } +}; + +/** The served item's own package (`_packageId`), or `null` for a package-less item. */ +const servedPackageOf = (item: Readonly>): string | null => { + const pkg = item._packageId; + return typeof pkg === 'string' && pkg !== '' && pkg !== METADATA_OBJECT ? pkg : null; +}; + +/** A served item as a body the door accepts: its runtime annotations (`_`-prefixed keys) dropped. */ +const authoredBodyOf = (item: Readonly>): Record => + JSON.parse(JSON.stringify(Object.fromEntries(Object.entries(item).filter(([k]) => !k.startsWith('_'))))) as Record; + +/** + * Carry every organization-layer withdrawal onto the environment, fail-closed: + * each view the environment serves (stored or from code, in every package) + * whose name a withdrawing organization row shares, and which still serves the + * withdrawn form open, takes the withdrawal (`sharing.enabled: false` on that + * form), saved through the metadata door into its own package. The judgement + * is the anonymous doors' own (`anonymousFormIntakeWithdrawnIn`), with the + * organization rows as the layer: a 17.x organization row replaced the served + * body whole, so its stored body is exactly what the doors judged. + * + * Under `single` a view whose own key an organization row holds is skipped: + * that row is promoted in place (its withdrawal travels with it) or is a + * conflict the operator resolves, and a door save there would make a conflict + * of a promotion. + */ +async function carryFormWithdrawals(run: Run, rows: readonly MetadataKeyRow[], summary: Record, blockers: string[]): Promise { + const t = METADATA_OBJECT; + const sql = run.sql; + const orgViews = rows.filter((r) => r.type === 'view' && r.organizationId !== null && (r.state ?? 'active') === 'active'); + if (orgViews.length === 0) return; + const layerOf = new Map[]>(); + const ids = orgViews.map((r) => r.id); + for (let i = 0; i < ids.length; i += IN_CHUNK) { + const chunk = ids.slice(i, i + IN_CHUNK); + const found = await sql.read( + t, + `SELECT ${['id', 'name', 'metadata'].map((c) => `${sql.q(c, t)} AS ${sql.q(c, t)}`).join(', ')} ` + + `FROM ${sql.q(t, t)} WHERE ${sql.q('id', t)} IN (${chunk.map(() => '?').join(', ')})`, + chunk, + ); + for (const row of found) { + const body = parseBody(row); + if (body) layerOf.set(String(row.name), [...(layerOf.get(String(row.name)) ?? []), body]); + } + } + if (layerOf.size === 0) return; + const heldByOrganization = new Set(orgViews.map((r) => `${r.name}\u0000${r.packageId ?? ''}`)); + for (const served of await run.opts.views.list()) { + const name = typeof served.name === 'string' ? served.name : undefined; + const layer = name === undefined ? undefined : layerOf.get(name); + if (!name || !layer) continue; + const packageId = servedPackageOf(served); + if (run.opts.posture === 'single' && heldByOrganization.has(`${name}\u0000${packageId ?? ''}`)) continue; + const body = authoredBodyOf(served); + const closing = anonymousFormIntakeCandidates(body).filter((c) => anonymousFormIntakeWithdrawnIn(layer, body, c)); + if (closing.length === 0) continue; + for (const candidate of closing) { + candidate.form.sharing = { ...(candidate.form.sharing as Record), enabled: false }; + } + try { + await run.opts.door.save('view', name, body, { packageId }); + summary.withdrawalsCarried += closing.length; + run.opts.onProgress?.(`carried ${closing.length} public-form withdrawal(s) onto the environment view ${name}`); + } catch (error) { + blockers.push( + `An organization withdrew the public form(s) ${closing.map((c) => c.slug).join(', ')} of the view ${name}` + + `${packageId ? ` (package ${packageId})` : ''}, and the metadata door refused to carry that withdrawal onto ` + + `the environment: ${error instanceof Error ? error.message : String(error)}. Withdraw the form there ` + + '(sharing.enabled false), then re-run. An upgrade never reopens a withdrawn form (ruling record 6020151485).', + ); + } + } +} + +async function runMetadataPromotion(run: Run): Promise { + const summary: Record = { withdrawalsCarried: 0, promoted: 0, conflicts: 0, reported: 0 }; + const blockers: string[] = []; + const notes: string[] = []; + const cols = await columnsOf(run, METADATA_OBJECT); + if (!cols?.has(ORG)) return { step: 'metadata-promotion', status: 'done', summary, blockers, notes }; + const rows = await readMetadataKeyRows(run.sql, METADATA_OBJECT, cols); + const orgRows = rows.filter((r) => r.organizationId !== null); + if (orgRows.length === 0) return { step: 'metadata-promotion', status: 'done', summary, blockers, notes }; + + // Withdrawals first, under every posture: closing a form is never a leak. + await carryFormWithdrawals(run, rows, summary, blockers); + + if (run.opts.posture !== 'single') { + summary.reported = orgRows.length; + notes.push( + `${orgRows.length} organization-scoped presentational ${METADATA_OBJECT} row(s) are reported, not promoted: under the ` + + `${run.opts.posture} posture one organization's customization is not the environment's. They keep the ` + + `${METADATA_OBJECT} column from being dropped.`, + ); + return { step: 'metadata-promotion', status: blockers.length ? 'blocked' : 'done', summary, blockers, notes }; + } + + const { promotable, conflicts } = groupMetadataKeyRows(rows); + summary.conflicts = conflicts.length; + for (const conflict of conflicts) { + blockers.push( + `The ${conflict.type} ${conflict.name}` + + `${conflict.packageId ? ` (package ${conflict.packageId})` : ''}${conflict.bundleMember ? ` [${conflict.bundleMember}]` : ''} ` + + `is held by ${conflict.candidates.length} rows once the organization leaves its key: ` + + conflict.candidates.map((c) => `${c.id} (${c.organizationId ? `organization ${c.organizationId}` : 'environment'}, ${c.state ?? 'no state'})`).join(', ') + + '. Which one the environment keeps is never guessed, and the ceremony deletes none of them: keep one row and ' + + `remove the others from ${METADATA_OBJECT}, then re-run.` + + (conflict.type === 'view' + ? ' If any of them withdraws a public form, keep one that withdraws it too: an upgrade never reopens a withdrawn form.' + : ''), + ); + } + const t = run.sql.q(METADATA_OBJECT, METADATA_OBJECT); + const org = run.sql.q(ORG, METADATA_OBJECT); + const id = run.sql.q('id', METADATA_OBJECT); + const byOrganization = new Map(); + for (const row of promotable) byOrganization.set(row.organizationId!, [...(byOrganization.get(row.organizationId!) ?? []), row.id]); + for (const [organizationId, ids] of byOrganization) { + for (let i = 0; i < ids.length; i += IN_CHUNK) { + const chunk = ids.slice(i, i + IN_CHUNK); + await execute( + run, + METADATA_OBJECT, + `UPDATE ${t} SET ${org} = NULL WHERE ${org} = ? AND ${id} IN (${chunk.map(() => '?').join(', ')})`, + [organizationId, ...chunk], + ); + } + summary.promoted += ids.length; + } + if (summary.promoted) run.opts.onProgress?.(`promotion: ${summary.promoted} organization customization(s) are environment rows now`); + return { step: 'metadata-promotion', status: blockers.length ? 'blocked' : 'done', summary, blockers, notes }; +} + // ── Step 2b: template promotion (ruling C) ────────────────────────────────── async function runTemplatePromotion(run: Run): Promise { @@ -1125,6 +1304,7 @@ async function runColumnDrops(run: Run): Promise { const RUNNERS: Record Promise> = { attribution: runAttribution, 'authored-conversion': runAuthoredConversion, + 'metadata-promotion': runMetadataPromotion, 'template-promotion': runTemplatePromotion, 'settings-move': runSettingsMove, rewrite: runRewrite, diff --git a/packages/cli/src/utils/organization-ownership-plan.ts b/packages/cli/src/utils/organization-ownership-plan.ts index 2fc20598904..2400d2428b1 100644 --- a/packages/cli/src/utils/organization-ownership-plan.ts +++ b/packages/cli/src/utils/organization-ownership-plan.ts @@ -154,6 +154,25 @@ export interface TablePlan { * until the operator chooses one row per group; nothing is guessed. */ templateConflicts?: TemplateConflict[]; + /** + * `sys_metadata` only: the keys of the promotion population that more than + * one row holds once the organization leaves the key — two organizations, or + * an organization and the environment. Kept and blocking until the operator + * keeps one row per key; nothing is guessed. + */ + metadataConflicts?: MetadataConflict[]; +} + +/** + * One overlay key of `sys_metadata` (`type`, `name`, package, bundle member) + * that the promotion cannot carry alone: every row that holds it, and whose. + */ +export interface MetadataConflict { + type: string; + name: string; + packageId: string | null; + bundleMember: string | null; + candidates: Array<{ id: string; organizationId: string | null; state: string | null }>; } /** One `(name, locale)` the promotion cannot carry without the operator's choice. */ @@ -189,6 +208,98 @@ export function templatePromotionSql(sql: Sql, cols: ReadonlySet): { tex }; } +/** The table whose organization-scoped customizations the ceremony promotes (ruling record 6020163868, A). */ +export const METADATA_OBJECT = 'sys_metadata'; + +/** + * The five presentational types whose organization-scoped rows are promoted to + * environment rows (ruling records 6020163868 and 6020151485, both A). The + * inventory's `organization-presentational-promotion` category names the same + * list, and a pin holds the two equal. + */ +export const PRESENTATIONAL_TYPES: readonly string[] = ['view', 'dashboard', 'report', 'translation', 'email_template']; + +/** One `sys_metadata` row of a presentational type, by its overlay key. */ +export interface MetadataKeyRow { + id: string; + type: string; + name: string; + packageId: string | null; + bundleMember: string | null; + /** `null` = an environment row. */ + organizationId: string | null; + state: string | null; +} + +/** + * Every `sys_metadata` row of a presentational type, by key, keyset-paged: + * environment rows and organization rows alike, so a key the environment + * already holds is seen as held. No body is read. + */ +export async function readMetadataKeyRows(sql: Sql, table: string, columns: ReadonlySet): Promise { + for (const column of ['type', 'name', ORG]) requireColumn(table, columns, column); + const select = ['id', 'type', 'name', ORG, ...['package_id', 'bundle_member', 'state'].filter((c) => columns.has(c))]; + const t = sql.q(table, table); + const id = sql.q(ID, table); + const types = PRESENTATIONAL_TYPES.map(() => '?').join(', '); + const out: MetadataKeyRow[] = []; + let after: unknown; + for (;;) { + const rows = await sql.read( + table, + `SELECT ${select.map((c) => `${sql.q(c, table)} AS ${sql.q(c, table)}`).join(', ')} FROM ${t} ` + + `WHERE ${sql.q('type', table)} IN (${types})${after === undefined ? '' : ` AND ${id} > ?`} ORDER BY ${id} LIMIT ${PAGE}`, + after === undefined ? [...PRESENTATIONAL_TYPES] : [...PRESENTATIONAL_TYPES, after], + ); + for (const row of rows) { + out.push({ + id: String(row.id), + type: String(row.type), + name: String(row.name), + packageId: nonEmptyId(row.package_id), + bundleMember: nonEmptyId(row.bundle_member), + organizationId: nonEmptyId(row[ORG]), + state: nonEmptyId(row.state), + }); + } + if (rows.length < PAGE) return out; + after = rows[rows.length - 1]!.id; + } +} + +/** + * Split the promotion population by overlay key: a key held by exactly one + * row, an organization's, is promotable in place; a key held by an + * organization's row and any other row (another organization's, or the + * environment's) is a conflict. Keys only the environment holds are not the + * ceremony's. + */ +export function groupMetadataKeyRows(rows: readonly MetadataKeyRow[]): { promotable: MetadataKeyRow[]; conflicts: MetadataConflict[] } { + const byKey = new Map(); + for (const row of rows) { + const key = [row.type, row.name, row.packageId ?? '', row.bundleMember ?? ''].join('\u0000'); + byKey.set(key, [...(byKey.get(key) ?? []), row]); + } + const promotable: MetadataKeyRow[] = []; + const conflicts: MetadataConflict[] = []; + for (const group of byKey.values()) { + if (!group.some((r) => r.organizationId !== null)) continue; + if (group.length === 1) { + promotable.push(group[0]!); + continue; + } + const first = group[0]!; + conflicts.push({ + type: first.type, + name: first.name, + packageId: first.packageId, + bundleMember: first.bundleMember, + candidates: group.map((r) => ({ id: r.id, organizationId: r.organizationId, state: r.state })), + }); + } + return { promotable, conflicts }; +} + /** The JSON of a definition's own keys, for comparing two bodies. */ export const definitionKey = (body: Readonly>, keys: readonly string[]): string => JSON.stringify(keys.map((k) => [k, body[k] ?? null])); @@ -717,6 +828,11 @@ async function planPresentTable(ctx: PlanContext, entry: InventoryEntry, source: } plan.categories.push({ id: category.id, label: category.label, citation: category.citation, rows, ...(groups !== undefined ? { groups } : {}) }); } + if (entry.object === METADATA_OBJECT && hasOrg) { + const conflicts: MetadataConflict[] = []; + for (const table of tables) conflicts.push(...groupMetadataKeyRows(await readMetadataKeyRows(sql, table, ctx.catalog.columns.get(table)!)).conflicts); + plan.metadataConflicts = conflicts; + } if (entry.object === TEMPLATE_OBJECT) { const conflicts: TemplateConflict[] = []; for (const table of tables) conflicts.push(...(await readTemplateConflicts(sql, table, ctx.catalog.columns.get(table)!))); diff --git a/packages/metadata-protocol/src/meta-overlay-cache.test.ts b/packages/metadata-protocol/src/meta-overlay-cache.test.ts index b9da8787201..5ae0ff54d0a 100644 --- a/packages/metadata-protocol/src/meta-overlay-cache.test.ts +++ b/packages/metadata-protocol/src/meta-overlay-cache.test.ts @@ -578,13 +578,11 @@ describe('[#11967] §7 distinct reads never share an entry', () => { expect((scoped.items as any[]).map((i) => i.name)).toEqual(['beta']); }); - // [ADR-0131 D6] The cache holds the environment's rows only. The one read - // that still reaches a legacy organization's rows — the anonymous form - // doors' `view` read, `legacyFormOrganizationId` — reads them uncached, - // on top of the environment's cached set, so it never answers from the - // environment-wide entry alone; and any other read naming an organization - // is the environment read. - it('the form doors\' legacy organization read does not answer from the env-wide entry', async () => { + // [ADR-0131 D6, C7] The cache holds the environment's rows only, and every + // read is the environment read: one naming an organization, by any key — + // the anonymous form doors' deleted `legacyFormOrganizationId` included — + // answers from the environment's entry. + it('a read naming an organization, by any key, answers from the env-wide entry', async () => { const h = makeHarness([ storedRow('view', 'alpha'), storedRow('view', 'org_only', { organization_id: 'org_1', id: 'r_view_org' }), @@ -592,15 +590,13 @@ describe('[#11967] §7 distinct reads never share an entry', () => { const envWide = await h.protocol.getMetaItems({ type: 'view' }); const findsAfterEnvWide = h.finds.length; - - const legacy = await h.protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_1' }); - expect(h.finds.length).toBeGreaterThan(findsAfterEnvWide); - expect((envWide.items as any[]).map((i) => i.name)).toEqual(['alpha']); - expect((legacy.items as any[]).map((i) => i.name).sort()).toEqual(['alpha', 'org_only']); - const named = await h.protocol.getMetaItems({ type: 'view', organizationId: 'org_1' } as { type: string }); - expect((named.items as any[]).map((i) => i.name)).toEqual(['alpha']); + for (const named of [{ legacyFormOrganizationId: 'org_1' }, { organizationId: 'org_1' }]) { + const read = await h.protocol.getMetaItems({ type: 'view', ...named } as { type: string }); + expect((read.items as any[]).map((i) => i.name)).toEqual(['alpha']); + } + expect(h.finds.length).toBe(findsAfterEnvWide); }); diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index 931912d2a98..8e46147d498 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -444,15 +444,19 @@ describe('a package-less organization overlay, two packages shipping its view na return { protocol, rows }; } - /** The organization read's copies of the view, where the doors find the form. */ + /** + * [ADR-0131 C7] The env-wide read's copies of the view, where the doors + * find the form: the legacy organization read is deleted. + */ async function servedCopies(protocol: any): Promise>> { - const org: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_a' }); - return (org.items as any[]).filter((v) => v?.name === NAME); + const envWide: any = await protocol.getMetaItems({ type: 'view' }); + return (envWide.items as any[]).filter((v) => v?.name === NAME); } /** - * The copies the anonymous doors would serve for the slug: open, and not - * withdrawn by the env-wide layer they read beneath the organization's. + * The copies the anonymous doors would serve for the slug, by their own + * composition: open, and not withdrawn by any body of the view name in + * that same env-wide list. */ async function doorsServe(protocol: any): Promise>> { const envWide: any = await protocol.getMetaItems({ type: 'view' }); @@ -477,13 +481,13 @@ describe('a package-less organization overlay, two packages shipping its view na .toEqual([null]); } - it('the organization read serves the overlay once per package, each copy stamped with that package', async () => { + it('[ADR-0131 C7] no read serves the legacy overlay: the doors serve each package\'s shipped copy', async () => { const { protocol, rows } = makeTwoPackageProtocol(); await storeOpenOverlay(protocol, rows); const copies = await servedCopies(protocol); - expect(copies.map((v) => v.label)).toEqual(['Intake (org)', 'Intake (org)']); + expect(copies.map((v) => v.label)).toEqual(['Intake', 'Intake']); expect(copies.map((v) => v._packageId).sort()).toEqual(['pkg_a', 'pkg_b']); - // Control: before any withdrawal the doors serve the overlay. + // Control: before any withdrawal the doors serve both copies. expect(await doorsServe(protocol)).toHaveLength(2); }); @@ -507,9 +511,8 @@ describe('a package-less organization overlay, two packages shipping its view na expect(named).toEqual([['pkg_a', withdrawing !== 'pkg_a'], ['pkg_b', withdrawing !== 'pkg_b']]); }); - it('the doors serve no copy of the overlay stored before the withdrawal', async () => { + it('the doors serve no copy once one package withdraws the name', async () => { const { protocol } = await withdrawAfterOverlay(); - // Both stamped copies are still the open overlay, and both are closed. expect((await servedCopies(protocol)).map((v) => v._packageId).sort()).toEqual(['pkg_a', 'pkg_b']); expect(await doorsServe(protocol)).toEqual([]); }); @@ -783,13 +786,13 @@ describe('each package\'s copy of a view container expands into its own package\ /** * What the anonymous doors serve for the slug, by their own composition - * (`registerFormEndpoints`): the organization's read, each open candidate - * judged over the env-wide view list beneath it. + * (`registerFormEndpoints`): the env-wide read, each open candidate + * judged over that same list (the legacy organization read is deleted, + * ADR-0131 C7). */ async function doorsServe(protocol: any): Promise { - const org: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_a' }); const envWide: any = await protocol.getMetaItems({ type: 'view' }); - return (org.items as any[]).filter((view) => anonymousFormIntakeCandidates(view) + return (envWide.items as any[]).filter((view) => anonymousFormIntakeCandidates(view) .some((c) => c.slug === SLUG && !anonymousFormIntakeWithdrawnIn(envWide.items, view, c))); } diff --git a/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts b/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts index d2780f05fb2..4065bc1488e 100644 --- a/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts +++ b/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts @@ -17,10 +17,11 @@ * 3. The boot report names both populations, per type, with their remedies * — the v18 migration ceremony for the legacy organization rows, and the * two remedies for the sealed overlays. Nothing is deleted or rewritten. - * 4. [Triage ruling Q3 A] The one read left that reaches a legacy - * organization's rows: the anonymous form doors' `view` list read - * (`legacyFormOrganizationId`), kept fail-closed until the ceremony. Any - * other type reads the environment alone, whoever passes the key. + * 4. [ADR-0131 C7] No read is left that reaches a legacy organization's + * rows: the anonymous form doors' `view` read of the Default + * Organization's layer (`legacyFormOrganizationId`) was deleted when the + * v18 ceremony began carrying those withdrawals onto the environment. A + * caller still passing the key reads the environment. * * Reverse verification (one-shot, recorded in the PR): restoring an * organization row to the served-row lookup (`findServedOverlayRow` with its @@ -377,7 +378,7 @@ describe('§3 the boot report names both populations, per type, with their remed }); }); -describe('§4 [triage ruling Q3 A] the anonymous form doors\' legacy layer is the one read left that reaches a legacy organization', () => { +describe('§4 [ADR-0131 C7] the anonymous form doors\' legacy organization read is deleted', () => { const seed = () => ({ sys_metadata: [ metaRow('view', 'intake', null, { label: 'env intake' }), @@ -386,20 +387,11 @@ describe('§4 [triage ruling Q3 A] the anonymous form doors\' legacy layer is th ], }); - it('a view list read with legacyFormOrganizationId prefers that organization\'s body', async () => { - const { protocol } = harness(seed()); - const list = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG }); - expect((list.items as Row[]).map((i) => i.label)).toEqual(['org intake']); - // …and nothing else reads it: the same list without the key, and with the retired key. - for (const caller of CALLERS) { - const plain = await protocol.getMetaItems(asCaller({ type: 'view' }, caller)); - expect((plain.items as Row[]).map((i) => i.label)).toEqual(['env intake']); - } - }); - - it('the key reaches no other type', async () => { - const { protocol } = harness(seed()); - const list = await protocol.getMetaItems({ type: 'dashboard', legacyFormOrganizationId: ORG }); - expect(list.items).toEqual([]); - }); + for (const type of ['view', 'dashboard'] as const) { + it(`a ${type} list read passing the deleted legacyFormOrganizationId key reads the environment`, async () => { + const { protocol } = harness(seed()); + const list = await protocol.getMetaItems({ type, legacyFormOrganizationId: ORG } as { type: string }); + expect((list.items as Row[]).map((i) => i.label)).toEqual(type === 'view' ? ['env intake'] : []); + }); + } }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 16c97de7c53..c3599572449 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2588,7 +2588,7 @@ function mutationEventMemberOfBody(type: string, body: unknown): { member?: stri * no package). */ interface ServedOverlayRowCandidate { - readonly scope: 'org' | 'env'; + readonly scope: 'env'; readonly organizationId: string | null; readonly spelling: 'canonical' | 'other'; readonly packageId: string | null | undefined; @@ -2612,12 +2612,10 @@ interface StoredRowPlace { * - Scope (ADR-0131 D6): the environment's rows only. A legacy * organization-scoped row is reported at boot * ({@link ObjectStackProtocolImplementation.reportUnhydratableOrgScopedRows}) - * and never served. The ONE exception is `legacyOrganizationId`, which only - * the anonymous form doors' read of the Default Organization's legacy - * layer passes (through the list read): that legacy organization's rows, - * then the environment's — kept fail-closed until the v18 migration - * ceremony carries those rows into the environment layer and deletes the - * read with them. + * and never served, by any read: the anonymous form doors' read of the + * Default Organization's legacy layer was deleted when the v18 migration + * ceremony began carrying those rows' withdrawals into the environment + * (ADR-0131 C7, rulings A on #22008 and #22011). * - Then the spelling: the canonical type, then the other spelling (the * at-rest tolerance the reads have always had, #4432). * - Then the package (ADR-0048 prefer-local): with a `packageId`, that @@ -2638,12 +2636,9 @@ interface StoredRowPlace { * scope (`resolveOverlayLockLayer` in `item-lock.ts`, #21761). */ function servedOverlayRowCandidates(address: { - readonly legacyOrganizationId?: string; readonly packageId: string | undefined; }): ServedOverlayRowCandidate[] { - const scopes: Array> = address.legacyOrganizationId - ? [{ scope: 'org', organizationId: address.legacyOrganizationId }, { scope: 'env', organizationId: null }] - : [{ scope: 'env', organizationId: null }]; + const scopes: Array> = [{ scope: 'env', organizationId: null }]; const packages: Array = address.packageId ? [address.packageId, null] : [undefined]; return scopes.flatMap((scope) => (['canonical', 'other'] as const).flatMap((spelling) => packages.map((packageId) => ({ ...scope, spelling, packageId })))); @@ -2722,8 +2717,7 @@ function servedViewExpansion( * • [#21804] When `records` are stored rows the caller read (`rowsRead`, * each record's `stored` place), the winner among them is the one * {@link servedOverlayRowCandidates} picks for `P`: `P`'s own row before - * the package-less one (the form doors' legacy organization row before - * the environment's, where they read that layer). It + * the package-less one. It * is the resolution `getMetaItem` takes, so it does not depend on the * order the store returned the rows in. Without `rowsRead` (the * MetadataService merge, whose `records` are already-resolved items), @@ -2771,7 +2765,7 @@ function mergePackageAwareOverlay( baseItems: unknown[], records: Array<{ data: unknown; packageId: string | undefined; stored?: StoredRowPlace; standIn?: boolean }>, transform?: (data: any, prev: any) => any, - rowsRead?: { readonly legacyOrganizationId?: string }, + rowsRead?: Readonly>, unseated?: WeakSet, ): unknown[] { // Per-SLOT, layer-ordered contributions; `pkg: undefined` = package-less. @@ -2795,7 +2789,7 @@ function mergePackageAwareOverlay( // `real` among a slot's contributions: the first candidate of // {@link servedOverlayRowCandidates} that one of the rows sits at. const servedStoredRow = (list: readonly Contribution[], real: string | undefined): any => { - for (const candidate of servedOverlayRowCandidates({ legacyOrganizationId: rowsRead?.legacyOrganizationId, packageId: real })) { + for (const candidate of servedOverlayRowCandidates({ packageId: real })) { const hit = list.find((c) => c.stored !== undefined && c.stored.organizationId === candidate.organizationId && (c.stored.type === type ? 'canonical' : 'other') === candidate.spelling @@ -9548,13 +9542,7 @@ export class ObjectStackProtocolImplementation implements ); } - /** - * `legacyFormOrganizationId` is not a request key of the spec's - * `GetMetaItemsRequest`: only the anonymous form doors pass it, for their - * read of the Default Organization's legacy `view` layer (triage ruling - * Q3 A; see {@link readFlattenedMetaItems}). - */ - async getMetaItems(request: { type: string; packageId?: string; previewDrafts?: boolean; legacyFormOrganizationId?: string }) { + async getMetaItems(request: { type: string; packageId?: string; previewDrafts?: boolean }) { // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. This one // is load-bearing twice over: the SchemaRegistry indexes code-authored // items under the SINGULAR type, and the overlay-hydration branch below @@ -9606,7 +9594,7 @@ export class ObjectStackProtocolImplementation implements * `request` arrives already folded by the calling face (#4432). */ private async readFlattenedMetaItems( - request: { type: string; packageId?: string; previewDrafts?: boolean; legacyFormOrganizationId?: string }, + request: { type: string; packageId?: string; previewDrafts?: boolean }, audience: 'served' | 'execution', ) { const { packageId } = request; @@ -9615,15 +9603,11 @@ export class ObjectStackProtocolImplementation implements // The list serves the environment's stored rows over the code // packages' definitions, and no organization's: a legacy // organization-scoped row is reported at boot - // ({@link reportUnhydratableOrgScopedRows}) and never served. The one - // exception is `legacyFormOrganizationId`, which only the anonymous - // form doors pass (triage ruling Q3 A): their read of a `view` keeps - // the Default Organization's legacy layer — that organization's body - // preferred, and a withdrawal in either layer closing the form — - // fail-closed until the v18 migration ceremony (ADR-0131 C7) carries - // those rows into the environment layer and deletes this read. Any - // other type reads the environment alone, whoever passes the key. - const orgId = canonicalMetaType(request.type) === 'view' ? request.legacyFormOrganizationId : undefined; + // ({@link reportUnhydratableOrgScopedRows}) and never served — the + // anonymous form doors included: their read of the Default + // Organization's legacy `view` layer was deleted when the v18 + // migration ceremony (ADR-0131 C7) began carrying its withdrawals into + // the environment, fail-closed. let items: unknown[] = []; // Unscoped kernels (control plane): read everything from SchemaRegistry. @@ -9694,8 +9678,8 @@ export class ObjectStackProtocolImplementation implements // the view containers it expands through the same two calls, so // the two doors cannot disagree about which containers are in // scope for one caller. - const records = await this.readActiveOverlayRows(request, orgId); - lockRows = packageId ? await this.readActiveOverlayRows({ type: request.type }, orgId) : records; + const records = await this.readActiveOverlayRows(request); + lockRows = packageId ? await this.readActiveOverlayRows({ type: request.type }) : records; const standInRows = packageId ? lockRows.filter((row) => (row?.package_id ?? null) === null) : []; if ((records && records.length > 0) || standInRows.length > 0) { const isView = (PLURAL_TO_SINGULAR[request.type] ?? request.type) === 'view'; @@ -9752,7 +9736,7 @@ export class ObjectStackProtocolImplementation implements return this.foldObjectExtendersFromRegistry( request.type, (data as { name?: unknown } | null)?.name, data, ); - }, { legacyOrganizationId: orgId }, unseated); + }, {}, unseated); // [#13407] Expand any aggregated `defineView` container this // READ just merged in, INLINE into this response's own `items` @@ -10281,10 +10265,7 @@ export class ObjectStackProtocolImplementation implements /** * [#21442, ADR-0131 D6] The active `sys_metadata` rows a read of * `request.type` consults: the environment's rows, restricted to - * `request.packageId` when one is given — plus, for the anonymous form - * doors' read alone, the rows of the legacy organization - * `legacyOrganizationId` names (that organization's row wins its slot; - * read uncached, and retired with the v18 migration ceremony). Moved here unchanged from + * `request.packageId` when one is given. Moved here unchanged from * {@link readFlattenedMetaItems}, the list read, so the by-name read * ({@link resolveRowlessExpandedView}) selects the view containers it * expands by this same rule — ⛔ never a second selection rule, which would @@ -10295,7 +10276,6 @@ export class ObjectStackProtocolImplementation implements */ private async readActiveOverlayRows( request: { type: string; packageId?: string }, - legacyOrganizationId?: string, ): Promise { const { packageId } = request; const queryByOrg = async (oid: string | null): Promise => { @@ -10370,21 +10350,7 @@ export class ObjectStackProtocolImplementation implements this.engine, overlayCacheKey, overlayCacheEpoch, records, overlayCacheTtlMs, overlayCacheNow, ); } - if (!legacyOrganizationId) return records; - // The anonymous form doors' legacy layer (triage Q3 A, ADR-0131 C7): - // that organization's rows override the environment's on name - // collision. ADR-0048 (#1828) — key by (package, name), not bare name, - // so a package A row and a package B row of the same name do not - // collapse. [#7774] …and for a bundled type the slot is `(package, - // name, locale)`: an environment row and the organization's row may - // customize DIFFERENT members of one bundle. - const orgRecords = await queryByOrg(legacyOrganizationId); - const mergedMap = new Map(); - const rowKey = (r: any): string => - metaItemKey(r.package_id, r.name, storedRowDiscriminator(request.type, r)); - for (const r of records) mergedMap.set(rowKey(r), r); - for (const r of orgRecords) mergedMap.set(rowKey(r), r); - return Array.from(mergedMap.values()); + return records; } /** @@ -20928,7 +20894,7 @@ export class ObjectStackProtocolImplementation implements try { // [ADR-0131 D6] A save lands environment-wide, so the pages it meets // are the environment-wide rows. - records = await this.readActiveOverlayRows({ type }, undefined); + records = await this.readActiveOverlayRows({ type }); } catch (error) { this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); } @@ -21071,7 +21037,7 @@ export class ObjectStackProtocolImplementation implements try { // [ADR-0131 D6] A save lands environment-wide, so its siblings are // the environment-wide rows. - records = await this.readActiveOverlayRows({ type }, undefined); + records = await this.readActiveOverlayRows({ type }); } catch (error) { // [#5532] The readers' rule: only an unprovisioned store means "no // rows". Any other failure is not answered as "no sibling". diff --git a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts index 152cac16f49..fa579d30896 100644 --- a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts +++ b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts @@ -115,6 +115,13 @@ async function plantOrgRow(engine: any, organizationId: string, name: string, it return { success: true }; } +/** A stored environment row, written through the overlay repository the way a Studio save writes it. */ +async function plantEnvRow(engine: any, name: string, item: unknown): Promise<{ success: true }> { + await new SysMetadataRepository({ engine }) + .put({ type: 'view', name } as any, item, { parentVersion: null, actor: null }); + return { success: true }; +} + /** The card's repro body: a `defineView` container, as `defineView` emits it. */ const leadContainer = { list: { @@ -293,7 +300,7 @@ describe('#7736 a runtime-authored view container is served', () => { * isolation because it operates only on rows already scoped to THIS * request's own org/environment (the pre-existing `queryByOrg` merge). */ -describe('#13407 environment-scoped runtime containers are served; [ADR-0131 D6] a legacy organization container only by the anonymous form doors\' legacy read', () => { +describe('#13407 environment-scoped runtime containers are served; [ADR-0131 D6, C7] a legacy organization container by no read', () => { it('derives the object binding from the containers own top-level `object` field, not just `list.data.object`', async () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); @@ -312,13 +319,13 @@ describe('#13407 environment-scoped runtime containers are served; [ADR-0131 D6] expect(switcherMatches(list.items, 'crm_lead').map((v: any) => v.name)).toEqual(['crm_lead.default']); }); - it('the cards own repro, at the one read left that reaches a legacy organization row: the anonymous form doors\' legacy layer expands it', async () => { + it('the cards own repro, on a stored environment row: the list read expands it', async () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); - await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); + await plantEnvRow(engine, 'crm_lead', leadContainer); - const list: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_acme' }); + const list: any = await protocol.getMetaItems({ type: 'view' }); const served = switcherMatches(list.items, 'crm_lead').map((v: any) => v.name).sort(); expect(served).toEqual(['crm_lead.default', 'crm_lead.pipeline']); }); @@ -328,7 +335,12 @@ describe('#13407 environment-scoped runtime containers are served; [ADR-0131 D6] const protocol = new ObjectStackProtocolImplementation(engine); await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); - for (const request of [{ type: 'view' }, { type: 'view', organizationId: 'org_acme' }]) { + for (const request of [ + { type: 'view' }, + { type: 'view', organizationId: 'org_acme' }, + // The anonymous form doors' legacy key, deleted with ADR-0131 C7. + { type: 'view', legacyFormOrganizationId: 'org_acme' }, + ]) { const list: any = await protocol.getMetaItems(request as any); expect(switcherMatches(list.items, 'crm_lead')).toEqual([]); } @@ -339,13 +351,13 @@ describe('#13407 environment-scoped runtime containers are served; [ADR-0131 D6] it('POSITIVE CONTROL: a pre-existing independent ViewItem for the same object is still served alongside the newly-expanded container', async () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); - await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); - await plantOrgRow(engine, 'org_acme', 'crm_lead.mine', { + await plantEnvRow(engine, 'crm_lead', leadContainer); + await plantEnvRow(engine, 'crm_lead.mine', { name: 'crm_lead.mine', object: 'crm_lead', viewKind: 'list', label: 'My Leads', config: { type: 'grid', data: { provider: 'object', object: 'crm_lead' }, columns: [{ field: 'name' }] }, }); - const list: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_acme' }); + const list: any = await protocol.getMetaItems({ type: 'view' }); const served = switcherMatches(list.items, 'crm_lead').map((v: any) => v.name).sort(); // A fix that merely "returned everything" could not distinguish these: // the pre-existing independent item and the two newly-expanded @@ -353,23 +365,11 @@ describe('#13407 environment-scoped runtime containers are served; [ADR-0131 D6] expect(served).toEqual(['crm_lead.default', 'crm_lead.mine', 'crm_lead.pipeline']); }); - it('ORG ISOLATION HELD: a container authored in org_acme is invisible reading as org_globex, and visible reading as org_acme', async () => { - const { engine } = makeStubEngine(); - const protocol = new ObjectStackProtocolImplementation(engine); - await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); - - const globex: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_globex' }); - expect(switcherMatches(globex.items, 'crm_lead')).toEqual([]); - - const acme: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_acme' }); - expect(switcherMatches(acme.items, 'crm_lead')).toHaveLength(2); - }); - - it('the isolation-safe path never registers an org-scoped expansion into the shared SchemaRegistry', async () => { + it('the isolation-safe path never registers a read\'s expansion into the shared SchemaRegistry', async () => { const { engine, registered } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); - await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); - await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_acme' }); + await plantEnvRow(engine, 'crm_lead', leadContainer); + await protocol.getMetaItems({ type: 'view' }); // The RESPONSE carries the expansion (re-confirmed above); the SHARED // registry — read by every org/environment this kernel serves — must diff --git a/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts b/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts index 3d29170aa60..8c66be2772d 100644 --- a/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts +++ b/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts @@ -35,10 +35,12 @@ * * ── The fix, and where it lives ───────────────────────────────────────── * - * [ADR-0131 D6] Since the per-organization overlay axis retired, the one - * list read left that merges a legacy organization's rows is the anonymous - * form doors' `view` read (`legacyFormOrganizationId`); the listing cases - * below drive that read, because it is the one the invariant still guards. + * [ADR-0131 D6, C7] Since the per-organization overlay axis retired, no list + * read merges a legacy organization's rows: the last one, the anonymous form + * doors' `view` read (`legacyFormOrganizationId`), was deleted when the v18 + * ceremony began carrying those rows' withdrawals onto the environment. The + * listing cases below still pass that key, and pin that it reads the + * environment and hydrates nothing of the organization's. * * `hydrateOverlayIntoRegistry` is the ONE shared choke point all three * hydration callers already route through (#4521 made it so: boot, the @@ -289,14 +291,12 @@ describe('#6602 — READ seam: getMetaItems hydration refuses org-scoped rows', protocol = new ObjectStackProtocolImplementation(engine); }); - it('an org-scoped listing SERVES the org row but does not graft it', async () => { + it('a listing passing the deleted organization key serves the org row NOWHERE and does not graft it', async () => { engine.plant(metaRow('view', viewBody('org_grid', 'Org A grid'), ORG_A)); - const listed = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); + const listed = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A } as any); - // Org readers keep their overlay — closing the leak must not close - // the feature. - expect(namesOf(listed as any)).toContain('org_grid'); + expect(namesOf(listed as any)).not.toContain('org_grid'); // …and the process-wide registry stays clean. expect(registry.getItem('view', 'org_grid')).toBeUndefined(); }); @@ -314,9 +314,9 @@ describe('#6602 — READ seam: getMetaItems hydration refuses org-scoped rows', engine.plant(metaRow('view', viewBody('env_grid', 'Env grid'), null)); engine.plant(metaRow('view', viewBody('org_grid', 'Org A grid'), ORG_A)); - const listed = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); + const listed = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A } as any); - expect(namesOf(listed as any).sort()).toEqual(['env_grid', 'org_grid']); + expect(namesOf(listed as any).sort()).toEqual(['env_grid']); expect(registry.getItem('view', 'env_grid')).toBeDefined(); expect(registry.getItem('view', 'org_grid')).toBeUndefined(); }); @@ -330,9 +330,9 @@ describe('#6602 — READ seam: getMetaItems hydration refuses org-scoped rows', await protocol.getMetaItems({ type: 'view' }); expect((registry.getItem('view', 'shared_grid') as any)?.label).toBe('Env grid'); - // Org A's own listing still shows org A's body (org-over-env merge)… - const listedA = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); - expect(itemNamed(listedA as any, 'shared_grid')?.label).toBe('Org A grid'); + // A listing passing the deleted organization key reads the environment… + const listedA = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A } as any); + expect(itemNamed(listedA as any, 'shared_grid')?.label).toBe('Env grid'); // …and the shared registry still holds the ENV-WIDE one. This is the // collision direction of the leak: pre-fix the org body overwrote the @@ -341,20 +341,15 @@ describe('#6602 — READ seam: getMetaItems hydration refuses org-scoped rows', expect((registry.getItem('view', 'shared_grid') as any)?.label).toBe('Env grid'); }); - it('an org-scoped listing hydrates NOTHING for a name its org overlays', async () => { - // Measured, and stated rather than presumed: `getMetaItems` merges the - // two record sets by (package, name) with the ORG row winning, so the - // env-wide row it shadows is not in the set this loop walks at all. - // The subtraction is the leak only — pre-fix this listing hydrated org - // A's body under the plain key, never the env-wide one, so nothing - // legitimate is lost. The env-wide entry arrives from boot / the - // unscoped read, as the case above shows. + it('a listing passing the deleted organization key never hydrates the org body of a name it shares', async () => { + // The listing is the environment's, so what it hydrates is the + // env-wide body; org A's row is in no set this loop walks. engine.plant(metaRow('view', viewBody('shared_grid', 'Env grid'), null)); engine.plant(metaRow('view', viewBody('shared_grid', 'Org A grid'), ORG_A)); - await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); + await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A } as any); - expect(registry.getItem('view', 'shared_grid')).toBeUndefined(); + expect((registry.getItem('view', 'shared_grid') as any)?.label).toBe('Env grid'); }); }); @@ -376,8 +371,8 @@ describe('#6602 — the disclosure shape, end to end', () => { // later reader started from it. engine.plant(metaRow('view', viewBody('org_a_only', 'Org A only'), ORG_A)); - await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); - const listedB = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_B }); + await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A } as any); + const listedB = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_B } as any); expect(namesOf(listedB as any)).not.toContain('org_a_only'); // The unscoped control-plane listing is a third reader of the same @@ -390,8 +385,8 @@ describe('#6602 — the disclosure shape, end to end', () => { engine.plant(metaRow('view', viewBody('shared_grid', 'Env grid'), null)); engine.plant(metaRow('view', viewBody('shared_grid', 'Org A grid'), ORG_A)); - await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); - const listedB = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_B }); + await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A } as any); + const listedB = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_B } as any); expect(itemNamed(listedB as any, 'shared_grid')?.label).toBe('Env grid'); }); @@ -635,10 +630,9 @@ describe('#6780 — the registry heal is org-gated: an org DELETE never evicts t await protocol.deleteMetaItem({ type: 'view', name: 'shared_grid' }); expect(registry.getItem('view', 'shared_grid')).toBeUndefined(); - // Org A's legacy row is untouched by an env-wide delete: the one read - // left that reaches it (the anonymous form doors' legacy layer) still - // finds it. - const legacy: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); - expect(itemNamed(legacy, 'shared_grid')?.label).toBe('Org A grid'); + // Org A's legacy row is untouched by an env-wide delete: it stays + // stored, for the v18 ceremony to promote or report. + const legacy = await engine.find('sys_metadata', { where: { organization_id: ORG_A } }); + expect(legacy.map((r: any) => JSON.parse(r.metadata).label)).toEqual(['Org A grid']); }); }); diff --git a/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts index ba6d80c2543..519e9b6c84e 100644 --- a/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts @@ -11,20 +11,22 @@ // retired the per-organization overlay axis, that save lands ENVIRONMENT-WIDE // even when the admin has an active organization. // -// The anonymous form doors still read the Default Organization's layer for -// its withdrawals, fail-closed, until ADR-0131 C7 carries those rows to the -// environment layer (triage ruling Q3 A on the retirement card). No door can -// write such a row any more, so this file PLANTS legacy organization rows -// at rest in `sys_metadata`, as a door left them before the retirement, and -// pins: +// The anonymous form doors read the environment alone (ADR-0131 C7): their +// read of the Default Organization's legacy layer is deleted, because the v18 +// ceremony (`os migrate organization-ownership --apply`) carries every +// organization-layer withdrawal onto the environment, fail-closed (rulings A +// on #22008 and #22011; the carry is pinned in the cli's +// `organization-ownership-apply.test.ts`). No door can write an organization +// row any more, so this file PLANTS legacy organization rows at rest in +// `sys_metadata`, as a door left them before the retirement, and pins: // // - a legacy organization overlay that keeps the form open does not survive // an environment withdrawal: both anonymous doors answer // `404 FORM_NOT_FOUND` and nothing lands; -// - ⭐ a legacy organization WITHDRAWAL still closes the form while it is -// open environment-wide (the read Q3 A keeps); +// - ⭐ a legacy organization row is read by no door: its withdrawal no longer +// closes a form open environment-wide — it is the ceremony's to carry; // - the admin's save with an active organization is environment-wide; -// - open at both layers (control): both doors accept, and the row lands in +// - open environment-wide (control): both doors accept, and the row lands in // the organization the doors resolve. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -121,7 +123,7 @@ describe('showcase: a public form withdrawal at any metadata layer holds', () => await stack?.stop(); }); - it('PRECONDITION: a legacy organization overlay that keeps the form open is served', async () => { + it('PRECONDITION: with a legacy organization overlay that keeps the form open, the form is served', async () => { await plantLegacyOrgOverlay(true); expect(await probe()).toEqual(OPEN); }); @@ -132,14 +134,14 @@ describe('showcase: a public form withdrawal at any metadata layer holds', () => expect(await probe()).toEqual(CLOSED); }); - it('⭐ a legacy organization WITHDRAWAL still closes the form while it is open environment-wide (fail-closed until C7)', async () => { + it('⭐ a legacy organization WITHDRAWAL is read by no door: the form open environment-wide is served until the ceremony carries it (ADR-0131 C7)', async () => { await scope(null); expect(await saved(true)).toMatch(/env-wide/); await plantLegacyOrgOverlay(false); - expect(await probe()).toEqual(CLOSED); + expect(await probe()).toEqual(OPEN); }); - it('open at both layers (control): both doors accept and the row lands in the organization', async () => { + it('open environment-wide (control): both doors accept and the row lands in the organization', async () => { await scope(organizationId); expect(await saved(true)).toMatch(/env-wide/); await plantLegacyOrgOverlay(true); diff --git a/packages/rest/src/public-form-withdrawal.test.ts b/packages/rest/src/public-form-withdrawal.test.ts index 34e4d9bcadb..352c9097d59 100644 --- a/packages/rest/src/public-form-withdrawal.test.ts +++ b/packages/rest/src/public-form-withdrawal.test.ts @@ -1,33 +1,30 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// [#21331] Withdrawing a public form from anonymous intake takes effect on -// every intake door. Both doors (`GET /forms/:slug` and -// `POST /forms/:slug/submit`) read the form in the organization the request -// belongs to: the tenancy service's `defaultOrgId()`. That is the same -// organization whose overlay the administrator's editor shows. A withdrawal -// saved in that organization therefore closes both doors, and nothing is -// written. +// [#21331, ADR-0131 C7] Withdrawing a public form from anonymous intake takes +// effect on every intake door. Both doors (`GET /forms/:slug` and +// `POST /forms/:slug/submit`) read the form in the environment, like every +// other metadata read: environment → code. Until v18 they also read the +// Default Organization's legacy layer; the v18 ceremony carries every +// organization-layer withdrawal onto the environment, fail-closed (rulings A +// on #22008 and #22011; pinned end to end in the cli's +// `organization-ownership-apply.test.ts`), and that read is deleted. // // Pinned both sides: -// - withdrawn in the organization: both doors answer 404 FORM_NOT_FOUND and -// `createData` is never called; -// - published in the organization (the control): both doors accept, and -// every metadata read the doors make names that organization; -// - withdrawn env-wide with no organization to resolve: both doors refuse; -// - tenancy never registered: the env-wide read, unchanged; +// - no view read names an organization, whatever the tenancy answers; +// - withdrawn in the environment: both doors answer 404 FORM_NOT_FOUND and +// `createData` is never called; published (the control): both accept; +// - tenancy never registered: the same read; // - tenancy registered but unreachable: both doors refuse (fail closed). // -// A withdrawal is a kill switch: an explicit withdrawal of the same form (the -// same view and slot, the link kept with a switch cleared) at any layer the -// doors read closes it, and layering may only narrow intake, never re-open it. -// The doors therefore read the env-wide layer beneath the organization's too: -// - withdrawn env-wide, re-opened in the organization: both doors refuse; -// - withdrawn in the organization, open env-wide: both doors refuse; -// - open in both: both doors accept; -// - a form only the organization has (no env-wide body): it is served; -// - not a withdrawal (the link cleared env-wide, a switch merely absent, or -// another view sharing the slug withdrawn): it is served; -// - the same view re-pointed at a new slug in the organization: still closed. +// A withdrawal is a kill switch: the list holds one item per package of a +// view name, and an explicit withdrawal of the same form (matched by slot or +// by slug, the link kept with a switch cleared) in any of them closes it: +// - another package's withdrawal of the same view name closes the form; +// - every package's body open (control): served; +// - not a withdrawal (the link cleared, a switch merely absent, or another +// view sharing the slug withdrawn): served; +// - the form moved to a new slug in one package's body while another's +// withdraws the old one: still closed. import { describe, it, expect, vi } from 'vitest'; import { RestServer } from './rest-server'; @@ -86,34 +83,24 @@ function notRegistered(): Error { } interface Setup { - /** What the env-wide (organization-less) read answers. */ + /** What the environment's read answers. */ envWide: boolean; - /** What the organization read answers; `undefined` = no overlay there. */ - inOrg?: boolean; /** The tenancy provider's behaviour. */ tenancy: 'org' | 'no-org' | 'not-registered' | 'unreachable'; - /** Replaces the form's whole `sharing` on every read (the `envWide`/`inOrg` switch is then ignored). */ + /** Replaces the form's whole `sharing` (the `envWide` switch is then ignored). */ sharing?: Record; - /** Replaces the env-wide read's whole view list (the organization read is unchanged). */ + /** Replaces the environment's whole view list. */ envWideViews?: unknown[]; - /** Extra views every read answers alongside the form view. */ + /** Extra views the read answers alongside the form view. */ extraViews?: unknown[]; - /** Replaces the organization read's whole view list (the env-wide read is unchanged). */ - orgViews?: unknown[]; } function build(setup: Setup) { const createData = vi.fn().mockResolvedValue({ object: 'inquiry', id: 'rec_1', record: {} }); - // [ADR-0131 D6, triage ruling Q3 A] The doors' `view` read reaches the - // Default Organization's legacy layer through `legacyFormOrganizationId`, the - // one read key left that names an organization. - const getMetaItems = vi.fn(async (req: { type: string; organizationId?: string; legacyFormOrganizationId?: string }) => { + const getMetaItems = vi.fn(async (req: Record & { type: string }) => { if (req.type === 'view') { - const org = req.legacyFormOrganizationId; - if (org !== ORG && setup.envWideViews) return setup.envWideViews; - if (org === ORG && setup.orgViews) return setup.orgViews; - const effective = org === ORG && setup.inOrg !== undefined ? setup.inOrg : setup.envWide; - return [formView(effective, setup.sharing), ...(setup.extraViews ?? [])]; + if (setup.envWideViews) return setup.envWideViews; + return [formView(setup.envWide, setup.sharing), ...(setup.extraViews ?? [])]; } if (req.type === 'object') return [inquiryObject]; return []; @@ -163,8 +150,19 @@ function build(setup: Setup) { } describe('[#21331] public form withdrawal reaches every intake door', () => { - it('withdrawn in the organization: both doors answer FORM_NOT_FOUND and nothing is written', async () => { - const s = build({ envWide: true, inOrg: false, tenancy: 'org' }); + it('[ADR-0131 C7] no view read names an organization, whatever the tenancy answers: one environment read per door', async () => { + const s = build({ envWide: true, tenancy: 'org' }); + expect((await s.get()).statusCode).toBe(200); + expect((await s.post()).statusCode).toBe(201); + const views = s.getMetaItems.mock.calls.map(([r]) => r).filter((r) => r.type === 'view'); + expect(views).toHaveLength(2); + for (const r of s.getMetaItems.mock.calls.map(([req]) => req)) { + expect(Object.keys(r).filter((k) => /organization/i.test(k))).toEqual([]); + } + }); + + it('withdrawn in the environment: both doors answer FORM_NOT_FOUND and nothing is written', async () => { + const s = build({ envWide: false, tenancy: 'org' }); const get = await s.get(); expect(get.statusCode).toBe(404); expect(get.body.code).toBe('FORM_NOT_FOUND'); @@ -174,8 +172,8 @@ describe('[#21331] public form withdrawal reaches every intake door', () => { expect(s.createData).not.toHaveBeenCalled(); }); - it('published in the organization (control): both doors accept, every read names the organization', async () => { - const s = build({ envWide: true, inOrg: true, tenancy: 'org' }); + it('published in the environment (control): both doors accept', async () => { + const s = build({ envWide: true, tenancy: 'org' }); const get = await s.get(); expect(get.statusCode).toBe(200); expect(get.body.object).toBe('inquiry'); @@ -183,32 +181,19 @@ describe('[#21331] public form withdrawal reaches every intake door', () => { const post = await s.post(); expect(post.statusCode).toBe(201); expect(s.createData).toHaveBeenCalledTimes(1); - // [ADR-0131 D6] No read names an organization by the retired key; the - // `view` read alone reaches the legacy layer, and the object read is the - // environment's. - const reads = s.getMetaItems.mock.calls.map(([r]) => [r.type, r.legacyFormOrganizationId]); - expect(reads.length).toBeGreaterThan(0); - for (const [r] of s.getMetaItems.mock.calls) expect(r.organizationId).toBeUndefined(); - for (const [type, legacy] of reads) { - if (type !== 'view') expect(legacy).toBeUndefined(); - } - // One resolution per door: the legacy organization's view read, then the env-wide one. - expect(reads.filter(([type]) => type === 'view').map(([, o]) => o)).toEqual([ORG, undefined, ORG, undefined]); }); - it('withdrawn env-wide with no organization to resolve: both doors refuse', async () => { + it('withdrawn with no organization to resolve: both doors refuse', async () => { const s = build({ envWide: false, tenancy: 'no-org' }); expect((await s.get()).body.code).toBe('FORM_NOT_FOUND'); expect((await s.post()).body.code).toBe('FORM_NOT_FOUND'); expect(s.createData).not.toHaveBeenCalled(); - for (const [r] of s.getMetaItems.mock.calls) expect(r.legacyFormOrganizationId).toBeUndefined(); }); - it('tenancy never registered: the env-wide read, unchanged', async () => { - const s = build({ envWide: true, inOrg: false, tenancy: 'not-registered' }); + it('tenancy never registered: the same environment read', async () => { + const s = build({ envWide: true, tenancy: 'not-registered' }); expect((await s.get()).statusCode).toBe(200); expect((await s.post()).statusCode).toBe(201); - for (const [r] of s.getMetaItems.mock.calls) expect(r.legacyFormOrganizationId).toBeUndefined(); }); it('tenancy registered but unreachable: both doors refuse instead of reading env-wide (fail closed)', async () => { @@ -254,7 +239,7 @@ describe('either declared switch withdraws a public form from every anonymous do } }); -describe('a public form withdrawal is a kill switch: layering only narrows intake', () => { +describe('a public form withdrawal is a kill switch: one package\'s withdrawal of a view name closes its form', () => { const expectClosed = async (s: ReturnType) => { const get = await s.get(); expect([get.statusCode, get.body.code]).toEqual([404, 'FORM_NOT_FOUND']); @@ -263,92 +248,58 @@ describe('a public form withdrawal is a kill switch: layering only narrows intak expect(s.createData).not.toHaveBeenCalled(); }; - it('withdrawn env-wide (allowAnonymous false), re-opened in the organization: both doors refuse', async () => { - await expectClosed(build({ envWide: false, inOrg: true, tenancy: 'org' })); - }); - - it('withdrawn env-wide through `enabled: false`, re-opened in the organization: both doors refuse', async () => { - const withdrawn = formView(true, { enabled: false, allowAnonymous: true, publicLink: '/forms/contact-us' }); - await expectClosed(build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [withdrawn] })); - }); - - it('not a withdrawal: the public link cleared env-wide leaves the organization\'s open form served', async () => { - const cleared = formView(true, { enabled: true, allowAnonymous: false }); - const s = build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [cleared] }); - expect((await s.get()).statusCode).toBe(200); - expect((await s.post()).statusCode).toBe(201); - }); - - // The list reads serve one item per package for a name, each carrying its + // The list read serves one item per package for a name, each carrying its // `_packageId`. Known limit (fails closed): the package is not compared, so // a withdrawal of a view name closes that name in every package. - it('another package\'s withdrawal of the same view name closes this package\'s form too', async () => { - const openA = { ...formView(true), _packageId: 'pkg_a' }; - await expectClosed(build({ - envWide: true, inOrg: true, tenancy: 'org', - orgViews: [openA], - envWideViews: [{ ...formView(true), _packageId: 'pkg_a' }, { ...formView(false), _packageId: 'pkg_b' }], - })); + for (const tenancy of ['org', 'no-org'] as const) { + it(`another package's withdrawal of the same view name closes this package's form (tenancy ${tenancy})`, async () => { + await expectClosed(build({ + envWide: true, tenancy, + envWideViews: [{ ...formView(true), _packageId: 'pkg_a' }, { ...formView(false), _packageId: 'pkg_b' }], + })); + }); + } + + it('withdrawn through `enabled: false` in another package: closed', async () => { + const withdrawn = { ...formView(true, { enabled: false, allowAnonymous: true, publicLink: '/forms/contact-us' }), _packageId: 'pkg_b' }; + await expectClosed(build({ envWide: true, tenancy: 'org', envWideViews: [{ ...formView(true), _packageId: 'pkg_a' }, withdrawn] })); }); it('control: every package\'s body of the view name open serves the form', async () => { - const openA = { ...formView(true), _packageId: 'pkg_a' }; const s = build({ - envWide: true, inOrg: true, tenancy: 'org', - orgViews: [openA], + envWide: true, tenancy: 'org', envWideViews: [{ ...formView(true), _packageId: 'pkg_a' }, { ...formView(true), _packageId: 'pkg_b' }], }); expect((await s.get()).statusCode).toBe(200); expect((await s.post()).statusCode).toBe(201); }); - it('the same package\'s withdrawal of the view name closes its form, beside another package\'s open one', async () => { - const openA = { ...formView(true), _packageId: 'pkg_a' }; - await expectClosed(build({ - envWide: true, inOrg: true, tenancy: 'org', - orgViews: [openA], - envWideViews: [{ ...formView(false), _packageId: 'pkg_a' }, { ...formView(true), _packageId: 'pkg_b' }], - })); - }); - - it('another view withdrawing the same slug env-wide does not close this view\'s form', async () => { - const other = { ...formView(false), name: 'legacy_contact' }; - const s = build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [formView(true), other] }); + it('not a withdrawal: another package\'s body with the public link cleared leaves the form served', async () => { + const cleared = { ...formView(true, { enabled: true, allowAnonymous: false }), _packageId: 'pkg_b' }; + const s = build({ envWide: true, tenancy: 'org', envWideViews: [{ ...formView(true), _packageId: 'pkg_a' }, cleared] }); expect((await s.get()).statusCode).toBe(200); expect((await s.post()).statusCode).toBe(201); }); - it('withdrawn in the organization, open env-wide: both doors refuse', async () => { - await expectClosed(build({ envWide: true, inOrg: false, tenancy: 'org' })); - }); - - it('open in both layers (control): both doors accept', async () => { - const s = build({ envWide: true, inOrg: true, tenancy: 'org' }); + it('only an explicit false withdraws: a body with the link, enabled true and allowAnonymous absent closes nothing', async () => { + const absent = { ...formView(true, { enabled: true, publicLink: '/forms/contact-us' }), _packageId: 'pkg_b' }; + const s = build({ envWide: true, tenancy: 'org', envWideViews: [{ ...formView(true), _packageId: 'pkg_a' }, absent] }); expect((await s.get()).statusCode).toBe(200); expect((await s.post()).statusCode).toBe(201); - expect(s.createData).toHaveBeenCalledTimes(1); }); - it('a form only the organization carries (no env-wide body, control): both doors accept', async () => { - const s = build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [] }); - expect((await s.get()).statusCode).toBe(200); - expect((await s.post()).statusCode).toBe(201); - }); - - it('only an explicit false withdraws: env-wide link + enabled true with allowAnonymous absent does not close the org\'s open form', async () => { - const absent = formView(true, { enabled: true, publicLink: '/forms/contact-us' }); - const s = build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [absent] }); + it('another view withdrawing the same slug does not close this view\'s form', async () => { + const other = { ...formView(false), name: 'legacy_contact' }; + const s = build({ envWide: true, tenancy: 'org', envWideViews: [formView(true), other] }); expect((await s.get()).statusCode).toBe(200); expect((await s.post()).statusCode).toBe(201); }); for (const link of ['/forms/contact-us-2', '/forms/Contact-Us']) { - it(`the same view re-pointed at a new slug (${link}) in the organization stays closed`, async () => { + it(`the form moved to a new slug (${link}) in one package, withdrawn at the old one in another: still closed (same slot)`, async () => { const slug = link.replace('/forms/', ''); - const s = build({ - envWide: true, inOrg: true, tenancy: 'org', envWideViews: [formView(false)], - sharing: { enabled: true, allowAnonymous: true, publicLink: link }, - }); + const moved = { ...formView(true, { enabled: true, allowAnonymous: true, publicLink: link }), _packageId: 'pkg_a' }; + const s = build({ envWide: true, tenancy: 'org', envWideViews: [moved, { ...formView(false), _packageId: 'pkg_b' }] }); const get = await s.get(slug); expect([get.statusCode, get.body.code]).toEqual([404, 'FORM_NOT_FOUND']); const post = await s.post(slug); @@ -360,9 +311,9 @@ describe('a public form withdrawal is a kill switch: layering only narrows intak for (const tenancy of ['org', 'no-org'] as const) { it(`two different views sharing a slug in one read do not close each other (tenancy ${tenancy})`, async () => { // Another view (another app's form) names the same slug and withdraws - // it, in every read: this view's open form is still served. + // it: this view's open form is still served. const other = { ...formView(false), name: 'legacy_contact' }; - const s = build({ envWide: true, tenancy, extraViews: [other], ...(tenancy === 'org' ? { inOrg: true } : {}) }); + const s = build({ envWide: true, tenancy, extraViews: [other] }); expect((await s.get()).statusCode).toBe(200); expect((await s.post()).statusCode).toBe(201); expect(s.createData).toHaveBeenCalledTimes(1); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index b59453d43cd..95243b4a2d0 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -79,6 +79,7 @@ import { // Which form candidates the anonymous form doors serve — the one rule the // metadata protocol also judges organization-scoped `view` writes by. anonymousFormIntakeCandidates, + // …and whether another body of the same view name withdraws a candidate. anonymousFormIntakeWithdrawnIn, // [#21476] Whether such a form can take intake on this posture, and why not // — the one predicate the runtime authoring gate's advisory reads too. @@ -301,18 +302,6 @@ export type RestProtocol = DataProtocol & MetadataProtocol; */ type TransportScopedMetaRequest = R & { environmentId?: string }; -/** - * [ADR-0131 D6, triage ruling Q3 A] The anonymous form doors' `view` list - * read: the one metadata read left that reaches a legacy organization's rows. - * `legacyFormOrganizationId` is not a key of the spec's `GetMetaItemsRequest` - * — every other read is environment → code — and the protocol honours it for - * `view` alone: the Default Organization's legacy body is preferred, and a - * withdrawal in either layer closes the form, fail-closed until the v18 - * migration ceremony (ADR-0131 C7) carries those rows into the environment - * layer and deletes this read with them. - */ -type LegacyFormViewsRequest = TransportScopedMetaRequest & { legacyFormOrganizationId?: string }; - // [#23050 slice 1] `ServerScopedDataRequest`, the data doors' request envelope, moved to // `data-action-routes.ts` (imported below): exporting it from this file for that module // changed the published `dist/*.d.ts` by one line, and a second copy is not an option. @@ -1852,13 +1841,6 @@ type MetaReadVerdict = // admin read below and the runtime authoring gate's save/publish advisory read // them from there, so none of the three can disagree with another. -/** [#21331] The organization an anonymous form request reads the form in (`defaultOrgId()`). */ -async function anonymousFormOrganization(tenancy: any): Promise { - if (!tenancy || typeof tenancy.defaultOrgId !== 'function') return undefined; - const organizationId = await tenancy.defaultOrgId(); - return typeof organizationId === 'string' && organizationId ? organizationId : undefined; -} - /** * [#21476] Put the admin read's intake reasons in `_diagnostics.warnings`, where * a derived view warning already goes (`stampRenameWarning`). A declared read @@ -9619,22 +9601,25 @@ export class RestServer { // `sharing.allowAnonymous === true` and a `publicLink` naming the slug. // // A withdrawal is a kill switch: layering may only narrow anonymous - // intake, never re-open it. A candidate is served only when no layer - // beneath the read it is found in explicitly withdraws the same form - // (`anonymousFormIntakeWithdrawnIn`: the same view name, matched by - // slot or by slug, the link kept with a switch set to `false`). - // Another view publishing the same slug is a different form and closes - // nothing. + // intake, never re-open it. The doors read one list, the + // environment's (environment → code), which holds one item per + // package of a view name, and judge every candidate against that same + // list as the layer beneath it: a candidate is served only when no + // body of its view name explicitly withdraws the same form + // (`anonymousFormIntakeWithdrawnIn`: matched by slot or by slug, the + // link kept with a switch set to `false`), so a withdrawal in one + // package closes the name's form in every package (a known limit that + // fails closed). Another view publishing the same slug is a different + // form and closes nothing. const findPublicFormView = ( views: any[], slug: string, - layers: ReadonlyArray>, ): { view: any; form: any; object: string } | null => { for (const view of views ?? []) { if (!view || typeof view !== 'object') continue; for (const c of anonymousFormIntakeCandidates(view)) { if (c.slug !== slug) continue; - if (layers.some((layer) => anonymousFormIntakeWithdrawnIn(layer, view, c))) continue; + if (anonymousFormIntakeWithdrawnIn(views, view, c)) continue; const objectName = anonymousFormObjectName(view, c.form); if (!objectName) continue; return { view, form: c.form, object: objectName }; @@ -9643,24 +9628,17 @@ export class RestServer { return null; }; - // [#21331] WHICH organization's metadata an anonymous form request - // reads. Before ADR-0131 D6 an administrator's edit of a packaged - // form was saved as an overlay of THEIR organization, and an org-less - // read missed every such edit, including the one that withdraws the - // form from anonymous intake. Since D6 no write lands - // organization-scoped and no other read serves such a row; this - // `view` read alone keeps the Default Organization's legacy layer - // (`legacyFormOrganizationId`, triage ruling Q3 A), fail-closed, until - // the v18 migration ceremony (ADR-0131 C7) carries those rows. + // [ADR-0131 D6, C7] WHICH metadata an anonymous form request reads: + // the environment's, like every other metadata read. Until v18 the + // `view` read also kept the Default Organization's legacy layer, so an + // organization's withdrawal still closed the form. The v18 ceremony + // (`os migrate organization-ownership --apply`, its metadata-promotion + // step) carries every organization-layer withdrawal onto the + // environment, fail-closed (rulings A on #22008 and #22011), and that + // read is deleted with it. // - // The answer is the tenancy service's `defaultOrgId()`: the - // organization a single-posture deployment binds every principal to. - // It is the organization the administrator's own session is in, and - // the one the engine stamps on the row this request inserts. It is - // `undefined` in two cases. Before any organization exists, no - // organization overlay can exist either. A walled posture has no - // install organization for an org-less request, so the env-wide state - // governs there exactly as before. + // The tenancy service is still read: it answers the posture the + // intake-availability rule judges (#21476). // // Asked ONCE per request, in `resolveFormBySlug`. Every door below // reads the form through that one resolution, so no door keeps its @@ -9670,7 +9648,7 @@ export class RestServer { // Fails CLOSED. A tenancy service that is registered but cannot be // reached raises `AuthzStoreUnavailableError`, the classification // `classifyAdmissionTenancyPosture` applies to the same seam. The - // door then refuses instead of falling back to the env-wide read. + // door then refuses instead of judging intake without a posture. // Only the registry's own "never registered" brand reads as the // supported no-tenancy composition. The wiring mirrors // `resolveProtocol`, so the tenancy service and the protocol always @@ -9683,11 +9661,9 @@ export class RestServer { const p = await this.resolveProtocol(environmentId, req); if (typeof (p as any).getMetaItems !== 'function') return null; const tenancy = await this.resolveAnonymousFormTenancy(environmentId, req); - const organizationId = await anonymousFormOrganization(tenancy); - const viewsRequest: LegacyFormViewsRequest = { + const viewsRequest: TransportScopedMetaRequest = { type: 'view', ...(environmentId ? { environmentId } : {}), - ...(organizationId ? { legacyFormOrganizationId: organizationId } : {}), }; const listOf = (result: any): any[] => (Array.isArray(result?.items) ? result.items @@ -9695,22 +9671,7 @@ export class RestServer { ? result : []); const items = listOf(await p.getMetaItems(viewsRequest)); - // The organization read prefers the organization's overlay of a - // view over the env-wide one, so on its own it cannot see an - // env-wide withdrawal that overlay disagrees with. Read the - // env-wide layer too, and let its withdrawal of the same form close - // it: an organization overlay can narrow intake, never re-open it. - // (The read the form is found in holds only that view's own body, - // which is open, so it withdraws nothing of its own.) - const layers: any[][] = []; - if (organizationId) { - const envWideRequest: TransportScopedMetaRequest = { - type: 'view', - ...(environmentId ? { environmentId } : {}), - }; - layers.push(listOf(await p.getMetaItems(envWideRequest))); - } - const match = findPublicFormView(items, slug, layers); + const match = findPublicFormView(items, slug); if (!match) return null; // [#21476] A form that cannot take intake on this posture is not // offered: `null` here IS the withdrawn form's answer on both From 7a9c23965857dc5fe3f0efed13155c3d4e29f0b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 07:18:36 +0000 Subject: [PATCH 02/17] test(cli,metadata-protocol): S1 pins, changeset, and the dead helpers the deleted read left (ADR-0131 D10, C7b S1) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH --- .../15211-ceremony-metadata-promotion.md | 27 +++++++++ .../organization-ownership-apply.test.ts | 14 +++-- .../organization-ownership-inventory.test.ts | 9 +++ ...ization-ownership-plan.integration.test.ts | 13 +++++ .../src/utils/organization-ownership-plan.ts | 5 ++ packages/metadata-protocol/src/protocol.ts | 58 +------------------ 6 files changed, 65 insertions(+), 61 deletions(-) create mode 100644 .changeset/15211-ceremony-metadata-promotion.md diff --git a/.changeset/15211-ceremony-metadata-promotion.md b/.changeset/15211-ceremony-metadata-promotion.md new file mode 100644 index 00000000000..28d6c5f1fe9 --- /dev/null +++ b/.changeset/15211-ceremony-metadata-promotion.md @@ -0,0 +1,27 @@ +--- +'@objectstack/cli': patch +'@objectstack/rest': patch +'@objectstack/metadata-protocol': patch +--- + +feat(cli): the organization-ownership ceremony promotes organization customizations and carries public-form withdrawals; the anonymous form doors read the environment alone (ADR-0131 D10) + +Clause-②: no + +`os migrate organization-ownership --apply` gains a step, `metadata-promotion`, after the authored catalog conversion: + +- **Form withdrawals are carried first, fail-closed, under every posture.** When an organization withdrew a public form (`sharing.enabled` or `sharing.allowAnonymous` set to `false`, link kept), every view the environment serves under that name that still serves the form open takes the withdrawal. That covers stored views and views served from code, in every package. The ceremony saves the withdrawal through the metadata door, into the view's own package. The judgement is the anonymous doors' own predicate. Any organization's withdrawal wins. +- **Under `single`, organization customizations become environment rows.** These are the `sys_metadata` rows of `view`, `dashboard`, `report`, `translation` and `email_template`. A row whose key (type, name, package, bundle member) no other row holds is promoted in place, by clearing its `organization_id`. +- **Conflicts are listed and block the step.** A key that two organizations hold, or an organization and the environment, is a conflict. The plan lists it (`metadataConflicts`, with every row id), and the apply blocks on it. The ceremony deletes none of those rows. Keep one row, remove the others, then re-run. +- **Under a wall, the rows are reported, not promoted.** One organization's customization is not every organization's, so those rows keep the column from being dropped. + +`GET /forms/:slug` and `POST /forms/:slug/submit` no longer read the Default Organization's legacy layer. Every metadata read is now environment → code. The doors judge each candidate against the environment's view list, which holds one item per package of a view name. So a withdrawal in one package still closes that name's form in every package. On a deployment with no organization this closes more than before, which is the fail-closed direction. + +The protocol's `getMetaItems` drops its undocumented `legacyFormOrganizationId` request key. It was never in the spec's `GetMetaItemsRequest`, and only those doors passed it. A caller that still passes it now gets the environment's list. + +Until the ceremony runs, a database whose withdrawals were saved only at the organization layer serves those forms open again. Run the ceremony as part of the v18 upgrade: + +```bash +os migrate organization-ownership --out plan.json # review plan.json: metadataConflicts lists every conflict +os migrate organization-ownership --apply --backup-taken +``` diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts index 7c1624c1426..23ff54c9215 100644 --- a/packages/cli/src/utils/organization-ownership-apply.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.test.ts @@ -142,7 +142,9 @@ function servedViews(c: ReturnType, code: Array 0; if (hasTable) { - for (const row of rows(`SELECT * FROM sys_metadata WHERE type = 'view' AND organization_id IS NULL AND state = 'active'`)) { + // Once column-drops has run, every row left is the environment's. + const scoped = rows(`SELECT name FROM pragma_table_info('sys_metadata') WHERE name = 'organization_id'`).length > 0; + for (const row of rows(`SELECT * FROM sys_metadata WHERE type = 'view' AND state = 'active'${scoped ? ' AND organization_id IS NULL' : ''}`)) { put(JSON.parse(String(row.metadata)) as Record, (row.package_id as string | null) ?? null); } } @@ -406,6 +408,7 @@ describe('ADR-0131 D10 --apply — the acceptance fixture', () => { expect(report.steps.map((s) => [s.step, s.status])).toEqual([ ['attribution', 'done'], ['authored-conversion', 'done'], + ['metadata-promotion', 'done'], ['template-promotion', 'done'], ['settings-move', 'done'], ['rewrite', 'done'], @@ -772,8 +775,11 @@ describe('ADR-0131 D10 --apply — organization customizations and form withdraw const md = (id: string, org: string | null, type: string, name: string, body: Record, pkg: string | null = null): string => `('${id}', ${org === null ? 'NULL' : `'${org}'`}, '${type}', '${name}', 'active', ${pkg === null ? 'NULL' : `'${pkg}'`}, NULL, '${JSON.stringify(body)}')`; const step = (report: OrganizationOwnershipApplyReport) => report.steps.find((s) => s.step === 'metadata-promotion')!; - const owners = (): Array<[unknown, unknown]> => - rows('SELECT id, organization_id FROM sys_metadata ORDER BY id').map((r) => [r.id, r.organization_id]); + /** Each row's owner; once column-drops has dropped the column, every row reads as the environment's (`null`). */ + const owners = (): Array<[unknown, unknown]> => { + const scoped = rows(`SELECT name FROM pragma_table_info('sys_metadata') WHERE name = 'organization_id'`).length > 0; + return rows(`SELECT id${scoped ? ', organization_id' : ''} FROM sys_metadata ORDER BY id`).map((r) => [r.id, r.organization_id ?? null]); + }; it('under single, an organization customization no other row holds becomes an environment row in place', async () => { metadataTable([ @@ -799,7 +805,7 @@ describe('ADR-0131 D10 --apply — organization customizations and form withdraw const report = await apply(c, ledger()); expect(step(report).status).toBe('blocked'); - expect(step(report).blockers.join('\n')).toMatch(/view deal_list is held by 2 rows .*md_org \(organization org_default, active\), md_env \(environment, active\)/); + expect(step(report).blockers.join('\n')).toMatch(/view deal_list is held by 2 rows .*md_env \(environment, active\), md_org \(organization org_default, active\)/); expect(writes.filter((w) => /sys_metadata/.test(w))).toEqual([]); expect(owners()).toEqual([['md_env', null], ['md_org', 'org_default']]); expect(c.viewSaves).toEqual([]); diff --git a/packages/cli/src/utils/organization-ownership-inventory.test.ts b/packages/cli/src/utils/organization-ownership-inventory.test.ts index c01b37cd2b9..3afc578072d 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.test.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.test.ts @@ -27,6 +27,7 @@ import { readHostFateSupplement, type InventoryEntry, } from './organization-ownership-inventory.js'; +import { PRESENTATIONAL_TYPES } from './organization-ownership-plan.js'; /** * The first census's platform population — 59 carrying the column, 25 not — @@ -310,3 +311,11 @@ describe('readHostFateSupplement — a file it cannot use is refused, naming the expect(await refusalOfAsync(() => readHostFateSupplement(file))).toMatchObject({ reason: 'fate-supplement-platform-object', file, row: 0, object: 'sys_member' }); }); }); + +describe('the promotion population is the inventory\'s', () => { + it('PRESENTATIONAL_TYPES, which the apply promotes, is the list the sys_metadata promotion category names', () => { + const category = inventoryEntryFor('sys_metadata')!.categories!.find((c) => c.id === 'organization-presentational-promotion')!; + const types = category.where!.find((c) => c.column === 'type') as { in?: readonly string[] }; + expect([...PRESENTATIONAL_TYPES].sort()).toEqual([...(types.in ?? [])].sort()); + }); +}); diff --git a/packages/cli/src/utils/organization-ownership-plan.integration.test.ts b/packages/cli/src/utils/organization-ownership-plan.integration.test.ts index f2fe6097343..27ad8e839d7 100644 --- a/packages/cli/src/utils/organization-ownership-plan.integration.test.ts +++ b/packages/cli/src/utils/organization-ownership-plan.integration.test.ts @@ -140,6 +140,19 @@ describe('ADR-0131 D10 preflight — the per-table plan', () => { 'organization-scoped-other-types': 1, 'environment-overlay-duplicates': 1, // (view, deal_list, no package) twice once the organization leaves the key }); + // The conflict list the promotion blocks on, by row id (rulings A on #22011 / #22008). + expect(metadata.metadataConflicts).toEqual([ + { + type: 'view', + name: 'deal_list', + packageId: null, + bundleMember: null, + candidates: [ + { id: 'md_2', organizationId: 'org_default', state: 'active' }, + { id: 'md_3', organizationId: 'org_b', state: 'active' }, + ], + }, + ]); expect(table(p, 'sys_position')).toMatchObject({ fate: 'mirror-deletion', diff --git a/packages/cli/src/utils/organization-ownership-plan.ts b/packages/cli/src/utils/organization-ownership-plan.ts index 2400d2428b1..a04d34140c3 100644 --- a/packages/cli/src/utils/organization-ownership-plan.ts +++ b/packages/cli/src/utils/organization-ownership-plan.ts @@ -727,6 +727,11 @@ async function applyAnchor( } async function planCategory(sql: Sql, table: string, columns: ReadonlySet, category: InventoryCategory): Promise<{ rows: number; groups?: number }> { + // A category that selects on the organization column has no rows left once + // the column is gone (the ceremony's post-check reads a dropped table too). + if (!columns.has(ORG) && conditionColumns(category.where).includes(ORG)) { + return category.conflictBy ? { rows: 0, groups: 0 } : { rows: 0 }; + } const t = sql.q(table, table); const where = predicateSql(sql, table, columns, category.where ?? []); if (!category.conflictBy) { diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index c3599572449..026db5d23b3 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -88,7 +88,7 @@ import { stripInjectedSystemColumns, // [#7774] The i18n-bundle identity table (#7730) and its reader. A type // listed here is identified by `(name, )`, so every merge in the - // unscoped `/meta` list keys on the pair — see {@link metaItemKey}. + // unscoped `/meta` list keys on the pair — see {@link mergePackageAwareOverlay}. ITEM_KEY_DISCRIMINATORS, itemDiscriminator, type MetadataItem, @@ -2389,62 +2389,6 @@ function isShippedViewItem(artifact: unknown): boolean { return typeof viewKind === 'string' && viewKind !== ''; } -/** - * ADR-0048 (#1828) — composite dedup identity for the unscoped metadata list. - * - * Two installed packages may legitimately ship the same `type`/`name` - * (e.g. `page/home`); the SchemaRegistry already stores them under distinct - * `${packageId}:${name}` keys. Any list-merge that deduplicates by bare `name` - * collapses the two packages' rows into one (last-write-wins), which is the - * bug this key closes. A `NUL` separator keeps names containing `:` unambiguous. - * - * [#7774] `discriminator` is a third component, and it is OPTIONAL on purpose: - * omitting it produces the exact two-component string this function has always - * produced, so every type identified by `(package, name)` keeps a - * byte-identical key and this change's blast radius is provable rather than - * argued. It is supplied only for a type listed in `ITEM_KEY_DISCRIMINATORS` - * (`email_template` today), whose identity the spec declares as - * `(name, locale)` — an i18n bundle. Without it the second locale's - * `Map.set` overwrote the first and `GET /meta/email_template` served one - * member of a bundle it had every member of. - */ -function metaItemKey( - packageId: string | null | undefined, - name: unknown, - discriminator?: string, -): string { - const base = `${packageId ?? ''}\u0000${String(name)}`; - return discriminator === undefined ? base : `${base}\u0000${discriminator}`; -} - -/** - * [#7774] The bundle discriminator a STORED `sys_metadata` row declares, read - * off the row's serialized body. - * - * The row's own COLUMNS cannot answer this: `sys_metadata`'s overlay - * uniqueness is `(type, name, organization_id, package_id)` — declared on - * `idx_sys_metadata_overlay_active` in `sys-metadata.object.ts` — and the - * table has no locale column at all. An `email_template` overlay's locale - * lives inside the `metadata` JSON payload, which is where its identity is. - * So a row-level merge keying on columns alone cannot tell two members of one - * bundle apart, and the body has to be consulted. - * - * The parse is skipped entirely for an undiscriminated type (all but one - * today): `undefined` comes back before any JSON work, so this costs nothing - * on the paths it does not serve. A body that fails to parse falls back to the - * canonical member instead of throwing — the caller a few lines on parses it - * again for real, and that failure is the one that should surface. - */ -function storedRowDiscriminator(type: string, record: unknown): string | undefined { - if (!ITEM_KEY_DISCRIMINATORS[type]) return undefined; - const raw = (record as { metadata?: unknown } | null | undefined)?.metadata; - let body: unknown = raw; - if (typeof raw === 'string') { - try { body = JSON.parse(raw); } catch { body = undefined; } - } - return itemDiscriminator(type, body); -} - /** * [#22877] The by-name doors' bundle member selector: the request member, and * the `?member=` query parameter in each REST route's closed set (ruling From d026bb21a945b24d794b9e133ee85e16763d2b3a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 07:50:50 +0000 Subject: [PATCH 03/17] feat(cli): --metadata-choice resolves a customization conflict; unchosen rows deleted after the chosen promotion is verified (ruling 6163114098 Q1 B, Q6 A; C7b S1) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH --- .../15211-ceremony-metadata-promotion.md | 6 +- .../migrate/organization-ownership.ts | 29 ++- .../organization-ownership-apply.test.ts | 115 ++++++++- .../src/utils/organization-ownership-apply.ts | 232 +++++++++++++++--- 4 files changed, 344 insertions(+), 38 deletions(-) diff --git a/.changeset/15211-ceremony-metadata-promotion.md b/.changeset/15211-ceremony-metadata-promotion.md index 28d6c5f1fe9..429f6560517 100644 --- a/.changeset/15211-ceremony-metadata-promotion.md +++ b/.changeset/15211-ceremony-metadata-promotion.md @@ -12,8 +12,8 @@ Clause-②: no - **Form withdrawals are carried first, fail-closed, under every posture.** When an organization withdrew a public form (`sharing.enabled` or `sharing.allowAnonymous` set to `false`, link kept), every view the environment serves under that name that still serves the form open takes the withdrawal. That covers stored views and views served from code, in every package. The ceremony saves the withdrawal through the metadata door, into the view's own package. The judgement is the anonymous doors' own predicate. Any organization's withdrawal wins. - **Under `single`, organization customizations become environment rows.** These are the `sys_metadata` rows of `view`, `dashboard`, `report`, `translation` and `email_template`. A row whose key (type, name, package, bundle member) no other row holds is promoted in place, by clearing its `organization_id`. -- **Conflicts are listed and block the step.** A key that two organizations hold, or an organization and the environment, is a conflict. The plan lists it (`metadataConflicts`, with every row id), and the apply blocks on it. The ceremony deletes none of those rows. Keep one row, remove the others, then re-run. -- **Under a wall, the rows are reported, not promoted.** One organization's customization is not every organization's, so those rows keep the column from being dropped. +- **Conflicts are listed and block the step until the operator chooses.** A key that two organizations hold, or an organization and the environment, is a conflict. The plan lists it (`metadataConflicts`, with every row id), and the apply blocks on it. Choose the row the environment keeps with `--metadata-choice ROW_ID`, once per conflict. The chosen row is promoted and read back, any public-form withdrawal an unchosen row holds is carried onto it, and only then are the unchosen rows deleted, by id. No row of a conflict is deleted before a choice is made. +- **Under a wall, nothing is promoted unless the operator names the row.** One organization's customization is not every organization's. A row named with `--metadata-choice` is promoted knowingly; the rest are reported and keep the column from being dropped. `GET /forms/:slug` and `POST /forms/:slug/submit` no longer read the Default Organization's legacy layer. Every metadata read is now environment → code. The doors judge each candidate against the environment's view list, which holds one item per package of a view name. So a withdrawal in one package still closes that name's form in every package. On a deployment with no organization this closes more than before, which is the fail-closed direction. @@ -23,5 +23,5 @@ Until the ceremony runs, a database whose withdrawals were saved only at the org ```bash os migrate organization-ownership --out plan.json # review plan.json: metadataConflicts lists every conflict -os migrate organization-ownership --apply --backup-taken +os migrate organization-ownership --apply --backup-taken --metadata-choice ROW_ID # one per listed conflict ``` diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts index d8471ae3010..bd5e699eb26 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.ts @@ -33,6 +33,7 @@ import { import { applyOrganizationOwnership, checkpointFromDetails, + MetadataChoiceRefusal, ORGANIZATION_OWNERSHIP_MIGRATION_ID, TemplateChoiceRefusal, type ApplyExecutor, @@ -173,6 +174,14 @@ export default class MigrateOrganizationOwnership extends Command { 'deleted only after the chosen body is promoted and verified', multiple: true, }), + 'metadata-choice': Flags.string({ + description: + 'With --apply: the sys_metadata row id the environment keeps for a listed customization conflict (rows ' + + 'of one type, name and package once the organization leaves the key). Repeat once per conflict; the ' + + 'other rows of that conflict are deleted only after the chosen row is promoted and read back. Under a ' + + 'wall, also an organization row promoted knowingly', + multiple: true, + }), 'fate-supplement': Flags.string({ description: 'A JSON file of the fates the host declares for the objects it provides (OrganizationOwnershipSupplementSchema, ' + @@ -237,6 +246,10 @@ export default class MigrateOrganizationOwnership extends Command { await refuse({ reason: 'flag-without-apply', detail: '--template-choice is only meaningful with --apply' }, '--template-choice is only meaningful with --apply.'); return; } + if (flags['metadata-choice']?.length) { + await refuse({ reason: 'flag-without-apply', detail: '--metadata-choice is only meaningful with --apply' }, '--metadata-choice is only meaningful with --apply.'); + return; + } if (flags['backup-taken']) { await refuse({ reason: 'flag-without-apply', detail: '--backup-taken is only meaningful with --apply' }, '--backup-taken is only meaningful with --apply.'); return; @@ -339,7 +352,7 @@ export default class MigrateOrganizationOwnership extends Command { printWarning( `Customization conflict ${conflict.type} ${conflict.name}${conflict.packageId ? ` (package ${conflict.packageId})` : ''}: ` + conflict.candidates.map((c) => `${c.id} (${c.organizationId ? `organization ${c.organizationId}` : 'environment'})`).join(', ') + - ' hold one key. --apply blocks on it until one row is kept and the others are removed.', + ' hold one key. --apply blocks on it until one row is chosen with --metadata-choice ROW_ID.', ); } } @@ -358,7 +371,14 @@ export default class MigrateOrganizationOwnership extends Command { /** `--apply`: the backup precondition, the boot, the ceremony, the report file. */ private async runApply( - flags: { 'database-url'?: string; out?: string; json?: boolean; 'backup-taken'?: boolean; 'template-choice'?: string[] }, + flags: { + 'database-url'?: string; + out?: string; + json?: boolean; + 'backup-taken'?: boolean; + 'template-choice'?: string[]; + 'metadata-choice'?: string[]; + }, posture: string, refuse: (payload: Record, message: string) => Promise, inventory: readonly InventoryEntry[] | undefined, @@ -457,6 +477,7 @@ export default class MigrateOrganizationOwnership extends Command { report = await applyOrganizationOwnership({ executor, ...(flags['template-choice']?.length ? { templateChoices: flags['template-choice'] } : {}), + ...(flags['metadata-choice']?.length ? { metadataChoices: flags['metadata-choice'] } : {}), posture, database: stack.dbLabel, catalog, @@ -538,6 +559,10 @@ export default class MigrateOrganizationOwnership extends Command { }); } catch (error: unknown) { if (isExitSignal(error)) throw error; + if (error instanceof MetadataChoiceRefusal) { + await refuse({ reason: 'metadata-choice-refused', detail: error.message }, `Refused: ${error.message}`); + return; + } if (error instanceof TemplateChoiceRefusal) { await refuse({ reason: 'template-choice-refused', detail: error.message }, `Refused: ${error.message}`); return; diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts index 23ff54c9215..6feea505c5c 100644 --- a/packages/cli/src/utils/organization-ownership-apply.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.test.ts @@ -25,6 +25,7 @@ import { permissionSetDefinitionFromRow } from '@objectstack/core'; import { anonymousFormIntakeCandidates, anonymousFormIntakeWithdrawnIn } from '@objectstack/metadata-core'; import { applyOrganizationOwnership, + MetadataChoiceRefusal, TemplateChoiceRefusal, type ApplyExecutor, type CatalogPort, @@ -340,13 +341,14 @@ async function accessAfter(c: ReturnType): Promise { const apply = ( c: ReturnType, l: ReturnType, - opts: { posture?: string; executor?: ApplyExecutor; templateChoices?: string[]; views?: ServedViewsPort } = {}, + opts: { posture?: string; executor?: ApplyExecutor; templateChoices?: string[]; metadataChoices?: string[]; views?: ServedViewsPort } = {}, ): Promise => { c.boot(); return applyOrganizationOwnership({ views: opts.views ?? servedViews(c), executor: opts.executor ?? executor(), ...(opts.templateChoices ? { templateChoices: opts.templateChoices } : {}), + ...(opts.metadataChoices ? { metadataChoices: opts.metadataChoices } : {}), posture: opts.posture ?? 'single', database: file, catalog: c, @@ -896,3 +898,114 @@ describe('ADR-0131 D10 --apply — organization customizations and form withdraw expect(step(report).blockers.join('\n')).toMatch(/lead-intake of the view crm_lead\.intake \(package pkg_a\).*NOT_OVERRIDABLE.*sharing\.enabled false/); }); }); + +describe('ADR-0131 D10 --apply — a customization conflict resolved by --metadata-choice (ruling 6163114098: Q1 B, Q6 A)', () => { + const metadataTable = (values: string): void => { + db.exec(` + CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, name TEXT, state TEXT, + package_id TEXT, bundle_member TEXT, metadata TEXT); + INSERT INTO sys_metadata VALUES ${values}; + `); + }; + const md = (id: string, org: string | null, type: string, name: string, body: Record, pkg: string | null = null): string => + `('${id}', ${org === null ? 'NULL' : `'${org}'`}, '${type}', '${name}', 'active', ${pkg === null ? 'NULL' : `'${pkg}'`}, NULL, '${JSON.stringify(body)}')`; + const step = (report: OrganizationOwnershipApplyReport) => report.steps.find((s) => s.step === 'metadata-promotion')!; + const owners = (): Array<[unknown, unknown]> => { + const scoped = rows(`SELECT name FROM pragma_table_info('sys_metadata') WHERE name = 'organization_id'`).length > 0; + return rows(`SELECT id${scoped ? ', organization_id' : ''} FROM sys_metadata ORDER BY id`).map((r) => [r.id, r.organization_id ?? null]); + }; + const deletes = (): string[] => writes.filter((w) => /^DELETE FROM "sys_metadata"/.test(w)); + const conflictRows = [ + md('md_org', 'org_default', 'view', 'deal_list', { name: 'deal_list', label: 'Org list' }), + md('md_env', null, 'view', 'deal_list', { name: 'deal_list', label: 'Env list' }), + ].join(', '); + + it('⭐ no row of a conflict is deleted before a choice is made', async () => { + metadataTable(conflictRows); + const report = await apply(catalog(), ledger()); + expect(step(report).status).toBe('blocked'); + expect(step(report).blockers.join('\n')).toMatch(/choose one with --metadata-choice ROW_ID/); + expect(deletes()).toEqual([]); + expect(owners()).toEqual([['md_env', null], ['md_org', 'org_default']]); + }); + + it('choosing the organization\'s row: it is promoted and read back, then the environment row it displaces is deleted by id', async () => { + metadataTable(conflictRows); + const report = await apply(catalog(), ledger(), { metadataChoices: ['md_org'] }); + expect(step(report)).toMatchObject({ status: 'done', summary: { chosen: 1, unchosenDeleted: 1, conflicts: 0 } }); + expect(owners()).toEqual([['md_org', null]]); + // The promotion's write precedes the delete. + const promote = writes.findIndex((w) => /^UPDATE "sys_metadata" SET "organization_id" = NULL/.test(w)); + const remove = writes.findIndex((w) => /^DELETE FROM "sys_metadata"/.test(w)); + expect(promote).toBeGreaterThanOrEqual(0); + expect(remove).toBeGreaterThan(promote); + }); + + it('choosing the environment\'s row: the organization row is deleted by id, and nothing is promoted', async () => { + metadataTable(conflictRows); + const report = await apply(catalog(), ledger(), { metadataChoices: ['md_env'] }); + expect(step(report)).toMatchObject({ status: 'done', summary: { chosen: 1, unchosenDeleted: 1, promoted: 0 } }); + expect(owners()).toEqual([['md_env', null]]); + }); + + it('two organizations on one key: the chosen one is promoted, the other deleted', async () => { + metadataTable([md('md_a', 'org_a', 'translation', 'fr', { name: 'fr', a: 1 }), md('md_b', 'org_b', 'translation', 'fr', { name: 'fr', b: 1 })].join(', ')); + const report = await apply(catalog(), ledger(), { metadataChoices: ['md_b'] }); + expect(step(report).summary).toMatchObject({ chosen: 1, unchosenDeleted: 1 }); + expect(owners()).toEqual([['md_b', null]]); + }); + + it('⭐ fail-closed: choosing a row that serves a form an unchosen row withdraws carries the withdrawal onto it before the delete', async () => { + metadataTable([ + md('md_org', 'org_default', 'view', 'crm_lead.intake', formView(false, 'Org copy')), + md('md_env', null, 'view', 'crm_lead.intake', formView(true, 'Env copy')), + ].join(', ')); + const c = catalog(); + const views = servedViews(c); + const report = await apply(c, ledger(), { views, metadataChoices: ['md_env'] }); + expect(step(report).status).toBe('done'); + expect(c.viewSaves.map((v) => [v.packageId, (v.body.config as any).sharing.enabled])).toEqual( + expect.arrayContaining([[null, false]]), + ); + expect(owners()).toEqual([['md_env', null]]); + expect(await doorsServe(views, 'lead-intake')).toEqual([]); + }); + + it('a door that refuses that carry blocks the conflict, and its unchosen row is not deleted', async () => { + metadataTable([ + md('md_org', 'org_default', 'view', 'crm_lead.intake', formView(false, 'Org copy')), + md('md_env', null, 'view', 'crm_lead.intake', formView(true, 'Env copy')), + ].join(', ')); + const c = catalog(); + const refusing: ReturnType = { + ...c, + async save(type, name, body, target) { + if (type === 'view' && target?.packageId === null) throw new Error('VALIDATION_ERROR: refused'); + return c.save(type, name, body, target); + }, + }; + const report = await apply(refusing, ledger(), { views: servedViews(c), metadataChoices: ['md_env'] }); + expect(step(report).status).toBe('blocked'); + expect(step(report).blockers.join('\n')).toMatch(/no unchosen row is deleted until then/); + expect(deletes()).toEqual([]); + expect(owners()).toEqual([['md_env', null], ['md_org', 'org_default']]); + }); + + it('under a wall a row is promoted only when chosen; the rest stay reported', async () => { + metadataTable([md('md_a', 'org_a', 'dashboard', 'sales', { name: 'sales' }), md('md_b', 'org_a', 'report', 'pipe', { name: 'pipe' })].join(', ')); + const report = await apply(catalog(), ledger(), { posture: 'isolated', metadataChoices: ['md_a'] }); + expect(step(report).summary).toMatchObject({ promoted: 1, reported: 1 }); + expect(owners()).toEqual([['md_a', null], ['md_b', 'org_a']]); + }); + + it('refuses, before any write, a choice naming no row awaiting one, and two choices for one conflict', async () => { + metadataTable([conflictRows, md('md_lone', 'org_default', 'dashboard', 'sales', { name: 'sales' })].join(', ')); + await expect(apply(catalog(), ledger(), { metadataChoices: ['md_nope'] })).rejects.toBeInstanceOf(MetadataChoiceRefusal); + // Under single a lone row is promoted without a choice, so it awaits none. + await expect(apply(catalog(), ledger(), { metadataChoices: ['md_lone'] })).rejects.toThrow(/md_lone names no row awaiting a choice/); + await expect(apply(catalog(), ledger(), { metadataChoices: ['md_org', 'md_env'] })).rejects.toThrow( + /md_org and md_env both choose for view deal_list/, + ); + expect(writes).toEqual([]); + }); +}); diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index cb4c183d921..0db5b8a5420 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -27,6 +27,7 @@ import { predicateSql, Sql, type AttributionSink, + type MetadataConflict, type MetadataKeyRow, type TemplateConflict, type OrganizationOwnershipPlan, @@ -64,9 +65,12 @@ import { * presentational types whose overlay key no other row holds becomes an * environment row in place (its `organization_id` cleared). A key held by * more than one row is a conflict: listed, kept, and blocking until the - * operator keeps one row (the ceremony deletes none of them: `sys_metadata` - * is not a fate-2 table). Under a wall the rows are reported, never - * promoted: one organization's view is not every organization's. + * operator chooses one row with `--metadata-choice ROW_ID`; the chosen row + * is promoted and read back, any form withdrawal an unchosen row holds is + * carried onto it, and only then are the unchosen rows deleted, by id + * (ruling B on #23413 Q1, the template path's shape). Under a wall + * nothing is promoted unless the operator names the row (ruling A on + * #23413 Q6): the rest are reported and hold the column. * 2b. `template-promotion` — ruling C on ADR-0131 §6 Q1: every * `sys_email_template` row an organization customized or authored becomes * an environment `email_template` item through the metadata door (core's @@ -260,6 +264,13 @@ export interface ApplyOptions { * anything is written ({@link TemplateChoiceRefusal}). */ templateChoices?: readonly string[]; + /** + * The operator's choice per `sys_metadata` conflict (the row the environment + * keeps; the others are deleted once it is promoted and read back), and under + * a wall the organization rows promoted knowingly. Validated before anything + * is written ({@link MetadataChoiceRefusal}). + */ + metadataChoices?: readonly string[]; now?: () => Date; /** One line per milestone, for the command's human face. */ onProgress?: (message: string) => void; @@ -316,6 +327,14 @@ interface Run { verifiedTemplateIds: Set; } +/** A `--metadata-choice` that names no row awaiting a choice, or two of one conflict. Nothing is written. */ +export class MetadataChoiceRefusal extends Error { + constructor(message: string) { + super(message); + this.name = 'MetadataChoiceRefusal'; + } +} + /** A `--template-choice` that names no candidate of a listed conflict, or two of one. Nothing is written. */ export class TemplateChoiceRefusal extends Error { constructor(message: string) { @@ -735,8 +754,108 @@ async function carryFormWithdrawals(run: Run, rows: readonly MetadataKeyRow[], s } } +/** One conflict the operator chose a row for, and the rows that go once that row is promoted and verified. */ +interface MetadataResolution { + conflict: MetadataConflict; + chosen: MetadataConflict['candidates'][number]; + unchosen: string[]; +} + +/** The `metadata` bodies of the given `sys_metadata` rows, by id. */ +async function metadataBodies(run: Run, ids: readonly string[]): Promise | null>> { + const t = METADATA_OBJECT; + const sql = run.sql; + const out = new Map | null>(); + for (let i = 0; i < ids.length; i += IN_CHUNK) { + const chunk = ids.slice(i, i + IN_CHUNK); + const found = await sql.read( + t, + `SELECT ${['id', 'name', 'metadata'].map((c) => `${sql.q(c, t)} AS ${sql.q(c, t)}`).join(', ')} ` + + `FROM ${sql.q(t, t)} WHERE ${sql.q('id', t)} IN (${chunk.map(() => '?').join(', ')})`, + chunk, + ); + for (const row of found) out.set(String(row.id), parseBody(row)); + } + return out; +} + +/** Clear `organization_id` on the given rows of one organization. */ +async function promoteRows(run: Run, organizationId: string, ids: readonly string[]): Promise { + const t = run.sql.q(METADATA_OBJECT, METADATA_OBJECT); + const org = run.sql.q(ORG, METADATA_OBJECT); + const id = run.sql.q('id', METADATA_OBJECT); + for (let i = 0; i < ids.length; i += IN_CHUNK) { + const chunk = ids.slice(i, i + IN_CHUNK); + await execute( + run, + METADATA_OBJECT, + `UPDATE ${t} SET ${org} = NULL WHERE ${org} = ? AND ${id} IN (${chunk.map(() => '?').join(', ')})`, + [organizationId, ...chunk], + ); + } +} + +/** + * Resolve one chosen conflict: promote the chosen row (an organization's), read + * it back as an environment row, carry onto it every public-form withdrawal an + * unchosen row holds (fail-closed: choosing never reopens a form), and only then + * delete the unchosen rows, by id — ruling B on #23413 Q1, the `--template-choice` + * path's shape. Returns a blocker, or `null` once the rows are gone. + */ +async function resolveMetadataConflict(run: Run, r: MetadataResolution): Promise { + const { conflict, chosen, unchosen } = r; + const sql = run.sql; + const t = sql.q(METADATA_OBJECT, METADATA_OBJECT); + const id = sql.q('id', METADATA_OBJECT); + const what = `the ${conflict.type} ${conflict.name}${conflict.packageId ? ` (package ${conflict.packageId})` : ''}`; + if (chosen.organizationId !== null) await promoteRows(run, chosen.organizationId, [chosen.id]); + const back = await sql.read( + METADATA_OBJECT, + `SELECT ${sql.q(ORG, METADATA_OBJECT)} AS ${sql.q(ORG, METADATA_OBJECT)} FROM ${t} WHERE ${id} = ?`, + [chosen.id], + ); + if (back.length !== 1 || (back[0]![ORG] !== null && back[0]![ORG] !== undefined && back[0]![ORG] !== '')) { + throw new Error( + `The chosen row ${chosen.id} of ${what} did not read back as an environment row; none of the rows ` + + `${unchosen.join(', ')} was deleted.`, + ); + } + if (conflict.type === 'view') { + const bodies = await metadataBodies(run, [chosen.id, ...unchosen]); + const body = bodies.get(chosen.id); + const layer = unchosen.map((u) => bodies.get(u)).filter((b): b is Record => !!b); + if (body) { + const closing = anonymousFormIntakeCandidates(body).filter((c) => anonymousFormIntakeWithdrawnIn(layer, body, c)); + if (closing.length > 0) { + for (const candidate of closing) { + candidate.form.sharing = { ...(candidate.form.sharing as Record), enabled: false }; + } + try { + await run.opts.door.save('view', conflict.name, body, { packageId: conflict.packageId }); + } catch (error) { + return ( + `The row ${chosen.id} chosen for ${what} serves the public form(s) ${closing.map((c) => c.slug).join(', ')} ` + + 'open, an unchosen row withdraws them, and the metadata door refused to carry that withdrawal onto the ' + + `chosen row: ${error instanceof Error ? error.message : String(error)}. Withdraw the form on the chosen row ` + + '(sharing.enabled false), then re-run; no unchosen row is deleted until then.' + ); + } + } + } + } + const ids = unchosen; + const where = `${id} IN (${ids.map(() => '?').join(', ')})`; + await execute(run, METADATA_OBJECT, `DELETE FROM ${t} WHERE ${where}`, ids); + if ((await sql.count(METADATA_OBJECT, `SELECT COUNT(*) AS n FROM ${t} WHERE ${where}`, ids)) !== 0) { + throw new Error(`The delete of the unchosen rows of ${what} left rows behind.`); + } + run.deleted.set(METADATA_OBJECT, (run.deleted.get(METADATA_OBJECT) ?? 0) + ids.length); + run.opts.onProgress?.(`promotion: ${what} keeps row ${chosen.id}; ${ids.length} unchosen row(s) deleted`); + return null; +} + async function runMetadataPromotion(run: Run): Promise { - const summary: Record = { withdrawalsCarried: 0, promoted: 0, conflicts: 0, reported: 0 }; + const summary: Record = { withdrawalsCarried: 0, promoted: 0, conflicts: 0, chosen: 0, unchosenDeleted: 0, reported: 0 }; const blockers: string[] = []; const notes: string[] = []; const cols = await columnsOf(run, METADATA_OBJECT); @@ -748,52 +867,100 @@ async function runMetadataPromotion(run: Run): Promise { // Withdrawals first, under every posture: closing a form is never a leak. await carryFormWithdrawals(run, rows, summary, blockers); - if (run.opts.posture !== 'single') { - summary.reported = orgRows.length; - notes.push( - `${orgRows.length} organization-scoped presentational ${METADATA_OBJECT} row(s) are reported, not promoted: under the ` + - `${run.opts.posture} posture one organization's customization is not the environment's. They keep the ` + - `${METADATA_OBJECT} column from being dropped.`, - ); - return { step: 'metadata-promotion', status: blockers.length ? 'blocked' : 'done', summary, blockers, notes }; - } - + // Under a wall nothing is promoted unless the operator names the row + // (--metadata-choice): one organization's customization is not the + // environment's, and only a knowing choice makes it so (ruling A on #23413 Q6). + const walled = run.opts.posture !== 'single'; + const choices = new Set(run.opts.metadataChoices ?? []); const { promotable, conflicts } = groupMetadataKeyRows(rows); - summary.conflicts = conflicts.length; + + const resolutions: MetadataResolution[] = []; for (const conflict of conflicts) { + const chosen = conflict.candidates.find((c) => choices.has(c.id)); + if (chosen) { + resolutions.push({ conflict, chosen, unchosen: conflict.candidates.filter((c) => c.id !== chosen.id).map((c) => c.id) }); + continue; + } + summary.conflicts++; blockers.push( `The ${conflict.type} ${conflict.name}` + `${conflict.packageId ? ` (package ${conflict.packageId})` : ''}${conflict.bundleMember ? ` [${conflict.bundleMember}]` : ''} ` + `is held by ${conflict.candidates.length} rows once the organization leaves its key: ` + conflict.candidates.map((c) => `${c.id} (${c.organizationId ? `organization ${c.organizationId}` : 'environment'}, ${c.state ?? 'no state'})`).join(', ') + - '. Which one the environment keeps is never guessed, and the ceremony deletes none of them: keep one row and ' + - `remove the others from ${METADATA_OBJECT}, then re-run.` + - (conflict.type === 'view' - ? ' If any of them withdraws a public form, keep one that withdraws it too: an upgrade never reopens a withdrawn form.' - : ''), + '. Which one the environment keeps is never guessed: choose one with --metadata-choice ROW_ID, then re-run. ' + + 'The others are deleted only once the chosen row is promoted and read back' + + (conflict.type === 'view' ? ', and a public form any of them withdraws stays withdrawn on the chosen row.' : '.'), ); } - const t = run.sql.q(METADATA_OBJECT, METADATA_OBJECT); - const org = run.sql.q(ORG, METADATA_OBJECT); - const id = run.sql.q('id', METADATA_OBJECT); + + const lone = promotable.filter((row) => !walled || choices.has(row.id)); const byOrganization = new Map(); - for (const row of promotable) byOrganization.set(row.organizationId!, [...(byOrganization.get(row.organizationId!) ?? []), row.id]); + for (const row of lone) byOrganization.set(row.organizationId!, [...(byOrganization.get(row.organizationId!) ?? []), row.id]); for (const [organizationId, ids] of byOrganization) { - for (let i = 0; i < ids.length; i += IN_CHUNK) { - const chunk = ids.slice(i, i + IN_CHUNK); - await execute( - run, - METADATA_OBJECT, - `UPDATE ${t} SET ${org} = NULL WHERE ${org} = ? AND ${id} IN (${chunk.map(() => '?').join(', ')})`, - [organizationId, ...chunk], + await promoteRows(run, organizationId, ids); + summary.promoted += ids.length; + } + for (const resolution of resolutions) { + const blocker = await resolveMetadataConflict(run, resolution); + if (blocker) { + blockers.push(blocker); + continue; + } + summary.chosen++; + summary.unchosenDeleted += resolution.unchosen.length; + } + + if (walled) { + const held = promotable.length - lone.length; + summary.reported = held; + if (held > 0) { + notes.push( + `${held} organization-scoped presentational ${METADATA_OBJECT} row(s) are reported, not promoted: under the ` + + `${run.opts.posture} posture one organization's customization is not the environment's. Promote one ` + + `knowingly with --metadata-choice ROW_ID; until then they keep the ${METADATA_OBJECT} column from being dropped.`, ); } - summary.promoted += ids.length; } if (summary.promoted) run.opts.onProgress?.(`promotion: ${summary.promoted} organization customization(s) are environment rows now`); return { step: 'metadata-promotion', status: blockers.length ? 'blocked' : 'done', summary, blockers, notes }; } +/** + * Refuse, before anything is written, a `--metadata-choice` that names no row + * awaiting a choice — a candidate of a listed conflict or, under a wall, an + * organization's presentational row its key alone holds — or two candidates + * of one conflict. + */ +async function validateMetadataChoices(run: Run): Promise { + const choices = run.opts.metadataChoices ?? []; + if (choices.length === 0) return; + const cols = await columnsOf(run, METADATA_OBJECT); + const rows = cols?.has(ORG) ? await readMetadataKeyRows(run.sql, METADATA_OBJECT, cols) : []; + const { promotable, conflicts } = groupMetadataKeyRows(rows); + const keyOf = (c: MetadataConflict): string => `${c.type} ${c.name}${c.packageId ? ` (package ${c.packageId})` : ''}`; + const groupOf = new Map(); + for (const c of conflicts) for (const cand of c.candidates) groupOf.set(cand.id, keyOf(c)); + if (run.opts.posture !== 'single') for (const row of promotable) groupOf.set(row.id, `the ${row.type} ${row.name} (row ${row.id})`); + const listed = conflicts.length + ? conflicts.map((c) => `${keyOf(c)}: ${c.candidates.map((cand) => cand.id).join(', ')}`).join('; ') + : 'none'; + const seen = new Map(); + for (const id of choices) { + const group = groupOf.get(id); + if (!group) { + throw new MetadataChoiceRefusal( + `--metadata-choice ${id} names no row awaiting a choice. The conflicts are: ${listed}` + + (run.opts.posture !== 'single' ? '; under a wall, any organization row of a presentational type its key alone holds.' : '.'), + ); + } + const other = seen.get(group); + if (other !== undefined && other !== id) { + throw new MetadataChoiceRefusal(`--metadata-choice ${other} and ${id} both choose for ${group}; choose exactly one row per conflict.`); + } + seen.set(group, id); + } +} + // ── Step 2b: template promotion (ruling C) ────────────────────────────────── async function runTemplatePromotion(run: Run): Promise { @@ -1369,6 +1536,7 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise Date: Wed, 14 Oct 2026 08:23:51 +0000 Subject: [PATCH 04/17] feat(cli): history-renumber step moves into S1; event_seq and version lineages unique environment-wide before the column goes (seat answer Q2 A, C7b S1) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH --- .../15211-ceremony-metadata-promotion.md | 2 + .../organization-ownership-apply.test.ts | 109 +++++++++++- .../src/utils/organization-ownership-apply.ts | 160 ++++++++++++++++++ 3 files changed, 267 insertions(+), 4 deletions(-) diff --git a/.changeset/15211-ceremony-metadata-promotion.md b/.changeset/15211-ceremony-metadata-promotion.md index 429f6560517..3474a1aad14 100644 --- a/.changeset/15211-ceremony-metadata-promotion.md +++ b/.changeset/15211-ceremony-metadata-promotion.md @@ -15,6 +15,8 @@ Clause-②: no - **Conflicts are listed and block the step until the operator chooses.** A key that two organizations hold, or an organization and the environment, is a conflict. The plan lists it (`metadataConflicts`, with every row id), and the apply blocks on it. Choose the row the environment keeps with `--metadata-choice ROW_ID`, once per conflict. The chosen row is promoted and read back, any public-form withdrawal an unchosen row holds is carried onto it, and only then are the unchosen rows deleted, by id. No row of a conflict is deleted before a choice is made. - **Under a wall, nothing is promoted unless the operator names the row.** One organization's customization is not every organization's. A row named with `--metadata-choice` is promoted knowingly; the rest are reported and keep the column from being dropped. +A second new step, `history-renumber`, runs first. Once the organization leaves the key, `sys_metadata_history.event_seq` and every `(type, name, version)` lineage must be unique environment-wide. Each organization's event range is shifted by one offset, and its commit ranges move with it. A version lineage is shifted only where another lineage of the same item holds those numbers, and the organization's overlay row's own `version` moves with it. Order within an organization, and the version a rollback resolves, are unchanged. The step writes numbers only, deletes nothing, and is safe to re-run after an interruption. + `GET /forms/:slug` and `POST /forms/:slug/submit` no longer read the Default Organization's legacy layer. Every metadata read is now environment → code. The doors judge each candidate against the environment's view list, which holds one item per package of a view name. So a withdrawal in one package still closes that name's form in every package. On a deployment with no organization this closes more than before, which is the fail-closed direction. The protocol's `getMetaItems` drops its undocumented `legacyFormOrganizationId` request key. It was never in the spec's `GetMetaItemsRequest`, and only those doors passed it. A caller that still passes it now gets the environment's list. diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts index 6feea505c5c..7c3b35ba929 100644 --- a/packages/cli/src/utils/organization-ownership-apply.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.test.ts @@ -410,6 +410,7 @@ describe('ADR-0131 D10 --apply — the acceptance fixture', () => { expect(report.steps.map((s) => [s.step, s.status])).toEqual([ ['attribution', 'done'], ['authored-conversion', 'done'], + ['history-renumber', 'done'], ['metadata-promotion', 'done'], ['template-promotion', 'done'], ['settings-move', 'done'], @@ -514,7 +515,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', 'column-drops', + 'attribution', 'authored-conversion', 'history-renumber', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite', 'mirror-deletion', 'column-drops', ]); expect(again.steps.every((s) => s.status === 'checkpointed')).toBe(true); expect(again.complete).toBe(true); @@ -526,14 +527,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', 'history-renumber', 'metadata-promotion', 'template-promotion', 'settings-move', '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']); + expect(resumed.resumedFrom).toEqual(['attribution', 'authored-conversion', 'history-renumber', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite']); + expect(resumed.steps.map((s) => s.status)).toEqual(['checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', '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 }]); @@ -1009,3 +1010,103 @@ describe('ADR-0131 D10 --apply — a customization conflict resolved by --metada expect(writes).toEqual([]); }); }); + +describe('ADR-0131 D10 --apply — history renumbering (seat answers 6162538253, Q2 A)', () => { + const history = (): void => { + db.exec(` + CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, name TEXT, state TEXT, + package_id TEXT, bundle_member TEXT, version INTEGER); + INSERT INTO sys_metadata VALUES + ('row_env', NULL, 'object', 'deal', 'active', NULL, NULL, 2), + ('row_a', 'org_a', 'view', 'board', 'active', NULL, NULL, 3), + ('row_b', 'org_b', 'view', 'board', 'active', NULL, NULL, 2); + ALTER TABLE sys_metadata ADD COLUMN metadata TEXT; + CREATE TABLE sys_metadata_history (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, name TEXT, + version INTEGER, event_seq INTEGER, checksum TEXT); + INSERT INTO sys_metadata_history VALUES + ('h_env1', NULL, 'object', 'deal', 1, 1, 'e1'), + ('h_env2', NULL, 'object', 'deal', 2, 2, 'e2'), + ('h_env3', NULL, 'view', 'board', 1, 3, 'e3'), + ('h_a1', 'org_a', 'view', 'board', 1, 1, 'a1'), + ('h_a2', 'org_a', 'view', 'board', 2, 2, 'a2'), + ('h_a3', 'org_a', 'view', 'board', 3, 3, 'a3'), + ('h_b1', 'org_b', 'view', 'board', 1, 1, 'b1'), + ('h_b2', 'org_b', 'view', 'board', 2, 2, 'b2'), + ('h_c1', 'org_c', 'report', 'solo', 1, 1, 'c1'); + CREATE TABLE sys_metadata_commit (id TEXT PRIMARY KEY, organization_id TEXT, event_seq_start INTEGER, event_seq_end INTEGER); + INSERT INTO sys_metadata_commit VALUES + ('c_env', NULL, 1, 3), ('c_a', 'org_a', 1, 3), ('c_b', 'org_b', 1, 2); + `); + }; + const seqs = (): Array<[unknown, unknown, unknown, unknown]> => + rows('SELECT id, organization_id, event_seq, version FROM sys_metadata_history ORDER BY id').map((r) => [r.id, r.organization_id, r.event_seq, r.version]); + const step = (report: OrganizationOwnershipApplyReport) => report.steps.find((s) => s.step === 'history-renumber')!; + + it('makes event_seq and every (type, name, version) unique environment-wide, writing numbers only', async () => { + history(); + const before = rows('SELECT COUNT(*) AS n FROM sys_metadata_history'); + const report = await apply(catalog(), ledger(), { posture: 'isolated' }); + expect(step(report)).toMatchObject({ status: 'done', summary: { organizationsShifted: 3, lineagesShifted: 2 } }); + expect(rows('SELECT COUNT(*) AS n FROM sys_metadata_history')).toEqual(before); + expect(rows('SELECT event_seq, COUNT(*) AS n FROM sys_metadata_history GROUP BY event_seq HAVING COUNT(*) > 1')).toEqual([]); + expect(rows('SELECT type, name, version FROM sys_metadata_history GROUP BY type, name, version HAVING COUNT(*) > 1')).toEqual([]); + expect(writes.some((w) => /^DELETE/.test(w))).toBe(false); + }); + + it('⭐ order within each organization and the version a rollback resolves are unchanged', async () => { + history(); + await apply(catalog(), ledger(), { posture: 'isolated' }); + const ordered = (org: string): string[] => + rows(`SELECT checksum FROM sys_metadata_history WHERE organization_id = '${org}' ORDER BY event_seq`).map((r) => String(r.checksum)); + expect(ordered('org_a')).toEqual(['a1', 'a2', 'a3']); + expect(ordered('org_b')).toEqual(['b1', 'b2']); + // The overlay row's version still names its own snapshot: a rollback by + // version lands on that organization's body, never another lineage's. + for (const id of ['row_a', 'row_b']) { + const [row] = rows(`SELECT organization_id, version FROM sys_metadata WHERE id = '${id}'`); + const snap = rows( + `SELECT checksum FROM sys_metadata_history WHERE type = 'view' AND name = 'board' AND version = ${Number(row!.version)}`, + ); + expect(snap).toEqual([{ checksum: id === 'row_a' ? 'a3' : 'b2' }]); + } + // A lineage no other one shares keeps its numbers. + expect(rows(`SELECT version FROM sys_metadata_history WHERE id = 'h_c1'`)).toEqual([{ version: 1 }]); + expect(rows(`SELECT version FROM sys_metadata WHERE id = 'row_env'`)).toEqual([{ version: 2 }]); + }); + + it('commit ranges move with their organization\'s history', async () => { + history(); + await apply(catalog(), ledger(), { posture: 'isolated' }); + for (const [commit, org] of [['c_a', 'org_a'], ['c_b', 'org_b']] as const) { + const [c] = rows(`SELECT event_seq_start AS s, event_seq_end AS e FROM sys_metadata_commit WHERE id = '${commit}'`); + const [range] = rows(`SELECT MIN(event_seq) AS lo, MAX(event_seq) AS hi FROM sys_metadata_history WHERE organization_id = '${org}'`); + expect([c!.s, c!.e]).toEqual([range!.lo, range!.hi]); + } + expect(rows(`SELECT event_seq_start AS s, event_seq_end AS e FROM sys_metadata_commit WHERE id = 'c_env'`)).toEqual([{ s: 1, e: 3 }]); + }); + + it('a second run moves nothing, and a run killed between the commit and history writes resumes without moving a commit twice', async () => { + history(); + await apply(catalog(), ledger(), { posture: 'isolated' }); + const settled = seqs(); + const commits = rows('SELECT * FROM sys_metadata_commit ORDER BY id'); + const again = await apply(catalog(), ledger(), { posture: 'isolated' }); + expect(step(again).summary).toMatchObject({ organizationsShifted: 0, lineagesShifted: 0 }); + expect(seqs()).toEqual(settled); + expect(rows('SELECT * FROM sys_metadata_commit ORDER BY id')).toEqual(commits); + + // Killed after org_a's commit range moved, before its history did. + db.close(); + db = new Database(file); + db.exec('DROP TABLE sys_metadata; DROP TABLE sys_metadata_history; DROP TABLE sys_metadata_commit;'); + history(); + await expect(apply(catalog(), ledger(), { + posture: 'isolated', + executor: executor({ failOnceOn: /^UPDATE "sys_metadata_history" SET "event_seq"/ }), + })).rejects.toThrow(/killed mid-step/); + const l = ledger(); + await apply(catalog(), l, { posture: 'isolated' }); + expect(seqs()).toEqual(settled); + expect(rows('SELECT * FROM sys_metadata_commit ORDER BY id')).toEqual(commits); + }); +}); diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index 0db5b8a5420..29441253802 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -54,6 +54,17 @@ import { * (`convertPositionBindingRows`). Only under `single` (ADR-0131 D3: a * Setup catalog write is an environment metadata write under `single` and * refused under a wall); under a wall the rows are reported. + * 2r. `history-renumber` — seat answer Q2 A (6162538253). Once the + * organization leaves the key, `sys_metadata_history.event_seq` and each + * item's `(type, name, version)` lineage are unique environment-wide; an + * organization's history kept its own sequences beside the + * environment's. Each organization's `event_seq` range is shifted by ONE + * offset, its commit ranges with it; a version lineage is shifted only + * where an environment lineage (or an earlier organization's) of the same + * `(type, name)` holds those numbers, the organization's `sys_metadata` + * row's own `version` with it. Order within an organization and the + * version a rollback resolves are unchanged. It writes numbers, never + * deletes, and runs while the organization ids still say whose a row is. * 2a. `metadata-promotion` — rulings A on #22011 and #22008 (records * 6020163868, 6020151485): first every public-form withdrawal an * organization saved is carried onto the environment, fail-closed (any @@ -124,6 +135,7 @@ export const ORGANIZATION_OWNERSHIP_MIGRATION_ID = 'adr-0131-organization-owners export const CEREMONY_STEPS = [ 'attribution', 'authored-conversion', + 'history-renumber', 'metadata-promotion', 'template-promotion', 'settings-move', @@ -666,6 +678,153 @@ async function runAuthoredConversion(run: Run): Promise { return { step: 'authored-conversion', status: blockers.length ? 'blocked' : 'done', summary, blockers, notes }; } +// ── Step 2r: history renumbering (seat answer Q2 A) ───────────────────────── + +const HISTORY = 'sys_metadata_history'; +const COMMITS = 'sys_metadata_commit'; +const OVERLAYS = 'sys_metadata'; + +const num = (value: unknown): number => { + const n = Number(value); + return Number.isFinite(n) ? n : 0; +}; + +/** + * One organization's numbers, moved above `base` without a transaction and + * safely re-runnable. The offset puts the new range wholly ABOVE both `base` + * and the old range, so a row's own number says whether it moved: the + * dependents (commit ranges, the overlay row's version) move first, each only + * where it still lies inside the old range, and the history rows last, in ONE + * statement. A re-run finds the history above `base` and moves nothing. + * Returns the new top of the range. + */ +async function shiftRange( + run: Run, + range: { min: number; max: number }, + base: number, + dependents: (offset: number, oldMax: number) => Promise, + history: (offset: number) => Promise, +): Promise<{ top: number; moved: boolean }> { + if (range.min > base) return { top: Math.max(base, range.max), moved: false }; + const offset = Math.max(base, range.max) - range.min + 1; + await dependents(offset, range.max); + await history(offset); + return { top: range.max + offset, moved: true }; +} + +async function runHistoryRenumber(run: Run): Promise { + const summary: Record = { organizationsShifted: 0, lineagesShifted: 0 }; + const hcols = await columnsOf(run, HISTORY); + if (!hcols?.has(ORG) || !hcols.has('event_seq')) { + return { step: 'history-renumber', status: 'done', summary, blockers: [], notes: [] }; + } + const sql = run.sql; + const h = sql.q(HISTORY, HISTORY); + const o = sql.q(ORG, HISTORY); + const ccols = await columnsOf(run, COMMITS); + const commitsCarry = !!ccols?.has(ORG) && ccols.has('event_seq_start') && ccols.has('event_seq_end'); + const mcols = await columnsOf(run, OVERLAYS); + const overlaysCarry = !!mcols?.has(ORG) && mcols.has('version'); + + // ── event_seq: one offset per organization, in organization order ── + const envTop = num((await sql.read(HISTORY, `SELECT MAX(${sql.q('event_seq', HISTORY)}) AS m FROM ${h} WHERE ${o} IS NULL`))[0]?.m); + const ranges = await sql.read( + HISTORY, + `SELECT ${o} AS org, MIN(${sql.q('event_seq', HISTORY)}) AS lo, MAX(${sql.q('event_seq', HISTORY)}) AS hi ` + + `FROM ${h} WHERE ${o} IS NOT NULL GROUP BY ${o} ORDER BY ${o}`, + ); + let base = envTop; + for (const r of ranges) { + const org = String(r.org); + const shifted = await shiftRange( + run, + { min: num(r.lo), max: num(r.hi) }, + base, + async (offset, oldMax) => { + if (!commitsCarry) return; + const c = sql.q(COMMITS, COMMITS); + const s0 = sql.q('event_seq_start', COMMITS); + const s1 = sql.q('event_seq_end', COMMITS); + await execute( + run, + COMMITS, + `UPDATE ${c} SET ${s0} = ${s0} + ?, ${s1} = ${s1} + ? WHERE ${sql.q(ORG, COMMITS)} = ? AND ${s0} <= ?`, + [offset, offset, org, oldMax], + ); + }, + async (offset) => { + await execute(run, HISTORY, `UPDATE ${h} SET ${sql.q('event_seq', HISTORY)} = ${sql.q('event_seq', HISTORY)} + ? WHERE ${o} = ?`, [offset, org]); + }, + ); + base = shifted.top; + if (shifted.moved) summary.organizationsShifted++; + } + + // ── versions: per (type, name), only where another lineage holds them ── + if (hcols.has('version')) { + const v = sql.q('version', HISTORY); + const ty = sql.q('type', HISTORY); + const nm = sql.q('name', HISTORY); + const lineages = await sql.read( + HISTORY, + `SELECT ${ty} AS t, ${nm} AS n, ${o} AS org, MIN(${v}) AS lo, MAX(${v}) AS hi FROM ${h} ` + + `GROUP BY ${ty}, ${nm}, ${o} ORDER BY ${ty}, ${nm}, ${o}`, + ); + const byItem = new Map>>(); + for (const l of lineages) { + const key = `${String(l.t)}\u0000${String(l.n)}`; + byItem.set(key, [...(byItem.get(key) ?? []), l]); + } + for (const group of byItem.values()) { + const env = group.find((l) => l.org === null || l.org === undefined || l.org === ''); + const orgs = group.filter((l) => l !== env); + if (orgs.length === 0) continue; + // With no environment lineage, the first organization keeps its numbers. + let vbase = env ? num(env.hi) : Number.NEGATIVE_INFINITY; + for (const l of orgs) { + const org = String(l.org); + if (vbase === Number.NEGATIVE_INFINITY) { + vbase = num(l.hi); + continue; + } + const shifted = await shiftRange( + run, + { min: num(l.lo), max: num(l.hi) }, + vbase, + async (offset, oldMax) => { + if (!overlaysCarry) return; + const m = sql.q(OVERLAYS, OVERLAYS); + const mv = sql.q('version', OVERLAYS); + await execute( + run, + OVERLAYS, + `UPDATE ${m} SET ${mv} = ${mv} + ? WHERE ${sql.q(ORG, OVERLAYS)} = ? AND ${sql.q('type', OVERLAYS)} = ? ` + + `AND ${sql.q('name', OVERLAYS)} = ? AND ${mv} <= ?`, + [offset, org, l.t, l.n, oldMax], + ); + }, + async (offset) => { + await execute( + run, + HISTORY, + `UPDATE ${h} SET ${v} = ${v} + ? WHERE ${o} = ? AND ${ty} = ? AND ${nm} = ?`, + [offset, org, l.t, l.n], + ); + }, + ); + vbase = shifted.top; + if (shifted.moved) summary.lineagesShifted++; + } + } + } + if (summary.organizationsShifted || summary.lineagesShifted) { + run.opts.onProgress?.( + `history renumbered: ${summary.organizationsShifted} organization event range(s), ${summary.lineagesShifted} version lineage(s)`, + ); + } + return { step: 'history-renumber', status: 'done', summary, blockers: [], notes: [] }; +} + // ── Step 2a: organization customizations and form withdrawals (rulings A) ── const parseBody = (row: Record): Record | null => { @@ -1471,6 +1630,7 @@ async function runColumnDrops(run: Run): Promise { const RUNNERS: Record Promise> = { attribution: runAttribution, 'authored-conversion': runAuthoredConversion, + 'history-renumber': runHistoryRenumber, 'metadata-promotion': runMetadataPromotion, 'template-promotion': runTemplatePromotion, 'settings-move': runSettingsMove, From c2d595ee79bb5aa953d8713d67cfe2bb8e24268b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:25:45 +0000 Subject: [PATCH 05/17] fix(cli): the overlay-duplicates category keys the bundle member, as the overlay key does (C7b S1) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH --- packages/cli/src/utils/organization-ownership-apply.test.ts | 1 + packages/cli/src/utils/organization-ownership-inventory.ts | 6 +++--- .../utils/organization-ownership-plan.integration.test.ts | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts index 7c3b35ba929..37d82fd5102 100644 --- a/packages/cli/src/utils/organization-ownership-apply.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.test.ts @@ -756,6 +756,7 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { db.exec(` CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, name TEXT, state TEXT, package_id TEXT); INSERT INTO sys_metadata VALUES ('md_1', 'org_b', 'object', 'deal', 'active', NULL); + ALTER TABLE sys_metadata ADD COLUMN bundle_member TEXT; `); const report = await apply(catalog(), ledger()); diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts index 042f650ac33..30306312766 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.ts @@ -318,10 +318,10 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [ }, { id: 'environment-overlay-duplicates', - label: 'active overlays sharing (type, name, package) once the organization leaves the key — reported, never dropped', - citation: 'triage pointer 6071418113 (the overlay index pre-flight joins the re-key stage)', + label: 'active overlays sharing (type, name, package, bundle member) once the organization leaves the key — reported, never dropped', + citation: 'triage pointer 6071418113 (the overlay index pre-flight joins the re-key stage); the bundle member is part of the overlay key (#22877)', where: [{ column: 'state', equals: 'active' }], - conflictBy: { groupBy: ['type', 'name', 'package_id'] }, + conflictBy: { groupBy: ['type', 'name', 'package_id', 'bundle_member'] }, }, ], }, diff --git a/packages/cli/src/utils/organization-ownership-plan.integration.test.ts b/packages/cli/src/utils/organization-ownership-plan.integration.test.ts index 27ad8e839d7..f9771c03d69 100644 --- a/packages/cli/src/utils/organization-ownership-plan.integration.test.ts +++ b/packages/cli/src/utils/organization-ownership-plan.integration.test.ts @@ -77,6 +77,7 @@ function seed(): void { ('md_2', 'org_default', 'view', 'deal_list', 'active', NULL), ('md_3', 'org_b', 'view', 'deal_list', 'active', NULL), ('md_4', 'org_b', 'flow', 'escalate', 'active', NULL); + ALTER TABLE sys_metadata ADD COLUMN bundle_member TEXT; -- fate 2: the catalog, seeded mirrors beside one Setup-authored row CREATE TABLE sys_position (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, managed_by TEXT); From f5066d7b40b795c6efdc205efadec52c8d352f88 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 08:57:14 +0000 Subject: [PATCH 06/17] fix(cli): renumber history where the ceremony's own environment saves cannot collide (C7b S1) The door numbers a save from history in its own scope, so the withdrawal carry, a chosen conflict's carry and the template promotion appended environment history at numbers history-renumber had just given an organization (measured on a seeded showcase database: event_seq 3 and (view, showcase_inquiry.contact, 3) each held twice after the run). - version-renumber, before the promotion: organization lineages laid out one above another, the environment's above them all, each overlay row's version with its own lineage. - sequence-renumber, just before column-drops: each organization's event range above the environment's, its commit ranges with it, as seat answer Q2 A reads; run on every apply, checkpoint or not. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH --- .../15211-ceremony-metadata-promotion.md | 7 +- .../organization-ownership-apply.test.ts | 135 ++++++++-- .../src/utils/organization-ownership-apply.ts | 248 +++++++++++------- 3 files changed, 277 insertions(+), 113 deletions(-) diff --git a/.changeset/15211-ceremony-metadata-promotion.md b/.changeset/15211-ceremony-metadata-promotion.md index 3474a1aad14..b9e40f099ed 100644 --- a/.changeset/15211-ceremony-metadata-promotion.md +++ b/.changeset/15211-ceremony-metadata-promotion.md @@ -15,7 +15,12 @@ Clause-②: no - **Conflicts are listed and block the step until the operator chooses.** A key that two organizations hold, or an organization and the environment, is a conflict. The plan lists it (`metadataConflicts`, with every row id), and the apply blocks on it. Choose the row the environment keeps with `--metadata-choice ROW_ID`, once per conflict. The chosen row is promoted and read back, any public-form withdrawal an unchosen row holds is carried onto it, and only then are the unchosen rows deleted, by id. No row of a conflict is deleted before a choice is made. - **Under a wall, nothing is promoted unless the operator names the row.** One organization's customization is not every organization's. A row named with `--metadata-choice` is promoted knowingly; the rest are reported and keep the column from being dropped. -A second new step, `history-renumber`, runs first. Once the organization leaves the key, `sys_metadata_history.event_seq` and every `(type, name, version)` lineage must be unique environment-wide. Each organization's event range is shifted by one offset, and its commit ranges move with it. A version lineage is shifted only where another lineage of the same item holds those numbers, and the organization's overlay row's own `version` moves with it. Order within an organization, and the version a rollback resolves, are unchanged. The step writes numbers only, deletes nothing, and is safe to re-run after an interruption. +Two more steps renumber `sys_metadata_history`, because `event_seq` and every `(type, name, version)` lineage must be unique environment-wide once the organization leaves the key. The metadata door numbers a save from history in its own scope, so each step runs where none of the ceremony's own environment saves can land on a number it gave an organization: + +- **`version-renumber`** runs before the promotion. Where two lineages of one item exist, the organizations' lineages are laid out one above another and the environment's goes above them all. Each overlay row's `version` moves with its own lineage, so the version a rollback resolves is unchanged. +- **`sequence-renumber`** runs after the last environment save, just before `column-drops`. Each organization's event range is shifted above the environment's by one offset, and its commit ranges move with it, so order within an organization is unchanged. It runs on every apply, even after a checkpoint, so a save made between two runs is covered too. + +Both steps write numbers only, delete nothing, and are safe to re-run after an interruption. `GET /forms/:slug` and `POST /forms/:slug/submit` no longer read the Default Organization's legacy layer. Every metadata read is now environment → code. The doors judge each candidate against the environment's view list, which holds one item per package of a view name. So a withdrawal in one package still closes that name's form in every package. On a deployment with no organization this closes more than before, which is the fail-closed direction. diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts index 37d82fd5102..2827b4cf457 100644 --- a/packages/cli/src/utils/organization-ownership-apply.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.test.ts @@ -341,7 +341,14 @@ async function accessAfter(c: ReturnType): Promise { const apply = ( c: ReturnType, l: ReturnType, - opts: { posture?: string; executor?: ApplyExecutor; templateChoices?: string[]; metadataChoices?: string[]; views?: ServedViewsPort } = {}, + opts: { + posture?: string; + executor?: ApplyExecutor; + templateChoices?: string[]; + metadataChoices?: string[]; + views?: ServedViewsPort; + columnDrops?: ColumnDropPort; + } = {}, ): Promise => { c.boot(); return applyOrganizationOwnership({ @@ -354,7 +361,7 @@ const apply = ( catalog: c, door: c, templates: c, - columnDrops, + columnDrops: opts.columnDrops ?? columnDrops, ledger: l, now: () => new Date('2026-10-12T00:00:00Z'), }); @@ -410,12 +417,13 @@ describe('ADR-0131 D10 --apply — the acceptance fixture', () => { expect(report.steps.map((s) => [s.step, s.status])).toEqual([ ['attribution', 'done'], ['authored-conversion', 'done'], - ['history-renumber', 'done'], + ['version-renumber', 'done'], ['metadata-promotion', 'done'], ['template-promotion', 'done'], ['settings-move', 'done'], ['rewrite', 'done'], ['mirror-deletion', 'done'], + ['sequence-renumber', 'done'], ['column-drops', 'done'], ]); expect(report.complete).toBe(true); @@ -515,7 +523,7 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { const again = await apply(c, l); expect(again.resumedFrom).toEqual([ - 'attribution', 'authored-conversion', 'history-renumber', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite', 'mirror-deletion', 'column-drops', + 'attribution', 'authored-conversion', 'version-renumber', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite', 'mirror-deletion', 'sequence-renumber', 'column-drops', ]); expect(again.steps.every((s) => s.status === 'checkpointed')).toBe(true); expect(again.complete).toBe(true); @@ -527,14 +535,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', 'history-renumber', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite']); + expect(Object.keys(l.checkpoint!.completed)).toEqual(['attribution', 'authored-conversion', 'version-renumber', 'metadata-promotion', 'template-promotion', 'settings-move', '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', 'history-renumber', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite']); - expect(resumed.steps.map((s) => s.status)).toEqual(['checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'done', 'done']); + expect(resumed.resumedFrom).toEqual(['attribution', 'authored-conversion', 'version-renumber', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite']); + expect(resumed.steps.map((s) => s.status)).toEqual(['checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', '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 }]); @@ -1013,6 +1021,42 @@ describe('ADR-0131 D10 --apply — a customization conflict resolved by --metada }); describe('ADR-0131 D10 --apply — history renumbering (seat answers 6162538253, Q2 A)', () => { + /** + * The door as the metadata repository numbers a save: from history in the + * writer's own scope (the environment's while the column stands), event_seq + * and the item's version each `MAX + 1`. + */ + const numberingDoor = (c: ReturnType): ReturnType => ({ + ...c, + async save(type, name, body, target) { + await c.save(type, name, body, target); + const scoped = rows(`SELECT name FROM pragma_table_info('sys_metadata_history') WHERE name = 'organization_id'`).length > 0; + const env = scoped ? 'WHERE organization_id IS NULL' : 'WHERE 1 = 1'; + const [seq] = rows(`SELECT COALESCE(MAX(event_seq), 0) + 1 AS n FROM sys_metadata_history ${env}`); + const [ver] = rows(`SELECT COALESCE(MAX(version), 0) + 1 AS n FROM sys_metadata_history ${env} AND type = '${type}' AND name = '${name}'`); + db.prepare( + `INSERT INTO sys_metadata_history (id, ${scoped ? 'organization_id, ' : ''}type, name, version, event_seq, checksum) ` + + `VALUES (?, ${scoped ? 'NULL, ' : ''}?, ?, ?, ?, ?)`, + ).run(`h_save_${String(seq!.n)}`, type, name, ver!.n, seq!.n, `save_${String(target?.packageId ?? '')}`); + }, + }); + + // A database holding only what each pin plants, so every step can finish + // under any posture and the late step is reached. + beforeEach(() => { + db.close(); + file = join(dir, 'renumber.db'); + db = new Database(file); + db.exec(`CREATE TABLE sys_organization (id TEXT PRIMARY KEY, slug TEXT); INSERT INTO sys_organization VALUES ('org_default', 'default');`); + writes = []; + }); + /** The schema-drift ceremony declining the drop: the column stays, so each pin reads whose a row is after the run. */ + const keepColumn: ColumnDropPort = { + async drop(tables) { + return tables.map((table) => ({ table, outcome: 'not-proposed' as const })); + }, + }; + const history = (): void => { db.exec(` CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, name TEXT, state TEXT, @@ -1020,7 +1064,7 @@ describe('ADR-0131 D10 --apply — history renumbering (seat answers 6162538253, INSERT INTO sys_metadata VALUES ('row_env', NULL, 'object', 'deal', 'active', NULL, NULL, 2), ('row_a', 'org_a', 'view', 'board', 'active', NULL, NULL, 3), - ('row_b', 'org_b', 'view', 'board', 'active', NULL, NULL, 2); + ('row_b', 'org_b', 'view', 'board', 'active', 'pkg_b', NULL, 2); ALTER TABLE sys_metadata ADD COLUMN metadata TEXT; CREATE TABLE sys_metadata_history (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, name TEXT, version INTEGER, event_seq INTEGER, checksum TEXT); @@ -1041,12 +1085,22 @@ describe('ADR-0131 D10 --apply — history renumbering (seat answers 6162538253, }; const seqs = (): Array<[unknown, unknown, unknown, unknown]> => rows('SELECT id, organization_id, event_seq, version FROM sys_metadata_history ORDER BY id').map((r) => [r.id, r.organization_id, r.event_seq, r.version]); - const step = (report: OrganizationOwnershipApplyReport) => report.steps.find((s) => s.step === 'history-renumber')!; + const step = (report: OrganizationOwnershipApplyReport) => ({ + ...report.steps.find((s) => s.step === 'sequence-renumber')!, + summary: { + ...report.steps.find((s) => s.step === 'sequence-renumber')!.summary, + ...report.steps.find((s) => s.step === 'version-renumber')!.summary, + }, + }); + const duplicates = (): unknown[] => [ + ...rows('SELECT event_seq, COUNT(*) AS n FROM sys_metadata_history GROUP BY event_seq HAVING COUNT(*) > 1'), + ...rows('SELECT type, name, version FROM sys_metadata_history GROUP BY type, name, version HAVING COUNT(*) > 1'), + ]; it('makes event_seq and every (type, name, version) unique environment-wide, writing numbers only', async () => { history(); const before = rows('SELECT COUNT(*) AS n FROM sys_metadata_history'); - const report = await apply(catalog(), ledger(), { posture: 'isolated' }); + const report = await apply(catalog(), ledger(), { posture: 'isolated', columnDrops: keepColumn }); expect(step(report)).toMatchObject({ status: 'done', summary: { organizationsShifted: 3, lineagesShifted: 2 } }); expect(rows('SELECT COUNT(*) AS n FROM sys_metadata_history')).toEqual(before); expect(rows('SELECT event_seq, COUNT(*) AS n FROM sys_metadata_history GROUP BY event_seq HAVING COUNT(*) > 1')).toEqual([]); @@ -1056,7 +1110,7 @@ describe('ADR-0131 D10 --apply — history renumbering (seat answers 6162538253, it('⭐ order within each organization and the version a rollback resolves are unchanged', async () => { history(); - await apply(catalog(), ledger(), { posture: 'isolated' }); + await apply(catalog(), ledger(), { posture: 'isolated', columnDrops: keepColumn }); const ordered = (org: string): string[] => rows(`SELECT checksum FROM sys_metadata_history WHERE organization_id = '${org}' ORDER BY event_seq`).map((r) => String(r.checksum)); expect(ordered('org_a')).toEqual(['a1', 'a2', 'a3']); @@ -1077,7 +1131,7 @@ describe('ADR-0131 D10 --apply — history renumbering (seat answers 6162538253, it('commit ranges move with their organization\'s history', async () => { history(); - await apply(catalog(), ledger(), { posture: 'isolated' }); + await apply(catalog(), ledger(), { posture: 'isolated', columnDrops: keepColumn }); for (const [commit, org] of [['c_a', 'org_a'], ['c_b', 'org_b']] as const) { const [c] = rows(`SELECT event_seq_start AS s, event_seq_end AS e FROM sys_metadata_commit WHERE id = '${commit}'`); const [range] = rows(`SELECT MIN(event_seq) AS lo, MAX(event_seq) AS hi FROM sys_metadata_history WHERE organization_id = '${org}'`); @@ -1088,10 +1142,10 @@ describe('ADR-0131 D10 --apply — history renumbering (seat answers 6162538253, it('a second run moves nothing, and a run killed between the commit and history writes resumes without moving a commit twice', async () => { history(); - await apply(catalog(), ledger(), { posture: 'isolated' }); + await apply(catalog(), ledger(), { posture: 'isolated', columnDrops: keepColumn }); const settled = seqs(); const commits = rows('SELECT * FROM sys_metadata_commit ORDER BY id'); - const again = await apply(catalog(), ledger(), { posture: 'isolated' }); + const again = await apply(catalog(), ledger(), { posture: 'isolated', columnDrops: keepColumn }); expect(step(again).summary).toMatchObject({ organizationsShifted: 0, lineagesShifted: 0 }); expect(seqs()).toEqual(settled); expect(rows('SELECT * FROM sys_metadata_commit ORDER BY id')).toEqual(commits); @@ -1103,11 +1157,62 @@ describe('ADR-0131 D10 --apply — history renumbering (seat answers 6162538253, history(); await expect(apply(catalog(), ledger(), { posture: 'isolated', + columnDrops: keepColumn, executor: executor({ failOnceOn: /^UPDATE "sys_metadata_history" SET "event_seq"/ }), })).rejects.toThrow(/killed mid-step/); const l = ledger(); - await apply(catalog(), l, { posture: 'isolated' }); + await apply(catalog(), l, { posture: 'isolated', columnDrops: keepColumn }); expect(seqs()).toEqual(settled); expect(rows('SELECT * FROM sys_metadata_commit ORDER BY id')).toEqual(commits); }); + + it('⭐ the ceremony\'s own environment saves never land on a number an organization holds (the withdrawal carry)', async () => { + // The organization withdrew the form; the environment and the organization + // each kept a lineage of it, numbered 1..2 in their own scopes. + db.exec(` + CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, name TEXT, state TEXT, + package_id TEXT, bundle_member TEXT, version INTEGER, metadata TEXT); + INSERT INTO sys_metadata VALUES + ('md_org', 'org_default', 'view', 'crm_lead.intake', 'active', NULL, NULL, 2, '${JSON.stringify(formView(false, 'Org copy'))}'); + CREATE TABLE sys_metadata_history (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, name TEXT, + version INTEGER, event_seq INTEGER, checksum TEXT); + INSERT INTO sys_metadata_history VALUES + ('h_env1', NULL, 'view', 'crm_lead.intake', 1, 1, 'e1'), + ('h_env2', NULL, 'view', 'crm_lead.intake', 2, 2, 'e2'), + ('h_org1', 'org_default', 'view', 'crm_lead.intake', 1, 1, 'o1'), + ('h_org2', 'org_default', 'view', 'crm_lead.intake', 2, 2, 'o2'); + `); + const c = numberingDoor(catalog()); + + const report = await apply(c, ledger(), { views: servedViews(c), columnDrops: keepColumn }); + + expect(report.steps.find((s) => s.step === 'metadata-promotion')!.summary).toMatchObject({ withdrawalsCarried: 1, promoted: 1 }); + // The carry saved through the door after the version layout, and before the sequence one. + expect(rows(`SELECT COUNT(*) AS n FROM sys_metadata_history WHERE id LIKE 'h_save_%' AND type = 'view'`)).toEqual([{ n: 1 }]); + expect(report.steps.find((s) => s.step === 'sequence-renumber')!.status).toBe('done'); + expect(duplicates()).toEqual([]); + // The promoted row still names its own snapshot; the organization's order is unchanged. + const [row] = rows(`SELECT organization_id, version FROM sys_metadata WHERE id = 'md_org'`); + expect(row!.organization_id).toBeNull(); + expect(rows(`SELECT checksum FROM sys_metadata_history WHERE type = 'view' AND name = 'crm_lead.intake' AND version = ${Number(row!.version)}`)) + .toEqual([{ checksum: 'o2' }]); + expect(rows(`SELECT checksum FROM sys_metadata_history WHERE organization_id = 'org_default' ORDER BY event_seq`).map((r) => r.checksum)) + .toEqual(['o1', 'o2']); + }); + + it('an environment save made between two runs is renumbered by the next one, the checkpoint notwithstanding', async () => { + history(); + const l = ledger(); + await apply(catalog(), l, { posture: 'isolated', columnDrops: keepColumn }); + // A save lands in the environment's scope, at the environment's own MAX + 1. + const [seq] = rows('SELECT MAX(event_seq) + 1 AS n FROM sys_metadata_history WHERE organization_id IS NULL'); + db.prepare(`INSERT INTO sys_metadata_history VALUES ('h_late', NULL, 'object', 'deal', 3, ?, 'late')`).run(seq!.n); + expect(duplicates()).not.toEqual([]); + + const again = await apply(catalog(), l, { posture: 'isolated', columnDrops: keepColumn }); + + expect(again.steps.find((s) => s.step === 'sequence-renumber')).toMatchObject({ status: 'done', summary: { organizationsShifted: 3 } }); + expect(again.steps.find((s) => s.step === 'version-renumber')!.status).toBe('checkpointed'); + expect(duplicates()).toEqual([]); + }); }); diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index 29441253802..20fa0c7db69 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -54,17 +54,17 @@ import { * (`convertPositionBindingRows`). Only under `single` (ADR-0131 D3: a * Setup catalog write is an environment metadata write under `single` and * refused under a wall); under a wall the rows are reported. - * 2r. `history-renumber` — seat answer Q2 A (6162538253). Once the - * organization leaves the key, `sys_metadata_history.event_seq` and each - * item's `(type, name, version)` lineage are unique environment-wide; an - * organization's history kept its own sequences beside the - * environment's. Each organization's `event_seq` range is shifted by ONE - * offset, its commit ranges with it; a version lineage is shifted only - * where an environment lineage (or an earlier organization's) of the same - * `(type, name)` holds those numbers, the organization's `sys_metadata` - * row's own `version` with it. Order within an organization and the - * version a rollback resolves are unchanged. It writes numbers, never - * deletes, and runs while the organization ids still say whose a row is. + * 2r. `version-renumber` — seat answer Q2 A (6162538253). Once the + * organization leaves the key, each item's `(type, name, version)` + * lineage is unique environment-wide; an organization's history kept its + * own lineage beside the environment's. Where two lineages of one item + * exist, the organizations' are laid out one above another (the first + * keeps its numbers) and the environment's goes above them all, each + * overlay row's `version` with its own lineage. It runs here, while every + * overlay row still says whose it is, and puts the environment on top so + * that every later environment save (numbered by the door from the + * environment's own history) lands above every organization's numbers. + * The version a rollback resolves is unchanged. Numbers only, no delete. * 2a. `metadata-promotion` — rulings A on #22011 and #22008 (records * 6020163868, 6020151485): first every public-form withdrawal an * organization saved is carried onto the environment, fail-closed (any @@ -105,6 +105,13 @@ import { * named categories are deleted. Templates: the promotion runs again first, * and beyond the seeded mirrors only the rows whose environment copy that * pass verified are deleted, by id. + * 4s. `sequence-renumber` — seat answer Q2 A: each organization's + * `sys_metadata_history.event_seq` range is shifted above the + * environment's by ONE offset, its commit ranges with it, so order within + * an organization is unchanged. It runs after the last environment save + * the ceremony makes, and on every apply even when checkpointed (it is + * idempotent), so no environment save is ever numbered onto an + * organization's range. Numbers only, no delete. * 5. `column-drops` (fate 1) — `organization_id` leaves the D7 objects * through the schema-drift ceremony ({@link ColumnDropPort}), never a hand * `ALTER`. @@ -135,12 +142,13 @@ export const ORGANIZATION_OWNERSHIP_MIGRATION_ID = 'adr-0131-organization-owners export const CEREMONY_STEPS = [ 'attribution', 'authored-conversion', - 'history-renumber', + 'version-renumber', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite', 'mirror-deletion', + 'sequence-renumber', 'column-drops', ] as const; export type CeremonyStep = (typeof CEREMONY_STEPS)[number]; @@ -678,7 +686,24 @@ async function runAuthoredConversion(run: Run): Promise { return { step: 'authored-conversion', status: blockers.length ? 'blocked' : 'done', summary, blockers, notes }; } -// ── Step 2r: history renumbering (seat answer Q2 A) ───────────────────────── +// ── Steps 2r and 4s: history renumbering (seat answer Q2 A) ───────────────── +// +// The metadata door numbers a write from history in its OWN scope +// (`MAX(event_seq) + 1`, `MAX(version) + 1` where `organization_id` is the +// writer's), and this ceremony's environment saves (the authored conversion, +// the form-withdrawal carry, a chosen conflict's carry, the template +// promotion) are environment-scoped. So each renumbering is placed where no +// environment save can land on a number it handed an organization: +// +// - `version-renumber` runs BEFORE the promotion, while every overlay row +// still says whose it is, and puts each item's environment lineage ABOVE +// its organization lineages. Every later environment save numbers above +// all of them, and a promoted row keeps its own lineage's numbers. +// - `sequence-renumber` runs AFTER the last environment save, immediately +// before the column goes, and moves each organization's `event_seq` range +// above the environment's, as the seat answer reads. It runs on every apply, +// checkpoint or not: it is idempotent, and a write between two runs is +// covered by the second. const HISTORY = 'sys_metadata_history'; const COMMITS = 'sys_metadata_commit'; @@ -690,16 +715,15 @@ const num = (value: unknown): number => { }; /** - * One organization's numbers, moved above `base` without a transaction and - * safely re-runnable. The offset puts the new range wholly ABOVE both `base` - * and the old range, so a row's own number says whether it moved: the - * dependents (commit ranges, the overlay row's version) move first, each only - * where it still lies inside the old range, and the history rows last, in ONE - * statement. A re-run finds the history above `base` and moves nothing. - * Returns the new top of the range. + * One range of numbers, moved above `base` without a transaction and safely + * re-runnable. The offset puts the new range wholly ABOVE both `base` and the + * old range, so a row's own number says whether it moved: the dependents + * (commit ranges, overlay rows' versions) move first, each only where it still + * lies inside the old range, and the history rows last, in ONE statement. A + * re-run finds the history above `base` and moves nothing. Returns the new + * top of the range. */ async function shiftRange( - run: Run, range: { min: number; max: number }, base: number, dependents: (offset: number, oldMax: number) => Promise, @@ -712,32 +736,108 @@ async function shiftRange( return { top: range.max + offset, moved: true }; } -async function runHistoryRenumber(run: Run): Promise { - const summary: Record = { organizationsShifted: 0, lineagesShifted: 0 }; +/** + * Each `(type, name)` lineage unique environment-wide: the organizations' + * lineages laid out one above another in organization order (the first keeps + * its numbers), then the environment's above them all. Each overlay row's + * `version` moves with its own lineage, so the version a rollback resolves is + * unchanged. Only where two lineages of one item exist; a lone one keeps its + * numbers. + */ +async function runVersionRenumber(run: Run): Promise { + const summary: Record = { lineagesShifted: 0 }; const hcols = await columnsOf(run, HISTORY); - if (!hcols?.has(ORG) || !hcols.has('event_seq')) { - return { step: 'history-renumber', status: 'done', summary, blockers: [], notes: [] }; + if (!hcols?.has(ORG) || !hcols.has('version')) { + return { step: 'version-renumber', status: 'done', summary, blockers: [], notes: [] }; } const sql = run.sql; const h = sql.q(HISTORY, HISTORY); const o = sql.q(ORG, HISTORY); - const ccols = await columnsOf(run, COMMITS); - const commitsCarry = !!ccols?.has(ORG) && ccols.has('event_seq_start') && ccols.has('event_seq_end'); + const v = sql.q('version', HISTORY); + const ty = sql.q('type', HISTORY); + const nm = sql.q('name', HISTORY); const mcols = await columnsOf(run, OVERLAYS); const overlaysCarry = !!mcols?.has(ORG) && mcols.has('version'); + const lineages = await sql.read( + HISTORY, + `SELECT ${ty} AS t, ${nm} AS n, ${o} AS org, MIN(${v}) AS lo, MAX(${v}) AS hi FROM ${h} ` + + `GROUP BY ${ty}, ${nm}, ${o} ORDER BY ${ty}, ${nm}, ${o}`, + ); + const byItem = new Map>>(); + for (const l of lineages) { + const key = `${String(l.t)}\u0000${String(l.n)}`; + byItem.set(key, [...(byItem.get(key) ?? []), l]); + } + const isEnv = (l: Record): boolean => l.org === null || l.org === undefined || l.org === ''; + for (const group of byItem.values()) { + if (group.length < 2) continue; + const env = group.find(isEnv); + const ordered = [...group.filter((l) => !isEnv(l)), ...(env ? [env] : [])]; + // The first organization's lineage keeps its numbers; each later one, and + // the environment's last, goes above the ones before it. + let vbase = num(ordered[0]!.hi); + for (const l of ordered.slice(1)) { + const owner = isEnv(l) ? `${sql.q(ORG, OVERLAYS)} IS NULL` : `${sql.q(ORG, OVERLAYS)} = ?`; + const ownerH = isEnv(l) ? `${o} IS NULL` : `${o} = ?`; + const ownerArgs = isEnv(l) ? [] : [String(l.org)]; + const shifted = await shiftRange( + { min: num(l.lo), max: num(l.hi) }, + vbase, + async (offset, oldMax) => { + if (!overlaysCarry) return; + const m = sql.q(OVERLAYS, OVERLAYS); + const mv = sql.q('version', OVERLAYS); + await execute( + run, + OVERLAYS, + `UPDATE ${m} SET ${mv} = ${mv} + ? WHERE ${owner} AND ${sql.q('type', OVERLAYS)} = ? ` + + `AND ${sql.q('name', OVERLAYS)} = ? AND ${mv} <= ?`, + [offset, ...ownerArgs, l.t, l.n, oldMax], + ); + }, + async (offset) => { + await execute(run, HISTORY, `UPDATE ${h} SET ${v} = ${v} + ? WHERE ${ownerH} AND ${ty} = ? AND ${nm} = ?`, [ + offset, + ...ownerArgs, + l.t, + l.n, + ]); + }, + ); + vbase = shifted.top; + if (shifted.moved) summary.lineagesShifted++; + } + } + if (summary.lineagesShifted) run.opts.onProgress?.(`history renumbered: ${summary.lineagesShifted} version lineage(s)`); + return { step: 'version-renumber', status: 'done', summary, blockers: [], notes: [] }; +} - // ── event_seq: one offset per organization, in organization order ── - const envTop = num((await sql.read(HISTORY, `SELECT MAX(${sql.q('event_seq', HISTORY)}) AS m FROM ${h} WHERE ${o} IS NULL`))[0]?.m); +/** + * `event_seq` unique environment-wide: each organization's range moved above + * the environment's (and the organizations' before it) by ONE offset, its + * commit ranges with it, so order within an organization is unchanged. + */ +async function runSequenceRenumber(run: Run): Promise { + const summary: Record = { organizationsShifted: 0 }; + const hcols = await columnsOf(run, HISTORY); + if (!hcols?.has(ORG) || !hcols.has('event_seq')) { + return { step: 'sequence-renumber', status: 'done', summary, blockers: [], notes: [] }; + } + const sql = run.sql; + const h = sql.q(HISTORY, HISTORY); + const o = sql.q(ORG, HISTORY); + const seq = sql.q('event_seq', HISTORY); + const ccols = await columnsOf(run, COMMITS); + const commitsCarry = !!ccols?.has(ORG) && ccols.has('event_seq_start') && ccols.has('event_seq_end'); + const envTop = num((await sql.read(HISTORY, `SELECT MAX(${seq}) AS m FROM ${h} WHERE ${o} IS NULL`))[0]?.m); const ranges = await sql.read( HISTORY, - `SELECT ${o} AS org, MIN(${sql.q('event_seq', HISTORY)}) AS lo, MAX(${sql.q('event_seq', HISTORY)}) AS hi ` + - `FROM ${h} WHERE ${o} IS NOT NULL GROUP BY ${o} ORDER BY ${o}`, + `SELECT ${o} AS org, MIN(${seq}) AS lo, MAX(${seq}) AS hi FROM ${h} WHERE ${o} IS NOT NULL GROUP BY ${o} ORDER BY ${o}`, ); let base = envTop; for (const r of ranges) { const org = String(r.org); const shifted = await shiftRange( - run, { min: num(r.lo), max: num(r.hi) }, base, async (offset, oldMax) => { @@ -753,76 +853,16 @@ async function runHistoryRenumber(run: Run): Promise { ); }, async (offset) => { - await execute(run, HISTORY, `UPDATE ${h} SET ${sql.q('event_seq', HISTORY)} = ${sql.q('event_seq', HISTORY)} + ? WHERE ${o} = ?`, [offset, org]); + await execute(run, HISTORY, `UPDATE ${h} SET ${seq} = ${seq} + ? WHERE ${o} = ?`, [offset, org]); }, ); base = shifted.top; if (shifted.moved) summary.organizationsShifted++; } - - // ── versions: per (type, name), only where another lineage holds them ── - if (hcols.has('version')) { - const v = sql.q('version', HISTORY); - const ty = sql.q('type', HISTORY); - const nm = sql.q('name', HISTORY); - const lineages = await sql.read( - HISTORY, - `SELECT ${ty} AS t, ${nm} AS n, ${o} AS org, MIN(${v}) AS lo, MAX(${v}) AS hi FROM ${h} ` + - `GROUP BY ${ty}, ${nm}, ${o} ORDER BY ${ty}, ${nm}, ${o}`, - ); - const byItem = new Map>>(); - for (const l of lineages) { - const key = `${String(l.t)}\u0000${String(l.n)}`; - byItem.set(key, [...(byItem.get(key) ?? []), l]); - } - for (const group of byItem.values()) { - const env = group.find((l) => l.org === null || l.org === undefined || l.org === ''); - const orgs = group.filter((l) => l !== env); - if (orgs.length === 0) continue; - // With no environment lineage, the first organization keeps its numbers. - let vbase = env ? num(env.hi) : Number.NEGATIVE_INFINITY; - for (const l of orgs) { - const org = String(l.org); - if (vbase === Number.NEGATIVE_INFINITY) { - vbase = num(l.hi); - continue; - } - const shifted = await shiftRange( - run, - { min: num(l.lo), max: num(l.hi) }, - vbase, - async (offset, oldMax) => { - if (!overlaysCarry) return; - const m = sql.q(OVERLAYS, OVERLAYS); - const mv = sql.q('version', OVERLAYS); - await execute( - run, - OVERLAYS, - `UPDATE ${m} SET ${mv} = ${mv} + ? WHERE ${sql.q(ORG, OVERLAYS)} = ? AND ${sql.q('type', OVERLAYS)} = ? ` + - `AND ${sql.q('name', OVERLAYS)} = ? AND ${mv} <= ?`, - [offset, org, l.t, l.n, oldMax], - ); - }, - async (offset) => { - await execute( - run, - HISTORY, - `UPDATE ${h} SET ${v} = ${v} + ? WHERE ${o} = ? AND ${ty} = ? AND ${nm} = ?`, - [offset, org, l.t, l.n], - ); - }, - ); - vbase = shifted.top; - if (shifted.moved) summary.lineagesShifted++; - } - } + if (summary.organizationsShifted) { + run.opts.onProgress?.(`history renumbered: ${summary.organizationsShifted} organization event range(s)`); } - if (summary.organizationsShifted || summary.lineagesShifted) { - run.opts.onProgress?.( - `history renumbered: ${summary.organizationsShifted} organization event range(s), ${summary.lineagesShifted} version lineage(s)`, - ); - } - return { step: 'history-renumber', status: 'done', summary, blockers: [], notes: [] }; + return { step: 'sequence-renumber', status: 'done', summary, blockers: [], notes: [] }; } // ── Step 2a: organization customizations and form withdrawals (rulings A) ── @@ -1630,12 +1670,13 @@ async function runColumnDrops(run: Run): Promise { const RUNNERS: Record Promise> = { attribution: runAttribution, 'authored-conversion': runAuthoredConversion, - 'history-renumber': runHistoryRenumber, + 'version-renumber': runVersionRenumber, 'metadata-promotion': runMetadataPromotion, 'template-promotion': runTemplatePromotion, 'settings-move': runSettingsMove, rewrite: runRewrite, 'mirror-deletion': runMirrorDeletion, + 'sequence-renumber': runSequenceRenumber, 'column-drops': runColumnDrops, }; @@ -1718,6 +1759,19 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise n > 0)) { + steps.push(again); + checkpoint.completed[step] = { at: now().toISOString(), summary: again.summary }; + await opts.ledger.write(checkpoint, { complete: false, blocking: 0, advisory: 0 }); + run.columns.clear(); + continue; + } + } steps.push({ step, status: 'checkpointed', summary: done.summary, blockers: [], notes: [] }); continue; } From b85b0914dbdeb18ede19f46ff4d54d1a438f3f3e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 09:40:19 +0000 Subject: [PATCH 07/17] fix(cli): the overlay-duplicates citation names the overlay index, not a tracker number (C7b S1) A runtime string carries no tracker id (check:doc-authoring's prose-id leg). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH --- packages/cli/src/utils/organization-ownership-inventory.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts index 30306312766..25bb2ac603c 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.ts @@ -319,7 +319,7 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [ { id: 'environment-overlay-duplicates', label: 'active overlays sharing (type, name, package, bundle member) once the organization leaves the key — reported, never dropped', - citation: 'triage pointer 6071418113 (the overlay index pre-flight joins the re-key stage); the bundle member is part of the overlay key (#22877)', + citation: 'triage pointer 6071418113 (the overlay index pre-flight joins the re-key stage); the bundle member is part of the overlay key (`sys_metadata`\'s overlay index)', where: [{ column: 'state', equals: 'active' }], conflictBy: { groupBy: ['type', 'name', 'package_id', 'bundle_member'] }, }, From 7878b47888da43fa67833e6b2b23ac19b3b4f745 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 10:28:18 +0000 Subject: [PATCH 08/17] =?UTF-8?q?fix(cli,metadata-protocol,rest):=20S1=20r?= =?UTF-8?q?eview=20round=20=E2=80=94=2017.x=20tables=20plan,=20versions=20?= =?UTF-8?q?unique=20by=20construction,=20one=20environment=20row=20per=20r?= =?UTF-8?q?esolved=20key=20(C7b=20S1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - F1: the overlay-duplicates category groups by bundle_member only where the column exists (17.x databases never had it); the fixtures that hid this are back in the 17.x shape, with a pin. - F2: a promoted row takes its history with it (its organization's lineage of the item moved above the environment's numbers, then re-tagged), so every later environment save numbers above it; the late history-renumber step lays out every lineage still tagged to an organization before the column goes, and under single folds it. - F3: a chosen conflict ends with exactly one environment row defining the chosen body (the key's environment row takes it through the door, in place; with none, the chosen row is promoted in place), read back before one DELETE of the key's organization rows; a rerun accepts the same --metadata-choice, recorded in the checkpoint before the delete. - Seat ruling (Q6 A's text): under a wall a choice deletes nothing, and an unchosen conflict is reported, not blocking. - Seat ruling: the form doors keep their organization read; its deletion moves to S2. rest and metadata-protocol are back to main. - The changeset: cli minor, Clause-②: yes (widening). Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH --- .../15211-ceremony-metadata-promotion.md | 40 +- .../migrate/organization-ownership.ts | 7 +- .../organization-ownership-apply.test.ts | 342 +++++++--- .../src/utils/organization-ownership-apply.ts | 617 +++++++++++------- ...ization-ownership-plan.integration.test.ts | 18 +- .../src/utils/organization-ownership-plan.ts | 21 +- .../src/meta-overlay-cache.test.ts | 24 +- .../protocol.org-scoped-write-refused.test.ts | 31 +- .../protocol.reads-environment-only.test.ts | 34 +- packages/metadata-protocol/src/protocol.ts | 138 +++- .../view-container-runtime-expansion.test.ts | 46 +- ...protocol-org-overlay-registry-gate.test.ts | 60 +- ...lic-form-withdrawal-layers.dogfood.test.ts | 28 +- .../rest/src/public-form-withdrawal.test.ts | 199 +++--- packages/rest/src/rest-server.ts | 89 ++- 15 files changed, 1127 insertions(+), 567 deletions(-) diff --git a/.changeset/15211-ceremony-metadata-promotion.md b/.changeset/15211-ceremony-metadata-promotion.md index b9e40f099ed..81d65c540e3 100644 --- a/.changeset/15211-ceremony-metadata-promotion.md +++ b/.changeset/15211-ceremony-metadata-promotion.md @@ -1,34 +1,36 @@ --- -'@objectstack/cli': patch -'@objectstack/rest': patch -'@objectstack/metadata-protocol': patch +'@objectstack/cli': minor --- -feat(cli): the organization-ownership ceremony promotes organization customizations and carries public-form withdrawals; the anonymous form doors read the environment alone (ADR-0131 D10) +feat(cli): the organization-ownership ceremony carries the `sys_metadata` organization layer into the environment (ADR-0131 D10) -Clause-②: no +Clause-②: yes (widening) -`os migrate organization-ownership --apply` gains a step, `metadata-promotion`, after the authored catalog conversion: +The plan of `os migrate organization-ownership` gains a key, `metadataConflicts` on the `sys_metadata` table. It lists every overlay key that more than one row would hold once the organization leaves the key, with each row's id. `--apply` gains a flag, `--metadata-choice ROW_ID`. Nothing is removed from the plan or the apply. -- **Form withdrawals are carried first, fail-closed, under every posture.** When an organization withdrew a public form (`sharing.enabled` or `sharing.allowAnonymous` set to `false`, link kept), every view the environment serves under that name that still serves the form open takes the withdrawal. That covers stored views and views served from code, in every package. The ceremony saves the withdrawal through the metadata door, into the view's own package. The judgement is the anonymous doors' own predicate. Any organization's withdrawal wins. -- **Under `single`, organization customizations become environment rows.** These are the `sys_metadata` rows of `view`, `dashboard`, `report`, `translation` and `email_template`. A row whose key (type, name, package, bundle member) no other row holds is promoted in place, by clearing its `organization_id`. -- **Conflicts are listed and block the step until the operator chooses.** A key that two organizations hold, or an organization and the environment, is a conflict. The plan lists it (`metadataConflicts`, with every row id), and the apply blocks on it. Choose the row the environment keeps with `--metadata-choice ROW_ID`, once per conflict. The chosen row is promoted and read back, any public-form withdrawal an unchosen row holds is carried onto it, and only then are the unchosen rows deleted, by id. No row of a conflict is deleted before a choice is made. -- **Under a wall, nothing is promoted unless the operator names the row.** One organization's customization is not every organization's. A row named with `--metadata-choice` is promoted knowingly; the rest are reported and keep the column from being dropped. +`--apply` gains a step, `metadata-promotion`, after the authored catalog conversion: -Two more steps renumber `sys_metadata_history`, because `event_seq` and every `(type, name, version)` lineage must be unique environment-wide once the organization leaves the key. The metadata door numbers a save from history in its own scope, so each step runs where none of the ceremony's own environment saves can land on a number it gave an organization: +- **Form withdrawals are carried first, under every posture, and fail-closed.** When an organization withdrew a public form (`sharing.enabled` or `sharing.allowAnonymous` set to `false`, link kept), every view the environment serves under that name that still serves the form open takes the withdrawal. That covers stored views and views served from code, in every package. The ceremony saves it through the metadata door, into the view's own package, and any organization's withdrawal wins. +- **Under `single`, an organization customization whose key no other row holds becomes the environment's, in place.** This covers the `sys_metadata` rows of `view`, `dashboard`, `report`, `translation` and `email_template`; the key is type, name, package and bundle member. The row takes its history with it. +- **A key held by more than one row is a conflict, listed in the plan.** Under `single` it blocks the step until the operator chooses one row with `--metadata-choice ROW_ID`, once per conflict. + - The key ends with exactly one environment row. Where the key has an environment row, that row takes the chosen body through the metadata door, in place. Where it has none, the chosen organization row is promoted in place. + - Any public-form withdrawal another row of the key holds is kept on that body. + - The environment row is read back. Only then are the key's organization rows deleted, in one statement, by id. + - A rerun after an interruption accepts the same `--metadata-choice` and finishes. +- **Under a wall (`isolated`, `group`), nothing is promoted unless the operator names the row, and nothing is ever deleted.** A named row becomes the environment's as above. The other organization rows stay where they are, reported, and they keep the column from being dropped. -- **`version-renumber`** runs before the promotion. Where two lineages of one item exist, the organizations' lineages are laid out one above another and the environment's goes above them all. Each overlay row's `version` moves with its own lineage, so the version a rollback resolves is unchanged. -- **`sequence-renumber`** runs after the last environment save, just before `column-drops`. Each organization's event range is shifted above the environment's by one offset, and its commit ranges move with it, so order within an organization is unchanged. It runs on every apply, even after a checkpoint, so a save made between two runs is covered too. +`event_seq` and every `(type, name, version)` of `sys_metadata_history` become unique environment-wide. The metadata door numbers a save from history in its own scope, so each lineage is placed where no environment save can land on it: -Both steps write numbers only, delete nothing, and are safe to re-run after an interruption. +- **A promoted row takes its history with it.** Before the row leaves its organization, that organization's lineage of the item is moved above the environment's numbers and becomes environment history. Every later environment save of the item numbers above it, and the row's version still names its own snapshot. +- **A new step, `history-renumber`, runs just before `column-drops`, on every apply, even after a checkpoint.** It lays out every lineage still tagged to an organization above the environment's: each organization's event range by one offset, its commit ranges with it, and each item's version lineage, the organization's rows with it. + - Under `single`, that history then becomes the environment's too. + - Under a wall it stays its organization's. -`GET /forms/:slug` and `POST /forms/:slug/submit` no longer read the Default Organization's legacy layer. Every metadata read is now environment → code. The doors judge each candidate against the environment's view list, which holds one item per package of a view name. So a withdrawal in one package still closes that name's form in every package. On a deployment with no organization this closes more than before, which is the fail-closed direction. +Order within an organization, and the version a rollback resolves, are unchanged. Nothing in history is deleted. -The protocol's `getMetaItems` drops its undocumented `legacyFormOrganizationId` request key. It was never in the spec's `GetMetaItemsRequest`, and only those doors passed it. A caller that still passes it now gets the environment's list. - -Until the ceremony runs, a database whose withdrawals were saved only at the organization layer serves those forms open again. Run the ceremony as part of the v18 upgrade: +On a 17.x database `sys_metadata` has no `bundle_member` column. The ceremony boots with schema changes held, so it reads that column as absent, the overlay key's NULL member, and plans the table rather than refusing it. ```bash os migrate organization-ownership --out plan.json # review plan.json: metadataConflicts lists every conflict -os migrate organization-ownership --apply --backup-taken --metadata-choice ROW_ID # one per listed conflict +os migrate organization-ownership --apply --backup-taken --plan-file plan.json --metadata-choice ROW_ID # one per listed conflict ``` diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts index bd5e699eb26..9e5f67420fc 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.ts @@ -177,9 +177,10 @@ export default class MigrateOrganizationOwnership extends Command { 'metadata-choice': Flags.string({ description: 'With --apply: the sys_metadata row id the environment keeps for a listed customization conflict (rows ' + - 'of one type, name and package once the organization leaves the key). Repeat once per conflict; the ' + - 'other rows of that conflict are deleted only after the chosen row is promoted and read back. Under a ' + - 'wall, also an organization row promoted knowingly', + 'of one type, name and package once the organization leaves the key). Repeat once per conflict. Under ' + + 'single the key keeps one environment row defining the chosen body, and its organization rows are deleted ' + + 'only after that row reads back; under a wall nothing is deleted, and a named organization row of a key it ' + + 'alone holds is promoted knowingly', multiple: true, }), 'fate-supplement': Flags.string({ diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts index 2827b4cf457..2136dc256df 100644 --- a/packages/cli/src/utils/organization-ownership-apply.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.test.ts @@ -155,6 +155,69 @@ function servedViews(c: ReturnType, code: Array): ReturnType & { envRowsAtSave: number[] } { + const has = (table: string, column?: string): boolean => + column === undefined + ? rows(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = '${table}'`).length > 0 + : rows(`SELECT name FROM pragma_table_info('${table}') WHERE name = '${column}'`).length > 0; + const envRowsAtSave: number[] = []; + return { + ...c, + envRowsAtSave, + async save(type, name, body, target) { + await c.save(type, name, body, target); + // The catalog conversion and the template promotion keep the catalog double's own store. + if (!['view', 'dashboard', 'report', 'translation'].includes(type)) return; + const pkg = target?.packageId ?? null; + let version = 1; + if (has('sys_metadata_history')) { + const scoped = has('sys_metadata_history', 'organization_id'); + const env = scoped ? 'organization_id IS NULL' : '1 = 1'; + const seq = Number((db.prepare(`SELECT COALESCE(MAX(event_seq), 0) + 1 AS n FROM sys_metadata_history WHERE ${env}`).get() as { n: number }).n); + version = Number( + (db.prepare(`SELECT COALESCE(MAX(version), 0) + 1 AS n FROM sys_metadata_history WHERE ${env} AND type = ? AND name = ?`).get(type, name) as { n: number }).n, + ); + db.prepare( + `INSERT INTO sys_metadata_history (id, ${scoped ? 'organization_id, ' : ''}type, name, version, event_seq, checksum) ` + + `VALUES (?, ${scoped ? 'NULL, ' : ''}?, ?, ?, ?, ?)`, + ).run(`h_save_${seq}`, type, name, version, seq, `save_${pkg ?? ''}`); + } + if (!has('sys_metadata')) return; + const scoped = has('sys_metadata', 'organization_id'); + const where = `${scoped ? 'organization_id IS NULL AND ' : ''}type = ? AND name = ? AND COALESCE(package_id, '') = ? AND state = 'active'`; + const found = db.prepare(`SELECT id FROM sys_metadata WHERE ${where}`).all(type, name, pkg ?? '') as Array<{ id: string }>; + envRowsAtSave.push(found.length); + if (found.length > 1) throw new Error(`two environment rows hold ${type} ${name}: the write head is a coin toss`); + const versioned = has('sys_metadata', 'version'); + if (found.length === 1) { + db.prepare(`UPDATE sys_metadata SET metadata = ?${versioned ? ', version = ?' : ''} WHERE id = ?`).run( + JSON.stringify(body), + ...(versioned ? [version] : []), + found[0]!.id, + ); + return; + } + const cols = ['id', 'type', 'name', 'state', 'package_id', 'metadata', ...(versioned ? ['version'] : [])]; + db.prepare(`INSERT INTO sys_metadata (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`).run( + `door_${type}_${name}_${pkg ?? ''}`, + type, + name, + 'active', + pkg, + JSON.stringify(body), + ...(versioned ? [version] : []), + ); + }, + }; +} + /** What the anonymous doors serve for a slug, by their own judgement over the env-wide list. */ async function doorsServe(views: ServedViewsPort, slug: string): Promise { const list = await views.list(); @@ -417,13 +480,12 @@ describe('ADR-0131 D10 --apply — the acceptance fixture', () => { expect(report.steps.map((s) => [s.step, s.status])).toEqual([ ['attribution', 'done'], ['authored-conversion', 'done'], - ['version-renumber', 'done'], ['metadata-promotion', 'done'], ['template-promotion', 'done'], ['settings-move', 'done'], ['rewrite', 'done'], ['mirror-deletion', 'done'], - ['sequence-renumber', 'done'], + ['history-renumber', 'done'], ['column-drops', 'done'], ]); expect(report.complete).toBe(true); @@ -523,7 +585,7 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { const again = await apply(c, l); expect(again.resumedFrom).toEqual([ - 'attribution', 'authored-conversion', 'version-renumber', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite', 'mirror-deletion', 'sequence-renumber', 'column-drops', + 'attribution', 'authored-conversion', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite', 'mirror-deletion', 'history-renumber', 'column-drops', ]); expect(again.steps.every((s) => s.status === 'checkpointed')).toBe(true); expect(again.complete).toBe(true); @@ -535,14 +597,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', 'version-renumber', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite']); + expect(Object.keys(l.checkpoint!.completed)).toEqual(['attribution', 'authored-conversion', 'metadata-promotion', 'template-promotion', 'settings-move', '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', 'version-renumber', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite']); - expect(resumed.steps.map((s) => s.status)).toEqual(['checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'checkpointed', 'done', 'done', 'done']); + 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(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 }]); @@ -764,7 +826,6 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { db.exec(` CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, name TEXT, state TEXT, package_id TEXT); INSERT INTO sys_metadata VALUES ('md_1', 'org_b', 'object', 'deal', 'active', NULL); - ALTER TABLE sys_metadata ADD COLUMN bundle_member TEXT; `); const report = await apply(catalog(), ledger()); @@ -924,6 +985,7 @@ describe('ADR-0131 D10 --apply — a customization conflict resolved by --metada const scoped = rows(`SELECT name FROM pragma_table_info('sys_metadata') WHERE name = 'organization_id'`).length > 0; return rows(`SELECT id${scoped ? ', organization_id' : ''} FROM sys_metadata ORDER BY id`).map((r) => [r.id, r.organization_id ?? null]); }; + const labelOf = (id: string): unknown => (JSON.parse(String(rows(`SELECT metadata FROM sys_metadata WHERE id = '${id}'`)[0]!.metadata)) as { label?: unknown }).label; const deletes = (): string[] => writes.filter((w) => /^DELETE FROM "sys_metadata"/.test(w)); const conflictRows = [ md('md_org', 'org_default', 'view', 'deal_list', { name: 'deal_list', label: 'Org list' }), @@ -932,37 +994,78 @@ describe('ADR-0131 D10 --apply — a customization conflict resolved by --metada it('⭐ no row of a conflict is deleted before a choice is made', async () => { metadataTable(conflictRows); - const report = await apply(catalog(), ledger()); + const report = await apply(storingDoor(catalog()), ledger()); expect(step(report).status).toBe('blocked'); expect(step(report).blockers.join('\n')).toMatch(/choose one with --metadata-choice ROW_ID/); expect(deletes()).toEqual([]); expect(owners()).toEqual([['md_env', null], ['md_org', 'org_default']]); }); - it('choosing the organization\'s row: it is promoted and read back, then the environment row it displaces is deleted by id', async () => { + it('⭐ choosing the organization\'s row over the environment\'s: the one environment row takes its body in place, then the organization row goes', async () => { metadataTable(conflictRows); - const report = await apply(catalog(), ledger(), { metadataChoices: ['md_org'] }); - expect(step(report)).toMatchObject({ status: 'done', summary: { chosen: 1, unchosenDeleted: 1, conflicts: 0 } }); - expect(owners()).toEqual([['md_org', null]]); - // The promotion's write precedes the delete. - const promote = writes.findIndex((w) => /^UPDATE "sys_metadata" SET "organization_id" = NULL/.test(w)); - const remove = writes.findIndex((w) => /^DELETE FROM "sys_metadata"/.test(w)); - expect(promote).toBeGreaterThanOrEqual(0); - expect(remove).toBeGreaterThan(promote); + const c = storingDoor(catalog()); + const report = await apply(c, ledger(), { metadataChoices: ['md_org'] }); + expect(step(report)).toMatchObject({ status: 'done', summary: { chosen: 1, organizationRowsDeleted: 1, conflicts: 0 } }); + expect(owners()).toEqual([['md_env', null]]); + expect(labelOf('md_env')).toBe('Org list'); + // Exactly one environment row at every point: the door wrote onto the one + // there was, and no UPDATE ever cleared an organization row of this key. + expect(c.envRowsAtSave).toEqual([1]); + expect(writes.some((w) => /^UPDATE "sys_metadata" SET "organization_id" = NULL/.test(w))).toBe(false); + const save = c.viewSaves.length; + expect(save).toBe(1); + }); + + it('the environment row is read back before any delete: a door that stores nothing deletes nothing', async () => { + metadataTable(conflictRows); + const c = catalog(); + await expect(apply(c, ledger(), { metadataChoices: ['md_org'] })).rejects.toThrow(/did not read back as one environment row/); + expect(deletes()).toEqual([]); + expect(owners()).toEqual([['md_env', null], ['md_org', 'org_default']]); + }); + + it('⭐ a crash between the replacement and the delete: the same --metadata-choice finishes it', async () => { + metadataTable(conflictRows); + const c = storingDoor(catalog()); + const l = ledger(); + await expect(apply(c, l, { metadataChoices: ['md_org'], executor: executor({ failOnceOn: /^DELETE FROM "sys_metadata"/ }) })).rejects.toThrow( + /killed mid-step/, + ); + // The replacement landed and is recorded; the organization row is still there. + expect(labelOf('md_env')).toBe('Org list'); + expect(owners()).toEqual([['md_env', null], ['md_org', 'org_default']]); + expect(l.checkpoint!.metadataChoicesApplied).toEqual(['md_org']); + + const again = await apply(c, l, { metadataChoices: ['md_org'] }); + expect(step(again)).toMatchObject({ status: 'done', summary: { chosen: 1, organizationRowsDeleted: 1 } }); + expect(owners()).toEqual([['md_env', null]]); + + // And after the delete, before the step's checkpoint: the choice names a row + // that is gone, which the checkpoint records as applied. + l.checkpoint!.completed = {}; + const third = await apply(c, l, { metadataChoices: ['md_org'] }); + expect(step(third).status).toBe('done'); + expect(owners()).toEqual([['md_env', null]]); }); it('choosing the environment\'s row: the organization row is deleted by id, and nothing is promoted', async () => { metadataTable(conflictRows); - const report = await apply(catalog(), ledger(), { metadataChoices: ['md_env'] }); - expect(step(report)).toMatchObject({ status: 'done', summary: { chosen: 1, unchosenDeleted: 1, promoted: 0 } }); + const report = await apply(storingDoor(catalog()), ledger(), { metadataChoices: ['md_env'] }); + expect(step(report)).toMatchObject({ status: 'done', summary: { chosen: 1, organizationRowsDeleted: 1, promoted: 0 } }); expect(owners()).toEqual([['md_env', null]]); + expect(labelOf('md_env')).toBe('Env list'); }); - it('two organizations on one key: the chosen one is promoted, the other deleted', async () => { + it('two organizations on one key and no environment row: the chosen one is promoted in place, the other deleted', async () => { metadataTable([md('md_a', 'org_a', 'translation', 'fr', { name: 'fr', a: 1 }), md('md_b', 'org_b', 'translation', 'fr', { name: 'fr', b: 1 })].join(', ')); - const report = await apply(catalog(), ledger(), { metadataChoices: ['md_b'] }); - expect(step(report).summary).toMatchObject({ chosen: 1, unchosenDeleted: 1 }); + const l = ledger(); + const c = storingDoor(catalog()); + const report = await apply(c, l, { metadataChoices: ['md_b'] }); + expect(step(report).summary).toMatchObject({ chosen: 1, organizationRowsDeleted: 1 }); expect(owners()).toEqual([['md_b', null]]); + // A rerun with the same choice: the chosen row is the key's environment row now. + l.checkpoint!.completed = {}; + expect(step(await apply(c, l, { metadataChoices: ['md_b'] })).status).toBe('done'); }); it('⭐ fail-closed: choosing a row that serves a form an unchosen row withdraws carries the withdrawal onto it before the delete', async () => { @@ -970,23 +1073,21 @@ describe('ADR-0131 D10 --apply — a customization conflict resolved by --metada md('md_org', 'org_default', 'view', 'crm_lead.intake', formView(false, 'Org copy')), md('md_env', null, 'view', 'crm_lead.intake', formView(true, 'Env copy')), ].join(', ')); - const c = catalog(); - const views = servedViews(c); + const c = storingDoor(catalog()); + const views = servedViews(c, []); const report = await apply(c, ledger(), { views, metadataChoices: ['md_env'] }); expect(step(report).status).toBe('done'); - expect(c.viewSaves.map((v) => [v.packageId, (v.body.config as any).sharing.enabled])).toEqual( - expect.arrayContaining([[null, false]]), - ); expect(owners()).toEqual([['md_env', null]]); + expect((JSON.parse(String(rows(`SELECT metadata FROM sys_metadata WHERE id = 'md_env'`)[0]!.metadata)) as any).config.sharing.enabled).toBe(false); expect(await doorsServe(views, 'lead-intake')).toEqual([]); }); - it('a door that refuses that carry blocks the conflict, and its unchosen row is not deleted', async () => { + it('a door that refuses the chosen body blocks the conflict, and no organization row is deleted', async () => { metadataTable([ md('md_org', 'org_default', 'view', 'crm_lead.intake', formView(false, 'Org copy')), md('md_env', null, 'view', 'crm_lead.intake', formView(true, 'Env copy')), ].join(', ')); - const c = catalog(); + const c = storingDoor(catalog()); const refusing: ReturnType = { ...c, async save(type, name, body, target) { @@ -994,25 +1095,51 @@ describe('ADR-0131 D10 --apply — a customization conflict resolved by --metada return c.save(type, name, body, target); }, }; - const report = await apply(refusing, ledger(), { views: servedViews(c), metadataChoices: ['md_env'] }); + const report = await apply(refusing, ledger(), { views: servedViews(c, []), metadataChoices: ['md_env'] }); expect(step(report).status).toBe('blocked'); - expect(step(report).blockers.join('\n')).toMatch(/no unchosen row is deleted until then/); + expect(step(report).blockers.join('\n')).toMatch(/Nothing was deleted; fix the cause, then re-run/); expect(deletes()).toEqual([]); expect(owners()).toEqual([['md_env', null], ['md_org', 'org_default']]); }); - it('under a wall a row is promoted only when chosen; the rest stay reported', async () => { - metadataTable([md('md_a', 'org_a', 'dashboard', 'sales', { name: 'sales' }), md('md_b', 'org_a', 'report', 'pipe', { name: 'pipe' })].join(', ')); - const report = await apply(catalog(), ledger(), { posture: 'isolated', metadataChoices: ['md_a'] }); - expect(step(report).summary).toMatchObject({ promoted: 1, reported: 1 }); - expect(owners()).toEqual([['md_a', null], ['md_b', 'org_a']]); + describe('under a wall (ruling 6163114098, Q6 A: the ceremony never deletes the unchosen rows)', () => { + it('⭐ a chosen organization row defines the environment row, and nothing is deleted', async () => { + metadataTable(conflictRows); + const report = await apply(storingDoor(catalog()), ledger(), { posture: 'isolated', metadataChoices: ['md_org'] }); + expect(step(report)).toMatchObject({ status: 'done', summary: { chosen: 1, organizationRowsDeleted: 0 } }); + expect(deletes()).toEqual([]); + expect(owners()).toEqual([['md_env', null], ['md_org', 'org_default']]); + expect(labelOf('md_env')).toBe('Org list'); + expect(step(report).notes.join('\n')).toMatch(/organization rows md_org are never deleted/); + }); + + it('⭐ two organizations on one key: the chosen one is promoted, the other stays', async () => { + metadataTable([md('md_a', 'org_a', 'dashboard', 'sales', { name: 'sales', a: 1 }), md('md_b', 'org_b', 'dashboard', 'sales', { name: 'sales', b: 1 })].join(', ')); + const report = await apply(storingDoor(catalog()), ledger(), { posture: 'isolated', metadataChoices: ['md_b'] }); + expect(step(report).status).toBe('done'); + expect(deletes()).toEqual([]); + expect(owners()).toEqual([['md_a', 'org_a'], ['md_b', null]]); + }); + + it('a conflict nobody chose for is reported, not blocking the step; its rows hold the column', async () => { + metadataTable(conflictRows); + const report = await apply(storingDoor(catalog()), ledger(), { posture: 'isolated' }); + expect(step(report)).toMatchObject({ status: 'done', summary: { conflicts: 1, reported: 1 } }); + expect(step(report).notes.join('\n')).toMatch(/view deal_list is held by 2 rows/); + expect(deletes()).toEqual([]); + }); + + it('a lone row is promoted only when chosen; the rest stay reported', async () => { + metadataTable([md('md_a', 'org_a', 'dashboard', 'sales', { name: 'sales' }), md('md_b', 'org_a', 'report', 'pipe', { name: 'pipe' })].join(', ')); + const report = await apply(catalog(), ledger(), { posture: 'isolated', metadataChoices: ['md_a'] }); + expect(step(report).summary).toMatchObject({ promoted: 1, reported: 1 }); + expect(owners()).toEqual([['md_a', null], ['md_b', 'org_a']]); + }); }); it('refuses, before any write, a choice naming no row awaiting one, and two choices for one conflict', async () => { metadataTable([conflictRows, md('md_lone', 'org_default', 'dashboard', 'sales', { name: 'sales' })].join(', ')); await expect(apply(catalog(), ledger(), { metadataChoices: ['md_nope'] })).rejects.toBeInstanceOf(MetadataChoiceRefusal); - // Under single a lone row is promoted without a choice, so it awaits none. - await expect(apply(catalog(), ledger(), { metadataChoices: ['md_lone'] })).rejects.toThrow(/md_lone names no row awaiting a choice/); await expect(apply(catalog(), ledger(), { metadataChoices: ['md_org', 'md_env'] })).rejects.toThrow( /md_org and md_env both choose for view deal_list/, ); @@ -1021,26 +1148,6 @@ describe('ADR-0131 D10 --apply — a customization conflict resolved by --metada }); describe('ADR-0131 D10 --apply — history renumbering (seat answers 6162538253, Q2 A)', () => { - /** - * The door as the metadata repository numbers a save: from history in the - * writer's own scope (the environment's while the column stands), event_seq - * and the item's version each `MAX + 1`. - */ - const numberingDoor = (c: ReturnType): ReturnType => ({ - ...c, - async save(type, name, body, target) { - await c.save(type, name, body, target); - const scoped = rows(`SELECT name FROM pragma_table_info('sys_metadata_history') WHERE name = 'organization_id'`).length > 0; - const env = scoped ? 'WHERE organization_id IS NULL' : 'WHERE 1 = 1'; - const [seq] = rows(`SELECT COALESCE(MAX(event_seq), 0) + 1 AS n FROM sys_metadata_history ${env}`); - const [ver] = rows(`SELECT COALESCE(MAX(version), 0) + 1 AS n FROM sys_metadata_history ${env} AND type = '${type}' AND name = '${name}'`); - db.prepare( - `INSERT INTO sys_metadata_history (id, ${scoped ? 'organization_id, ' : ''}type, name, version, event_seq, checksum) ` + - `VALUES (?, ${scoped ? 'NULL, ' : ''}?, ?, ?, ?, ?)`, - ).run(`h_save_${String(seq!.n)}`, type, name, ver!.n, seq!.n, `save_${String(target?.packageId ?? '')}`); - }, - }); - // A database holding only what each pin plants, so every step can finish // under any posture and the late step is reached. beforeEach(() => { @@ -1085,13 +1192,7 @@ describe('ADR-0131 D10 --apply — history renumbering (seat answers 6162538253, }; const seqs = (): Array<[unknown, unknown, unknown, unknown]> => rows('SELECT id, organization_id, event_seq, version FROM sys_metadata_history ORDER BY id').map((r) => [r.id, r.organization_id, r.event_seq, r.version]); - const step = (report: OrganizationOwnershipApplyReport) => ({ - ...report.steps.find((s) => s.step === 'sequence-renumber')!, - summary: { - ...report.steps.find((s) => s.step === 'sequence-renumber')!.summary, - ...report.steps.find((s) => s.step === 'version-renumber')!.summary, - }, - }); + const step = (report: OrganizationOwnershipApplyReport) => report.steps.find((s) => s.step === 'history-renumber')!; const duplicates = (): unknown[] => [ ...rows('SELECT event_seq, COUNT(*) AS n FROM sys_metadata_history GROUP BY event_seq HAVING COUNT(*) > 1'), ...rows('SELECT type, name, version FROM sys_metadata_history GROUP BY type, name, version HAVING COUNT(*) > 1'), @@ -1166,53 +1267,114 @@ describe('ADR-0131 D10 --apply — history renumbering (seat answers 6162538253, expect(rows('SELECT * FROM sys_metadata_commit ORDER BY id')).toEqual(commits); }); - it('⭐ the ceremony\'s own environment saves never land on a number an organization holds (the withdrawal carry)', async () => { - // The organization withdrew the form; the environment and the organization - // each kept a lineage of it, numbered 1..2 in their own scopes. + /** A view row and its history: one lineage per scope, as planted. */ + const viewWithHistory = (orgRows: string, historyRows: string, versioned = true): void => { db.exec(` CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, name TEXT, state TEXT, package_id TEXT, bundle_member TEXT, version INTEGER, metadata TEXT); - INSERT INTO sys_metadata VALUES - ('md_org', 'org_default', 'view', 'crm_lead.intake', 'active', NULL, NULL, 2, '${JSON.stringify(formView(false, 'Org copy'))}'); + INSERT INTO sys_metadata VALUES ${orgRows}; CREATE TABLE sys_metadata_history (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, name TEXT, version INTEGER, event_seq INTEGER, checksum TEXT); - INSERT INTO sys_metadata_history VALUES - ('h_env1', NULL, 'view', 'crm_lead.intake', 1, 1, 'e1'), - ('h_env2', NULL, 'view', 'crm_lead.intake', 2, 2, 'e2'), - ('h_org1', 'org_default', 'view', 'crm_lead.intake', 1, 1, 'o1'), - ('h_org2', 'org_default', 'view', 'crm_lead.intake', 2, 2, 'o2'); + ${historyRows ? `INSERT INTO sys_metadata_history VALUES ${historyRows};` : ''} `); - const c = numberingDoor(catalog()); + void versioned; + }; + const withdrawn = JSON.stringify(formView(false, 'Org copy')); + const snapshotOf = (id: string): unknown[] => { + const [row] = rows(`SELECT type, name, version FROM sys_metadata WHERE id = '${id}'`); + return rows(`SELECT checksum FROM sys_metadata_history WHERE type = '${String(row!.type)}' AND name = '${String(row!.name)}' AND version = ${Number(row!.version)}`) + .map((r) => r.checksum); + }; + + it('⭐ the ceremony\'s own environment saves never land on a number an organization holds (the withdrawal carry)', async () => { + viewWithHistory( + `('md_org', 'org_default', 'view', 'crm_lead.intake', 'active', NULL, NULL, 2, '${withdrawn}')`, + `('h_env1', NULL, 'view', 'crm_lead.intake', 1, 1, 'e1'), ('h_env2', NULL, 'view', 'crm_lead.intake', 2, 2, 'e2'), + ('h_org1', 'org_default', 'view', 'crm_lead.intake', 1, 1, 'o1'), ('h_org2', 'org_default', 'view', 'crm_lead.intake', 2, 2, 'o2')`, + ); + const c = storingDoor(catalog()); const report = await apply(c, ledger(), { views: servedViews(c), columnDrops: keepColumn }); expect(report.steps.find((s) => s.step === 'metadata-promotion')!.summary).toMatchObject({ withdrawalsCarried: 1, promoted: 1 }); - // The carry saved through the door after the version layout, and before the sequence one. expect(rows(`SELECT COUNT(*) AS n FROM sys_metadata_history WHERE id LIKE 'h_save_%' AND type = 'view'`)).toEqual([{ n: 1 }]); - expect(report.steps.find((s) => s.step === 'sequence-renumber')!.status).toBe('done'); expect(duplicates()).toEqual([]); - // The promoted row still names its own snapshot; the organization's order is unchanged. - const [row] = rows(`SELECT organization_id, version FROM sys_metadata WHERE id = 'md_org'`); - expect(row!.organization_id).toBeNull(); - expect(rows(`SELECT checksum FROM sys_metadata_history WHERE type = 'view' AND name = 'crm_lead.intake' AND version = ${Number(row!.version)}`)) - .toEqual([{ checksum: 'o2' }]); - expect(rows(`SELECT checksum FROM sys_metadata_history WHERE organization_id = 'org_default' ORDER BY event_seq`).map((r) => r.checksum)) - .toEqual(['o1', 'o2']); + // The promoted row took its history with it, and still names its own snapshot. + expect(rows(`SELECT organization_id FROM sys_metadata_history WHERE id IN ('h_org1', 'h_org2')`)).toEqual([{ organization_id: null }, { organization_id: null }]); + expect(snapshotOf('md_org')).toEqual(['o2']); + expect(rows(`SELECT checksum FROM sys_metadata_history WHERE id IN ('h_org1', 'h_org2') ORDER BY event_seq`).map((r) => r.checksum)).toEqual(['o1', 'o2']); + }); + + it('⭐ an item with only an organization history: no environment save, during the run or after it, lands on its numbers', async () => { + // F2: no environment lineage at all. The package's code view is carried + // onto (another key), the package-less organization row is promoted. + viewWithHistory( + `('md_org', 'org_default', 'view', 'crm_lead.intake', 'active', NULL, NULL, 2, '${withdrawn}')`, + `('h_org1', 'org_default', 'view', 'crm_lead.intake', 1, 1, 'o1'), ('h_org2', 'org_default', 'view', 'crm_lead.intake', 2, 2, 'o2')`, + ); + const c = storingDoor(catalog()); + + await apply(c, ledger(), { views: servedViews(c), columnDrops: keepColumn }); + expect(duplicates()).toEqual([]); + expect(snapshotOf('md_org')).toEqual(['o2']); + + // A runtime save of the promoted item, and of the package's copy, after the run. + await c.save('view', 'crm_lead.intake', formView(false, 'Edited'), { packageId: null }); + await c.save('view', 'crm_lead.intake', formView(false, 'Edited'), { packageId: 'pkg_a' }); + expect(duplicates()).toEqual([]); + }); + + it('⭐ under a wall, a carry onto an item an organization row still holds is numbered apart by the late step', async () => { + viewWithHistory( + `('md_org', 'org_default', 'view', 'crm_lead.intake', 'active', NULL, NULL, 2, '${withdrawn}')`, + `('h_org1', 'org_default', 'view', 'crm_lead.intake', 1, 1, 'o1'), ('h_org2', 'org_default', 'view', 'crm_lead.intake', 2, 2, 'o2')`, + ); + const c = storingDoor(catalog()); + + const report = await apply(c, ledger(), { posture: 'isolated', views: servedViews(c), columnDrops: keepColumn }); + + expect(report.steps.find((s) => s.step === 'metadata-promotion')!.summary).toMatchObject({ withdrawalsCarried: 1, promoted: 0 }); + expect(duplicates()).toEqual([]); + // The organization row stays its organization's, and still names its own snapshot. + expect(rows(`SELECT organization_id FROM sys_metadata WHERE id = 'md_org'`)).toEqual([{ organization_id: 'org_default' }]); + expect(snapshotOf('md_org')).toEqual(['o2']); + }); + + it('⭐ two organizations on one key, no environment history, one chosen and carried onto (single)', async () => { + viewWithHistory( + `('md_a', 'org_a', 'view', 'crm_lead.intake', 'active', NULL, NULL, 2, '${withdrawn}'), + ('md_b', 'org_b', 'view', 'crm_lead.intake', 'active', NULL, NULL, 2, '${JSON.stringify(formView(true, 'B copy'))}')`, + `('h_a1', 'org_a', 'view', 'crm_lead.intake', 1, 1, 'a1'), ('h_a2', 'org_a', 'view', 'crm_lead.intake', 2, 2, 'a2'), + ('h_b1', 'org_b', 'view', 'crm_lead.intake', 1, 1, 'b1'), ('h_b2', 'org_b', 'view', 'crm_lead.intake', 2, 2, 'b2')`, + ); + const c = storingDoor(catalog()); + + const report = await apply(c, ledger(), { views: servedViews(c, []), metadataChoices: ['md_b'], columnDrops: keepColumn }); + + expect(report.steps.find((s) => s.step === 'metadata-promotion')!.summary).toMatchObject({ chosen: 1, organizationRowsDeleted: 1 }); + expect(duplicates()).toEqual([]); + // The chosen row now carries the unchosen row's withdrawal, saved above its own lineage. + expect((JSON.parse(String(rows(`SELECT metadata FROM sys_metadata WHERE id = 'md_b'`)[0]!.metadata)) as any).config.sharing.enabled).toBe(false); + await c.save('view', 'crm_lead.intake', formView(false, 'Edited'), { packageId: null }); + expect(duplicates()).toEqual([]); }); it('an environment save made between two runs is renumbered by the next one, the checkpoint notwithstanding', async () => { history(); const l = ledger(); await apply(catalog(), l, { posture: 'isolated', columnDrops: keepColumn }); - // A save lands in the environment's scope, at the environment's own MAX + 1. - const [seq] = rows('SELECT MAX(event_seq) + 1 AS n FROM sys_metadata_history WHERE organization_id IS NULL'); - db.prepare(`INSERT INTO sys_metadata_history VALUES ('h_late', NULL, 'object', 'deal', 3, ?, 'late')`).run(seq!.n); + // Environment saves since (numbered in the environment's own scope) have + // reached the numbers the first run gave org_a: the next one lands there. + const [seq] = rows(`SELECT MIN(event_seq) AS n FROM sys_metadata_history WHERE organization_id = 'org_a'`); + const [ver] = rows(`SELECT MIN(version) AS n FROM sys_metadata_history WHERE organization_id = 'org_a' AND type = 'view' AND name = 'board'`); + db.prepare(`INSERT INTO sys_metadata_history VALUES ('h_late', NULL, 'view', 'board', ?, ?, 'late')`).run(ver!.n, seq!.n); expect(duplicates()).not.toEqual([]); const again = await apply(catalog(), l, { posture: 'isolated', columnDrops: keepColumn }); - expect(again.steps.find((s) => s.step === 'sequence-renumber')).toMatchObject({ status: 'done', summary: { organizationsShifted: 3 } }); - expect(again.steps.find((s) => s.step === 'version-renumber')!.status).toBe('checkpointed'); + expect(step(again)).toMatchObject({ status: 'done', summary: { organizationsShifted: 3, lineagesShifted: 2 } }); expect(duplicates()).toEqual([]); + expect(snapshotOf('row_a')).toEqual(['a3']); + expect(snapshotOf('row_b')).toEqual(['b2']); }); }); diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index 20fa0c7db69..b0a3c47b0c2 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -54,17 +54,6 @@ import { * (`convertPositionBindingRows`). Only under `single` (ADR-0131 D3: a * Setup catalog write is an environment metadata write under `single` and * refused under a wall); under a wall the rows are reported. - * 2r. `version-renumber` — seat answer Q2 A (6162538253). Once the - * organization leaves the key, each item's `(type, name, version)` - * lineage is unique environment-wide; an organization's history kept its - * own lineage beside the environment's. Where two lineages of one item - * exist, the organizations' are laid out one above another (the first - * keeps its numbers) and the environment's goes above them all, each - * overlay row's `version` with its own lineage. It runs here, while every - * overlay row still says whose it is, and puts the environment on top so - * that every later environment save (numbered by the door from the - * environment's own history) lands above every organization's numbers. - * The version a rollback resolves is unchanged. Numbers only, no delete. * 2a. `metadata-promotion` — rulings A on #22011 and #22008 (records * 6020163868, 6020151485): first every public-form withdrawal an * organization saved is carried onto the environment, fail-closed (any @@ -74,14 +63,18 @@ import { * judge by (`anonymousFormIntakeWithdrawnIn`) decides. Then, under * `single`, each organization-scoped `sys_metadata` row of the five * presentational types whose overlay key no other row holds becomes an - * environment row in place (its `organization_id` cleared). A key held by - * more than one row is a conflict: listed, kept, and blocking until the - * operator chooses one row with `--metadata-choice ROW_ID`; the chosen row - * is promoted and read back, any form withdrawal an unchosen row holds is - * carried onto it, and only then are the unchosen rows deleted, by id - * (ruling B on #23413 Q1, the template path's shape). Under a wall - * nothing is promoted unless the operator names the row (ruling A on - * #23413 Q6): the rest are reported and hold the column. + * environment row in place, its history with it (its `organization_id` + * cleared). A key held by more than one row is a conflict: listed, kept, + * and blocking under `single` until the operator chooses one row with + * `--metadata-choice ROW_ID`. The key then ends with exactly ONE + * environment row defining the chosen body (the key's environment row + * takes it through the door, in place; with none, the chosen row is + * promoted in place), with every withdrawal another row holds kept; it is + * read back, and only then are the key's organization rows deleted, in + * one statement, by id (ruling B on #23413 Q1). Under a wall nothing is + * promoted unless the operator names the row, and nothing is deleted + * (ruling A on #23413 Q6, ruling 6163114098): the rest are reported and + * hold the column. * 2b. `template-promotion` — ruling C on ADR-0131 §6 Q1: every * `sys_email_template` row an organization customized or authored becomes * an environment `email_template` item through the metadata door (core's @@ -105,13 +98,14 @@ import { * named categories are deleted. Templates: the promotion runs again first, * and beyond the seeded mirrors only the rows whose environment copy that * pass verified are deleted, by id. - * 4s. `sequence-renumber` — seat answer Q2 A: each organization's - * `sys_metadata_history.event_seq` range is shifted above the - * environment's by ONE offset, its commit ranges with it, so order within - * an organization is unchanged. It runs after the last environment save - * the ceremony makes, and on every apply even when checkpointed (it is - * idempotent), so no environment save is ever numbered onto an - * organization's range. Numbers only, no delete. + * 4h. `history-renumber` — seat answer Q2 A: `sys_metadata_history`'s + * `event_seq` and every `(type, name, version)` unique environment-wide + * before the column goes. Every lineage still tagged to an organization + * is laid out above the environment's (each organization's event range by + * ONE offset, its commit ranges with it; each item's version lineage, the + * organization's rows with it); under `single` that history then becomes + * the environment's. It runs on every apply, even when checkpointed, so an + * environment save made since is covered. Numbers only, no delete. * 5. `column-drops` (fate 1) — `organization_id` leaves the D7 objects * through the schema-drift ceremony ({@link ColumnDropPort}), never a hand * `ALTER`. @@ -142,13 +136,12 @@ export const ORGANIZATION_OWNERSHIP_MIGRATION_ID = 'adr-0131-organization-owners export const CEREMONY_STEPS = [ 'attribution', 'authored-conversion', - 'version-renumber', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite', 'mirror-deletion', - 'sequence-renumber', + 'history-renumber', 'column-drops', ] as const; export type CeremonyStep = (typeof CEREMONY_STEPS)[number]; @@ -166,7 +159,7 @@ export interface CatalogPort { /** The metadata door: an environment metadata save. Throws when the door refuses. */ export interface MetadataDoorPort { save( - type: 'position' | 'permission' | 'email_template' | 'view', + type: 'position' | 'permission' | 'email_template' | 'view' | 'dashboard' | 'report' | 'translation', name: string, body: Record, target?: { packageId: string | null }, @@ -200,6 +193,12 @@ export interface CeremonyCheckpoint { ceremony: 'ADR-0131 D10'; inventoryDigest: string; completed: Partial }>>; + /** + * The `--metadata-choice` rows whose conflict this ceremony resolved, recorded + * before the conflict's organization rows are deleted: a rerun after a crash + * between the two accepts the same choice and finishes. + */ + metadataChoicesApplied?: string[]; } /** Where the checkpoint lives (the command: the `sys_migration` row). */ @@ -345,6 +344,8 @@ interface Run { * promotion pass mirror deletion re-runs, so a resumed run verifies afresh. */ verifiedTemplateIds: Set; + /** The checkpoint this run writes into (set once the ledger is read). */ + checkpoint?: CeremonyCheckpoint; } /** A `--metadata-choice` that names no row awaiting a choice, or two of one conflict. Nothing is written. */ @@ -686,24 +687,26 @@ async function runAuthoredConversion(run: Run): Promise { return { step: 'authored-conversion', status: blockers.length ? 'blocked' : 'done', summary, blockers, notes }; } -// ── Steps 2r and 4s: history renumbering (seat answer Q2 A) ───────────────── +// ── History numbering (seat answer Q2 A) ──────────────────────────────────── // -// The metadata door numbers a write from history in its OWN scope -// (`MAX(event_seq) + 1`, `MAX(version) + 1` where `organization_id` is the -// writer's), and this ceremony's environment saves (the authored conversion, -// the form-withdrawal carry, a chosen conflict's carry, the template -// promotion) are environment-scoped. So each renumbering is placed where no -// environment save can land on a number it handed an organization: +// The metadata door numbers a save from history in its OWN scope: `event_seq` +// and an item's `version` are each `MAX + 1` over the rows whose +// `organization_id` is the writer's. While the column stands, the environment +// and each organization therefore count independently, and once it goes, +// `event_seq` and every `(type, name, version)` must be unique. Two rules make +// them so: // -// - `version-renumber` runs BEFORE the promotion, while every overlay row -// still says whose it is, and puts each item's environment lineage ABOVE -// its organization lineages. Every later environment save numbers above -// all of them, and a promoted row keeps its own lineage's numbers. -// - `sequence-renumber` runs AFTER the last environment save, immediately -// before the column goes, and moves each organization's `event_seq` range -// above the environment's, as the seat answer reads. It runs on every apply, -// checkpoint or not: it is idempotent, and a write between two runs is -// covered by the second. +// - **A promoted row takes its history with it** ({@link foldLineage}). Before +// a row leaves its organization, that organization's lineage of the item is +// moved above the environment's numbers and becomes environment history. The +// door then counts it, so every later environment save of the item, the +// ceremony's or the runtime's, numbers above it, and the row's own version +// still names its own snapshot. +// - **Every other organization lineage is laid out above the environment's +// just before the column goes** ({@link runHistoryRenumber}, on every apply). +// Those lineages belong to rows that stay organization rows (reported under +// a wall) or are gone (unchosen rows), so no environment row's version points +// into them; the step moves each organization row's version with its own. const HISTORY = 'sys_metadata_history'; const COMMITS = 'sys_metadata_commit'; @@ -736,133 +739,199 @@ async function shiftRange( return { top: range.max + offset, moved: true }; } +/** The history columns the numbering reads, or `null` when the table carries no organization column. */ +async function historyShape(run: Run): Promise<{ version: boolean; seq: boolean } | null> { + const hcols = await columnsOf(run, HISTORY); + if (!hcols?.has(ORG)) return null; + return { version: hcols.has('version'), seq: hcols.has('event_seq') }; +} + /** - * Each `(type, name)` lineage unique environment-wide: the organizations' - * lineages laid out one above another in organization order (the first keeps - * its numbers), then the environment's above them all. Each overlay row's - * `version` moves with its own lineage, so the version a rollback resolves is - * unchanged. Only where two lineages of one item exist; a lone one keeps its - * numbers. + * Make one organization's lineage of `(type, name)` environment history, so the + * row about to be promoted keeps it: its versions moved above the environment + * lineage of the item (the organization's overlay rows of the item with them), + * its events moved above the environment's last event (order kept), then the + * rows re-tagged. Each statement is conditioned on the state it changes, so a + * rerun after a crash anywhere in it finishes it. Commit ranges are not moved + * (no reader resolves history through them). */ -async function runVersionRenumber(run: Run): Promise { - const summary: Record = { lineagesShifted: 0 }; - const hcols = await columnsOf(run, HISTORY); - if (!hcols?.has(ORG) || !hcols.has('version')) { - return { step: 'version-renumber', status: 'done', summary, blockers: [], notes: [] }; - } +async function foldLineage(run: Run, organizationId: string, type: string, name: string): Promise { + const shape = await historyShape(run); + if (!shape) return; const sql = run.sql; const h = sql.q(HISTORY, HISTORY); const o = sql.q(ORG, HISTORY); - const v = sql.q('version', HISTORY); const ty = sql.q('type', HISTORY); const nm = sql.q('name', HISTORY); - const mcols = await columnsOf(run, OVERLAYS); - const overlaysCarry = !!mcols?.has(ORG) && mcols.has('version'); - const lineages = await sql.read( - HISTORY, - `SELECT ${ty} AS t, ${nm} AS n, ${o} AS org, MIN(${v}) AS lo, MAX(${v}) AS hi FROM ${h} ` + - `GROUP BY ${ty}, ${nm}, ${o} ORDER BY ${ty}, ${nm}, ${o}`, - ); - const byItem = new Map>>(); - for (const l of lineages) { - const key = `${String(l.t)}\u0000${String(l.n)}`; - byItem.set(key, [...(byItem.get(key) ?? []), l]); - } - const isEnv = (l: Record): boolean => l.org === null || l.org === undefined || l.org === ''; - for (const group of byItem.values()) { - if (group.length < 2) continue; - const env = group.find(isEnv); - const ordered = [...group.filter((l) => !isEnv(l)), ...(env ? [env] : [])]; - // The first organization's lineage keeps its numbers; each later one, and - // the environment's last, goes above the ones before it. - let vbase = num(ordered[0]!.hi); - for (const l of ordered.slice(1)) { - const owner = isEnv(l) ? `${sql.q(ORG, OVERLAYS)} IS NULL` : `${sql.q(ORG, OVERLAYS)} = ?`; - const ownerH = isEnv(l) ? `${o} IS NULL` : `${o} = ?`; - const ownerArgs = isEnv(l) ? [] : [String(l.org)]; - const shifted = await shiftRange( - { min: num(l.lo), max: num(l.hi) }, - vbase, + const mine = `${o} = ? AND ${ty} = ? AND ${nm} = ?`; + const args = [organizationId, type, name]; + if (shape.version) { + const v = sql.q('version', HISTORY); + const [own] = await sql.read(HISTORY, `SELECT MIN(${v}) AS lo, MAX(${v}) AS hi, COUNT(*) AS n FROM ${h} WHERE ${mine}`, args); + if (num(own?.n) > 0) { + const [env] = await sql.read(HISTORY, `SELECT MAX(${v}) AS m FROM ${h} WHERE ${o} IS NULL AND ${ty} = ? AND ${nm} = ?`, [type, name]); + const mcols = await columnsOf(run, OVERLAYS); + await shiftRange( + { min: num(own!.lo), max: num(own!.hi) }, + num(env?.m), async (offset, oldMax) => { - if (!overlaysCarry) return; + if (!mcols?.has(ORG) || !mcols.has('version')) return; const m = sql.q(OVERLAYS, OVERLAYS); const mv = sql.q('version', OVERLAYS); await execute( run, OVERLAYS, - `UPDATE ${m} SET ${mv} = ${mv} + ? WHERE ${owner} AND ${sql.q('type', OVERLAYS)} = ? ` + + `UPDATE ${m} SET ${mv} = ${mv} + ? WHERE ${sql.q(ORG, OVERLAYS)} = ? AND ${sql.q('type', OVERLAYS)} = ? ` + `AND ${sql.q('name', OVERLAYS)} = ? AND ${mv} <= ?`, - [offset, ...ownerArgs, l.t, l.n, oldMax], + [offset, ...args, oldMax], ); }, async (offset) => { - await execute(run, HISTORY, `UPDATE ${h} SET ${v} = ${v} + ? WHERE ${ownerH} AND ${ty} = ? AND ${nm} = ?`, [ - offset, - ...ownerArgs, - l.t, - l.n, - ]); + await execute(run, HISTORY, `UPDATE ${h} SET ${v} = ${v} + ? WHERE ${mine}`, [offset, ...args]); }, ); - vbase = shifted.top; - if (shifted.moved) summary.lineagesShifted++; } } - if (summary.lineagesShifted) run.opts.onProgress?.(`history renumbered: ${summary.lineagesShifted} version lineage(s)`); - return { step: 'version-renumber', status: 'done', summary, blockers: [], notes: [] }; + if (shape.seq) { + const seq = sql.q('event_seq', HISTORY); + const [own] = await sql.read(HISTORY, `SELECT MIN(${seq}) AS lo, COUNT(*) AS n FROM ${h} WHERE ${mine}`, args); + const [env] = await sql.read(HISTORY, `SELECT MAX(${seq}) AS m FROM ${h} WHERE ${o} IS NULL`); + if (num(own?.n) > 0 && num(own!.lo) <= num(env?.m)) { + await execute(run, HISTORY, `UPDATE ${h} SET ${seq} = ${seq} + ? WHERE ${mine}`, [num(env?.m) - num(own!.lo) + 1, ...args]); + } + } + await execute(run, HISTORY, `UPDATE ${h} SET ${o} = NULL WHERE ${mine}`, args); } /** - * `event_seq` unique environment-wide: each organization's range moved above - * the environment's (and the organizations' before it) by ONE offset, its - * commit ranges with it, so order within an organization is unchanged. + * `event_seq` and every `(type, name, version)` unique environment-wide over + * the history still tagged to an organization: each organization's event range + * moved above the environment's (and the organizations' before it) by ONE + * offset, its commit ranges with it; each organization lineage of an item laid + * out above the environment's lineage of it (and the ones before it), the + * organization's overlay rows of the item with it. Order within an + * organization, and the snapshot each row's version names, are unchanged. */ -async function runSequenceRenumber(run: Run): Promise { - const summary: Record = { organizationsShifted: 0 }; - const hcols = await columnsOf(run, HISTORY); - if (!hcols?.has(ORG) || !hcols.has('event_seq')) { - return { step: 'sequence-renumber', status: 'done', summary, blockers: [], notes: [] }; - } +async function runHistoryRenumber(run: Run): Promise { + const summary: Record = { organizationsShifted: 0, lineagesShifted: 0 }; + const shape = await historyShape(run); + if (!shape) return { step: 'history-renumber', status: 'done', summary, blockers: [], notes: [] }; const sql = run.sql; const h = sql.q(HISTORY, HISTORY); const o = sql.q(ORG, HISTORY); - const seq = sql.q('event_seq', HISTORY); - const ccols = await columnsOf(run, COMMITS); - const commitsCarry = !!ccols?.has(ORG) && ccols.has('event_seq_start') && ccols.has('event_seq_end'); - const envTop = num((await sql.read(HISTORY, `SELECT MAX(${seq}) AS m FROM ${h} WHERE ${o} IS NULL`))[0]?.m); - const ranges = await sql.read( - HISTORY, - `SELECT ${o} AS org, MIN(${seq}) AS lo, MAX(${seq}) AS hi FROM ${h} WHERE ${o} IS NOT NULL GROUP BY ${o} ORDER BY ${o}`, - ); - let base = envTop; - for (const r of ranges) { - const org = String(r.org); - const shifted = await shiftRange( - { min: num(r.lo), max: num(r.hi) }, - base, - async (offset, oldMax) => { - if (!commitsCarry) return; - const c = sql.q(COMMITS, COMMITS); - const s0 = sql.q('event_seq_start', COMMITS); - const s1 = sql.q('event_seq_end', COMMITS); - await execute( - run, - COMMITS, - `UPDATE ${c} SET ${s0} = ${s0} + ?, ${s1} = ${s1} + ? WHERE ${sql.q(ORG, COMMITS)} = ? AND ${s0} <= ?`, - [offset, offset, org, oldMax], + + if (shape.version) { + const v = sql.q('version', HISTORY); + const ty = sql.q('type', HISTORY); + const nm = sql.q('name', HISTORY); + const mcols = await columnsOf(run, OVERLAYS); + const overlaysCarry = !!mcols?.has(ORG) && mcols.has('version'); + const lineages = await sql.read( + HISTORY, + `SELECT ${ty} AS t, ${nm} AS n, ${o} AS org, MIN(${v}) AS lo, MAX(${v}) AS hi FROM ${h} ` + + `GROUP BY ${ty}, ${nm}, ${o} ORDER BY ${ty}, ${nm}, ${o}`, + ); + const isEnv = (l: Record): boolean => l.org === null || l.org === undefined || l.org === ''; + const byItem = new Map>>(); + for (const l of lineages) { + const key = `${String(l.t)}\u0000${String(l.n)}`; + byItem.set(key, [...(byItem.get(key) ?? []), l]); + } + for (const group of byItem.values()) { + const env = group.find(isEnv); + const orgs = group.filter((l) => !isEnv(l)); + if (orgs.length === 0 || (!env && orgs.length === 1)) continue; + // Above the environment's lineage; with none, the first organization keeps its numbers. + let vbase = env ? num(env.hi) : num(orgs[0]!.hi); + for (const l of env ? orgs : orgs.slice(1)) { + const org = String(l.org); + const shifted = await shiftRange( + { min: num(l.lo), max: num(l.hi) }, + vbase, + async (offset, oldMax) => { + if (!overlaysCarry) return; + const m = sql.q(OVERLAYS, OVERLAYS); + const mv = sql.q('version', OVERLAYS); + await execute( + run, + OVERLAYS, + `UPDATE ${m} SET ${mv} = ${mv} + ? WHERE ${sql.q(ORG, OVERLAYS)} = ? AND ${sql.q('type', OVERLAYS)} = ? ` + + `AND ${sql.q('name', OVERLAYS)} = ? AND ${mv} <= ?`, + [offset, org, l.t, l.n, oldMax], + ); + }, + async (offset) => { + await execute(run, HISTORY, `UPDATE ${h} SET ${v} = ${v} + ? WHERE ${o} = ? AND ${ty} = ? AND ${nm} = ?`, [ + offset, + org, + l.t, + l.n, + ]); + }, ); - }, - async (offset) => { - await execute(run, HISTORY, `UPDATE ${h} SET ${seq} = ${seq} + ? WHERE ${o} = ?`, [offset, org]); - }, + vbase = shifted.top; + if (shifted.moved) summary.lineagesShifted++; + } + } + } + + if (shape.seq) { + const seq = sql.q('event_seq', HISTORY); + const ccols = await columnsOf(run, COMMITS); + const commitsCarry = !!ccols?.has(ORG) && ccols.has('event_seq_start') && ccols.has('event_seq_end'); + const envTop = num((await sql.read(HISTORY, `SELECT MAX(${seq}) AS m FROM ${h} WHERE ${o} IS NULL`))[0]?.m); + const ranges = await sql.read( + HISTORY, + `SELECT ${o} AS org, MIN(${seq}) AS lo, MAX(${seq}) AS hi FROM ${h} WHERE ${o} IS NOT NULL GROUP BY ${o} ORDER BY ${o}`, ); - base = shifted.top; - if (shifted.moved) summary.organizationsShifted++; + let base = envTop; + for (const r of ranges) { + const org = String(r.org); + const shifted = await shiftRange( + { min: num(r.lo), max: num(r.hi) }, + base, + async (offset, oldMax) => { + if (!commitsCarry) return; + const c = sql.q(COMMITS, COMMITS); + const s0 = sql.q('event_seq_start', COMMITS); + const s1 = sql.q('event_seq_end', COMMITS); + await execute( + run, + COMMITS, + `UPDATE ${c} SET ${s0} = ${s0} + ?, ${s1} = ${s1} + ? WHERE ${sql.q(ORG, COMMITS)} = ? AND ${s0} <= ?`, + [offset, offset, org, oldMax], + ); + }, + async (offset) => { + await execute(run, HISTORY, `UPDATE ${h} SET ${seq} = ${seq} + ? WHERE ${o} = ?`, [offset, org]); + }, + ); + base = shifted.top; + if (shifted.moved) summary.organizationsShifted++; + } } - if (summary.organizationsShifted) { - run.opts.onProgress?.(`history renumbered: ${summary.organizationsShifted} organization event range(s)`); + // Under `single` the environment is the one tenant: what history is left + // tagged to an organization (rows reported or deleted) becomes the + // environment's too, so the door counts it and no later save collides. + // Under a wall it stays its organization's, renumbered here on every apply. + if (run.opts.posture === 'single') { + const [left] = await sql.read(HISTORY, `SELECT COUNT(*) AS n FROM ${h} WHERE ${o} IS NOT NULL`); + if (num(left?.n) > 0) { + await execute(run, HISTORY, `UPDATE ${h} SET ${o} = NULL WHERE ${o} IS NOT NULL`, []); + summary.folded = num(left?.n); + } + const ccols = await columnsOf(run, COMMITS); + if (ccols?.has(ORG)) { + const c = sql.q(COMMITS, COMMITS); + await execute(run, COMMITS, `UPDATE ${c} SET ${sql.q(ORG, COMMITS)} = NULL WHERE ${sql.q(ORG, COMMITS)} IS NOT NULL`, []); + } + } + if (summary.organizationsShifted || summary.lineagesShifted) { + run.opts.onProgress?.( + `history renumbered: ${summary.organizationsShifted} organization event range(s), ${summary.lineagesShifted} version lineage(s)`, + ); } - return { step: 'sequence-renumber', status: 'done', summary, blockers: [], notes: [] }; + return { step: 'history-renumber', status: 'done', summary, blockers: [], notes: [] }; } // ── Step 2a: organization customizations and form withdrawals (rulings A) ── @@ -953,11 +1022,10 @@ async function carryFormWithdrawals(run: Run, rows: readonly MetadataKeyRow[], s } } -/** One conflict the operator chose a row for, and the rows that go once that row is promoted and verified. */ +/** One conflict the operator chose a row for. */ interface MetadataResolution { conflict: MetadataConflict; chosen: MetadataConflict['candidates'][number]; - unchosen: string[]; } /** The `metadata` bodies of the given `sys_metadata` rows, by id. */ @@ -978,11 +1046,23 @@ async function metadataBodies(run: Run, ids: readonly string[]): Promise { +/** + * Promote one organization's rows in place: each row's lineage becomes + * environment history first ({@link foldLineage}), then its `organization_id` + * is cleared. Conditioned on the owner, so a rerun promotes nothing twice. + */ +async function promoteRows(run: Run, organizationId: string, rows: ReadonlyArray<{ id: string; type: string; name: string }>): Promise { + const seen = new Set(); + for (const row of rows) { + const item = `${row.type}\u0000${row.name}`; + if (seen.has(item)) continue; + seen.add(item); + await foldLineage(run, organizationId, row.type, row.name); + } const t = run.sql.q(METADATA_OBJECT, METADATA_OBJECT); const org = run.sql.q(ORG, METADATA_OBJECT); const id = run.sql.q('id', METADATA_OBJECT); + const ids = rows.map((r) => r.id); for (let i = 0; i < ids.length; i += IN_CHUNK) { const chunk = ids.slice(i, i + IN_CHUNK); await execute( @@ -994,67 +1074,141 @@ async function promoteRows(run: Run, organizationId: string, ids: readonly strin } } +/** The environment row of one overlay key, read by key (the doors' view of it). */ +async function environmentRowOf(run: Run, conflict: MetadataConflict): Promise | null }>> { + const sql = run.sql; + const t = METADATA_OBJECT; + const cols = (await columnsOf(run, t)) ?? new Set(); + const where = [`${sql.q(ORG, t)} IS NULL`, `${sql.q('type', t)} = ?`, `${sql.q('name', t)} = ?`]; + const params: unknown[] = [conflict.type, conflict.name]; + for (const [column, value] of [['package_id', conflict.packageId], ['bundle_member', conflict.bundleMember]] as const) { + if (!cols.has(column)) continue; + if (value === null) where.push(`(${sql.q(column, t)} IS NULL OR ${sql.q(column, t)} = '')`); + else { + where.push(`${sql.q(column, t)} = ?`); + params.push(value); + } + } + if (cols.has('state')) where.push(`${sql.q('state', t)} = 'active'`); + const found = await sql.read( + t, + `SELECT ${['id', 'name', 'metadata'].map((c) => `${sql.q(c, t)} AS ${sql.q(c, t)}`).join(', ')} FROM ${sql.q(t, t)} ` + + `WHERE ${where.join(' AND ')}`, + params, + ); + return found.map((row) => ({ id: String(row.id), body: parseBody(row) })); +} + +/** Two bodies define the same item (key order aside). */ +const sameBody = (a: Readonly>, b: Readonly>): boolean => { + const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort(); + return definitionKey(a, keys) === definitionKey(b, keys); +}; + /** - * Resolve one chosen conflict: promote the chosen row (an organization's), read - * it back as an environment row, carry onto it every public-form withdrawal an - * unchosen row holds (fail-closed: choosing never reopens a form), and only then - * delete the unchosen rows, by id — ruling B on #23413 Q1, the `--template-choice` - * path's shape. Returns a blocker, or `null` once the rows are gone. + * Resolve one chosen conflict so that the key holds exactly ONE environment + * row at every point, and a rerun after a crash anywhere in it finishes it: + * + * 1. The body the environment keeps: the chosen row's, with every public-form + * withdrawal an unchosen row holds applied (fail-closed: a choice never + * reopens a form). + * 2. Where the key has an environment row, that row takes the body through the + * metadata door, in place (the door's history and version with it), even + * when an organization's row was chosen. Where it has none, the chosen + * organization row is promoted in place, its history with it, and a + * withdrawal is then saved onto it through the door. + * 3. The environment row is read back and must define that body. + * 4. The choice is recorded in the checkpoint. + * 5. Under `single`, the conflict's organization rows that remain are deleted, + * in ONE statement, by id (ruling B on #23413 Q1). Under a wall nothing is + * deleted: they stay organization rows, reported, and hold the column + * (ruling A on #23413 Q6). + * + * Returns a blocker, or `null` once resolved. */ -async function resolveMetadataConflict(run: Run, r: MetadataResolution): Promise { - const { conflict, chosen, unchosen } = r; +async function resolveMetadataConflict(run: Run, r: MetadataResolution, notes: string[]): Promise { + const { conflict, chosen } = r; const sql = run.sql; const t = sql.q(METADATA_OBJECT, METADATA_OBJECT); const id = sql.q('id', METADATA_OBJECT); const what = `the ${conflict.type} ${conflict.name}${conflict.packageId ? ` (package ${conflict.packageId})` : ''}`; - if (chosen.organizationId !== null) await promoteRows(run, chosen.organizationId, [chosen.id]); - const back = await sql.read( - METADATA_OBJECT, - `SELECT ${sql.q(ORG, METADATA_OBJECT)} AS ${sql.q(ORG, METADATA_OBJECT)} FROM ${t} WHERE ${id} = ?`, - [chosen.id], - ); - if (back.length !== 1 || (back[0]![ORG] !== null && back[0]![ORG] !== undefined && back[0]![ORG] !== '')) { + const others = conflict.candidates.filter((c) => c.id !== chosen.id); + const bodies = await metadataBodies(run, conflict.candidates.map((c) => c.id)); + const chosenBody = bodies.get(chosen.id); + if (!chosenBody) { + return `The row ${chosen.id} chosen for ${what} has no readable body, so the environment cannot keep it. Choose another row, or repair it, then re-run.`; + } + const body = JSON.parse(JSON.stringify(chosenBody)) as Record; + let closing: string[] = []; + if (conflict.type === 'view') { + const layer = others.map((o) => bodies.get(o.id)).filter((b): b is Record => !!b); + const forms = anonymousFormIntakeCandidates(body).filter((c) => anonymousFormIntakeWithdrawnIn(layer, body, c)); + for (const candidate of forms) { + candidate.form.sharing = { ...(candidate.form.sharing as Record), enabled: false }; + } + closing = forms.map((c) => c.slug); + } + + const environment = await environmentRowOf(run, conflict); + if (environment.length > 1) { + return ( + `${what} has ${environment.length} active environment rows (${environment.map((e) => e.id).join(', ')}), so no ` + + 'single row can take the chosen body. Delete all but one, then re-run.' + ); + } + try { + if (environment.length === 0 && chosen.organizationId !== null) { + await promoteRows(run, chosen.organizationId, [{ id: chosen.id, type: conflict.type, name: conflict.name }]); + if (closing.length > 0) await run.opts.door.save(conflict.type as never, conflict.name, body, { packageId: conflict.packageId }); + } else if (environment.length === 1 && !(environment[0]!.body && sameBody(environment[0]!.body, body))) { + await run.opts.door.save(conflict.type as never, conflict.name, body, { packageId: conflict.packageId }); + } + } catch (error) { + return ( + `The metadata door refused to give ${what} the body of the chosen row ${chosen.id}` + + `${closing.length ? ` (with the public form(s) ${closing.join(', ')} an unchosen row withdraws, withdrawn)` : ''}: ` + + `${error instanceof Error ? error.message : String(error)}. Nothing was deleted; fix the cause, then re-run.` + ); + } + const after = await environmentRowOf(run, conflict); + if (after.length !== 1 || !after[0]!.body || !sameBody(after[0]!.body, body)) { throw new Error( - `The chosen row ${chosen.id} of ${what} did not read back as an environment row; none of the rows ` + - `${unchosen.join(', ')} was deleted.`, + `${what} did not read back as one environment row defining the chosen row ${chosen.id}'s body; none of the ` + + `rows ${others.map((o) => o.id).join(', ')} was deleted.`, ); } - if (conflict.type === 'view') { - const bodies = await metadataBodies(run, [chosen.id, ...unchosen]); - const body = bodies.get(chosen.id); - const layer = unchosen.map((u) => bodies.get(u)).filter((b): b is Record => !!b); - if (body) { - const closing = anonymousFormIntakeCandidates(body).filter((c) => anonymousFormIntakeWithdrawnIn(layer, body, c)); - if (closing.length > 0) { - for (const candidate of closing) { - candidate.form.sharing = { ...(candidate.form.sharing as Record), enabled: false }; - } - try { - await run.opts.door.save('view', conflict.name, body, { packageId: conflict.packageId }); - } catch (error) { - return ( - `The row ${chosen.id} chosen for ${what} serves the public form(s) ${closing.map((c) => c.slug).join(', ')} ` + - 'open, an unchosen row withdraws them, and the metadata door refused to carry that withdrawal onto the ' + - `chosen row: ${error instanceof Error ? error.message : String(error)}. Withdraw the form on the chosen row ` + - '(sharing.enabled false), then re-run; no unchosen row is deleted until then.' - ); - } - } + if (run.checkpoint) { + run.checkpoint.metadataChoicesApplied = [...new Set([...(run.checkpoint.metadataChoicesApplied ?? []), chosen.id])]; + await run.opts.ledger.write(run.checkpoint, { complete: false, blocking: 0, advisory: 0 }); + } + + const keeper = after[0]!.id; + const leaving = conflict.candidates.filter((c) => c.organizationId !== null && c.id !== keeper).map((c) => c.id); + if (run.opts.posture !== 'single') { + if (leaving.length) { + notes.push( + `${what}: the environment row ${keeper} keeps the chosen row ${chosen.id}'s body; under the ${run.opts.posture} ` + + `posture the organization rows ${leaving.join(', ')} are never deleted. They stay reported, and keep the ` + + `${METADATA_OBJECT} column from being dropped.`, + ); } + run.opts.onProgress?.(`promotion: ${what} is defined by row ${chosen.id}'s body; nothing deleted under a wall`); + return null; } - const ids = unchosen; - const where = `${id} IN (${ids.map(() => '?').join(', ')})`; - await execute(run, METADATA_OBJECT, `DELETE FROM ${t} WHERE ${where}`, ids); - if ((await sql.count(METADATA_OBJECT, `SELECT COUNT(*) AS n FROM ${t} WHERE ${where}`, ids)) !== 0) { - throw new Error(`The delete of the unchosen rows of ${what} left rows behind.`); + if (leaving.length) { + const where = `${id} IN (${leaving.map(() => '?').join(', ')})`; + await execute(run, METADATA_OBJECT, `DELETE FROM ${t} WHERE ${where}`, leaving); + if ((await sql.count(METADATA_OBJECT, `SELECT COUNT(*) AS n FROM ${t} WHERE ${where}`, leaving)) !== 0) { + throw new Error(`The delete of the organization rows of ${what} left rows behind.`); + } + run.deleted.set(METADATA_OBJECT, (run.deleted.get(METADATA_OBJECT) ?? 0) + leaving.length); } - run.deleted.set(METADATA_OBJECT, (run.deleted.get(METADATA_OBJECT) ?? 0) + ids.length); - run.opts.onProgress?.(`promotion: ${what} keeps row ${chosen.id}; ${ids.length} unchosen row(s) deleted`); + run.opts.onProgress?.(`promotion: ${what} is defined by row ${chosen.id}'s body; ${leaving.length} organization row(s) deleted`); return null; } async function runMetadataPromotion(run: Run): Promise { - const summary: Record = { withdrawalsCarried: 0, promoted: 0, conflicts: 0, chosen: 0, unchosenDeleted: 0, reported: 0 }; + const summary: Record = { withdrawalsCarried: 0, promoted: 0, conflicts: 0, chosen: 0, organizationRowsDeleted: 0, reported: 0 }; const blockers: string[] = []; const notes: string[] = []; const cols = await columnsOf(run, METADATA_OBJECT); @@ -1074,44 +1228,54 @@ async function runMetadataPromotion(run: Run): Promise { const { promotable, conflicts } = groupMetadataKeyRows(rows); const resolutions: MetadataResolution[] = []; + let heldInConflicts = 0; for (const conflict of conflicts) { const chosen = conflict.candidates.find((c) => choices.has(c.id)); if (chosen) { - resolutions.push({ conflict, chosen, unchosen: conflict.candidates.filter((c) => c.id !== chosen.id).map((c) => c.id) }); + resolutions.push({ conflict, chosen }); continue; } summary.conflicts++; - blockers.push( + const listing = `The ${conflict.type} ${conflict.name}` + - `${conflict.packageId ? ` (package ${conflict.packageId})` : ''}${conflict.bundleMember ? ` [${conflict.bundleMember}]` : ''} ` + - `is held by ${conflict.candidates.length} rows once the organization leaves its key: ` + - conflict.candidates.map((c) => `${c.id} (${c.organizationId ? `organization ${c.organizationId}` : 'environment'}, ${c.state ?? 'no state'})`).join(', ') + - '. Which one the environment keeps is never guessed: choose one with --metadata-choice ROW_ID, then re-run. ' + - 'The others are deleted only once the chosen row is promoted and read back' + - (conflict.type === 'view' ? ', and a public form any of them withdraws stays withdrawn on the chosen row.' : '.'), + `${conflict.packageId ? ` (package ${conflict.packageId})` : ''}${conflict.bundleMember ? ` [${conflict.bundleMember}]` : ''} ` + + `is held by ${conflict.candidates.length} rows once the organization leaves its key: ` + + conflict.candidates.map((c) => `${c.id} (${c.organizationId ? `organization ${c.organizationId}` : 'environment'}, ${c.state ?? 'no state'})`).join(', '); + if (walled) { + // Under a wall the organization rows stay where they are, reported; + // they hold the column, and a choice promotes one knowingly. + heldInConflicts += conflict.candidates.filter((c) => c.organizationId !== null).length; + notes.push(`${listing}. Under the ${run.opts.posture} posture they stay organization rows; choose one with --metadata-choice ROW_ID to make it the environment's.`); + continue; + } + blockers.push( + `${listing}. Which one the environment keeps is never guessed: choose one with --metadata-choice ROW_ID, then re-run. ` + + 'The organization rows are deleted only once the environment row defines the chosen body and reads back' + + (conflict.type === 'view' ? ', and a public form any of them withdraws stays withdrawn there.' : '.'), ); } const lone = promotable.filter((row) => !walled || choices.has(row.id)); - const byOrganization = new Map(); - for (const row of lone) byOrganization.set(row.organizationId!, [...(byOrganization.get(row.organizationId!) ?? []), row.id]); - for (const [organizationId, ids] of byOrganization) { - await promoteRows(run, organizationId, ids); - summary.promoted += ids.length; + const byOrganization = new Map(); + for (const row of lone) byOrganization.set(row.organizationId!, [...(byOrganization.get(row.organizationId!) ?? []), row]); + for (const [organizationId, own] of byOrganization) { + await promoteRows(run, organizationId, own); + summary.promoted += own.length; } for (const resolution of resolutions) { - const blocker = await resolveMetadataConflict(run, resolution); + const deletedBefore = run.deleted.get(METADATA_OBJECT) ?? 0; + const blocker = await resolveMetadataConflict(run, resolution, notes); if (blocker) { blockers.push(blocker); continue; } summary.chosen++; - summary.unchosenDeleted += resolution.unchosen.length; + summary.organizationRowsDeleted += (run.deleted.get(METADATA_OBJECT) ?? 0) - deletedBefore; } if (walled) { const held = promotable.length - lone.length; - summary.reported = held; + summary.reported = held + heldInConflicts; if (held > 0) { notes.push( `${held} organization-scoped presentational ${METADATA_OBJECT} row(s) are reported, not promoted: under the ` + @@ -1126,11 +1290,13 @@ async function runMetadataPromotion(run: Run): Promise { /** * Refuse, before anything is written, a `--metadata-choice` that names no row - * awaiting a choice — a candidate of a listed conflict or, under a wall, an - * organization's presentational row its key alone holds — or two candidates - * of one conflict. + * awaiting a choice, or two candidates of one conflict. A row awaits a choice + * when it is a candidate of a listed conflict or an organization's row its key + * alone holds. So that a rerun after a crash finishes with the same flags, a + * choice this ceremony already applied is accepted too: its row is now the + * key's only environment row, or it is gone and the checkpoint records it. */ -async function validateMetadataChoices(run: Run): Promise { +async function validateMetadataChoices(run: Run, applied: ReadonlySet): Promise { const choices = run.opts.metadataChoices ?? []; if (choices.length === 0) return; const cols = await columnsOf(run, METADATA_OBJECT); @@ -1139,7 +1305,8 @@ async function validateMetadataChoices(run: Run): Promise { const keyOf = (c: MetadataConflict): string => `${c.type} ${c.name}${c.packageId ? ` (package ${c.packageId})` : ''}`; const groupOf = new Map(); for (const c of conflicts) for (const cand of c.candidates) groupOf.set(cand.id, keyOf(c)); - if (run.opts.posture !== 'single') for (const row of promotable) groupOf.set(row.id, `the ${row.type} ${row.name} (row ${row.id})`); + for (const row of promotable) groupOf.set(row.id, `the ${row.type} ${row.name} (row ${row.id})`); + const present = new Set(rows.map((r) => r.id)); const listed = conflicts.length ? conflicts.map((c) => `${keyOf(c)}: ${c.candidates.map((cand) => cand.id).join(', ')}`).join('; ') : 'none'; @@ -1147,9 +1314,11 @@ async function validateMetadataChoices(run: Run): Promise { for (const id of choices) { const group = groupOf.get(id); if (!group) { + const resolved = rows.some((r) => r.id === id && r.organizationId === null) || (applied.has(id) && !present.has(id)); + if (resolved) continue; throw new MetadataChoiceRefusal( - `--metadata-choice ${id} names no row awaiting a choice. The conflicts are: ${listed}` + - (run.opts.posture !== 'single' ? '; under a wall, any organization row of a presentational type its key alone holds.' : '.'), + `--metadata-choice ${id} names no row awaiting a choice. The conflicts are: ${listed}; or any organization ` + + 'row of a presentational type its key alone holds.', ); } const other = seen.get(group); @@ -1670,13 +1839,12 @@ async function runColumnDrops(run: Run): Promise { const RUNNERS: Record Promise> = { attribution: runAttribution, 'authored-conversion': runAuthoredConversion, - 'version-renumber': runVersionRenumber, 'metadata-promotion': runMetadataPromotion, 'template-promotion': runTemplatePromotion, 'settings-move': runSettingsMove, rewrite: runRewrite, 'mirror-deletion': runMirrorDeletion, - 'sequence-renumber': runSequenceRenumber, + 'history-renumber': runHistoryRenumber, 'column-drops': runColumnDrops, }; @@ -1737,14 +1905,17 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise checkpoint.completed[s]); const steps: StepReport[] = []; if (stored && resumedFrom.length === 0 && Object.keys(stored.completed ?? {}).length > 0) { @@ -1759,10 +1930,10 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise n > 0)) { steps.push(again); @@ -1868,5 +2039,11 @@ export function checkpointFromDetails(details: unknown): CeremonyCheckpoint | nu } const cp = (parsed as { checkpoint?: unknown } | null)?.checkpoint as CeremonyCheckpoint | undefined; if (!cp || cp.ceremony !== 'ADR-0131 D10' || typeof cp.inventoryDigest !== 'string' || typeof cp.completed !== 'object') return null; - return { ceremony: cp.ceremony, inventoryDigest: cp.inventoryDigest, completed: { ...cp.completed } }; + const applied = Array.isArray(cp.metadataChoicesApplied) ? cp.metadataChoicesApplied.filter((x): x is string => typeof x === 'string') : []; + return { + ceremony: cp.ceremony, + inventoryDigest: cp.inventoryDigest, + completed: { ...cp.completed }, + ...(applied.length ? { metadataChoicesApplied: applied } : {}), + }; } diff --git a/packages/cli/src/utils/organization-ownership-plan.integration.test.ts b/packages/cli/src/utils/organization-ownership-plan.integration.test.ts index f9771c03d69..906fce74fd0 100644 --- a/packages/cli/src/utils/organization-ownership-plan.integration.test.ts +++ b/packages/cli/src/utils/organization-ownership-plan.integration.test.ts @@ -70,14 +70,14 @@ function seed(): void { -- fate 1, nothing to drop: better-auth's own table CREATE TABLE sys_user (id TEXT PRIMARY KEY, email TEXT); INSERT INTO sys_user VALUES ('usr_1', 'a@example.com'); - -- fate 1 with the ruled promotion categories + -- fate 1 with the ruled promotion categories, in the 17.x shape: no + -- bundle_member column (it first shipped in 18.0.0-next.0) CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, name TEXT, state TEXT, package_id TEXT); INSERT INTO sys_metadata VALUES ('md_1', NULL, 'object', 'crm_deal', 'active', NULL), ('md_2', 'org_default', 'view', 'deal_list', 'active', NULL), ('md_3', 'org_b', 'view', 'deal_list', 'active', NULL), ('md_4', 'org_b', 'flow', 'escalate', 'active', NULL); - ALTER TABLE sys_metadata ADD COLUMN bundle_member TEXT; -- fate 2: the catalog, seeded mirrors beside one Setup-authored row CREATE TABLE sys_position (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT, managed_by TEXT); @@ -119,6 +119,20 @@ afterEach(() => { }); describe('ADR-0131 D10 preflight — the per-table plan', () => { + it('plans a 17.x sys_metadata with no bundle_member column, and keys by the member where the column exists', async () => { + // The 17.x shape (the seed): planned, not refused for the missing column. + const before = table(await plan('isolated'), 'sys_metadata'); + expect(before.categories.find((c) => c.id === 'environment-overlay-duplicates')?.groups).toBe(1); + + // An 18.x shape: two locales of one item are two keys, not a duplicate. + db.exec(`ALTER TABLE sys_metadata ADD COLUMN bundle_member TEXT; + UPDATE sys_metadata SET bundle_member = 'en-US' WHERE id = 'md_2'; + UPDATE sys_metadata SET bundle_member = 'fr-FR' WHERE id = 'md_3';`); + const after = table(await plan('isolated'), 'sys_metadata'); + expect(after.categories.find((c) => c.id === 'environment-overlay-duplicates')?.groups).toBe(0); + expect(after.metadataConflicts).toEqual([]); + }); + it('names each fate with its counts, the unattributable ids, and NOT NULL readiness (isolated)', async () => { const p = await plan('isolated'); diff --git a/packages/cli/src/utils/organization-ownership-plan.ts b/packages/cli/src/utils/organization-ownership-plan.ts index a04d34140c3..b7c32b3d4ad 100644 --- a/packages/cli/src/utils/organization-ownership-plan.ts +++ b/packages/cli/src/utils/organization-ownership-plan.ts @@ -726,6 +726,14 @@ async function applyAnchor( return left; } +/** + * Overlay-key columns that arrived after 17.x: `sys_metadata.bundle_member` + * first shipped in 18.0.0-next.0. Both the plan and the apply boot with schema + * changes held, so on the 17.x databases this ceremony migrates the column is + * absent, which the overlay key reads as NULL (its NULL-folded part). + */ +const FOLDED_KEY_COLUMNS: ReadonlySet = new Set(['bundle_member']); + async function planCategory(sql: Sql, table: string, columns: ReadonlySet, category: InventoryCategory): Promise<{ rows: number; groups?: number }> { // A category that selects on the organization column has no rows left once // the column is gone (the ceremony's post-check reads a dropped table too). @@ -737,10 +745,15 @@ async function planCategory(sql: Sql, table: string, columns: ReadonlySet { - requireColumn(table, columns, c); - return sql.q(c, table); - }); + // A key column a 17.x database never had (see FOLDED_KEY_COLUMNS) reads as + // NULL on every row there, and grouping by a constant is no grouping: it is + // grouped by only where it exists. Every other column is required. + const groupBy = category.conflictBy.groupBy + .filter((c) => columns.has(c) || !FOLDED_KEY_COLUMNS.has(c)) + .map((c) => { + requireColumn(table, columns, c); + return sql.q(c, table); + }); const distinct = category.conflictBy.distinct; if (distinct) requireColumn(table, columns, distinct); const having = distinct ? `COUNT(DISTINCT ${sql.q(distinct, table)}) > 1` : 'COUNT(*) > 1'; diff --git a/packages/metadata-protocol/src/meta-overlay-cache.test.ts b/packages/metadata-protocol/src/meta-overlay-cache.test.ts index 5ae0ff54d0a..b9da8787201 100644 --- a/packages/metadata-protocol/src/meta-overlay-cache.test.ts +++ b/packages/metadata-protocol/src/meta-overlay-cache.test.ts @@ -578,11 +578,13 @@ describe('[#11967] §7 distinct reads never share an entry', () => { expect((scoped.items as any[]).map((i) => i.name)).toEqual(['beta']); }); - // [ADR-0131 D6, C7] The cache holds the environment's rows only, and every - // read is the environment read: one naming an organization, by any key — - // the anonymous form doors' deleted `legacyFormOrganizationId` included — - // answers from the environment's entry. - it('a read naming an organization, by any key, answers from the env-wide entry', async () => { + // [ADR-0131 D6] The cache holds the environment's rows only. The one read + // that still reaches a legacy organization's rows — the anonymous form + // doors' `view` read, `legacyFormOrganizationId` — reads them uncached, + // on top of the environment's cached set, so it never answers from the + // environment-wide entry alone; and any other read naming an organization + // is the environment read. + it('the form doors\' legacy organization read does not answer from the env-wide entry', async () => { const h = makeHarness([ storedRow('view', 'alpha'), storedRow('view', 'org_only', { organization_id: 'org_1', id: 'r_view_org' }), @@ -590,13 +592,15 @@ describe('[#11967] §7 distinct reads never share an entry', () => { const envWide = await h.protocol.getMetaItems({ type: 'view' }); const findsAfterEnvWide = h.finds.length; + + const legacy = await h.protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_1' }); + expect(h.finds.length).toBeGreaterThan(findsAfterEnvWide); + expect((envWide.items as any[]).map((i) => i.name)).toEqual(['alpha']); + expect((legacy.items as any[]).map((i) => i.name).sort()).toEqual(['alpha', 'org_only']); - for (const named of [{ legacyFormOrganizationId: 'org_1' }, { organizationId: 'org_1' }]) { - const read = await h.protocol.getMetaItems({ type: 'view', ...named } as { type: string }); - expect((read.items as any[]).map((i) => i.name)).toEqual(['alpha']); - } - expect(h.finds.length).toBe(findsAfterEnvWide); + const named = await h.protocol.getMetaItems({ type: 'view', organizationId: 'org_1' } as { type: string }); + expect((named.items as any[]).map((i) => i.name)).toEqual(['alpha']); }); diff --git a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts index 8e46147d498..931912d2a98 100644 --- a/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts +++ b/packages/metadata-protocol/src/protocol.org-scoped-write-refused.test.ts @@ -444,19 +444,15 @@ describe('a package-less organization overlay, two packages shipping its view na return { protocol, rows }; } - /** - * [ADR-0131 C7] The env-wide read's copies of the view, where the doors - * find the form: the legacy organization read is deleted. - */ + /** The organization read's copies of the view, where the doors find the form. */ async function servedCopies(protocol: any): Promise>> { - const envWide: any = await protocol.getMetaItems({ type: 'view' }); - return (envWide.items as any[]).filter((v) => v?.name === NAME); + const org: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_a' }); + return (org.items as any[]).filter((v) => v?.name === NAME); } /** - * The copies the anonymous doors would serve for the slug, by their own - * composition: open, and not withdrawn by any body of the view name in - * that same env-wide list. + * The copies the anonymous doors would serve for the slug: open, and not + * withdrawn by the env-wide layer they read beneath the organization's. */ async function doorsServe(protocol: any): Promise>> { const envWide: any = await protocol.getMetaItems({ type: 'view' }); @@ -481,13 +477,13 @@ describe('a package-less organization overlay, two packages shipping its view na .toEqual([null]); } - it('[ADR-0131 C7] no read serves the legacy overlay: the doors serve each package\'s shipped copy', async () => { + it('the organization read serves the overlay once per package, each copy stamped with that package', async () => { const { protocol, rows } = makeTwoPackageProtocol(); await storeOpenOverlay(protocol, rows); const copies = await servedCopies(protocol); - expect(copies.map((v) => v.label)).toEqual(['Intake', 'Intake']); + expect(copies.map((v) => v.label)).toEqual(['Intake (org)', 'Intake (org)']); expect(copies.map((v) => v._packageId).sort()).toEqual(['pkg_a', 'pkg_b']); - // Control: before any withdrawal the doors serve both copies. + // Control: before any withdrawal the doors serve the overlay. expect(await doorsServe(protocol)).toHaveLength(2); }); @@ -511,8 +507,9 @@ describe('a package-less organization overlay, two packages shipping its view na expect(named).toEqual([['pkg_a', withdrawing !== 'pkg_a'], ['pkg_b', withdrawing !== 'pkg_b']]); }); - it('the doors serve no copy once one package withdraws the name', async () => { + it('the doors serve no copy of the overlay stored before the withdrawal', async () => { const { protocol } = await withdrawAfterOverlay(); + // Both stamped copies are still the open overlay, and both are closed. expect((await servedCopies(protocol)).map((v) => v._packageId).sort()).toEqual(['pkg_a', 'pkg_b']); expect(await doorsServe(protocol)).toEqual([]); }); @@ -786,13 +783,13 @@ describe('each package\'s copy of a view container expands into its own package\ /** * What the anonymous doors serve for the slug, by their own composition - * (`registerFormEndpoints`): the env-wide read, each open candidate - * judged over that same list (the legacy organization read is deleted, - * ADR-0131 C7). + * (`registerFormEndpoints`): the organization's read, each open candidate + * judged over the env-wide view list beneath it. */ async function doorsServe(protocol: any): Promise { + const org: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_a' }); const envWide: any = await protocol.getMetaItems({ type: 'view' }); - return (envWide.items as any[]).filter((view) => anonymousFormIntakeCandidates(view) + return (org.items as any[]).filter((view) => anonymousFormIntakeCandidates(view) .some((c) => c.slug === SLUG && !anonymousFormIntakeWithdrawnIn(envWide.items, view, c))); } diff --git a/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts b/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts index 4065bc1488e..d2780f05fb2 100644 --- a/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts +++ b/packages/metadata-protocol/src/protocol.reads-environment-only.test.ts @@ -17,11 +17,10 @@ * 3. The boot report names both populations, per type, with their remedies * — the v18 migration ceremony for the legacy organization rows, and the * two remedies for the sealed overlays. Nothing is deleted or rewritten. - * 4. [ADR-0131 C7] No read is left that reaches a legacy organization's - * rows: the anonymous form doors' `view` read of the Default - * Organization's layer (`legacyFormOrganizationId`) was deleted when the - * v18 ceremony began carrying those withdrawals onto the environment. A - * caller still passing the key reads the environment. + * 4. [Triage ruling Q3 A] The one read left that reaches a legacy + * organization's rows: the anonymous form doors' `view` list read + * (`legacyFormOrganizationId`), kept fail-closed until the ceremony. Any + * other type reads the environment alone, whoever passes the key. * * Reverse verification (one-shot, recorded in the PR): restoring an * organization row to the served-row lookup (`findServedOverlayRow` with its @@ -378,7 +377,7 @@ describe('§3 the boot report names both populations, per type, with their remed }); }); -describe('§4 [ADR-0131 C7] the anonymous form doors\' legacy organization read is deleted', () => { +describe('§4 [triage ruling Q3 A] the anonymous form doors\' legacy layer is the one read left that reaches a legacy organization', () => { const seed = () => ({ sys_metadata: [ metaRow('view', 'intake', null, { label: 'env intake' }), @@ -387,11 +386,20 @@ describe('§4 [ADR-0131 C7] the anonymous form doors\' legacy organization read ], }); - for (const type of ['view', 'dashboard'] as const) { - it(`a ${type} list read passing the deleted legacyFormOrganizationId key reads the environment`, async () => { - const { protocol } = harness(seed()); - const list = await protocol.getMetaItems({ type, legacyFormOrganizationId: ORG } as { type: string }); - expect((list.items as Row[]).map((i) => i.label)).toEqual(type === 'view' ? ['env intake'] : []); - }); - } + it('a view list read with legacyFormOrganizationId prefers that organization\'s body', async () => { + const { protocol } = harness(seed()); + const list = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG }); + expect((list.items as Row[]).map((i) => i.label)).toEqual(['org intake']); + // …and nothing else reads it: the same list without the key, and with the retired key. + for (const caller of CALLERS) { + const plain = await protocol.getMetaItems(asCaller({ type: 'view' }, caller)); + expect((plain.items as Row[]).map((i) => i.label)).toEqual(['env intake']); + } + }); + + it('the key reaches no other type', async () => { + const { protocol } = harness(seed()); + const list = await protocol.getMetaItems({ type: 'dashboard', legacyFormOrganizationId: ORG }); + expect(list.items).toEqual([]); + }); }); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 026db5d23b3..16c97de7c53 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -88,7 +88,7 @@ import { stripInjectedSystemColumns, // [#7774] The i18n-bundle identity table (#7730) and its reader. A type // listed here is identified by `(name, )`, so every merge in the - // unscoped `/meta` list keys on the pair — see {@link mergePackageAwareOverlay}. + // unscoped `/meta` list keys on the pair — see {@link metaItemKey}. ITEM_KEY_DISCRIMINATORS, itemDiscriminator, type MetadataItem, @@ -2389,6 +2389,62 @@ function isShippedViewItem(artifact: unknown): boolean { return typeof viewKind === 'string' && viewKind !== ''; } +/** + * ADR-0048 (#1828) — composite dedup identity for the unscoped metadata list. + * + * Two installed packages may legitimately ship the same `type`/`name` + * (e.g. `page/home`); the SchemaRegistry already stores them under distinct + * `${packageId}:${name}` keys. Any list-merge that deduplicates by bare `name` + * collapses the two packages' rows into one (last-write-wins), which is the + * bug this key closes. A `NUL` separator keeps names containing `:` unambiguous. + * + * [#7774] `discriminator` is a third component, and it is OPTIONAL on purpose: + * omitting it produces the exact two-component string this function has always + * produced, so every type identified by `(package, name)` keeps a + * byte-identical key and this change's blast radius is provable rather than + * argued. It is supplied only for a type listed in `ITEM_KEY_DISCRIMINATORS` + * (`email_template` today), whose identity the spec declares as + * `(name, locale)` — an i18n bundle. Without it the second locale's + * `Map.set` overwrote the first and `GET /meta/email_template` served one + * member of a bundle it had every member of. + */ +function metaItemKey( + packageId: string | null | undefined, + name: unknown, + discriminator?: string, +): string { + const base = `${packageId ?? ''}\u0000${String(name)}`; + return discriminator === undefined ? base : `${base}\u0000${discriminator}`; +} + +/** + * [#7774] The bundle discriminator a STORED `sys_metadata` row declares, read + * off the row's serialized body. + * + * The row's own COLUMNS cannot answer this: `sys_metadata`'s overlay + * uniqueness is `(type, name, organization_id, package_id)` — declared on + * `idx_sys_metadata_overlay_active` in `sys-metadata.object.ts` — and the + * table has no locale column at all. An `email_template` overlay's locale + * lives inside the `metadata` JSON payload, which is where its identity is. + * So a row-level merge keying on columns alone cannot tell two members of one + * bundle apart, and the body has to be consulted. + * + * The parse is skipped entirely for an undiscriminated type (all but one + * today): `undefined` comes back before any JSON work, so this costs nothing + * on the paths it does not serve. A body that fails to parse falls back to the + * canonical member instead of throwing — the caller a few lines on parses it + * again for real, and that failure is the one that should surface. + */ +function storedRowDiscriminator(type: string, record: unknown): string | undefined { + if (!ITEM_KEY_DISCRIMINATORS[type]) return undefined; + const raw = (record as { metadata?: unknown } | null | undefined)?.metadata; + let body: unknown = raw; + if (typeof raw === 'string') { + try { body = JSON.parse(raw); } catch { body = undefined; } + } + return itemDiscriminator(type, body); +} + /** * [#22877] The by-name doors' bundle member selector: the request member, and * the `?member=` query parameter in each REST route's closed set (ruling @@ -2532,7 +2588,7 @@ function mutationEventMemberOfBody(type: string, body: unknown): { member?: stri * no package). */ interface ServedOverlayRowCandidate { - readonly scope: 'env'; + readonly scope: 'org' | 'env'; readonly organizationId: string | null; readonly spelling: 'canonical' | 'other'; readonly packageId: string | null | undefined; @@ -2556,10 +2612,12 @@ interface StoredRowPlace { * - Scope (ADR-0131 D6): the environment's rows only. A legacy * organization-scoped row is reported at boot * ({@link ObjectStackProtocolImplementation.reportUnhydratableOrgScopedRows}) - * and never served, by any read: the anonymous form doors' read of the - * Default Organization's legacy layer was deleted when the v18 migration - * ceremony began carrying those rows' withdrawals into the environment - * (ADR-0131 C7, rulings A on #22008 and #22011). + * and never served. The ONE exception is `legacyOrganizationId`, which only + * the anonymous form doors' read of the Default Organization's legacy + * layer passes (through the list read): that legacy organization's rows, + * then the environment's — kept fail-closed until the v18 migration + * ceremony carries those rows into the environment layer and deletes the + * read with them. * - Then the spelling: the canonical type, then the other spelling (the * at-rest tolerance the reads have always had, #4432). * - Then the package (ADR-0048 prefer-local): with a `packageId`, that @@ -2580,9 +2638,12 @@ interface StoredRowPlace { * scope (`resolveOverlayLockLayer` in `item-lock.ts`, #21761). */ function servedOverlayRowCandidates(address: { + readonly legacyOrganizationId?: string; readonly packageId: string | undefined; }): ServedOverlayRowCandidate[] { - const scopes: Array> = [{ scope: 'env', organizationId: null }]; + const scopes: Array> = address.legacyOrganizationId + ? [{ scope: 'org', organizationId: address.legacyOrganizationId }, { scope: 'env', organizationId: null }] + : [{ scope: 'env', organizationId: null }]; const packages: Array = address.packageId ? [address.packageId, null] : [undefined]; return scopes.flatMap((scope) => (['canonical', 'other'] as const).flatMap((spelling) => packages.map((packageId) => ({ ...scope, spelling, packageId })))); @@ -2661,7 +2722,8 @@ function servedViewExpansion( * • [#21804] When `records` are stored rows the caller read (`rowsRead`, * each record's `stored` place), the winner among them is the one * {@link servedOverlayRowCandidates} picks for `P`: `P`'s own row before - * the package-less one. It + * the package-less one (the form doors' legacy organization row before + * the environment's, where they read that layer). It * is the resolution `getMetaItem` takes, so it does not depend on the * order the store returned the rows in. Without `rowsRead` (the * MetadataService merge, whose `records` are already-resolved items), @@ -2709,7 +2771,7 @@ function mergePackageAwareOverlay( baseItems: unknown[], records: Array<{ data: unknown; packageId: string | undefined; stored?: StoredRowPlace; standIn?: boolean }>, transform?: (data: any, prev: any) => any, - rowsRead?: Readonly>, + rowsRead?: { readonly legacyOrganizationId?: string }, unseated?: WeakSet, ): unknown[] { // Per-SLOT, layer-ordered contributions; `pkg: undefined` = package-less. @@ -2733,7 +2795,7 @@ function mergePackageAwareOverlay( // `real` among a slot's contributions: the first candidate of // {@link servedOverlayRowCandidates} that one of the rows sits at. const servedStoredRow = (list: readonly Contribution[], real: string | undefined): any => { - for (const candidate of servedOverlayRowCandidates({ packageId: real })) { + for (const candidate of servedOverlayRowCandidates({ legacyOrganizationId: rowsRead?.legacyOrganizationId, packageId: real })) { const hit = list.find((c) => c.stored !== undefined && c.stored.organizationId === candidate.organizationId && (c.stored.type === type ? 'canonical' : 'other') === candidate.spelling @@ -9486,7 +9548,13 @@ export class ObjectStackProtocolImplementation implements ); } - async getMetaItems(request: { type: string; packageId?: string; previewDrafts?: boolean }) { + /** + * `legacyFormOrganizationId` is not a request key of the spec's + * `GetMetaItemsRequest`: only the anonymous form doors pass it, for their + * read of the Default Organization's legacy `view` layer (triage ruling + * Q3 A; see {@link readFlattenedMetaItems}). + */ + async getMetaItems(request: { type: string; packageId?: string; previewDrafts?: boolean; legacyFormOrganizationId?: string }) { // #4432 — CANONICAL TYPE KEY. See {@link canonicalMetaType}. This one // is load-bearing twice over: the SchemaRegistry indexes code-authored // items under the SINGULAR type, and the overlay-hydration branch below @@ -9538,7 +9606,7 @@ export class ObjectStackProtocolImplementation implements * `request` arrives already folded by the calling face (#4432). */ private async readFlattenedMetaItems( - request: { type: string; packageId?: string; previewDrafts?: boolean }, + request: { type: string; packageId?: string; previewDrafts?: boolean; legacyFormOrganizationId?: string }, audience: 'served' | 'execution', ) { const { packageId } = request; @@ -9547,11 +9615,15 @@ export class ObjectStackProtocolImplementation implements // The list serves the environment's stored rows over the code // packages' definitions, and no organization's: a legacy // organization-scoped row is reported at boot - // ({@link reportUnhydratableOrgScopedRows}) and never served — the - // anonymous form doors included: their read of the Default - // Organization's legacy `view` layer was deleted when the v18 - // migration ceremony (ADR-0131 C7) began carrying its withdrawals into - // the environment, fail-closed. + // ({@link reportUnhydratableOrgScopedRows}) and never served. The one + // exception is `legacyFormOrganizationId`, which only the anonymous + // form doors pass (triage ruling Q3 A): their read of a `view` keeps + // the Default Organization's legacy layer — that organization's body + // preferred, and a withdrawal in either layer closing the form — + // fail-closed until the v18 migration ceremony (ADR-0131 C7) carries + // those rows into the environment layer and deletes this read. Any + // other type reads the environment alone, whoever passes the key. + const orgId = canonicalMetaType(request.type) === 'view' ? request.legacyFormOrganizationId : undefined; let items: unknown[] = []; // Unscoped kernels (control plane): read everything from SchemaRegistry. @@ -9622,8 +9694,8 @@ export class ObjectStackProtocolImplementation implements // the view containers it expands through the same two calls, so // the two doors cannot disagree about which containers are in // scope for one caller. - const records = await this.readActiveOverlayRows(request); - lockRows = packageId ? await this.readActiveOverlayRows({ type: request.type }) : records; + const records = await this.readActiveOverlayRows(request, orgId); + lockRows = packageId ? await this.readActiveOverlayRows({ type: request.type }, orgId) : records; const standInRows = packageId ? lockRows.filter((row) => (row?.package_id ?? null) === null) : []; if ((records && records.length > 0) || standInRows.length > 0) { const isView = (PLURAL_TO_SINGULAR[request.type] ?? request.type) === 'view'; @@ -9680,7 +9752,7 @@ export class ObjectStackProtocolImplementation implements return this.foldObjectExtendersFromRegistry( request.type, (data as { name?: unknown } | null)?.name, data, ); - }, {}, unseated); + }, { legacyOrganizationId: orgId }, unseated); // [#13407] Expand any aggregated `defineView` container this // READ just merged in, INLINE into this response's own `items` @@ -10209,7 +10281,10 @@ export class ObjectStackProtocolImplementation implements /** * [#21442, ADR-0131 D6] The active `sys_metadata` rows a read of * `request.type` consults: the environment's rows, restricted to - * `request.packageId` when one is given. Moved here unchanged from + * `request.packageId` when one is given — plus, for the anonymous form + * doors' read alone, the rows of the legacy organization + * `legacyOrganizationId` names (that organization's row wins its slot; + * read uncached, and retired with the v18 migration ceremony). Moved here unchanged from * {@link readFlattenedMetaItems}, the list read, so the by-name read * ({@link resolveRowlessExpandedView}) selects the view containers it * expands by this same rule — ⛔ never a second selection rule, which would @@ -10220,6 +10295,7 @@ export class ObjectStackProtocolImplementation implements */ private async readActiveOverlayRows( request: { type: string; packageId?: string }, + legacyOrganizationId?: string, ): Promise { const { packageId } = request; const queryByOrg = async (oid: string | null): Promise => { @@ -10294,7 +10370,21 @@ export class ObjectStackProtocolImplementation implements this.engine, overlayCacheKey, overlayCacheEpoch, records, overlayCacheTtlMs, overlayCacheNow, ); } - return records; + if (!legacyOrganizationId) return records; + // The anonymous form doors' legacy layer (triage Q3 A, ADR-0131 C7): + // that organization's rows override the environment's on name + // collision. ADR-0048 (#1828) — key by (package, name), not bare name, + // so a package A row and a package B row of the same name do not + // collapse. [#7774] …and for a bundled type the slot is `(package, + // name, locale)`: an environment row and the organization's row may + // customize DIFFERENT members of one bundle. + const orgRecords = await queryByOrg(legacyOrganizationId); + const mergedMap = new Map(); + const rowKey = (r: any): string => + metaItemKey(r.package_id, r.name, storedRowDiscriminator(request.type, r)); + for (const r of records) mergedMap.set(rowKey(r), r); + for (const r of orgRecords) mergedMap.set(rowKey(r), r); + return Array.from(mergedMap.values()); } /** @@ -20838,7 +20928,7 @@ export class ObjectStackProtocolImplementation implements try { // [ADR-0131 D6] A save lands environment-wide, so the pages it meets // are the environment-wide rows. - records = await this.readActiveOverlayRows({ type }); + records = await this.readActiveOverlayRows({ type }, undefined); } catch (error) { this.rethrowUnlessMetadataStoreUnprovisioned(error, 'sys_metadata'); } @@ -20981,7 +21071,7 @@ export class ObjectStackProtocolImplementation implements try { // [ADR-0131 D6] A save lands environment-wide, so its siblings are // the environment-wide rows. - records = await this.readActiveOverlayRows({ type }); + records = await this.readActiveOverlayRows({ type }, undefined); } catch (error) { // [#5532] The readers' rule: only an unprovisioned store means "no // rows". Any other failure is not answered as "no sibling". diff --git a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts index fa579d30896..152cac16f49 100644 --- a/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts +++ b/packages/metadata-protocol/src/view-container-runtime-expansion.test.ts @@ -115,13 +115,6 @@ async function plantOrgRow(engine: any, organizationId: string, name: string, it return { success: true }; } -/** A stored environment row, written through the overlay repository the way a Studio save writes it. */ -async function plantEnvRow(engine: any, name: string, item: unknown): Promise<{ success: true }> { - await new SysMetadataRepository({ engine }) - .put({ type: 'view', name } as any, item, { parentVersion: null, actor: null }); - return { success: true }; -} - /** The card's repro body: a `defineView` container, as `defineView` emits it. */ const leadContainer = { list: { @@ -300,7 +293,7 @@ describe('#7736 a runtime-authored view container is served', () => { * isolation because it operates only on rows already scoped to THIS * request's own org/environment (the pre-existing `queryByOrg` merge). */ -describe('#13407 environment-scoped runtime containers are served; [ADR-0131 D6, C7] a legacy organization container by no read', () => { +describe('#13407 environment-scoped runtime containers are served; [ADR-0131 D6] a legacy organization container only by the anonymous form doors\' legacy read', () => { it('derives the object binding from the containers own top-level `object` field, not just `list.data.object`', async () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); @@ -319,13 +312,13 @@ describe('#13407 environment-scoped runtime containers are served; [ADR-0131 D6, expect(switcherMatches(list.items, 'crm_lead').map((v: any) => v.name)).toEqual(['crm_lead.default']); }); - it('the cards own repro, on a stored environment row: the list read expands it', async () => { + it('the cards own repro, at the one read left that reaches a legacy organization row: the anonymous form doors\' legacy layer expands it', async () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); - await plantEnvRow(engine, 'crm_lead', leadContainer); + await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); - const list: any = await protocol.getMetaItems({ type: 'view' }); + const list: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_acme' }); const served = switcherMatches(list.items, 'crm_lead').map((v: any) => v.name).sort(); expect(served).toEqual(['crm_lead.default', 'crm_lead.pipeline']); }); @@ -335,12 +328,7 @@ describe('#13407 environment-scoped runtime containers are served; [ADR-0131 D6, const protocol = new ObjectStackProtocolImplementation(engine); await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); - for (const request of [ - { type: 'view' }, - { type: 'view', organizationId: 'org_acme' }, - // The anonymous form doors' legacy key, deleted with ADR-0131 C7. - { type: 'view', legacyFormOrganizationId: 'org_acme' }, - ]) { + for (const request of [{ type: 'view' }, { type: 'view', organizationId: 'org_acme' }]) { const list: any = await protocol.getMetaItems(request as any); expect(switcherMatches(list.items, 'crm_lead')).toEqual([]); } @@ -351,13 +339,13 @@ describe('#13407 environment-scoped runtime containers are served; [ADR-0131 D6, it('POSITIVE CONTROL: a pre-existing independent ViewItem for the same object is still served alongside the newly-expanded container', async () => { const { engine } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); - await plantEnvRow(engine, 'crm_lead', leadContainer); - await plantEnvRow(engine, 'crm_lead.mine', { + await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); + await plantOrgRow(engine, 'org_acme', 'crm_lead.mine', { name: 'crm_lead.mine', object: 'crm_lead', viewKind: 'list', label: 'My Leads', config: { type: 'grid', data: { provider: 'object', object: 'crm_lead' }, columns: [{ field: 'name' }] }, }); - const list: any = await protocol.getMetaItems({ type: 'view' }); + const list: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_acme' }); const served = switcherMatches(list.items, 'crm_lead').map((v: any) => v.name).sort(); // A fix that merely "returned everything" could not distinguish these: // the pre-existing independent item and the two newly-expanded @@ -365,11 +353,23 @@ describe('#13407 environment-scoped runtime containers are served; [ADR-0131 D6, expect(served).toEqual(['crm_lead.default', 'crm_lead.mine', 'crm_lead.pipeline']); }); - it('the isolation-safe path never registers a read\'s expansion into the shared SchemaRegistry', async () => { + it('ORG ISOLATION HELD: a container authored in org_acme is invisible reading as org_globex, and visible reading as org_acme', async () => { + const { engine } = makeStubEngine(); + const protocol = new ObjectStackProtocolImplementation(engine); + await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); + + const globex: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_globex' }); + expect(switcherMatches(globex.items, 'crm_lead')).toEqual([]); + + const acme: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_acme' }); + expect(switcherMatches(acme.items, 'crm_lead')).toHaveLength(2); + }); + + it('the isolation-safe path never registers an org-scoped expansion into the shared SchemaRegistry', async () => { const { engine, registered } = makeStubEngine(); const protocol = new ObjectStackProtocolImplementation(engine); - await plantEnvRow(engine, 'crm_lead', leadContainer); - await protocol.getMetaItems({ type: 'view' }); + await plantOrgRow(engine, 'org_acme', 'crm_lead', leadContainer); + await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: 'org_acme' }); // The RESPONSE carries the expansion (re-confirmed above); the SHARED // registry — read by every org/environment this kernel serves — must diff --git a/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts b/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts index 8c66be2772d..3d29170aa60 100644 --- a/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts +++ b/packages/objectql/src/protocol-org-overlay-registry-gate.test.ts @@ -35,12 +35,10 @@ * * ── The fix, and where it lives ───────────────────────────────────────── * - * [ADR-0131 D6, C7] Since the per-organization overlay axis retired, no list - * read merges a legacy organization's rows: the last one, the anonymous form - * doors' `view` read (`legacyFormOrganizationId`), was deleted when the v18 - * ceremony began carrying those rows' withdrawals onto the environment. The - * listing cases below still pass that key, and pin that it reads the - * environment and hydrates nothing of the organization's. + * [ADR-0131 D6] Since the per-organization overlay axis retired, the one + * list read left that merges a legacy organization's rows is the anonymous + * form doors' `view` read (`legacyFormOrganizationId`); the listing cases + * below drive that read, because it is the one the invariant still guards. * * `hydrateOverlayIntoRegistry` is the ONE shared choke point all three * hydration callers already route through (#4521 made it so: boot, the @@ -291,12 +289,14 @@ describe('#6602 — READ seam: getMetaItems hydration refuses org-scoped rows', protocol = new ObjectStackProtocolImplementation(engine); }); - it('a listing passing the deleted organization key serves the org row NOWHERE and does not graft it', async () => { + it('an org-scoped listing SERVES the org row but does not graft it', async () => { engine.plant(metaRow('view', viewBody('org_grid', 'Org A grid'), ORG_A)); - const listed = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A } as any); + const listed = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); - expect(namesOf(listed as any)).not.toContain('org_grid'); + // Org readers keep their overlay — closing the leak must not close + // the feature. + expect(namesOf(listed as any)).toContain('org_grid'); // …and the process-wide registry stays clean. expect(registry.getItem('view', 'org_grid')).toBeUndefined(); }); @@ -314,9 +314,9 @@ describe('#6602 — READ seam: getMetaItems hydration refuses org-scoped rows', engine.plant(metaRow('view', viewBody('env_grid', 'Env grid'), null)); engine.plant(metaRow('view', viewBody('org_grid', 'Org A grid'), ORG_A)); - const listed = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A } as any); + const listed = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); - expect(namesOf(listed as any).sort()).toEqual(['env_grid']); + expect(namesOf(listed as any).sort()).toEqual(['env_grid', 'org_grid']); expect(registry.getItem('view', 'env_grid')).toBeDefined(); expect(registry.getItem('view', 'org_grid')).toBeUndefined(); }); @@ -330,9 +330,9 @@ describe('#6602 — READ seam: getMetaItems hydration refuses org-scoped rows', await protocol.getMetaItems({ type: 'view' }); expect((registry.getItem('view', 'shared_grid') as any)?.label).toBe('Env grid'); - // A listing passing the deleted organization key reads the environment… - const listedA = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A } as any); - expect(itemNamed(listedA as any, 'shared_grid')?.label).toBe('Env grid'); + // Org A's own listing still shows org A's body (org-over-env merge)… + const listedA = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); + expect(itemNamed(listedA as any, 'shared_grid')?.label).toBe('Org A grid'); // …and the shared registry still holds the ENV-WIDE one. This is the // collision direction of the leak: pre-fix the org body overwrote the @@ -341,15 +341,20 @@ describe('#6602 — READ seam: getMetaItems hydration refuses org-scoped rows', expect((registry.getItem('view', 'shared_grid') as any)?.label).toBe('Env grid'); }); - it('a listing passing the deleted organization key never hydrates the org body of a name it shares', async () => { - // The listing is the environment's, so what it hydrates is the - // env-wide body; org A's row is in no set this loop walks. + it('an org-scoped listing hydrates NOTHING for a name its org overlays', async () => { + // Measured, and stated rather than presumed: `getMetaItems` merges the + // two record sets by (package, name) with the ORG row winning, so the + // env-wide row it shadows is not in the set this loop walks at all. + // The subtraction is the leak only — pre-fix this listing hydrated org + // A's body under the plain key, never the env-wide one, so nothing + // legitimate is lost. The env-wide entry arrives from boot / the + // unscoped read, as the case above shows. engine.plant(metaRow('view', viewBody('shared_grid', 'Env grid'), null)); engine.plant(metaRow('view', viewBody('shared_grid', 'Org A grid'), ORG_A)); - await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A } as any); + await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); - expect((registry.getItem('view', 'shared_grid') as any)?.label).toBe('Env grid'); + expect(registry.getItem('view', 'shared_grid')).toBeUndefined(); }); }); @@ -371,8 +376,8 @@ describe('#6602 — the disclosure shape, end to end', () => { // later reader started from it. engine.plant(metaRow('view', viewBody('org_a_only', 'Org A only'), ORG_A)); - await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A } as any); - const listedB = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_B } as any); + await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); + const listedB = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_B }); expect(namesOf(listedB as any)).not.toContain('org_a_only'); // The unscoped control-plane listing is a third reader of the same @@ -385,8 +390,8 @@ describe('#6602 — the disclosure shape, end to end', () => { engine.plant(metaRow('view', viewBody('shared_grid', 'Env grid'), null)); engine.plant(metaRow('view', viewBody('shared_grid', 'Org A grid'), ORG_A)); - await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A } as any); - const listedB = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_B } as any); + await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); + const listedB = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_B }); expect(itemNamed(listedB as any, 'shared_grid')?.label).toBe('Env grid'); }); @@ -630,9 +635,10 @@ describe('#6780 — the registry heal is org-gated: an org DELETE never evicts t await protocol.deleteMetaItem({ type: 'view', name: 'shared_grid' }); expect(registry.getItem('view', 'shared_grid')).toBeUndefined(); - // Org A's legacy row is untouched by an env-wide delete: it stays - // stored, for the v18 ceremony to promote or report. - const legacy = await engine.find('sys_metadata', { where: { organization_id: ORG_A } }); - expect(legacy.map((r: any) => JSON.parse(r.metadata).label)).toEqual(['Org A grid']); + // Org A's legacy row is untouched by an env-wide delete: the one read + // left that reaches it (the anonymous form doors' legacy layer) still + // finds it. + const legacy: any = await protocol.getMetaItems({ type: 'view', legacyFormOrganizationId: ORG_A }); + expect(itemNamed(legacy, 'shared_grid')?.label).toBe('Org A grid'); }); }); diff --git a/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts b/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts index 519e9b6c84e..ba6d80c2543 100644 --- a/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-public-form-withdrawal-layers.dogfood.test.ts @@ -11,22 +11,20 @@ // retired the per-organization overlay axis, that save lands ENVIRONMENT-WIDE // even when the admin has an active organization. // -// The anonymous form doors read the environment alone (ADR-0131 C7): their -// read of the Default Organization's legacy layer is deleted, because the v18 -// ceremony (`os migrate organization-ownership --apply`) carries every -// organization-layer withdrawal onto the environment, fail-closed (rulings A -// on #22008 and #22011; the carry is pinned in the cli's -// `organization-ownership-apply.test.ts`). No door can write an organization -// row any more, so this file PLANTS legacy organization rows at rest in -// `sys_metadata`, as a door left them before the retirement, and pins: +// The anonymous form doors still read the Default Organization's layer for +// its withdrawals, fail-closed, until ADR-0131 C7 carries those rows to the +// environment layer (triage ruling Q3 A on the retirement card). No door can +// write such a row any more, so this file PLANTS legacy organization rows +// at rest in `sys_metadata`, as a door left them before the retirement, and +// pins: // // - a legacy organization overlay that keeps the form open does not survive // an environment withdrawal: both anonymous doors answer // `404 FORM_NOT_FOUND` and nothing lands; -// - ⭐ a legacy organization row is read by no door: its withdrawal no longer -// closes a form open environment-wide — it is the ceremony's to carry; +// - ⭐ a legacy organization WITHDRAWAL still closes the form while it is +// open environment-wide (the read Q3 A keeps); // - the admin's save with an active organization is environment-wide; -// - open environment-wide (control): both doors accept, and the row lands in +// - open at both layers (control): both doors accept, and the row lands in // the organization the doors resolve. import { describe, it, expect, beforeAll, afterAll } from 'vitest'; @@ -123,7 +121,7 @@ describe('showcase: a public form withdrawal at any metadata layer holds', () => await stack?.stop(); }); - it('PRECONDITION: with a legacy organization overlay that keeps the form open, the form is served', async () => { + it('PRECONDITION: a legacy organization overlay that keeps the form open is served', async () => { await plantLegacyOrgOverlay(true); expect(await probe()).toEqual(OPEN); }); @@ -134,14 +132,14 @@ describe('showcase: a public form withdrawal at any metadata layer holds', () => expect(await probe()).toEqual(CLOSED); }); - it('⭐ a legacy organization WITHDRAWAL is read by no door: the form open environment-wide is served until the ceremony carries it (ADR-0131 C7)', async () => { + it('⭐ a legacy organization WITHDRAWAL still closes the form while it is open environment-wide (fail-closed until C7)', async () => { await scope(null); expect(await saved(true)).toMatch(/env-wide/); await plantLegacyOrgOverlay(false); - expect(await probe()).toEqual(OPEN); + expect(await probe()).toEqual(CLOSED); }); - it('open environment-wide (control): both doors accept and the row lands in the organization', async () => { + it('open at both layers (control): both doors accept and the row lands in the organization', async () => { await scope(organizationId); expect(await saved(true)).toMatch(/env-wide/); await plantLegacyOrgOverlay(true); diff --git a/packages/rest/src/public-form-withdrawal.test.ts b/packages/rest/src/public-form-withdrawal.test.ts index 352c9097d59..34e4d9bcadb 100644 --- a/packages/rest/src/public-form-withdrawal.test.ts +++ b/packages/rest/src/public-form-withdrawal.test.ts @@ -1,30 +1,33 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. // -// [#21331, ADR-0131 C7] Withdrawing a public form from anonymous intake takes -// effect on every intake door. Both doors (`GET /forms/:slug` and -// `POST /forms/:slug/submit`) read the form in the environment, like every -// other metadata read: environment → code. Until v18 they also read the -// Default Organization's legacy layer; the v18 ceremony carries every -// organization-layer withdrawal onto the environment, fail-closed (rulings A -// on #22008 and #22011; pinned end to end in the cli's -// `organization-ownership-apply.test.ts`), and that read is deleted. +// [#21331] Withdrawing a public form from anonymous intake takes effect on +// every intake door. Both doors (`GET /forms/:slug` and +// `POST /forms/:slug/submit`) read the form in the organization the request +// belongs to: the tenancy service's `defaultOrgId()`. That is the same +// organization whose overlay the administrator's editor shows. A withdrawal +// saved in that organization therefore closes both doors, and nothing is +// written. // // Pinned both sides: -// - no view read names an organization, whatever the tenancy answers; -// - withdrawn in the environment: both doors answer 404 FORM_NOT_FOUND and -// `createData` is never called; published (the control): both accept; -// - tenancy never registered: the same read; +// - withdrawn in the organization: both doors answer 404 FORM_NOT_FOUND and +// `createData` is never called; +// - published in the organization (the control): both doors accept, and +// every metadata read the doors make names that organization; +// - withdrawn env-wide with no organization to resolve: both doors refuse; +// - tenancy never registered: the env-wide read, unchanged; // - tenancy registered but unreachable: both doors refuse (fail closed). // -// A withdrawal is a kill switch: the list holds one item per package of a -// view name, and an explicit withdrawal of the same form (matched by slot or -// by slug, the link kept with a switch cleared) in any of them closes it: -// - another package's withdrawal of the same view name closes the form; -// - every package's body open (control): served; -// - not a withdrawal (the link cleared, a switch merely absent, or another -// view sharing the slug withdrawn): served; -// - the form moved to a new slug in one package's body while another's -// withdraws the old one: still closed. +// A withdrawal is a kill switch: an explicit withdrawal of the same form (the +// same view and slot, the link kept with a switch cleared) at any layer the +// doors read closes it, and layering may only narrow intake, never re-open it. +// The doors therefore read the env-wide layer beneath the organization's too: +// - withdrawn env-wide, re-opened in the organization: both doors refuse; +// - withdrawn in the organization, open env-wide: both doors refuse; +// - open in both: both doors accept; +// - a form only the organization has (no env-wide body): it is served; +// - not a withdrawal (the link cleared env-wide, a switch merely absent, or +// another view sharing the slug withdrawn): it is served; +// - the same view re-pointed at a new slug in the organization: still closed. import { describe, it, expect, vi } from 'vitest'; import { RestServer } from './rest-server'; @@ -83,24 +86,34 @@ function notRegistered(): Error { } interface Setup { - /** What the environment's read answers. */ + /** What the env-wide (organization-less) read answers. */ envWide: boolean; + /** What the organization read answers; `undefined` = no overlay there. */ + inOrg?: boolean; /** The tenancy provider's behaviour. */ tenancy: 'org' | 'no-org' | 'not-registered' | 'unreachable'; - /** Replaces the form's whole `sharing` (the `envWide` switch is then ignored). */ + /** Replaces the form's whole `sharing` on every read (the `envWide`/`inOrg` switch is then ignored). */ sharing?: Record; - /** Replaces the environment's whole view list. */ + /** Replaces the env-wide read's whole view list (the organization read is unchanged). */ envWideViews?: unknown[]; - /** Extra views the read answers alongside the form view. */ + /** Extra views every read answers alongside the form view. */ extraViews?: unknown[]; + /** Replaces the organization read's whole view list (the env-wide read is unchanged). */ + orgViews?: unknown[]; } function build(setup: Setup) { const createData = vi.fn().mockResolvedValue({ object: 'inquiry', id: 'rec_1', record: {} }); - const getMetaItems = vi.fn(async (req: Record & { type: string }) => { + // [ADR-0131 D6, triage ruling Q3 A] The doors' `view` read reaches the + // Default Organization's legacy layer through `legacyFormOrganizationId`, the + // one read key left that names an organization. + const getMetaItems = vi.fn(async (req: { type: string; organizationId?: string; legacyFormOrganizationId?: string }) => { if (req.type === 'view') { - if (setup.envWideViews) return setup.envWideViews; - return [formView(setup.envWide, setup.sharing), ...(setup.extraViews ?? [])]; + const org = req.legacyFormOrganizationId; + if (org !== ORG && setup.envWideViews) return setup.envWideViews; + if (org === ORG && setup.orgViews) return setup.orgViews; + const effective = org === ORG && setup.inOrg !== undefined ? setup.inOrg : setup.envWide; + return [formView(effective, setup.sharing), ...(setup.extraViews ?? [])]; } if (req.type === 'object') return [inquiryObject]; return []; @@ -150,19 +163,8 @@ function build(setup: Setup) { } describe('[#21331] public form withdrawal reaches every intake door', () => { - it('[ADR-0131 C7] no view read names an organization, whatever the tenancy answers: one environment read per door', async () => { - const s = build({ envWide: true, tenancy: 'org' }); - expect((await s.get()).statusCode).toBe(200); - expect((await s.post()).statusCode).toBe(201); - const views = s.getMetaItems.mock.calls.map(([r]) => r).filter((r) => r.type === 'view'); - expect(views).toHaveLength(2); - for (const r of s.getMetaItems.mock.calls.map(([req]) => req)) { - expect(Object.keys(r).filter((k) => /organization/i.test(k))).toEqual([]); - } - }); - - it('withdrawn in the environment: both doors answer FORM_NOT_FOUND and nothing is written', async () => { - const s = build({ envWide: false, tenancy: 'org' }); + it('withdrawn in the organization: both doors answer FORM_NOT_FOUND and nothing is written', async () => { + const s = build({ envWide: true, inOrg: false, tenancy: 'org' }); const get = await s.get(); expect(get.statusCode).toBe(404); expect(get.body.code).toBe('FORM_NOT_FOUND'); @@ -172,8 +174,8 @@ describe('[#21331] public form withdrawal reaches every intake door', () => { expect(s.createData).not.toHaveBeenCalled(); }); - it('published in the environment (control): both doors accept', async () => { - const s = build({ envWide: true, tenancy: 'org' }); + it('published in the organization (control): both doors accept, every read names the organization', async () => { + const s = build({ envWide: true, inOrg: true, tenancy: 'org' }); const get = await s.get(); expect(get.statusCode).toBe(200); expect(get.body.object).toBe('inquiry'); @@ -181,19 +183,32 @@ describe('[#21331] public form withdrawal reaches every intake door', () => { const post = await s.post(); expect(post.statusCode).toBe(201); expect(s.createData).toHaveBeenCalledTimes(1); + // [ADR-0131 D6] No read names an organization by the retired key; the + // `view` read alone reaches the legacy layer, and the object read is the + // environment's. + const reads = s.getMetaItems.mock.calls.map(([r]) => [r.type, r.legacyFormOrganizationId]); + expect(reads.length).toBeGreaterThan(0); + for (const [r] of s.getMetaItems.mock.calls) expect(r.organizationId).toBeUndefined(); + for (const [type, legacy] of reads) { + if (type !== 'view') expect(legacy).toBeUndefined(); + } + // One resolution per door: the legacy organization's view read, then the env-wide one. + expect(reads.filter(([type]) => type === 'view').map(([, o]) => o)).toEqual([ORG, undefined, ORG, undefined]); }); - it('withdrawn with no organization to resolve: both doors refuse', async () => { + it('withdrawn env-wide with no organization to resolve: both doors refuse', async () => { const s = build({ envWide: false, tenancy: 'no-org' }); expect((await s.get()).body.code).toBe('FORM_NOT_FOUND'); expect((await s.post()).body.code).toBe('FORM_NOT_FOUND'); expect(s.createData).not.toHaveBeenCalled(); + for (const [r] of s.getMetaItems.mock.calls) expect(r.legacyFormOrganizationId).toBeUndefined(); }); - it('tenancy never registered: the same environment read', async () => { - const s = build({ envWide: true, tenancy: 'not-registered' }); + it('tenancy never registered: the env-wide read, unchanged', async () => { + const s = build({ envWide: true, inOrg: false, tenancy: 'not-registered' }); expect((await s.get()).statusCode).toBe(200); expect((await s.post()).statusCode).toBe(201); + for (const [r] of s.getMetaItems.mock.calls) expect(r.legacyFormOrganizationId).toBeUndefined(); }); it('tenancy registered but unreachable: both doors refuse instead of reading env-wide (fail closed)', async () => { @@ -239,7 +254,7 @@ describe('either declared switch withdraws a public form from every anonymous do } }); -describe('a public form withdrawal is a kill switch: one package\'s withdrawal of a view name closes its form', () => { +describe('a public form withdrawal is a kill switch: layering only narrows intake', () => { const expectClosed = async (s: ReturnType) => { const get = await s.get(); expect([get.statusCode, get.body.code]).toEqual([404, 'FORM_NOT_FOUND']); @@ -248,58 +263,92 @@ describe('a public form withdrawal is a kill switch: one package\'s withdrawal o expect(s.createData).not.toHaveBeenCalled(); }; - // The list read serves one item per package for a name, each carrying its + it('withdrawn env-wide (allowAnonymous false), re-opened in the organization: both doors refuse', async () => { + await expectClosed(build({ envWide: false, inOrg: true, tenancy: 'org' })); + }); + + it('withdrawn env-wide through `enabled: false`, re-opened in the organization: both doors refuse', async () => { + const withdrawn = formView(true, { enabled: false, allowAnonymous: true, publicLink: '/forms/contact-us' }); + await expectClosed(build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [withdrawn] })); + }); + + it('not a withdrawal: the public link cleared env-wide leaves the organization\'s open form served', async () => { + const cleared = formView(true, { enabled: true, allowAnonymous: false }); + const s = build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [cleared] }); + expect((await s.get()).statusCode).toBe(200); + expect((await s.post()).statusCode).toBe(201); + }); + + // The list reads serve one item per package for a name, each carrying its // `_packageId`. Known limit (fails closed): the package is not compared, so // a withdrawal of a view name closes that name in every package. - for (const tenancy of ['org', 'no-org'] as const) { - it(`another package's withdrawal of the same view name closes this package's form (tenancy ${tenancy})`, async () => { - await expectClosed(build({ - envWide: true, tenancy, - envWideViews: [{ ...formView(true), _packageId: 'pkg_a' }, { ...formView(false), _packageId: 'pkg_b' }], - })); - }); - } - - it('withdrawn through `enabled: false` in another package: closed', async () => { - const withdrawn = { ...formView(true, { enabled: false, allowAnonymous: true, publicLink: '/forms/contact-us' }), _packageId: 'pkg_b' }; - await expectClosed(build({ envWide: true, tenancy: 'org', envWideViews: [{ ...formView(true), _packageId: 'pkg_a' }, withdrawn] })); + it('another package\'s withdrawal of the same view name closes this package\'s form too', async () => { + const openA = { ...formView(true), _packageId: 'pkg_a' }; + await expectClosed(build({ + envWide: true, inOrg: true, tenancy: 'org', + orgViews: [openA], + envWideViews: [{ ...formView(true), _packageId: 'pkg_a' }, { ...formView(false), _packageId: 'pkg_b' }], + })); }); it('control: every package\'s body of the view name open serves the form', async () => { + const openA = { ...formView(true), _packageId: 'pkg_a' }; const s = build({ - envWide: true, tenancy: 'org', + envWide: true, inOrg: true, tenancy: 'org', + orgViews: [openA], envWideViews: [{ ...formView(true), _packageId: 'pkg_a' }, { ...formView(true), _packageId: 'pkg_b' }], }); expect((await s.get()).statusCode).toBe(200); expect((await s.post()).statusCode).toBe(201); }); - it('not a withdrawal: another package\'s body with the public link cleared leaves the form served', async () => { - const cleared = { ...formView(true, { enabled: true, allowAnonymous: false }), _packageId: 'pkg_b' }; - const s = build({ envWide: true, tenancy: 'org', envWideViews: [{ ...formView(true), _packageId: 'pkg_a' }, cleared] }); + it('the same package\'s withdrawal of the view name closes its form, beside another package\'s open one', async () => { + const openA = { ...formView(true), _packageId: 'pkg_a' }; + await expectClosed(build({ + envWide: true, inOrg: true, tenancy: 'org', + orgViews: [openA], + envWideViews: [{ ...formView(false), _packageId: 'pkg_a' }, { ...formView(true), _packageId: 'pkg_b' }], + })); + }); + + it('another view withdrawing the same slug env-wide does not close this view\'s form', async () => { + const other = { ...formView(false), name: 'legacy_contact' }; + const s = build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [formView(true), other] }); expect((await s.get()).statusCode).toBe(200); expect((await s.post()).statusCode).toBe(201); }); - it('only an explicit false withdraws: a body with the link, enabled true and allowAnonymous absent closes nothing', async () => { - const absent = { ...formView(true, { enabled: true, publicLink: '/forms/contact-us' }), _packageId: 'pkg_b' }; - const s = build({ envWide: true, tenancy: 'org', envWideViews: [{ ...formView(true), _packageId: 'pkg_a' }, absent] }); + it('withdrawn in the organization, open env-wide: both doors refuse', async () => { + await expectClosed(build({ envWide: true, inOrg: false, tenancy: 'org' })); + }); + + it('open in both layers (control): both doors accept', async () => { + const s = build({ envWide: true, inOrg: true, tenancy: 'org' }); expect((await s.get()).statusCode).toBe(200); expect((await s.post()).statusCode).toBe(201); + expect(s.createData).toHaveBeenCalledTimes(1); }); - it('another view withdrawing the same slug does not close this view\'s form', async () => { - const other = { ...formView(false), name: 'legacy_contact' }; - const s = build({ envWide: true, tenancy: 'org', envWideViews: [formView(true), other] }); + it('a form only the organization carries (no env-wide body, control): both doors accept', async () => { + const s = build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [] }); + expect((await s.get()).statusCode).toBe(200); + expect((await s.post()).statusCode).toBe(201); + }); + + it('only an explicit false withdraws: env-wide link + enabled true with allowAnonymous absent does not close the org\'s open form', async () => { + const absent = formView(true, { enabled: true, publicLink: '/forms/contact-us' }); + const s = build({ envWide: true, inOrg: true, tenancy: 'org', envWideViews: [absent] }); expect((await s.get()).statusCode).toBe(200); expect((await s.post()).statusCode).toBe(201); }); for (const link of ['/forms/contact-us-2', '/forms/Contact-Us']) { - it(`the form moved to a new slug (${link}) in one package, withdrawn at the old one in another: still closed (same slot)`, async () => { + it(`the same view re-pointed at a new slug (${link}) in the organization stays closed`, async () => { const slug = link.replace('/forms/', ''); - const moved = { ...formView(true, { enabled: true, allowAnonymous: true, publicLink: link }), _packageId: 'pkg_a' }; - const s = build({ envWide: true, tenancy: 'org', envWideViews: [moved, { ...formView(false), _packageId: 'pkg_b' }] }); + const s = build({ + envWide: true, inOrg: true, tenancy: 'org', envWideViews: [formView(false)], + sharing: { enabled: true, allowAnonymous: true, publicLink: link }, + }); const get = await s.get(slug); expect([get.statusCode, get.body.code]).toEqual([404, 'FORM_NOT_FOUND']); const post = await s.post(slug); @@ -311,9 +360,9 @@ describe('a public form withdrawal is a kill switch: one package\'s withdrawal o for (const tenancy of ['org', 'no-org'] as const) { it(`two different views sharing a slug in one read do not close each other (tenancy ${tenancy})`, async () => { // Another view (another app's form) names the same slug and withdraws - // it: this view's open form is still served. + // it, in every read: this view's open form is still served. const other = { ...formView(false), name: 'legacy_contact' }; - const s = build({ envWide: true, tenancy, extraViews: [other] }); + const s = build({ envWide: true, tenancy, extraViews: [other], ...(tenancy === 'org' ? { inOrg: true } : {}) }); expect((await s.get()).statusCode).toBe(200); expect((await s.post()).statusCode).toBe(201); expect(s.createData).toHaveBeenCalledTimes(1); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 95243b4a2d0..b59453d43cd 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -79,7 +79,6 @@ import { // Which form candidates the anonymous form doors serve — the one rule the // metadata protocol also judges organization-scoped `view` writes by. anonymousFormIntakeCandidates, - // …and whether another body of the same view name withdraws a candidate. anonymousFormIntakeWithdrawnIn, // [#21476] Whether such a form can take intake on this posture, and why not // — the one predicate the runtime authoring gate's advisory reads too. @@ -302,6 +301,18 @@ export type RestProtocol = DataProtocol & MetadataProtocol; */ type TransportScopedMetaRequest = R & { environmentId?: string }; +/** + * [ADR-0131 D6, triage ruling Q3 A] The anonymous form doors' `view` list + * read: the one metadata read left that reaches a legacy organization's rows. + * `legacyFormOrganizationId` is not a key of the spec's `GetMetaItemsRequest` + * — every other read is environment → code — and the protocol honours it for + * `view` alone: the Default Organization's legacy body is preferred, and a + * withdrawal in either layer closes the form, fail-closed until the v18 + * migration ceremony (ADR-0131 C7) carries those rows into the environment + * layer and deletes this read with them. + */ +type LegacyFormViewsRequest = TransportScopedMetaRequest & { legacyFormOrganizationId?: string }; + // [#23050 slice 1] `ServerScopedDataRequest`, the data doors' request envelope, moved to // `data-action-routes.ts` (imported below): exporting it from this file for that module // changed the published `dist/*.d.ts` by one line, and a second copy is not an option. @@ -1841,6 +1852,13 @@ type MetaReadVerdict = // admin read below and the runtime authoring gate's save/publish advisory read // them from there, so none of the three can disagree with another. +/** [#21331] The organization an anonymous form request reads the form in (`defaultOrgId()`). */ +async function anonymousFormOrganization(tenancy: any): Promise { + if (!tenancy || typeof tenancy.defaultOrgId !== 'function') return undefined; + const organizationId = await tenancy.defaultOrgId(); + return typeof organizationId === 'string' && organizationId ? organizationId : undefined; +} + /** * [#21476] Put the admin read's intake reasons in `_diagnostics.warnings`, where * a derived view warning already goes (`stampRenameWarning`). A declared read @@ -9601,25 +9619,22 @@ export class RestServer { // `sharing.allowAnonymous === true` and a `publicLink` naming the slug. // // A withdrawal is a kill switch: layering may only narrow anonymous - // intake, never re-open it. The doors read one list, the - // environment's (environment → code), which holds one item per - // package of a view name, and judge every candidate against that same - // list as the layer beneath it: a candidate is served only when no - // body of its view name explicitly withdraws the same form - // (`anonymousFormIntakeWithdrawnIn`: matched by slot or by slug, the - // link kept with a switch set to `false`), so a withdrawal in one - // package closes the name's form in every package (a known limit that - // fails closed). Another view publishing the same slug is a different - // form and closes nothing. + // intake, never re-open it. A candidate is served only when no layer + // beneath the read it is found in explicitly withdraws the same form + // (`anonymousFormIntakeWithdrawnIn`: the same view name, matched by + // slot or by slug, the link kept with a switch set to `false`). + // Another view publishing the same slug is a different form and closes + // nothing. const findPublicFormView = ( views: any[], slug: string, + layers: ReadonlyArray>, ): { view: any; form: any; object: string } | null => { for (const view of views ?? []) { if (!view || typeof view !== 'object') continue; for (const c of anonymousFormIntakeCandidates(view)) { if (c.slug !== slug) continue; - if (anonymousFormIntakeWithdrawnIn(views, view, c)) continue; + if (layers.some((layer) => anonymousFormIntakeWithdrawnIn(layer, view, c))) continue; const objectName = anonymousFormObjectName(view, c.form); if (!objectName) continue; return { view, form: c.form, object: objectName }; @@ -9628,17 +9643,24 @@ export class RestServer { return null; }; - // [ADR-0131 D6, C7] WHICH metadata an anonymous form request reads: - // the environment's, like every other metadata read. Until v18 the - // `view` read also kept the Default Organization's legacy layer, so an - // organization's withdrawal still closed the form. The v18 ceremony - // (`os migrate organization-ownership --apply`, its metadata-promotion - // step) carries every organization-layer withdrawal onto the - // environment, fail-closed (rulings A on #22008 and #22011), and that - // read is deleted with it. + // [#21331] WHICH organization's metadata an anonymous form request + // reads. Before ADR-0131 D6 an administrator's edit of a packaged + // form was saved as an overlay of THEIR organization, and an org-less + // read missed every such edit, including the one that withdraws the + // form from anonymous intake. Since D6 no write lands + // organization-scoped and no other read serves such a row; this + // `view` read alone keeps the Default Organization's legacy layer + // (`legacyFormOrganizationId`, triage ruling Q3 A), fail-closed, until + // the v18 migration ceremony (ADR-0131 C7) carries those rows. // - // The tenancy service is still read: it answers the posture the - // intake-availability rule judges (#21476). + // The answer is the tenancy service's `defaultOrgId()`: the + // organization a single-posture deployment binds every principal to. + // It is the organization the administrator's own session is in, and + // the one the engine stamps on the row this request inserts. It is + // `undefined` in two cases. Before any organization exists, no + // organization overlay can exist either. A walled posture has no + // install organization for an org-less request, so the env-wide state + // governs there exactly as before. // // Asked ONCE per request, in `resolveFormBySlug`. Every door below // reads the form through that one resolution, so no door keeps its @@ -9648,7 +9670,7 @@ export class RestServer { // Fails CLOSED. A tenancy service that is registered but cannot be // reached raises `AuthzStoreUnavailableError`, the classification // `classifyAdmissionTenancyPosture` applies to the same seam. The - // door then refuses instead of judging intake without a posture. + // door then refuses instead of falling back to the env-wide read. // Only the registry's own "never registered" brand reads as the // supported no-tenancy composition. The wiring mirrors // `resolveProtocol`, so the tenancy service and the protocol always @@ -9661,9 +9683,11 @@ export class RestServer { const p = await this.resolveProtocol(environmentId, req); if (typeof (p as any).getMetaItems !== 'function') return null; const tenancy = await this.resolveAnonymousFormTenancy(environmentId, req); - const viewsRequest: TransportScopedMetaRequest = { + const organizationId = await anonymousFormOrganization(tenancy); + const viewsRequest: LegacyFormViewsRequest = { type: 'view', ...(environmentId ? { environmentId } : {}), + ...(organizationId ? { legacyFormOrganizationId: organizationId } : {}), }; const listOf = (result: any): any[] => (Array.isArray(result?.items) ? result.items @@ -9671,7 +9695,22 @@ export class RestServer { ? result : []); const items = listOf(await p.getMetaItems(viewsRequest)); - const match = findPublicFormView(items, slug); + // The organization read prefers the organization's overlay of a + // view over the env-wide one, so on its own it cannot see an + // env-wide withdrawal that overlay disagrees with. Read the + // env-wide layer too, and let its withdrawal of the same form close + // it: an organization overlay can narrow intake, never re-open it. + // (The read the form is found in holds only that view's own body, + // which is open, so it withdraws nothing of its own.) + const layers: any[][] = []; + if (organizationId) { + const envWideRequest: TransportScopedMetaRequest = { + type: 'view', + ...(environmentId ? { environmentId } : {}), + }; + layers.push(listOf(await p.getMetaItems(envWideRequest))); + } + const match = findPublicFormView(items, slug, layers); if (!match) return null; // [#21476] A form that cannot take intake on this posture is not // offered: `null` here IS the withdrawn form's answer on both From 917e0b8ccd9060b38b6f3bfc2c417328c50c3fb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 11:59:38 +0000 Subject: [PATCH 09/17] fix(cli): the history fold moves a lineage's events wholly above its own range too (C7b S1) A 17.x database holds history's per-scope counters under unique indexes ((organization_id, event_seq), (organization_id, type, name, version)), checked row by row: an offset smaller than the lineage's own span landed a row on a sibling's number mid-statement (measured on a seeded showcase database). The test fixtures now carry those indexes. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH --- .../src/utils/organization-ownership-apply.test.ts | 11 +++++++++++ .../cli/src/utils/organization-ownership-apply.ts | 7 +++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts index 2136dc256df..5b8de3b0d7e 100644 --- a/packages/cli/src/utils/organization-ownership-apply.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.test.ts @@ -1148,6 +1148,15 @@ describe('ADR-0131 D10 --apply — a customization conflict resolved by --metada }); describe('ADR-0131 D10 --apply — history renumbering (seat answers 6162538253, Q2 A)', () => { + /** + * The unique indexes a 17.x runtime created on history (its per-scope + * counters), checked row by row: a renumbering may never land a row on a + * sibling's number mid-statement. + */ + const HISTORY_UNIQUE = ` + CREATE UNIQUE INDEX uniq_history_org_seq ON sys_metadata_history (organization_id, event_seq); + CREATE UNIQUE INDEX uniq_history_org_version ON sys_metadata_history (organization_id, type, name, version);`; + // A database holding only what each pin plants, so every step can finish // under any posture and the late step is reached. beforeEach(() => { @@ -1175,6 +1184,7 @@ describe('ADR-0131 D10 --apply — history renumbering (seat answers 6162538253, ALTER TABLE sys_metadata ADD COLUMN metadata TEXT; CREATE TABLE sys_metadata_history (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, name TEXT, version INTEGER, event_seq INTEGER, checksum TEXT); + ${HISTORY_UNIQUE} INSERT INTO sys_metadata_history VALUES ('h_env1', NULL, 'object', 'deal', 1, 1, 'e1'), ('h_env2', NULL, 'object', 'deal', 2, 2, 'e2'), @@ -1275,6 +1285,7 @@ describe('ADR-0131 D10 --apply — history renumbering (seat answers 6162538253, INSERT INTO sys_metadata VALUES ${orgRows}; CREATE TABLE sys_metadata_history (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, name TEXT, version INTEGER, event_seq INTEGER, checksum TEXT); + ${HISTORY_UNIQUE} ${historyRows ? `INSERT INTO sys_metadata_history VALUES ${historyRows};` : ''} `); void versioned; diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index b0a3c47b0c2..f44694659d5 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -794,10 +794,13 @@ async function foldLineage(run: Run, organizationId: string, type: string, name: } if (shape.seq) { const seq = sql.q('event_seq', HISTORY); - const [own] = await sql.read(HISTORY, `SELECT MIN(${seq}) AS lo, COUNT(*) AS n FROM ${h} WHERE ${mine}`, args); + const [own] = await sql.read(HISTORY, `SELECT MIN(${seq}) AS lo, MAX(${seq}) AS hi, COUNT(*) AS n FROM ${h} WHERE ${mine}`, args); const [env] = await sql.read(HISTORY, `SELECT MAX(${seq}) AS m FROM ${h} WHERE ${o} IS NULL`); if (num(own?.n) > 0 && num(own!.lo) <= num(env?.m)) { - await execute(run, HISTORY, `UPDATE ${h} SET ${seq} = ${seq} + ? WHERE ${mine}`, [num(env?.m) - num(own!.lo) + 1, ...args]); + // Wholly above the old range too: a unique (organization_id, event_seq) + // index is checked row by row, so no row may land on a sibling's number. + const offset = Math.max(num(env?.m), num(own!.hi)) - num(own!.lo) + 1; + await execute(run, HISTORY, `UPDATE ${h} SET ${seq} = ${seq} + ? WHERE ${mine}`, [offset, ...args]); } } await execute(run, HISTORY, `UPDATE ${h} SET ${o} = NULL WHERE ${mine}`, args); From b0093a33372e3c2ec68d23d5c912600328c98ae6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 12:33:18 +0000 Subject: [PATCH 10/17] chore(changeset): S1's note states how it relates to the --apply 18.0.0-next.1 shipped (C7b S1) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH --- .changeset/15211-ceremony-metadata-promotion.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.changeset/15211-ceremony-metadata-promotion.md b/.changeset/15211-ceremony-metadata-promotion.md index 81d65c540e3..78568511828 100644 --- a/.changeset/15211-ceremony-metadata-promotion.md +++ b/.changeset/15211-ceremony-metadata-promotion.md @@ -8,6 +8,8 @@ Clause-②: yes (widening) The plan of `os migrate organization-ownership` gains a key, `metadataConflicts` on the `sys_metadata` table. It lists every overlay key that more than one row would hold once the organization leaves the key, with each row's id. `--apply` gains a flag, `--metadata-choice ROW_ID`. Nothing is removed from the plan or the apply. +`--apply` shipped in 18.0.0-next.1. This adds to it, and refuses no invocation that release could finish: a conflict now blocks the new step until it is chosen, but on such a database 18.0.0-next.1's `--apply` already stopped at `column-drops`, which never drops `sys_metadata`'s still-declared column. + `--apply` gains a step, `metadata-promotion`, after the authored catalog conversion: - **Form withdrawals are carried first, under every posture, and fail-closed.** When an organization withdrew a public form (`sharing.enabled` or `sharing.allowAnonymous` set to `false`, link kept), every view the environment serves under that name that still serves the form open takes the withdrawal. That covers stored views and views served from code, in every package. The ceremony saves it through the metadata door, into the view's own package, and any organization's withdrawal wins. From 9058ad10bbdc8d837b5a8d237697cdf3097ec422 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 15:15:55 +0000 Subject: [PATCH 11/17] feat(cli): a --metadata-choice or --template-choice deletes only rows the reviewed plan listed (C7b S1, seat option A) One mechanism for both flags, on S4's ReviewedPlan: the plan file's metadataConflicts and templateConflicts are read into the reviewed plan, and before anything is written each chosen group must equal its apply-time group, or the apply refuses with plan-conflict-mismatch, naming the conflict, the rows added and the rows gone. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH --- .../15211-ceremony-metadata-promotion.md | 2 + .../organization-ownership-apply.test.ts | 130 +++++++++++++++++- .../src/utils/organization-ownership-apply.ts | 104 +++++++++++++- 3 files changed, 228 insertions(+), 8 deletions(-) diff --git a/.changeset/15211-ceremony-metadata-promotion.md b/.changeset/15211-ceremony-metadata-promotion.md index 78568511828..7d430fbe06f 100644 --- a/.changeset/15211-ceremony-metadata-promotion.md +++ b/.changeset/15211-ceremony-metadata-promotion.md @@ -21,6 +21,8 @@ The plan of `os migrate organization-ownership` gains a key, `metadataConflicts` - A rerun after an interruption accepts the same `--metadata-choice` and finishes. - **Under a wall (`isolated`, `group`), nothing is promoted unless the operator names the row, and nothing is ever deleted.** A named row becomes the environment's as above. The other organization rows stay where they are, reported, and they keep the column from being dropped. +**A choice deletes only rows the reviewed plan listed.** `--metadata-choice` and `--template-choice` delete the other rows of their conflict. So the apply reads the conflict groups the plan file records (`metadataConflicts` and `templateConflicts`). It refuses, before anything is written, a choice whose conflict now holds other rows than the reviewed plan listed, with a new reason, `plan-conflict-mismatch`. The refusal names the conflict, the rows added and the rows gone. Plan again, review, and pass the new file. + `event_seq` and every `(type, name, version)` of `sys_metadata_history` become unique environment-wide. The metadata door numbers a save from history in its own scope, so each lineage is placed where no environment save can land on it: - **A promoted row takes its history with it.** Before the row leaves its organization, that organization's lineage of the item is moved above the environment's numbers and becomes environment history. Every later environment save of the item numbers above it, and the row's version still names its own snapshot. diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts index 4b54a9f5c1e..c0573558a95 100644 --- a/packages/cli/src/utils/organization-ownership-apply.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.test.ts @@ -23,11 +23,12 @@ import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { permissionSetDefinitionFromRow } from '@objectstack/core'; import { anonymousFormIntakeCandidates, anonymousFormIntakeWithdrawnIn } from '@objectstack/metadata-core'; -import { inventoryDigest } from './organization-ownership-plan.js'; +import { buildOrganizationOwnershipPlan, inventoryDigest } from './organization-ownership-plan.js'; import { applyOrganizationOwnership, MetadataChoiceRefusal, ReviewedPlanRefusal, + reviewedConflictsOf, reviewedPlanFromFile, TemplateChoiceRefusal, type ApplyExecutor, @@ -37,6 +38,7 @@ import { type ColumnDropPort, type MetadataDoorPort, type OrganizationOwnershipApplyReport, + type ReviewedConflict, type ReviewedPlan, type ServedViewsPort, type TemplatePort, @@ -419,15 +421,17 @@ const apply = ( } = {}, ): Promise => { c.boot(); - return applyOrganizationOwnership({ + return reviewedConflictsNow(opts.posture ?? 'single').then((conflicts) => applyOrganizationOwnership({ views: opts.views ?? servedViews(c), - // The plan the operator reviewed: by default, the one this run computes. + // The plan the operator reviewed: by default, the one a plan run computes + // over the database as it stands, conflict groups included. reviewedPlan: { file: 'plan.json', generatedAt: '2026-10-11T00:00:00Z', database: file, inventoryDigest: inventoryDigest(), posture: opts.posture ?? 'single', + conflicts, ...opts.reviewed, }, executor: opts.executor ?? executor(), @@ -441,9 +445,18 @@ const apply = ( columnDrops: opts.columnDrops ?? columnDrops, ledger: l, now: () => new Date('2026-10-12T00:00:00Z'), - }); + })); }; +/** The conflict groups a plan run lists over the database as it stands (none when the plan itself refuses). */ +async function reviewedConflictsNow(posture: string): Promise { + try { + return reviewedConflictsOf(await buildOrganizationOwnershipPlan({ reader: executor(), posture, database: file })); + } catch { + return []; + } +} + /** Run `body` against a database holding nothing but the given templates, so every step can finish under any posture. */ async function onlyTemplates(values: string, body: () => Promise): Promise { const only = new Database(join(dir, 'templates.db')); @@ -672,10 +685,24 @@ describe('reviewedPlanFromFile', () => { it('reads the compared fields of a plan file', () => { expect(reviewedPlanFromFile('p.json', JSON.stringify({ ...plan, tables: [] }))).toEqual({ - file: 'p.json', generatedAt: 'g', database: 'd', inventoryDigest: 'x', posture: 'single', + file: 'p.json', generatedAt: 'g', database: 'd', inventoryDigest: 'x', posture: 'single', conflicts: [], }); }); + it('reads both conflict lists the plan file records, keyed and sorted', () => { + const tables = [ + { + object: 'sys_metadata', + metadataConflicts: [{ type: 'view', name: 'deal_list', packageId: 'pkg_a', bundleMember: null, candidates: [{ id: 'md_b' }, { id: 'md_a' }] }], + }, + { object: 'sys_email_template', templateConflicts: [{ name: 'crm.note', locale: 'en-US', candidates: [{ id: 'tpl_b' }, { id: 'tpl_a' }] }] }, + ]; + expect(reviewedPlanFromFile('p.json', JSON.stringify({ ...plan, tables })).conflicts).toEqual([ + { kind: 'metadata', key: 'view deal_list (package pkg_a)', candidates: ['md_a', 'md_b'] }, + { kind: 'template', key: 'crm.note (en-US)', candidates: ['tpl_a', 'tpl_b'] }, + ]); + }); + it.each([ ['not JSON', '{'], ['an apply report, not a plan', JSON.stringify({ ...plan, kind: 'organization-ownership-apply' })], @@ -970,7 +997,9 @@ describe('ADR-0131 D10 --apply — idempotent, resumable, gated', () => { // The control: the same table on the resumed run, answered `not-proposed` // (a declared object the differ proposed no drop for). const notProposed = await answering('not-proposed'); - expect(notProposed.resumedFrom).toEqual(['attribution', 'authored-conversion', 'template-promotion', 'settings-move', 'rewrite', 'mirror-deletion']); + expect(notProposed.resumedFrom).toEqual([ + 'attribution', 'authored-conversion', 'metadata-promotion', 'template-promotion', 'settings-move', 'rewrite', 'mirror-deletion', 'history-renumber', + ]); const control = notProposed.steps.find((s) => s.step === 'column-drops')!; expect(control.status).toBe('blocked'); expect(control.summary).toEqual({ dropped: 0, due: 1, notDeclared: 0 }); @@ -1303,6 +1332,95 @@ describe('ADR-0131 D10 --apply — a customization conflict resolved by --metada }); }); +describe('ADR-0131 D10 --apply — a choice deletes only rows the reviewed plan listed (plan-conflict-mismatch)', () => { + const metadataConflict = (extra = ''): void => { + db.exec(` + CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, name TEXT, state TEXT, + package_id TEXT, bundle_member TEXT, metadata TEXT); + INSERT INTO sys_metadata VALUES + ('md_org', 'org_default', 'view', 'deal_list', 'active', NULL, NULL, '{"name":"deal_list","label":"Org"}'), + ('md_env', NULL, 'view', 'deal_list', 'active', NULL, NULL, '{"name":"deal_list","label":"Env"}')${extra}; + `); + }; + const reviewedMetadata = (candidates: string[]): Partial => ({ + conflicts: [{ kind: 'metadata', key: 'view deal_list', candidates }], + }); + const refusalOf = async (run: Promise): Promise => { + try { + await run; + } catch (error) { + return error as ReviewedPlanRefusal; + } + throw new Error('expected the apply to be refused'); + }; + + it('⭐ --metadata-choice: a row that joined the conflict since the review refuses the apply, naming it; nothing is written', async () => { + metadataConflict(`, ('md_new', 'org_b', 'view', 'deal_list', 'active', NULL, NULL, '{"name":"deal_list","label":"New"}')`); + const err = await refusalOf(apply(storingDoor(catalog()), ledger(), { metadataChoices: ['md_org'], reviewed: reviewedMetadata(['md_env', 'md_org']) })); + expect(err).toBeInstanceOf(ReviewedPlanRefusal); + expect(err).toMatchObject({ + reason: 'plan-conflict-mismatch', + detail: { flag: '--metadata-choice', choice: 'md_org', conflict: 'view deal_list', reviewed: 'md_env,md_org', current: 'md_env,md_new,md_org' }, + }); + expect(err.message).toMatch(/added since: md_new.*Nothing was written/s); + expect(writes).toEqual([]); + expect(rows('SELECT id FROM sys_metadata ORDER BY id').map((r) => r.id)).toEqual(['md_env', 'md_new', 'md_org']); + }); + + it('--metadata-choice: a reviewed row gone since refuses too, and a plan file that lists no such conflict', async () => { + metadataConflict(); + const gone = await refusalOf(apply(storingDoor(catalog()), ledger(), { metadataChoices: ['md_org'], reviewed: reviewedMetadata(['md_env', 'md_old', 'md_org']) })); + expect(gone).toMatchObject({ reason: 'plan-conflict-mismatch' }); + expect(gone.message).toMatch(/gone since: md_old/); + const unlisted = await refusalOf(apply(storingDoor(catalog()), ledger(), { metadataChoices: ['md_org'], reviewed: { conflicts: [] } })); + expect(unlisted.message).toMatch(/lists no such conflict/); + expect(writes).toEqual([]); + }); + + it('--metadata-choice: the reviewed group, unchanged, resolves as reviewed', async () => { + metadataConflict(); + const report = await apply(storingDoor(catalog()), ledger(), { metadataChoices: ['md_org'], reviewed: reviewedMetadata(['md_env', 'md_org']) }); + expect(report.steps.find((s) => s.step === 'metadata-promotion')!.summary).toMatchObject({ chosen: 1, organizationRowsDeleted: 1 }); + }); + + it('⭐ --template-choice: a row that joined the conflict since the review refuses the apply, naming it; nothing is written', async () => { + await onlyTemplates( + `('tpl_a', 'org_a', 'crm.note', 'en-US', 'From A', 1, 'platform'), ('tpl_b', 'org_b', 'crm.note', 'en-US', 'From B', 1, 'platform'), + ('tpl_c', 'org_c', 'crm.note', 'en-US', 'From C', 1, 'platform')`, + async () => { + const c = catalog(); + const err = await refusalOf(apply(c, ledger(), { + posture: 'isolated', + templateChoices: ['tpl_b'], + reviewed: { conflicts: [{ kind: 'template', key: 'crm.note (en-US)', candidates: ['tpl_a', 'tpl_b'] }] }, + })); + expect(err).toMatchObject({ + reason: 'plan-conflict-mismatch', + detail: { flag: '--template-choice', choice: 'tpl_b', conflict: 'crm.note (en-US)', current: 'tpl_a,tpl_b,tpl_c' }, + }); + expect(err.message).toMatch(/added since: tpl_c/); + expect(writes).toEqual([]); + expect(c.templates.size).toBe(0); + expect(rows('SELECT id FROM sys_email_template ORDER BY id').map((r) => r.id)).toEqual(['tpl_a', 'tpl_b', 'tpl_c']); + }, + ); + }); + + it('--template-choice: the reviewed group, unchanged, resolves as reviewed', async () => { + await onlyTemplates( + `('tpl_a', 'org_a', 'crm.note', 'en-US', 'From A', 1, 'platform'), ('tpl_b', 'org_b', 'crm.note', 'en-US', 'From B', 1, 'platform')`, + async () => { + const report = await apply(catalog(), ledger(), { + posture: 'isolated', + templateChoices: ['tpl_b'], + reviewed: { conflicts: [{ kind: 'template', key: 'crm.note (en-US)', candidates: ['tpl_a', 'tpl_b'] }] }, + }); + expect(report.complete).toBe(true); + }, + ); + }); +}); + describe('ADR-0131 D10 --apply — history renumbering (seat answers 6162538253, Q2 A)', () => { /** * The unique indexes a 17.x runtime created on history (its per-scope diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index 27cfa6d18cc..3a9f33e01a3 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -241,6 +241,48 @@ export interface ReviewedPlan { inventoryDigest: string; /** The tenancy posture the plan was computed under. */ posture: string; + /** + * The conflict groups the plan listed, by key: the only rows a + * `--metadata-choice` or `--template-choice` may delete. Absent means the + * file listed none. + */ + conflicts?: ReviewedConflict[]; +} + +/** One conflict group a plan lists: the rows a choice there resolves. */ +export interface ReviewedConflict { + kind: 'metadata' | 'template'; + /** The group's key as the operator reads it: `view deal_list (package p)`, `crm.note (en-US)`. */ + key: string; + /** Its rows, by id, sorted. */ + candidates: string[]; +} + +/** + * The conflict groups of a plan, from the plan the apply computes or from the + * kept file (both carry `tables[].metadataConflicts` and + * `tables[].templateConflicts`), normalized so the two compare. + */ +export function reviewedConflictsOf(plan: unknown): ReviewedConflict[] { + const tables = (plan as { tables?: unknown } | null)?.tables; + if (!Array.isArray(tables)) return []; + const ids = (candidates: unknown): string[] => + Array.isArray(candidates) ? candidates.map((c) => String((c as { id?: unknown })?.id)).sort() : []; + const out: ReviewedConflict[] = []; + for (const table of tables as Array>) { + for (const c of Array.isArray(table?.metadataConflicts) ? (table.metadataConflicts as Array>) : []) { + out.push({ + kind: 'metadata', + key: `${String(c.type)} ${String(c.name)}${c.packageId ? ` (package ${String(c.packageId)})` : ''}` + + `${c.bundleMember ? ` [${String(c.bundleMember)}]` : ''}`, + candidates: ids(c.candidates), + }); + } + for (const c of Array.isArray(table?.templateConflicts) ? (table.templateConflicts as Array>) : []) { + out.push({ kind: 'template', key: `${String(c.name)} (${String(c.locale)})`, candidates: ids(c.candidates) }); + } + } + return out; } /** @@ -250,7 +292,12 @@ export interface ReviewedPlan { */ export class ReviewedPlanRefusal extends Error { constructor( - readonly reason: 'plan-file-unusable' | 'plan-digest-mismatch' | 'plan-posture-mismatch' | 'plan-database-mismatch', + readonly reason: + | 'plan-file-unusable' + | 'plan-digest-mismatch' + | 'plan-posture-mismatch' + | 'plan-database-mismatch' + | 'plan-conflict-mismatch', message: string, readonly detail: Readonly> = {}, ) { @@ -296,7 +343,14 @@ export function reviewedPlanFromFile(file: string, text: string): ReviewedPlan { { file }, ); } - return { file, generatedAt: p.generatedAt, database: p.database, inventoryDigest: p.inventoryDigest, posture: p.posture }; + return { + file, + generatedAt: p.generatedAt, + database: p.database, + inventoryDigest: p.inventoryDigest, + posture: p.posture, + conflicts: reviewedConflictsOf(p), + }; } export interface StepReport { @@ -2040,6 +2094,51 @@ function refuseUnreviewedPlan(plan: OrganizationOwnershipPlan, reviewed: Reviewe } } +/** + * Refuse, before anything is written, a choice whose conflict group is not the + * one the reviewed plan listed. A `--metadata-choice` or `--template-choice` + * deletes the other rows of its group, so the apply deletes only within a group + * the operator reviewed, and only while it holds exactly the reviewed rows: a + * row that joined the group since, or one gone from it, refuses the apply with + * the group, the rows added and the rows gone. A choice in no group (a lone + * row, or a conflict an earlier run resolved) deletes nothing and is not judged. + */ +function refuseUnreviewedChoices(plan: OrganizationOwnershipPlan, opts: ApplyOptions): void { + const current = reviewedConflictsOf(plan); + const reviewed = opts.reviewedPlan.conflicts ?? []; + const flagOf = { metadata: '--metadata-choice', template: '--template-choice' } as const; + for (const [kind, choices] of [['metadata', opts.metadataChoices ?? []], ['template', opts.templateChoices ?? []]] as const) { + for (const choice of choices) { + const group = current.find((g) => g.kind === kind && g.candidates.includes(choice)); + if (!group) continue; + const listed = reviewed.find((g) => g.kind === kind && g.key === group.key); + const was = new Set(listed?.candidates ?? []); + const added = group.candidates.filter((id) => !was.has(id)); + const gone = [...was].filter((id) => !group.candidates.includes(id)); + if (listed && added.length === 0 && gone.length === 0) continue; + throw new ReviewedPlanRefusal( + 'plan-conflict-mismatch', + `${flagOf[kind]} ${choice} resolves the ${kind === 'metadata' ? 'customization' : 'template'} conflict ` + + `${group.key}, which now holds ${group.candidates.join(', ')}` + + (listed + ? `, while the plan file ${opts.reviewedPlan.file} reviewed ${listed.candidates.join(', ')}` + + `${added.length ? ` (added since: ${added.join(', ')})` : ''}${gone.length ? ` (gone since: ${gone.join(', ')})` : ''}` + : `, and the plan file ${opts.reviewedPlan.file} lists no such conflict`) + + '. A choice deletes the other rows of its conflict, so it deletes only rows the reviewed plan listed. ' + + 'Nothing was written. Run the plan again, review the conflict, and pass the new file with --plan-file.', + { + file: opts.reviewedPlan.file, + flag: flagOf[kind], + choice, + conflict: group.key, + reviewed: listed ? listed.candidates.join(',') : '', + current: group.candidates.join(','), + }, + ); + } + } +} + /** * Run the ceremony's apply: the fates in order, resuming from the checkpoint, * then the post-check. Throws only for a failure mid-write (the checkpoint @@ -2075,6 +2174,7 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise checkpoint.completed[s]); const steps: StepReport[] = []; From f8c44a476bb899651cfc13335b07ce1e324f6bcd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 15:17:00 +0000 Subject: [PATCH 12/17] docs(cli): the apply module doc names the choice binding (C7b S1) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH --- packages/cli/src/utils/organization-ownership-apply.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index 3a9f33e01a3..5d42117eb64 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -133,6 +133,10 @@ import { * recorded, the posture it runs under is not the one the plan was computed * under (the posture decides what attribution and the authored conversion * write), or the file names another database ({@link ReviewedPlanRefusal}). + * A choice deletes the other rows of its conflict, so the reviewed plan also + * binds what a `--metadata-choice` or `--template-choice` may delete: the + * chosen conflict must hold exactly the rows the file listed for it + * ({@link ReviewedConflict}), or the apply refuses (`plan-conflict-mismatch`). * The remedy is always the same: plan again, review it, hand the new file in. * A checkpoint recorded under another digest is therefore one the reviewed * plan supersedes: the run starts over (each step is idempotent), and the From 0162d47cd3c4fef34df3ac2066b509836d46e142 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 15:35:08 +0000 Subject: [PATCH 13/17] fix(cli): a resolved conflict's environment row takes the chosen body in place, not through the door (C7b S1) Measured on a seeded showcase database: the metadata door writes an environment customization of a packaged item into that package's slot, whatever the target, so a package-less environment row of the key never took the chosen body (the read-back caught it; nothing was deleted). The key's environment row now takes the body by one UPDATE, its version the chosen row's after the fold; a withdrawal the body keeps is still saved through the door, for its history and the package's slot. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH --- .../organization-ownership-apply.test.ts | 44 ++++++++++--- .../src/utils/organization-ownership-apply.ts | 63 +++++++++++++++---- 2 files changed, 89 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts index c0573558a95..7543569ecdd 100644 --- a/packages/cli/src/utils/organization-ownership-apply.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.test.ts @@ -1193,18 +1193,48 @@ describe('ADR-0131 D10 --apply — a customization conflict resolved by --metada expect(step(report)).toMatchObject({ status: 'done', summary: { chosen: 1, organizationRowsDeleted: 1, conflicts: 0 } }); expect(owners()).toEqual([['md_env', null]]); expect(labelOf('md_env')).toBe('Org list'); - // Exactly one environment row at every point: the door wrote onto the one - // there was, and no UPDATE ever cleared an organization row of this key. - expect(c.envRowsAtSave).toEqual([1]); + // Exactly one environment row at every point: the environment row took the + // body in place, by one UPDATE, and no UPDATE ever cleared an organization + // row of this key. No withdrawal to record, so the door was not asked. + expect(writes.filter((w) => /^UPDATE "sys_metadata" SET "metadata" = \?/.test(w))).toHaveLength(1); expect(writes.some((w) => /^UPDATE "sys_metadata" SET "organization_id" = NULL/.test(w))).toBe(false); - const save = c.viewSaves.length; - expect(save).toBe(1); + expect(c.viewSaves).toEqual([]); + }); + + it('⭐ an item a package ships: a package-less environment row of the key takes the body, though the door writes only into the package', async () => { + // The real door writes an environment customization of a packaged item into + // that package's slot, whatever the target (measured on the showcase). + metadataTable([ + md('md_org', 'org_default', 'view', 'crm_lead.intake', formView(true, 'Org copy')), + md('md_env', null, 'view', 'crm_lead.intake', formView(false, 'Env copy')), + ].join(', ')); + const base = storingDoor(catalog()); + const packaged: ReturnType = { + ...base, + async save(type, name, body) { + return base.save(type, name, body, { packageId: 'pkg_a' }); + }, + }; + const report = await apply(packaged, ledger(), { views: servedViews(base, []), metadataChoices: ['md_org'] }); + expect(step(report)).toMatchObject({ status: 'done', summary: { chosen: 1, organizationRowsDeleted: 1 } }); + // The key's own environment row holds the chosen body, with the environment row's withdrawal kept. + expect(labelOf('md_env')).toBe('Org copy'); + expect((JSON.parse(String(rows(`SELECT metadata FROM sys_metadata WHERE id = 'md_env'`)[0]!.metadata)) as any).config.sharing.enabled).toBe(false); + expect(rows(`SELECT id FROM sys_metadata WHERE id = 'md_org'`)).toEqual([]); }); - it('the environment row is read back before any delete: a door that stores nothing deletes nothing', async () => { + it('the environment row is read back before any delete: a write that does not land deletes nothing', async () => { metadataTable(conflictRows); const c = catalog(); - await expect(apply(c, ledger(), { metadataChoices: ['md_org'] })).rejects.toThrow(/did not read back as one environment row/); + const base = executor(); + const lost: ApplyExecutor = { + ...base, + async execute(statement, params) { + if (/^UPDATE "sys_metadata" SET "metadata"/.test(statement)) return; + return base.execute(statement, params); + }, + }; + await expect(apply(c, ledger(), { metadataChoices: ['md_org'], executor: lost })).rejects.toThrow(/did not read back as one environment row/); expect(deletes()).toEqual([]); expect(owners()).toEqual([['md_env', null], ['md_org', 'org_default']]); }); diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index 5d42117eb64..680ac655fde 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -1261,6 +1261,33 @@ async function environmentRowOf(run: Run, conflict: MetadataConflict): Promise ({ id: String(row.id), body: parseBody(row) })); } +/** One `sys_metadata` row takes a body in place, by ONE statement (its version too, when given and stored). */ +async function writeBody(run: Run, rowId: string, body: Record, version?: number): Promise { + const sql = run.sql; + const t = METADATA_OBJECT; + const cols = (await columnsOf(run, t)) ?? new Set(); + const sets = [`${sql.q('metadata', t)} = ?`]; + const params: unknown[] = [JSON.stringify(body)]; + if (version !== undefined && cols.has('version')) { + sets.push(`${sql.q('version', t)} = ?`); + params.push(version); + } + await execute(run, t, `UPDATE ${sql.q(t, t)} SET ${sets.join(', ')} WHERE ${sql.q('id', t)} = ?`, [...params, rowId]); +} + +/** A `sys_metadata` row's stored version, when the table stores one. */ +async function versionOf(run: Run, rowId: string): Promise { + const cols = (await columnsOf(run, METADATA_OBJECT)) ?? new Set(); + if (!cols.has('version')) return undefined; + const [row] = await run.sql.read( + METADATA_OBJECT, + `SELECT ${run.sql.q('version', METADATA_OBJECT)} AS v FROM ${run.sql.q(METADATA_OBJECT, METADATA_OBJECT)} WHERE ${run.sql.q('id', METADATA_OBJECT)} = ?`, + [rowId], + ); + const v = Number(row?.v); + return Number.isFinite(v) ? v : undefined; +} + /** Two bodies define the same item (key order aside). */ const sameBody = (a: Readonly>, b: Readonly>): boolean => { const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort(); @@ -1274,11 +1301,16 @@ const sameBody = (a: Readonly>, b: Readonly 0) await run.opts.door.save(conflict.type as never, conflict.name, body, { packageId: conflict.packageId }); - } else if (environment.length === 1 && !(environment[0]!.body && sameBody(environment[0]!.body, body))) { - await run.opts.door.save(conflict.type as never, conflict.name, body, { packageId: conflict.packageId }); + if (!env) { + if (chosen.organizationId !== null) { + await promoteRows(run, chosen.organizationId, [{ id: chosen.id, type: conflict.type, name: conflict.name }]); + if (closing.length) await writeBody(run, chosen.id, body); + } + } else if (!(env.body && sameBody(env.body, body))) { + let version: number | undefined; + if (chosen.organizationId !== null) { + await foldLineage(run, chosen.organizationId, conflict.type, conflict.name); + version = await versionOf(run, chosen.id); + } + await writeBody(run, env.id, body, version); } + if (closing.length) await run.opts.door.save(conflict.type as never, conflict.name, body, { packageId: conflict.packageId }); } catch (error) { return ( - `The metadata door refused to give ${what} the body of the chosen row ${chosen.id}` + + `${what} could not take the body of the chosen row ${chosen.id}` + `${closing.length ? ` (with the public form(s) ${closing.join(', ')} an unchosen row withdraws, withdrawn)` : ''}: ` + `${error instanceof Error ? error.message : String(error)}. Nothing was deleted; fix the cause, then re-run.` ); From 468b74a09d9678b2304c3c0cd4b67bd326def7bb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 15:35:43 +0000 Subject: [PATCH 14/17] chore(changeset): the resolved environment row takes the chosen body in place (C7b S1) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH --- .changeset/15211-ceremony-metadata-promotion.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/15211-ceremony-metadata-promotion.md b/.changeset/15211-ceremony-metadata-promotion.md index 7d430fbe06f..2607a460285 100644 --- a/.changeset/15211-ceremony-metadata-promotion.md +++ b/.changeset/15211-ceremony-metadata-promotion.md @@ -15,7 +15,7 @@ The plan of `os migrate organization-ownership` gains a key, `metadataConflicts` - **Form withdrawals are carried first, under every posture, and fail-closed.** When an organization withdrew a public form (`sharing.enabled` or `sharing.allowAnonymous` set to `false`, link kept), every view the environment serves under that name that still serves the form open takes the withdrawal. That covers stored views and views served from code, in every package. The ceremony saves it through the metadata door, into the view's own package, and any organization's withdrawal wins. - **Under `single`, an organization customization whose key no other row holds becomes the environment's, in place.** This covers the `sys_metadata` rows of `view`, `dashboard`, `report`, `translation` and `email_template`; the key is type, name, package and bundle member. The row takes its history with it. - **A key held by more than one row is a conflict, listed in the plan.** Under `single` it blocks the step until the operator chooses one row with `--metadata-choice ROW_ID`, once per conflict. - - The key ends with exactly one environment row. Where the key has an environment row, that row takes the chosen body through the metadata door, in place. Where it has none, the chosen organization row is promoted in place. + - The key ends with exactly one environment row. Where the key has an environment row, that row takes the chosen body in place, and its version names the chosen row's snapshot. Where it has none, the chosen organization row is promoted in place. A withdrawal the body keeps is also saved through the metadata door. - Any public-form withdrawal another row of the key holds is kept on that body. - The environment row is read back. Only then are the key's organization rows deleted, in one statement, by id. - A rerun after an interruption accepts the same `--metadata-choice` and finishes. From f5c3ef44888706e11ba2e8f04096b5e22758fcac Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 16:13:05 +0000 Subject: [PATCH 15/17] =?UTF-8?q?fix(cli):=20S1=20delta=20review=20?= =?UTF-8?q?=E2=80=94=20the=20fold's=20base=20is=20the=20organization's=20w?= =?UTF-8?q?hole=20history;=20the=20in-place=20write=20does=20what=20the=20?= =?UTF-8?q?door=20does;=20column-drops=20waits=20on=20verified=20uniquenes?= =?UTF-8?q?s=20(C7b=20S1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - N1: each fold moves a lineage above the environment's numbers AND the same organization's other events and versions (17.x numbered event_seq per organization under a unique index); the base excludes the lineage itself, so a rerun moves nothing twice. Pinned from the review's smallest case, with a crash and a rerun. - N2: the in-place UPDATE asks the door's lock verdict first (the item read's editable; a locked item stays locked), keeps the row's stored _lock family, and restamps checksum with hashSpec. - The wall with an environment row (ruled as built): each organization row left is named in the report as remaining and blocking column-drops, with the remedy. - Uniqueness under a wall (ruled, on condition): history-renumber verifies event_seq and version uniqueness environment-wide on every apply, and column-drops runs only after that verification in the same apply. - validateMetadataChoices keys a conflict with its bundle member. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH --- .../migrate/organization-ownership.ts | 18 ++ ...p-apply.served-catalog.integration.test.ts | 1 + .../organization-ownership-apply.test.ts | 111 ++++++++++- .../src/utils/organization-ownership-apply.ts | 182 +++++++++++++++--- 4 files changed, 286 insertions(+), 26 deletions(-) diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts index f7075d5c1e7..3c32d643397 100644 --- a/packages/cli/src/commands/migrate/organization-ownership.ts +++ b/packages/cli/src/commands/migrate/organization-ownership.ts @@ -496,6 +496,7 @@ export default class MigrateOrganizationOwnership extends Command { saveMetaItem?: (request: Record) => Promise; getMetaItems?: (request: { type: string }) => Promise<{ items?: unknown[] } | unknown[]>; getMetaItemsForExecution?: (request: { type: string }) => Promise<{ items?: unknown[] }>; + getMetaItem?: (request: { type: string; name: string; packageId?: string }) => Promise; } | undefined; const catalog = createSecurityCatalogReader({ registry: engine.registry as never, @@ -544,6 +545,23 @@ export default class MigrateOrganizationOwnership extends Command { }); }, }, + lock: { + // The save door's own verdict: the item read reports it as `editable`. + verdict: async (type, name, packageId) => { + if (typeof protocol?.getMetaItem !== 'function') { + throw new Error('no metadata protocol is composed on this stack'); + } + const read = (await protocol.getMetaItem({ type, name, ...(packageId ? { packageId } : {}) })) as + | { editable?: unknown; lock?: unknown; lockReason?: unknown } + | null + | undefined; + return { + editable: read?.editable !== false, + ...(typeof read?.lock === 'string' ? { lock: read.lock } : {}), + ...(typeof read?.lockReason === 'string' ? { lockReason: read.lockReason } : {}), + }; + }, + }, views: { // The env-wide view list, exactly as the anonymous form doors read it. list: async () => { diff --git a/packages/cli/src/utils/organization-ownership-apply.served-catalog.integration.test.ts b/packages/cli/src/utils/organization-ownership-apply.served-catalog.integration.test.ts index 4ede94fb00b..f0a02b0e2dd 100644 --- a/packages/cli/src/utils/organization-ownership-apply.served-catalog.integration.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.served-catalog.integration.test.ts @@ -132,6 +132,7 @@ async function ceremony(posture: string, db: Database.Database): Promise [] }, + lock: { verdict: async () => ({ editable: true }) }, door: { save: async (type, name) => { throw new Error(`the door was asked to save ${type} ${name}`); } }, templates: { find: async () => undefined }, columnDrops: { drop: async (tables) => tables.map((table) => ({ table, outcome: 'not-proposed' as const })) }, diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts index 7543569ecdd..ff8833953fb 100644 --- a/packages/cli/src/utils/organization-ownership-apply.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.test.ts @@ -22,7 +22,7 @@ import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { permissionSetDefinitionFromRow } from '@objectstack/core'; -import { anonymousFormIntakeCandidates, anonymousFormIntakeWithdrawnIn } from '@objectstack/metadata-core'; +import { anonymousFormIntakeCandidates, anonymousFormIntakeWithdrawnIn, hashSpec } from '@objectstack/metadata-core'; import { buildOrganizationOwnershipPlan, inventoryDigest } from './organization-ownership-plan.js'; import { applyOrganizationOwnership, @@ -37,6 +37,7 @@ import { type CeremonyLedgerPort, type ColumnDropPort, type MetadataDoorPort, + type MetadataLockPort, type OrganizationOwnershipApplyReport, type ReviewedConflict, type ReviewedPlan, @@ -418,11 +419,13 @@ const apply = ( views?: ServedViewsPort; columnDrops?: ColumnDropPort; reviewed?: Partial; + lock?: MetadataLockPort; } = {}, ): Promise => { c.boot(); return reviewedConflictsNow(opts.posture ?? 'single').then((conflicts) => applyOrganizationOwnership({ views: opts.views ?? servedViews(c), + lock: opts.lock ?? { verdict: async () => ({ editable: true }) }, // The plan the operator reviewed: by default, the one a plan run computes // over the database as it stands, conflict groups included. reviewedPlan: { @@ -1317,6 +1320,64 @@ describe('ADR-0131 D10 --apply — a customization conflict resolved by --metada expect(owners()).toEqual([['md_env', null], ['md_org', 'org_default']]); }); + describe('the in-place write does what the door does to that row (N2)', () => { + const withChecksum = (): void => { + db.exec(`ALTER TABLE sys_metadata ADD COLUMN checksum TEXT; UPDATE sys_metadata SET checksum = 'sha256:stale';`); + }; + + it('⭐ restamps checksum from the written body, as the door does (the version, the etag, the no-op check)', async () => { + metadataTable(conflictRows); + withChecksum(); + await apply(storingDoor(catalog()), ledger(), { metadataChoices: ['md_org'] }); + const [row] = rows(`SELECT metadata, checksum FROM sys_metadata WHERE id = 'md_env'`); + expect(row!.checksum).toBe(hashSpec(JSON.parse(String(row!.metadata)), 'view')); + expect(row!.checksum).not.toBe('sha256:stale'); + }); + + it('⭐ keeps the lock family the environment row stores, whatever the chosen body carries', async () => { + metadataTable([ + md('md_org', 'org_default', 'view', 'deal_list', { name: 'deal_list', label: 'Org list', _lock: 'none' }), + md('md_env', null, 'view', 'deal_list', { name: 'deal_list', label: 'Env list', _lock: 'customize', _lockReason: 'stamped' }), + ].join(', ')); + await apply(storingDoor(catalog()), ledger(), { metadataChoices: ['md_org'] }); + const body = JSON.parse(String(rows(`SELECT metadata FROM sys_metadata WHERE id = 'md_env'`)[0]!.metadata)) as Record; + expect(body).toMatchObject({ label: 'Org list', _lock: 'customize', _lockReason: 'stamped' }); + }); + + it('⭐ a locked item stays locked: the door\'s verdict refuses the write, and nothing is written or deleted', async () => { + metadataTable(conflictRows); + const asked: Array<[string, string, string | null]> = []; + const report = await apply(storingDoor(catalog()), ledger(), { + metadataChoices: ['md_org'], + lock: { + verdict: async (type, name, packageId) => { + asked.push([type, name, packageId]); + return { editable: false, lock: 'full', lockReason: 'shipped locked' }; + }, + }, + }); + expect(asked).toEqual([['view', 'deal_list', null]]); + expect(step(report).status).toBe('blocked'); + expect(step(report).blockers.join('\n')).toMatch(/is locked \(_lock=full: shipped locked\).*Nothing was deleted/s); + expect(labelOf('md_env')).toBe('Env list'); + expect(deletes()).toEqual([]); + }); + }); + + it('two conflicts of one item that differ only by bundle member are two conflicts, each chosen for', async () => { + db.exec(` + CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, name TEXT, state TEXT, + package_id TEXT, bundle_member TEXT, metadata TEXT); + INSERT INTO sys_metadata VALUES + ('en_org', 'org_default', 'email_template', 'crm.note', 'active', NULL, 'en-US', '{"name":"crm.note","locale":"en-US","s":"o"}'), + ('en_env', NULL, 'email_template', 'crm.note', 'active', NULL, 'en-US', '{"name":"crm.note","locale":"en-US","s":"e"}'), + ('fr_org', 'org_default', 'email_template', 'crm.note', 'active', NULL, 'fr-FR', '{"name":"crm.note","locale":"fr-FR","s":"o"}'), + ('fr_env', NULL, 'email_template', 'crm.note', 'active', NULL, 'fr-FR', '{"name":"crm.note","locale":"fr-FR","s":"e"}'); + `); + const report = await apply(storingDoor(catalog()), ledger(), { metadataChoices: ['en_org', 'fr_env'] }); + expect(step(report)).toMatchObject({ status: 'done', summary: { chosen: 2 } }); + }); + describe('under a wall (ruling 6163114098, Q6 A: the ceremony never deletes the unchosen rows)', () => { it('⭐ a chosen organization row defines the environment row, and nothing is deleted', async () => { metadataTable(conflictRows); @@ -1325,7 +1386,12 @@ describe('ADR-0131 D10 --apply — a customization conflict resolved by --metada expect(deletes()).toEqual([]); expect(owners()).toEqual([['md_env', null], ['md_org', 'org_default']]); expect(labelOf('md_env')).toBe('Org list'); - expect(step(report).notes.join('\n')).toMatch(/organization rows md_org are never deleted/); + // The ruled report line: each organization row left, named as remaining and blocking column-drops, with the remedy. + expect(step(report).notes).toContainEqual( + expect.stringMatching( + /^Remaining: sys_metadata row md_org \(organization org_default, the chosen row, whose body the environment row md_env now holds\) of the view deal_list blocks column-drops on sys_metadata: .*never deletes it\. Remedy: check the environment row, then delete this row yourself/, + ), + ); }); it('⭐ two organizations on one key: the chosen one is promoted, the other stays', async () => { @@ -1674,6 +1740,47 @@ describe('ADR-0131 D10 --apply — history renumbering (seat answers 6162538253, expect(duplicates()).toEqual([]); }); + it('⭐ N1: folding one item of an organization never lands on that organization\'s other events, on a rerun too', async () => { + // The review's smallest case: the environment holds event 1; the + // organization holds view A at 1-2 and view B at 3-4, both lone under single. + viewWithHistory( + `('md_a', 'org_default', 'view', 'view_a', 'active', NULL, NULL, 2, '{"name":"view_a"}'), + ('md_b', 'org_default', 'view', 'view_b', 'active', NULL, NULL, 4, '{"name":"view_b"}')`, + `('h_env', NULL, 'object', 'deal', 1, 1, 'e'), + ('h_a1', 'org_default', 'view', 'view_a', 1, 1, 'a1'), ('h_a2', 'org_default', 'view', 'view_a', 2, 2, 'a2'), + ('h_b1', 'org_default', 'view', 'view_b', 3, 3, 'b1'), ('h_b2', 'org_default', 'view', 'view_b', 4, 4, 'b2')`, + ); + const c = storingDoor(catalog()); + // A crash right after the first fold's event move, then the rerun. + await expect(apply(c, ledger(), { views: servedViews(c, []), columnDrops: keepColumn, executor: executor({ failOnceOn: /^UPDATE "sys_metadata_history" SET "organization_id" = NULL/ }) })) + .rejects.toThrow(/killed mid-step/); + const report = await apply(c, ledger(), { views: servedViews(c, []), columnDrops: keepColumn }); + expect(report.steps.find((s) => s.step === 'metadata-promotion')!.summary).toMatchObject({ promoted: 2 }); + expect(duplicates()).toEqual([]); + expect(snapshotOf('md_a')).toEqual(['a2']); + expect(snapshotOf('md_b')).toEqual(['b2']); + expect(rows(`SELECT checksum FROM sys_metadata_history WHERE id LIKE 'h_a%' OR id LIKE 'h_b%' ORDER BY event_seq`).map((r) => r.checksum)) + .toEqual(['a1', 'a2', 'b1', 'b2']); + }); + + it('⭐ column-drops never runs unless this apply\'s history-renumber verified uniqueness environment-wide', async () => { + // Two environment events share a number: the 17.x index never covered the + // environment scope (NULL is distinct), and no layout can move them. + viewWithHistory( + `('md_env', NULL, 'object', 'deal', 'active', NULL, NULL, 1, '{"name":"deal"}')`, + `('h_e1', NULL, 'object', 'deal', 1, 7, 'e1'), ('h_e2', NULL, 'object', 'deal', 2, 7, 'e2')`, + ); + const drops: string[][] = []; + const report = await apply(catalog(), ledger(), { + columnDrops: { drop: async (tables) => { drops.push([...tables]); return tables.map((table) => ({ table, outcome: 'dropped' as const })); } }, + }); + const renumber = report.steps.find((s) => s.step === 'history-renumber')!; + expect(renumber.status).toBe('blocked'); + expect(renumber.blockers.join('\n')).toMatch(/event_seq is not unique environment-wide: 7 \(2 rows\)/); + expect(report.steps.find((s) => s.step === 'column-drops')!.status).toBe('not-reached'); + expect(drops).toEqual([]); + }); + it('an environment save made between two runs is renumbered by the next one, the checkpoint notwithstanding', async () => { history(); const l = ledger(); diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index 680ac655fde..5c374962155 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -5,7 +5,7 @@ import { permissionSetDefinitionFromRow, positionDefinitionFromRow, } from '@objectstack/core'; -import { anonymousFormIntakeCandidates, anonymousFormIntakeWithdrawnIn } from '@objectstack/metadata-core'; +import { anonymousFormIntakeCandidates, anonymousFormIntakeWithdrawnIn, hashSpec } from '@objectstack/metadata-core'; import { inventoryEntryFor, type InventoryEntry, @@ -190,6 +190,15 @@ export interface ServedViewsPort { list(): Promise>>>; } +/** + * The metadata door's own verdict on a write to an item (ADR-0010 §3.3): the + * item read's `editable`, which reports the save door's lock gate. A write the + * ceremony makes in place, not through the door, asks it first. + */ +export interface MetadataLockPort { + verdict(type: string, name: string, packageId: string | null): Promise<{ editable: boolean; lock?: string; lockReason?: string }>; +} + /** The `email_template` item the registry serves for one `(name, locale)` bundle member. */ export interface TemplatePort { find(name: string, locale: string): Promise<{ definition: Readonly>; environment: boolean } | undefined>; @@ -431,6 +440,8 @@ export interface ApplyOptions { templates: TemplatePort; /** The served views, for carrying form withdrawals. */ views: ServedViewsPort; + /** The door's lock verdict, for the one write the ceremony makes in place. */ + lock: MetadataLockPort; columnDrops: ColumnDropPort; ledger: CeremonyLedgerPort; /** The kept plan file the operator reviewed; the run refuses unless it matches (see the module doc). */ @@ -505,6 +516,8 @@ interface Run { verifiedTemplateIds: Set; /** The checkpoint this run writes into (set once the ledger is read). */ checkpoint?: CeremonyCheckpoint; + /** This apply's `history-renumber` verified `event_seq` and version uniqueness environment-wide. */ + historyVerified?: boolean; } /** A `--metadata-choice` that names no row awaiting a choice, or two of one conflict. Nothing is written. */ @@ -907,12 +920,17 @@ async function historyShape(run: Run): Promise<{ version: boolean; seq: boolean /** * Make one organization's lineage of `(type, name)` environment history, so the - * row about to be promoted keeps it: its versions moved above the environment - * lineage of the item (the organization's overlay rows of the item with them), - * its events moved above the environment's last event (order kept), then the - * rows re-tagged. Each statement is conditioned on the state it changes, so a - * rerun after a crash anywhere in it finishes it. Commit ranges are not moved - * (no reader resolves history through them). + * row about to be promoted keeps it: its versions and its events each moved, by + * one offset that keeps their order, above BOTH the environment's numbers and + * every other number the same organization holds, then the rows re-tagged. The + * organization's whole history is the base because 17.x numbered `event_seq` + * per organization across all its items and holds it under a unique + * `(organization_id, event_seq)` index, checked row by row: an offset measured + * on the item alone lands on the organization's own other events. Versions take + * the same base, as ruled. The base excludes the lineage being moved, so once + * moved it sits above the base, and a rerun after a crash anywhere in this moves + * nothing twice. Commit ranges are not moved (no reader resolves history + * through them). */ async function foldLineage(run: Run, organizationId: string, type: string, name: string): Promise { const shape = await historyShape(run); @@ -929,10 +947,15 @@ async function foldLineage(run: Run, organizationId: string, type: string, name: const [own] = await sql.read(HISTORY, `SELECT MIN(${v}) AS lo, MAX(${v}) AS hi, COUNT(*) AS n FROM ${h} WHERE ${mine}`, args); if (num(own?.n) > 0) { const [env] = await sql.read(HISTORY, `SELECT MAX(${v}) AS m FROM ${h} WHERE ${o} IS NULL AND ${ty} = ? AND ${nm} = ?`, [type, name]); + const [others] = await sql.read( + HISTORY, + `SELECT MAX(${v}) AS m FROM ${h} WHERE ${o} = ? AND NOT (${ty} = ? AND ${nm} = ?)`, + args, + ); const mcols = await columnsOf(run, OVERLAYS); await shiftRange( { min: num(own!.lo), max: num(own!.hi) }, - num(env?.m), + Math.max(num(env?.m), num(others?.m)), async (offset, oldMax) => { if (!mcols?.has(ORG) || !mcols.has('version')) return; const m = sql.q(OVERLAYS, OVERLAYS); @@ -955,10 +978,16 @@ async function foldLineage(run: Run, organizationId: string, type: string, name: const seq = sql.q('event_seq', HISTORY); const [own] = await sql.read(HISTORY, `SELECT MIN(${seq}) AS lo, MAX(${seq}) AS hi, COUNT(*) AS n FROM ${h} WHERE ${mine}`, args); const [env] = await sql.read(HISTORY, `SELECT MAX(${seq}) AS m FROM ${h} WHERE ${o} IS NULL`); - if (num(own?.n) > 0 && num(own!.lo) <= num(env?.m)) { - // Wholly above the old range too: a unique (organization_id, event_seq) - // index is checked row by row, so no row may land on a sibling's number. - const offset = Math.max(num(env?.m), num(own!.hi)) - num(own!.lo) + 1; + const [others] = await sql.read( + HISTORY, + `SELECT MAX(${seq}) AS m FROM ${h} WHERE ${o} = ? AND NOT (${ty} = ? AND ${nm} = ?)`, + args, + ); + const base = Math.max(num(env?.m), num(others?.m)); + if (num(own?.n) > 0 && num(own!.lo) <= base) { + // Wholly above the base and the old range: no row may land on the + // environment's numbers, the organization's other events, or a sibling's. + const offset = Math.max(base, num(own!.hi)) - num(own!.lo) + 1; await execute(run, HISTORY, `UPDATE ${h} SET ${seq} = ${seq} + ? WHERE ${mine}`, [offset, ...args]); } } @@ -975,6 +1004,56 @@ async function foldLineage(run: Run, organizationId: string, type: string, name: * organization, and the snapshot each row's version names, are unchanged. */ async function runHistoryRenumber(run: Run): Promise { + run.historyVerified = false; + const report = await renumberHistory(run); + const blockers = await historyDuplicates(run); + if (blockers.length) return { ...report, status: 'blocked', blockers }; + run.historyVerified = true; + return report; +} + +/** + * `event_seq` and `(type, name, version)` duplicated anywhere in history, as + * blockers: `column-drops` runs only once this reads none (the seat's ruling on + * uniqueness under a wall). The layout above leaves none between scopes; a + * duplicate within one scope is a database's own, and is named, never guessed. + */ +async function historyDuplicates(run: Run): Promise { + const hcols = await columnsOf(run, HISTORY); + if (!hcols) return []; + const sql = run.sql; + const h = sql.q(HISTORY, HISTORY); + const out: string[] = []; + if (hcols.has('event_seq')) { + const seq = sql.q('event_seq', HISTORY); + const dup = await sql.read(HISTORY, `SELECT ${seq} AS s, COUNT(*) AS n FROM ${h} GROUP BY ${seq} HAVING COUNT(*) > 1 ORDER BY ${seq}`); + if (dup.length) { + out.push( + `${HISTORY}.event_seq is not unique environment-wide: ${dup.slice(0, 10).map((d) => `${String(d.s)} (${String(d.n)} rows)`).join(', ')}` + + `${dup.length > 10 ? `, and ${dup.length - 10} more` : ''}. The column is not dropped until every event has its own ` + + 'number. Renumber or remove the duplicated events, then re-run --apply.', + ); + } + } + if (hcols.has('version')) { + const [ty, nm, v] = ['type', 'name', 'version'].map((c) => sql.q(c, HISTORY)); + const dup = await sql.read( + HISTORY, + `SELECT ${ty} AS t, ${nm} AS n, ${v} AS v, COUNT(*) AS c FROM ${h} GROUP BY ${ty}, ${nm}, ${v} HAVING COUNT(*) > 1 ORDER BY ${ty}, ${nm}, ${v}`, + ); + if (dup.length) { + out.push( + `${HISTORY} holds one version of an item more than once: ` + + `${dup.slice(0, 10).map((d) => `${String(d.t)} ${String(d.n)} v${String(d.v)} (${String(d.c)} rows)`).join(', ')}` + + `${dup.length > 10 ? `, and ${dup.length - 10} more` : ''}. The column is not dropped until every version names one ` + + 'snapshot. Renumber or remove the duplicated versions, then re-run --apply.', + ); + } + } + return out; +} + +async function renumberHistory(run: Run): Promise { const summary: Record = { organizationsShifted: 0, lineagesShifted: 0 }; const shape = await historyShape(run); if (!shape) return { step: 'history-renumber', status: 'done', summary, blockers: [], notes: [] }; @@ -1261,18 +1340,48 @@ async function environmentRowOf(run: Run, conflict: MetadataConflict): Promise ({ id: String(row.id), body: parseBody(row) })); } -/** One `sys_metadata` row takes a body in place, by ONE statement (its version too, when given and stored). */ -async function writeBody(run: Run, rowId: string, body: Record, version?: number): Promise { +/** + * One `sys_metadata` row takes a body in place, by ONE statement, doing to the + * row what the metadata door does on a save: the door's lock gate first (a + * locked item stays locked: the write is refused, nothing written); the row's + * stored `_lock` family kept (the door never sets, changes or removes it, + * ADR-0010 §3.5), whatever the body carries; and `checksum` restamped from the + * written body (`hashSpec`, the door's own), which the repository serves as + * the row's version and etag and the view save's no-op check compares. The + * version moves too, when given and stored. Returns the body written. + */ +async function writeBody( + run: Run, + row: { id: string; stored: Readonly> | null | undefined }, + body: Record, + address: { type: string; name: string; packageId: string | null }, + version?: number, +): Promise> { + const verdict = await run.opts.lock.verdict(address.type, address.name, address.packageId); + if (!verdict.editable) { + throw new Error( + `the ${address.type} ${address.name} is locked (_lock=${verdict.lock ?? 'unknown'}` + + `${verdict.lockReason ? `: ${verdict.lockReason}` : ''}), so the metadata door would refuse this write, and the ` + + 'ceremony does not make it', + ); + } + const written: Record = Object.fromEntries(Object.entries(body).filter(([k]) => !k.startsWith('_lock'))); + for (const [k, v] of Object.entries(row.stored ?? {})) if (k.startsWith('_lock')) written[k] = v; const sql = run.sql; const t = METADATA_OBJECT; const cols = (await columnsOf(run, t)) ?? new Set(); const sets = [`${sql.q('metadata', t)} = ?`]; - const params: unknown[] = [JSON.stringify(body)]; + const params: unknown[] = [JSON.stringify(written)]; + if (cols.has('checksum')) { + sets.push(`${sql.q('checksum', t)} = ?`); + params.push(hashSpec(written, address.type)); + } if (version !== undefined && cols.has('version')) { sets.push(`${sql.q('version', t)} = ?`); params.push(version); } - await execute(run, t, `UPDATE ${sql.q(t, t)} SET ${sets.join(', ')} WHERE ${sql.q('id', t)} = ?`, [...params, rowId]); + await execute(run, t, `UPDATE ${sql.q(t, t)} SET ${sets.join(', ')} WHERE ${sql.q('id', t)} = ?`, [...params, row.id]); + return written; } /** A `sys_metadata` row's stored version, when the table stores one. */ @@ -1351,11 +1460,16 @@ async function resolveMetadataConflict(run: Run, r: MetadataResolution, notes: s ); } const env = environment[0]; + const address = { type: conflict.type, name: conflict.name, packageId: conflict.packageId }; + // The body the key's environment row must read back with: the chosen body, as written. + let expected: Record = env?.body && sameBody(env.body, body) ? env.body : body; try { if (!env) { if (chosen.organizationId !== null) { await promoteRows(run, chosen.organizationId, [{ id: chosen.id, type: conflict.type, name: conflict.name }]); - if (closing.length) await writeBody(run, chosen.id, body); + if (closing.length) { + expected = await writeBody(run, { id: chosen.id, stored: chosenBody }, body, address); + } } } else if (!(env.body && sameBody(env.body, body))) { let version: number | undefined; @@ -1363,7 +1477,7 @@ async function resolveMetadataConflict(run: Run, r: MetadataResolution, notes: s await foldLineage(run, chosen.organizationId, conflict.type, conflict.name); version = await versionOf(run, chosen.id); } - await writeBody(run, env.id, body, version); + expected = await writeBody(run, { id: env.id, stored: env.body }, body, address, version); } if (closing.length) await run.opts.door.save(conflict.type as never, conflict.name, body, { packageId: conflict.packageId }); } catch (error) { @@ -1374,7 +1488,7 @@ async function resolveMetadataConflict(run: Run, r: MetadataResolution, notes: s ); } const after = await environmentRowOf(run, conflict); - if (after.length !== 1 || !after[0]!.body || !sameBody(after[0]!.body, body)) { + if (after.length !== 1 || !after[0]!.body || !sameBody(after[0]!.body, expected)) { throw new Error( `${what} did not read back as one environment row defining the chosen row ${chosen.id}'s body; none of the ` + `rows ${others.map((o) => o.id).join(', ')} was deleted.`, @@ -1388,11 +1502,13 @@ async function resolveMetadataConflict(run: Run, r: MetadataResolution, notes: s const keeper = after[0]!.id; const leaving = conflict.candidates.filter((c) => c.organizationId !== null && c.id !== keeper).map((c) => c.id); if (run.opts.posture !== 'single') { - if (leaving.length) { + for (const remaining of conflict.candidates.filter((c) => c.organizationId !== null && c.id !== keeper)) { notes.push( - `${what}: the environment row ${keeper} keeps the chosen row ${chosen.id}'s body; under the ${run.opts.posture} ` + - `posture the organization rows ${leaving.join(', ')} are never deleted. They stay reported, and keep the ` + - `${METADATA_OBJECT} column from being dropped.`, + `Remaining: ${METADATA_OBJECT} row ${remaining.id} (organization ${remaining.organizationId}` + + `${remaining.id === chosen.id ? `, the chosen row, whose body the environment row ${keeper} now holds` : ''}) ` + + `of ${what} blocks column-drops on ${METADATA_OBJECT}: under the ${run.opts.posture} posture the ceremony ` + + 'never deletes it. Remedy: check the environment row, then delete this row yourself, or keep it and the ' + + 'column stays; re-run --apply.', ); } run.opts.onProgress?.(`promotion: ${what} is defined by row ${chosen.id}'s body; nothing deleted under a wall`); @@ -1505,7 +1621,8 @@ async function validateMetadataChoices(run: Run, applied: ReadonlySet): const cols = await columnsOf(run, METADATA_OBJECT); const rows = cols?.has(ORG) ? await readMetadataKeyRows(run.sql, METADATA_OBJECT, cols) : []; const { promotable, conflicts } = groupMetadataKeyRows(rows); - const keyOf = (c: MetadataConflict): string => `${c.type} ${c.name}${c.packageId ? ` (package ${c.packageId})` : ''}`; + const keyOf = (c: MetadataConflict): string => + `${c.type} ${c.name}${c.packageId ? ` (package ${c.packageId})` : ''}${c.bundleMember ? ` [${c.bundleMember}]` : ''}`; const groupOf = new Map(); for (const c of conflicts) for (const cand of c.candidates) groupOf.set(cand.id, keyOf(c)); for (const row of promotable) groupOf.set(row.id, `the ${row.type} ${row.name} (row ${row.id})`); @@ -2003,6 +2120,18 @@ async function deletePromotedTemplates(run: Run, tables: readonly string[]): Pro // ── Step 5: column drops (fate 1) ──────────────────────────────────────────── async function runColumnDrops(run: Run): Promise { + if (!run.historyVerified) { + return { + step: 'column-drops', + status: 'blocked', + summary: { dropped: 0, due: 0 }, + blockers: [ + 'column-drops runs only after this apply\'s history-renumber verified sys_metadata_history\'s event_seq and ' + + 'versions unique environment-wide, and it did not. Re-run --apply.', + ], + notes: [], + }; + } const plan = await planFor(run); const blockers: string[] = []; const due: string[] = []; @@ -2246,6 +2375,11 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise n > 0)) { steps.push(again); checkpoint.completed[step] = { at: now().toISOString(), summary: again.summary }; From d02b699d656bf54e05d564c9e2098d2d08b3b428 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 16:16:59 +0000 Subject: [PATCH 16/17] chore(changeset): S1 note names the door-equivalent write, the uniqueness gate and the remaining-row report (C7b S1) Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH --- .changeset/15211-ceremony-metadata-promotion.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.changeset/15211-ceremony-metadata-promotion.md b/.changeset/15211-ceremony-metadata-promotion.md index 2607a460285..1e766e7e671 100644 --- a/.changeset/15211-ceremony-metadata-promotion.md +++ b/.changeset/15211-ceremony-metadata-promotion.md @@ -16,20 +16,23 @@ The plan of `os migrate organization-ownership` gains a key, `metadataConflicts` - **Under `single`, an organization customization whose key no other row holds becomes the environment's, in place.** This covers the `sys_metadata` rows of `view`, `dashboard`, `report`, `translation` and `email_template`; the key is type, name, package and bundle member. The row takes its history with it. - **A key held by more than one row is a conflict, listed in the plan.** Under `single` it blocks the step until the operator chooses one row with `--metadata-choice ROW_ID`, once per conflict. - The key ends with exactly one environment row. Where the key has an environment row, that row takes the chosen body in place, and its version names the chosen row's snapshot. Where it has none, the chosen organization row is promoted in place. A withdrawal the body keeps is also saved through the metadata door. + - The in-place write does to the row what the door does. It asks the door's lock verdict first, so a locked item stays locked and the conflict blocks. It keeps the row's stored `_lock` keys. It restamps `checksum`. - Any public-form withdrawal another row of the key holds is kept on that body. - The environment row is read back. Only then are the key's organization rows deleted, in one statement, by id. - A rerun after an interruption accepts the same `--metadata-choice` and finishes. -- **Under a wall (`isolated`, `group`), nothing is promoted unless the operator names the row, and nothing is ever deleted.** A named row becomes the environment's as above. The other organization rows stay where they are, reported, and they keep the column from being dropped. +- **Under a wall (`isolated`, `group`), nothing is promoted unless the operator names the row, and nothing is ever deleted.** A named row's body becomes the environment's as above. Every organization row left, the chosen one included where the key has an environment row, is named in the report as remaining and blocking `column-drops`, with the remedy. **A choice deletes only rows the reviewed plan listed.** `--metadata-choice` and `--template-choice` delete the other rows of their conflict. So the apply reads the conflict groups the plan file records (`metadataConflicts` and `templateConflicts`). It refuses, before anything is written, a choice whose conflict now holds other rows than the reviewed plan listed, with a new reason, `plan-conflict-mismatch`. The refusal names the conflict, the rows added and the rows gone. Plan again, review, and pass the new file. `event_seq` and every `(type, name, version)` of `sys_metadata_history` become unique environment-wide. The metadata door numbers a save from history in its own scope, so each lineage is placed where no environment save can land on it: -- **A promoted row takes its history with it.** Before the row leaves its organization, that organization's lineage of the item is moved above the environment's numbers and becomes environment history. Every later environment save of the item numbers above it, and the row's version still names its own snapshot. +- **A promoted row takes its history with it.** Before the row leaves its organization, that organization's lineage of the item is moved above the environment's numbers, and above every other number that organization holds, then becomes environment history. Every later environment save of the item numbers above it, and the row's version still names its own snapshot. - **A new step, `history-renumber`, runs just before `column-drops`, on every apply, even after a checkpoint.** It lays out every lineage still tagged to an organization above the environment's: each organization's event range by one offset, its commit ranges with it, and each item's version lineage, the organization's rows with it. - Under `single`, that history then becomes the environment's too. - Under a wall it stays its organization's. +`column-drops` runs only after that same apply's `history-renumber` has verified `event_seq` and every `(type, name, version)` unique environment-wide. A duplicate it cannot lay out is named, and blocks. + Order within an organization, and the version a rollback resolves, are unchanged. Nothing in history is deleted. On a 17.x database `sys_metadata` has no `bundle_member` column. The ceremony boots with schema changes held, so it reads that column as absent, the overlay key's NULL member, and plans the table rather than refusing it. From 82bd7e0b803b57b01b8d66b880331d4b89e19e80 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 14 Oct 2026 16:59:11 +0000 Subject: [PATCH 17/17] =?UTF-8?q?fix(cli):=20S1=20merge=20round=20?= =?UTF-8?q?=E2=80=94=20the=20lock=20is=20asked=20before=20any=20write=20of?= =?UTF-8?q?=20a=20resolution;=20the=20follow-up=20door=20save=20sends=20th?= =?UTF-8?q?e=20row=20as=20written;=20history=20duplicates=20are=20named=20?= =?UTF-8?q?(C7b=20S1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The door's lock verdict is asked once, before the history fold, the promotion and the in-place write: a refusal leaves nothing written. Pinned under a wall with a history table. - The door save after the in-place write sends the row as written, its stored lock family kept, so a rerun meets the same body. - history-renumber's refusal names the table, each item holding the duplicated number and the remedy; both messages pinned. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH --- .../organization-ownership-apply.test.ts | 58 +++++++++++++++++- .../src/utils/organization-ownership-apply.ts | 59 +++++++++++++------ 2 files changed, 95 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/utils/organization-ownership-apply.test.ts b/packages/cli/src/utils/organization-ownership-apply.test.ts index 66be0c58729..fbb5601837c 100644 --- a/packages/cli/src/utils/organization-ownership-apply.test.ts +++ b/packages/cli/src/utils/organization-ownership-apply.test.ts @@ -1365,12 +1365,54 @@ describe('ADR-0131 D10 --apply — a customization conflict resolved by --metada }); expect(asked).toEqual([['view', 'deal_list', null]]); expect(step(report).status).toBe('blocked'); - expect(step(report).blockers.join('\n')).toMatch(/is locked \(_lock=full: shipped locked\).*Nothing was deleted/s); + expect(step(report).blockers.join('\n')).toMatch(/is locked \(_lock=full: shipped locked\).*Nothing was written or deleted/s); expect(labelOf('md_env')).toBe('Env list'); expect(deletes()).toEqual([]); }); }); + it('⭐ the lock is asked before the history fold and the promotion: under a wall, a refusal leaves history and rows as they were', async () => { + metadataTable([ + md('md_org', 'org_default', 'view', 'deal_list', { name: 'deal_list', label: 'Org list' }), + md('md_env', null, 'view', 'deal_list', { name: 'deal_list', label: 'Env list' }), + md('md_lone', 'org_default', 'dashboard', 'sales', { name: 'sales' }), + ].join(', ')); + db.exec(` + CREATE TABLE sys_metadata_history (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, name TEXT, + version INTEGER, event_seq INTEGER, checksum TEXT); + INSERT INTO sys_metadata_history VALUES + ('h_env', NULL, 'view', 'deal_list', 1, 1, 'e1'), + ('h_org1', 'org_default', 'view', 'deal_list', 1, 1, 'o1'), ('h_org2', 'org_default', 'view', 'deal_list', 2, 2, 'o2'); + `); + const history = rows('SELECT * FROM sys_metadata_history ORDER BY id'); + const report = await apply(storingDoor(catalog()), ledger(), { + posture: 'isolated', + metadataChoices: ['md_org'], + columnDrops: { drop: async (columns) => columns.map(({ table, column }) => ({ table, column, outcome: 'not-proposed' as const })) }, + lock: { verdict: async (type) => (type === 'view' ? { editable: false, lock: 'full' } : { editable: true }) }, + }); + expect(step(report).status).toBe('blocked'); + expect(step(report).blockers.join('\n')).toMatch(/view deal_list could not take the body .*Nothing was written or deleted/s); + // No fold, no promotion, no write: history and both rows of the key are as they were. + expect(writes.filter((w) => /sys_metadata/.test(w))).toEqual([]); + expect(rows('SELECT * FROM sys_metadata_history ORDER BY id')).toEqual(history); + expect(owners()).toEqual([['md_env', null], ['md_lone', 'org_default'], ['md_org', 'org_default']]); + expect(labelOf('md_env')).toBe('Env list'); + }); + + it('the door save after the in-place write sends the row as written, its stored lock family kept', async () => { + metadataTable([ + md('md_org', 'org_default', 'view', 'crm_lead.intake', formView(true, 'Org copy')), + md('md_other', 'org_b', 'view', 'crm_lead.intake', formView(false, 'Other copy')), + md('md_env', null, 'view', 'crm_lead.intake', { ...formView(true, 'Env copy'), _lock: 'customize' }), + ].join(', ')); + const c = storingDoor(catalog()); + const report = await apply(c, ledger(), { views: servedViews(c, []), metadataChoices: ['md_org'] }); + expect(step(report).status).toBe('done'); + expect(c.viewSaves.at(-1)!.body).toMatchObject({ label: 'Org copy', _lock: 'customize' }); + expect((c.viewSaves.at(-1)!.body.config as any).sharing.enabled).toBe(false); + }); + it('two conflicts of one item that differ only by bundle member are two conflicts, each chosen for', async () => { db.exec(` CREATE TABLE sys_metadata (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, name TEXT, state TEXT, @@ -1775,7 +1817,8 @@ describe('ADR-0131 D10 --apply — history renumbering (seat answers 6162538253, // environment scope (NULL is distinct), and no layout can move them. viewWithHistory( `('md_env', NULL, 'object', 'deal', 'active', NULL, NULL, 1, '{"name":"deal"}')`, - `('h_e1', NULL, 'object', 'deal', 1, 7, 'e1'), ('h_e2', NULL, 'object', 'deal', 2, 7, 'e2')`, + `('h_e1', NULL, 'object', 'deal', 1, 7, 'e1'), ('h_e2', NULL, 'object', 'deal', 2, 7, 'e2'), + ('h_v1', NULL, 'object', 'account', 3, 8, 'v1'), ('h_v2', NULL, 'object', 'account', 3, 9, 'v2')`, ); const drops: string[][] = []; const report = await apply(catalog(), ledger(), { @@ -1788,7 +1831,16 @@ describe('ADR-0131 D10 --apply — history renumbering (seat answers 6162538253, }); const renumber = report.steps.find((s) => s.step === 'history-renumber')!; expect(renumber.status).toBe('blocked'); - expect(renumber.blockers.join('\n')).toMatch(/event_seq is not unique environment-wide: 7 \(2 rows\)/); + // The refusal names the table, each item holding the number, and the remedy. + expect(renumber.blockers).toContainEqual( + 'sys_metadata_history: event_seq is not unique environment-wide — event_seq 7: object deal (row h_e1), object deal ' + + '(row h_e2). The ceremony never renumbers history a database numbered twice: the organization_id column is not ' + + 'dropped until every event has its own number. Remedy: give each of these rows a number no other row holds (or ' + + 'remove the duplicate), then re-run --apply.', + ); + expect(renumber.blockers).toContainEqual(expect.stringMatching( + /^sys_metadata_history: a version of an item is held more than once — object account version 3 \(2 rows\)\. .*Remedy: give each of these rows a version no other row of the item holds/, + )); expect(report.steps.find((s) => s.step === 'column-drops')!.status).toBe('not-reached'); expect(drops).toEqual([]); }); diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index 0f494442cdc..8fa3392d285 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -1049,12 +1049,24 @@ async function historyDuplicates(run: Run): Promise { const out: string[] = []; if (hcols.has('event_seq')) { const seq = sql.q('event_seq', HISTORY); - const dup = await sql.read(HISTORY, `SELECT ${seq} AS s, COUNT(*) AS n FROM ${h} GROUP BY ${seq} HAVING COUNT(*) > 1 ORDER BY ${seq}`); + const dup = await sql.read(HISTORY, `SELECT ${seq} AS s FROM ${h} GROUP BY ${seq} HAVING COUNT(*) > 1 ORDER BY ${seq}`); if (dup.length) { + const shown = dup.slice(0, 10).map((d) => d.s); + const [ty, nm] = ['type', 'name'].map((c) => sql.q(c, HISTORY)); + const holders = await sql.read( + HISTORY, + `SELECT ${seq} AS s, ${sql.q('id', HISTORY)} AS id, ${ty} AS t, ${nm} AS n FROM ${h} ` + + `WHERE ${seq} IN (${shown.map(() => '?').join(', ')}) ORDER BY ${seq}, ${sql.q('id', HISTORY)}`, + shown, + ); + const byNumber = shown.map( + (n) => `event_seq ${String(n)}: ${holders.filter((r) => String(r.s) === String(n)).map((r) => `${String(r.t)} ${String(r.n)} (row ${String(r.id)})`).join(', ')}`, + ); out.push( - `${HISTORY}.event_seq is not unique environment-wide: ${dup.slice(0, 10).map((d) => `${String(d.s)} (${String(d.n)} rows)`).join(', ')}` + - `${dup.length > 10 ? `, and ${dup.length - 10} more` : ''}. The column is not dropped until every event has its own ` + - 'number. Renumber or remove the duplicated events, then re-run --apply.', + `${HISTORY}: event_seq is not unique environment-wide — ${byNumber.join('; ')}` + + `${dup.length > 10 ? `; and ${dup.length - 10} more number(s)` : ''}. The ceremony never renumbers history ` + + 'a database numbered twice: the organization_id column is not dropped until every event has its own number. ' + + 'Remedy: give each of these rows a number no other row holds (or remove the duplicate), then re-run --apply.', ); } } @@ -1066,10 +1078,11 @@ async function historyDuplicates(run: Run): Promise { ); if (dup.length) { out.push( - `${HISTORY} holds one version of an item more than once: ` + - `${dup.slice(0, 10).map((d) => `${String(d.t)} ${String(d.n)} v${String(d.v)} (${String(d.c)} rows)`).join(', ')}` + - `${dup.length > 10 ? `, and ${dup.length - 10} more` : ''}. The column is not dropped until every version names one ` + - 'snapshot. Renumber or remove the duplicated versions, then re-run --apply.', + `${HISTORY}: a version of an item is held more than once — ` + + `${dup.slice(0, 10).map((d) => `${String(d.t)} ${String(d.n)} version ${String(d.v)} (${String(d.c)} rows)`).join('; ')}` + + `${dup.length > 10 ? `; and ${dup.length - 10} more` : ''}. The ceremony never renumbers history a database ` + + 'numbered twice: the organization_id column is not dropped until every version names one snapshot. Remedy: give ' + + 'each of these rows a version no other row of the item holds (or remove the duplicate), then re-run --apply.', ); } } @@ -1365,8 +1378,8 @@ async function environmentRowOf(run: Run, conflict: MetadataConflict): Promise> { - const verdict = await run.opts.lock.verdict(address.type, address.name, address.packageId); - if (!verdict.editable) { - throw new Error( - `the ${address.type} ${address.name} is locked (_lock=${verdict.lock ?? 'unknown'}` + - `${verdict.lockReason ? `: ${verdict.lockReason}` : ''}), so the metadata door would refuse this write, and the ` + - 'ceremony does not make it', - ); - } const written: Record = Object.fromEntries(Object.entries(body).filter(([k]) => !k.startsWith('_lock'))); for (const [k, v] of Object.entries(row.stored ?? {})) if (k.startsWith('_lock')) written[k] = v; const sql = run.sql; @@ -1484,6 +1489,21 @@ async function resolveMetadataConflict(run: Run, r: MetadataResolution, notes: s } const env = environment[0]; const address = { type: conflict.type, name: conflict.name, packageId: conflict.packageId }; + // The door's lock gate, asked ONCE and before any write of this resolution + // (the history fold, the promotion, the in-place write): a locked item stays + // locked, and a refusal leaves nothing written. + const writes = env ? !(env.body && sameBody(env.body, body)) : chosen.organizationId !== null; + if (writes) { + const verdict = await run.opts.lock.verdict(address.type, address.name, address.packageId); + if (!verdict.editable) { + return ( + `${what} could not take the body of the chosen row ${chosen.id}: the item is locked (_lock=${verdict.lock ?? 'unknown'}` + + `${verdict.lockReason ? `: ${verdict.lockReason}` : ''}), so the metadata door would refuse the write, and the ` + + 'ceremony does not make it. Nothing was written or deleted; unlock the item, or choose the row the environment ' + + 'already holds, then re-run.' + ); + } + } // The body the key's environment row must read back with: the chosen body, as written. let expected: Record = env?.body && sameBody(env.body, body) ? env.body : body; try { @@ -1502,7 +1522,8 @@ async function resolveMetadataConflict(run: Run, r: MetadataResolution, notes: s } expected = await writeBody(run, { id: env.id, stored: env.body }, body, address, version); } - if (closing.length) await run.opts.door.save(conflict.type as never, conflict.name, body, { packageId: conflict.packageId }); + // The row as written (its stored lock family kept), so a rerun's door save meets the same body. + if (closing.length) await run.opts.door.save(conflict.type as never, conflict.name, expected, { packageId: conflict.packageId }); } catch (error) { return ( `${what} could not take the body of the chosen row ${chosen.id}` +