From 0546039d4f20398c799337f4e205aedf616d6ce6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 00:17:42 +0000 Subject: [PATCH 1/8] test(cli): pin findServedOverlayRow's legacy-organization filter on its own The _lock case asserted only lock and editable, which overlayLockLayerAt selects apart from the content; with findServedOverlayRow's filter alone removed, getMetaItem served the organization's body and every case stayed green. It now asserts the served item is not the organization's body. Claude-Session: https://claude.ai/code/session_01TekAoE8QbLrxFVJsdNuRgg Co-authored-by: Claude --- .../meta-stored-legacy-organization.integration.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/cli/src/commands/migrate/meta-stored-legacy-organization.integration.test.ts b/packages/cli/src/commands/migrate/meta-stored-legacy-organization.integration.test.ts index 07bf970a61c..beecf33d29a 100644 --- a/packages/cli/src/commands/migrate/meta-stored-legacy-organization.integration.test.ts +++ b/packages/cli/src/commands/migrate/meta-stored-legacy-organization.integration.test.ts @@ -307,6 +307,11 @@ describe('[ADR-0131 D6/D7] the ceremony boot\'s readers ignore a legacy organiza }); expect((read as { lock?: unknown } | null)?.lock ?? 'none').not.toBe('full'); expect((read as { editable?: unknown } | null)?.editable).not.toBe(false); + // The served CONTENT is not the organization's body either. The lock is + // selected apart from the content (`overlayLockLayerAt`), so the two + // assertions above stay green when only the content read + // (`findServedOverlayRow`) serves the organization's row; this one does not. + expect((read as { item?: { subject?: unknown } } | null)?.item?.subject).not.toBe('Org A locked'); // The `_lock` gate runs BEFORE the write. It did not refuse over the // organization's lock: the save reached the store, where the re-keyed // unique index refuses an environment row beside the organization's row of From a6f23cd97b420fa532e8270440aa51e5e236a764 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 00:23:07 +0000 Subject: [PATCH 2/8] fix(metadata-protocol): a paged family read leaves legacy organization rows out before the page is cut auditMetaItem, listCommits, revertStoredPackage's limit: 1 probe and the repository's list({ limit }) filtered the legacy organization rows after limit, so a page they sat in came back short, and the probe answered 409 "never been published" when its one row was an organization's. When a paged read's page holds a legacy row (or an offset could have counted one), it is re-read with them excluded in the query itself. A migrated or fresh store holds none, and its single read is unchanged (ADR-0131 D6/D7). Claude-Session: https://claude.ai/code/session_01TekAoE8QbLrxFVJsdNuRgg Co-authored-by: Claude --- ...ocol.legacy-organization-read-seam.test.ts | 219 ++++++++++++++++++ packages/metadata-protocol/src/protocol.ts | 23 +- .../src/sys-metadata-repository.ts | 21 +- 3 files changed, 257 insertions(+), 6 deletions(-) create mode 100644 packages/metadata-protocol/src/protocol.legacy-organization-read-seam.test.ts diff --git a/packages/metadata-protocol/src/protocol.legacy-organization-read-seam.test.ts b/packages/metadata-protocol/src/protocol.legacy-organization-read-seam.test.ts new file mode 100644 index 00000000000..4856f687251 --- /dev/null +++ b/packages/metadata-protocol/src/protocol.legacy-organization-read-seam.test.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D6/D7, #23609] The legacy-organization read seam on a database the + * organization-ownership ceremony has not migrated: the `sys_metadata` family + * declares no organization column, the engine shapes every read to the + * declared columns, and the physical column still holds each legacy + * organization's rows. Every case runs twice: on that v17 shape, and on a + * migrated store (no physical column, the CONTROL), whose answers are the + * ones every read gave before. + * + * 1. A read that PAGES leaves the legacy rows out before the page is cut: + * `auditMetaItem` (`orderBy` + `limit`), `listCommits` (`limit`), + * `revertStoredPackage`'s `limit: 1` probe and the repository's + * `list({ limit })`. Filtered after the cut, the page came back short, and + * the probe answered 409 "never been published" for a package whose first + * active row was an organization's. + * + * The real-engine pins over a v17-shape SQLite file are in `@objectstack/cli` + * (`meta-stored-legacy-organization.integration.test.ts`). + */ +import { describe, expect, it } from 'vitest'; +import { + assertEngineDeleteDispatch, + assertEngineFindOnePredicate, + assertEngineUpdateDispatch, +} from '@objectstack/metadata-core'; +import { ObjectStackProtocolImplementation } from './protocol.js'; +import { SysMetadataRepository } from './sys-metadata-repository.js'; + +type Row = Record; + +/** Equality (NULL included), `$and`, `$in` and `$nin` — the subset these reads emit. */ +function matches(row: Row, where: Record = {}): boolean { + for (const [k, v] of Object.entries(where)) { + if (k === '$and') { + if (!(v as Row[]).every((clause) => matches(row, clause))) return false; + continue; + } + // Any other combinator is refused, never read as a field name. + if (k.startsWith('$')) throw new Error(`matches: unsupported combinator ${k}`); + if (v === undefined) continue; + const actual = row[k] ?? null; + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + const ops = v as Record; + for (const op of Object.keys(ops)) { + if (op !== '$in' && op !== '$nin') throw new Error(`matches: unsupported operator ${op}`); + } + if ('$in' in ops && !(ops.$in as unknown[]).includes(actual)) return false; + if ('$nin' in ops && (ops.$nin as unknown[]).includes(actual)) return false; + continue; + } + if (actual !== v) return false; + } + return true; +} + +/** + * An engine over `seed`. `legacyShape` puts the physical `organization_id` + * column on disk: the driver's catalog read lists it and its row read answers + * the rows that carry a value. Either way the engine's own reads never return + * the key, as the real engine's do not. + */ +function harness(seed: Record, legacyShape: boolean) { + const tables = new Map(Object.entries(seed).map(([t, rows]) => [t, rows.map((r) => ({ ...r }))])); + const table = (t: string) => { + if (!tables.has(t)) tables.set(t, []); + return tables.get(t)!; + }; + const declared = ({ organization_id: _physical, ...row }: Row): Row => row; + const select = (t: string, q?: { where?: Row; orderBy?: Array<{ field: string; order: 'asc' | 'desc' }>; limit?: number; offset?: number }) => { + let rows = table(t).filter((r) => matches(r, q?.where)); + for (const { field, order } of [...(q?.orderBy ?? [])].reverse()) { + rows = [...rows].sort((a, b) => { + const cmp = String(a[field]).localeCompare(String(b[field])); + return order === 'desc' ? -cmp : cmp; + }); + } + const start = q?.offset ?? 0; + return rows.slice(start, typeof q?.limit === 'number' ? start + q.limit : undefined).map(declared); + }; + const engine: any = { + async find(t: string, q?: any) { return select(t, q); }, + async findOne(t: string, q: { where: Row }) { + assertEngineFindOnePredicate(t, q); + return select(t, q)[0] ?? null; + }, + async count(t: string, q?: { where?: Row }) { return select(t, q).length; }, + async insert(t: string, data: Row) { + table(t).push({ ...data }); + return { id: data.id }; + }, + async update(_t: string, data: Row, o: { where: Row }) { + assertEngineUpdateDispatch(data, o); + return { id: null }; + }, + async delete(_t: string, o: { where: Row }) { + assertEngineDeleteDispatch(o); + return { deleted: 0 }; + }, + async transaction(cb: (ctx: unknown, info: { owned: boolean }) => Promise): Promise { + return cb(undefined, { owned: true }); + }, + getDriverForObject(t: string) { + return { + dialectName: 'sqlite', + async execute(sql: string) { + if (/column_name/.test(sql)) { + return [{ column_name: 'id' }, ...(legacyShape ? [{ column_name: 'organization_id' }] : [])]; + } + return table(t) + .filter((r) => r.organization_id !== undefined && r.organization_id !== null && r.organization_id !== '') + .map((r) => ({ id: r.id, organization_id: r.organization_id })); + }, + }; + }, + registry: { + registerItem: () => {}, + registerObject: () => {}, + getObject: () => undefined, + getPackage: () => undefined, + isPackageDisabled: () => false, + listItems: () => [], + getItem: () => undefined, + getArtifactItem: () => undefined, + }, + }; + const protocol = new ObjectStackProtocolImplementation(engine, () => new Map(), 'env_prod') as any; + protocol.overlayIndexEnsured = true; + return { protocol, engine }; +} + +/** The v17 shape keeps the legacy rows on disk; a migrated store holds none of them. */ +const store = (rows: Row[], legacyShape: boolean): Row[] => + legacyShape ? rows : rows.filter((r) => r.organization_id === null || r.organization_id === undefined); + +const SHAPES = [ + { shape: 'v17 shape (legacy organization rows on disk)', legacyShape: true }, + { shape: 'CONTROL: a migrated store', legacyShape: false }, +] as const; + +const audit = (id: string, at: string, organization_id: string | null): Row => ({ + id, type: 'view', name: 'grid', occurred_at: at, actor: 'u', source: null, operation: 'save', + outcome: 'allowed', code: 'OK', lock_state: null, lock_overridden: false, request_id: null, note: null, + organization_id, +}); + +const commit = (id: string, at: string, organization_id: string | null): Row => ({ + id, package_id: 'app.pkg', operation: 'apply', item_count: 0, items: '[]', created_at: at, organization_id, +}); + +const active = (id: string, name: string, organization_id: string | null): Row => ({ + id, type: 'view', name, package_id: 'app.pkg', state: 'active', version: 1, + metadata: JSON.stringify({ name }), organization_id, +}); + +describe('[#23609] a read that pages leaves the legacy organization rows out before the page is cut', () => { + for (const { shape, legacyShape } of SHAPES) { + describe(shape, () => { + it('auditMetaItem: the newest `limit` events of the environment, never a short page', async () => { + const rows = store([ + audit('e_old', '2026-01-01T00:00:00.000Z', null), + audit('e_mid', '2026-01-02T00:00:00.000Z', null), + audit('o_new1', '2026-01-03T00:00:00.000Z', 'org_a'), + audit('o_new2', '2026-01-04T00:00:00.000Z', 'org_a'), + ], legacyShape); + const { protocol } = harness({ sys_metadata_audit: rows }, legacyShape); + const { events } = await protocol.auditMetaItem({ type: 'view', name: 'grid', limit: 2 }); + expect(events.map((e: { id: unknown }) => e.id)).toEqual(['e_mid', 'e_old']); + }); + + it('listCommits: `limit` environment commits', async () => { + const rows = store([ + commit('o_c1', '2026-01-01T00:00:00.000Z', 'org_a'), + commit('c_env', '2026-01-02T00:00:00.000Z', null), + ], legacyShape); + const { protocol } = harness({ sys_metadata_commit: rows }, legacyShape); + const commits = await protocol.listCommits({ packageId: 'app.pkg', limit: 1 }); + expect(commits.map((c: { id: string }) => c.id)).toEqual(['c_env']); + }); + + it('revertStoredPackage: a package the environment published is reverted, whatever row comes first', async () => { + const rows = store([active('o_row', 'org_grid', 'org_a'), active('e_row', 'grid', null)], legacyShape); + const { protocol } = harness({ sys_metadata: rows }, legacyShape); + await expect(protocol.revertStoredPackage({ packageId: 'app.pkg' })) + .resolves.toEqual({ stored: true, discarded: [] }); + }); + + it('revertStoredPackage: a package only an organization published holds nothing of the environment\'s', async () => { + const rows = store([active('o_row', 'org_grid', 'org_a')], legacyShape); + const { protocol } = harness({ sys_metadata: rows }, legacyShape); + await expect(protocol.revertStoredPackage({ packageId: 'app.pkg' })) + .resolves.toEqual({ stored: false, discarded: [] }); + }); + + it('an offset counts the environment\'s rows only', async () => { + const rows = store([ + commit('o_c1', '2026-01-01T00:00:00.000Z', 'org_a'), + commit('c_1', '2026-01-02T00:00:00.000Z', null), + commit('c_2', '2026-01-03T00:00:00.000Z', null), + ], legacyShape); + const { protocol } = harness({ sys_metadata_commit: rows }, legacyShape); + const page = await protocol.findEnvironmentRows('sys_metadata_commit', { + where: { package_id: 'app.pkg' }, offset: 1, limit: 1, context: { isSystem: true }, + }); + expect(page.map((r: Row) => r.id)).toEqual(['c_2']); + }); + + it('the repository\'s list({ limit }): `limit` environment rows', async () => { + const rows = store([active('o_row', 'org_grid', 'org_a'), active('e_row', 'grid', null)], legacyShape); + const { engine } = harness({ sys_metadata: rows }, legacyShape); + const repo = new SysMetadataRepository({ engine } as any); + const names: string[] = []; + for await (const header of repo.list({ type: 'view', limit: 1 })) names.push(String(header.ref.name)); + expect(names).toEqual(['grid']); + }); + }); + } +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 3f210894875..51b68ce66a0 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -10351,12 +10351,33 @@ export class ObjectStackProtocolImplementation implements * cannot answer throws. A read inside a transaction joins it * (`context.transaction`). * + * [#23609] A read that PAGES (`limit`, `offset`) leaves the legacy rows out + * before the page is cut, never after: filtered after, a page the legacy + * rows sat in came back short (an audit page, a commit page), and a + * `limit: 1` probe whose one row was a legacy organization's read as "no + * row" ({@link revertStoredPackage}'s "never been published"). The read + * stays the common path; only when the legacy rows could have taken a + * place in the page is it re-read with them excluded in the query itself. + * On a migrated or fresh database there are none, and the one read is the + * whole answer. + * * Two reads deliberately do NOT: {@link migrateStoredMetadata}, which must * see a legacy row to report it, and {@link revertCommit}'s commit read, * which refuses a legacy commit by name; each reads the column itself. */ private async findEnvironmentRows(table: string, query: { where: Record; context?: unknown; [key: string]: unknown }): Promise { - return this.withoutLegacyOrganizationRows(table, await this.engine.find(table, query as never), query.context); + const rows = (await this.engine.find(table, query as never)) as any[] | null | undefined; + if (query.limit === undefined && query.offset === undefined) { + return this.withoutLegacyOrganizationRows(table, rows, query.context); + } + // An offset counts rows before the page, so any legacy row can shift + // it; a limit alone, only one the page itself holds. + if ((!rows || rows.length === 0) && query.offset === undefined) return rows ?? []; + const legacy = await this.legacyOrganizationRowsOf(table, query.context); + if (legacy.size === 0) return rows ?? []; + if (query.offset === undefined && !(rows ?? []).some((row) => legacy.has(String(row?.id)))) return rows ?? []; + const where = { $and: [query.where, { id: { $nin: [...legacy.keys()] } }] }; + return ((await this.engine.find(table, { ...query, where } as never)) as any[] | null | undefined) ?? []; } /** diff --git a/packages/metadata-protocol/src/sys-metadata-repository.ts b/packages/metadata-protocol/src/sys-metadata-repository.ts index 2f2030f9d20..5630b9de7b4 100644 --- a/packages/metadata-protocol/src/sys-metadata-repository.ts +++ b/packages/metadata-protocol/src/sys-metadata-repository.ts @@ -1632,11 +1632,22 @@ export class SysMetadataRepository implements MetadataRepository { }; if (filter.type) where.type = filter.type; // [#21908] The explicit system opt-in, as `get` carries — see its note. - const rows = await this.withoutLegacyOrganizationRows(await this.engine.find('sys_metadata', { - where, - limit: filter.limit, - context: { isSystem: true }, - })); + const query = { where, limit: filter.limit, context: { isSystem: true } }; + let rows: any[] = (await this.engine.find('sys_metadata', query)) ?? []; + if (filter.limit === undefined) { + rows = await this.withoutLegacyOrganizationRows(rows); + } else if (rows.length > 0) { + // [#23609] The legacy rows are left out BEFORE `limit` cuts the page: + // filtered after, a page they sat in came back short. Only when the page + // holds one is it re-read with them excluded in the query itself. + const legacy = await this.legacyOrganizationRowIds(query.context); + if (rows.some((row) => legacy.has(String(row?.id)))) { + rows = (await this.engine.find('sys_metadata', { + ...query, + where: { $and: [where, { id: { $nin: [...legacy] } }] }, + })) ?? []; + } + } for (const row of rows) { if (filter.nameContains && !String(row.name).includes(filter.nameContains)) continue; const item = this.rowToItem( From 7a52179db3dbf9dbc25e677506eb91dcb7cfdc8d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 00:24:47 +0000 Subject: [PATCH 3/8] fix(metadata): DatabaseLoader._count leaves legacy organization rows out, as its reads do exists() answered true for an address only a legacy organization row held while load() answered null, and queryHistory's total counted rows no page serves. The count is re-taken with the legacy rows excluded in the query only when it is not zero and the table holds any; a migrated store's count is unchanged (ADR-0131 D6/D7). Claude-Session: https://claude.ai/code/session_01TekAoE8QbLrxFVJsdNuRgg Co-authored-by: Claude --- ...e-loader.legacy-organization-count.test.ts | 101 ++++++++++++++++++ .../metadata/src/loaders/database-loader.ts | 18 +++- 2 files changed, 115 insertions(+), 4 deletions(-) create mode 100644 packages/metadata/src/loaders/database-loader.legacy-organization-count.test.ts diff --git a/packages/metadata/src/loaders/database-loader.legacy-organization-count.test.ts b/packages/metadata/src/loaders/database-loader.legacy-organization-count.test.ts new file mode 100644 index 00000000000..60af6f112fa --- /dev/null +++ b/packages/metadata/src/loaders/database-loader.legacy-organization-count.test.ts @@ -0,0 +1,101 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [ADR-0131 D6/D7, #23609] `DatabaseLoader`'s count agrees with its reads on a + * database the organization-ownership ceremony has not migrated. The family + * declares no organization column; the physical column still holds each + * legacy organization's rows, which `load()` and the history page leave out. + * Counted with them, `exists()` answered true for an address only a legacy row + * held (while `load()` answered null), and the history `total` counted rows no + * page serves. Each case runs on that v17 shape and on a migrated store (the + * CONTROL), whose answers are the ones the loader always gave. + */ +import { describe, expect, it } from 'vitest'; +import { DatabaseLoader } from './database-loader.js'; + +type Row = Record; + +/** Equality, `$and` and `$nin` — the subset these reads emit. */ +function matches(row: Row, where: Record = {}): boolean { + for (const [k, v] of Object.entries(where)) { + if (k === '$and') { + if (!(v as Row[]).every((clause) => matches(row, clause))) return false; + continue; + } + if (k.startsWith('$')) throw new Error(`matches: unsupported combinator ${k}`); + if (v !== null && typeof v === 'object' && !Array.isArray(v)) { + const ops = v as Record; + for (const op of Object.keys(ops)) if (op !== '$nin') throw new Error(`matches: unsupported operator ${op}`); + if ((ops.$nin as unknown[]).includes(row[k])) return false; + continue; + } + if (row[k] !== v) return false; + } + return true; +} + +/** An engine over `seed`; `legacyShape` puts the physical organization column on disk. */ +function engineOver(seed: Record, legacyShape: boolean) { + const rowsOf = (t: string) => (seed[t] ?? []).filter((r) => legacyShape || !r.organization_id); + const declared = ({ organization_id: _physical, ...row }: Row): Row => row; + const select = (t: string, q?: { where?: Row; limit?: number; offset?: number }) => { + const start = q?.offset ?? 0; + return rowsOf(t).filter((r) => matches(r, q?.where)) + .slice(start, typeof q?.limit === 'number' ? start + q.limit : undefined).map(declared); + }; + return { + async find(t: string, q?: any) { return select(t, q); }, + async findOne(t: string, q?: any) { return select(t, q)[0] ?? null; }, + async count(t: string, q?: any) { return rowsOf(t).filter((r) => matches(r, q?.where)).length; }, + getDriverForObject(t: string) { + return { + dialectName: 'sqlite', + async execute(sql: string) { + if (/column_name/.test(sql)) { + return [{ column_name: 'id' }, ...(legacyShape ? [{ column_name: 'organization_id' }] : [])]; + } + return rowsOf(t).filter((r) => r.organization_id).map((r) => ({ id: r.id, organization_id: r.organization_id })); + }, + }; + }, + }; +} + +const meta = (id: string, name: string, organization_id: string | null): Row => ({ + id, type: 'object', name, state: 'active', version: 1, checksum: `c_${id}`, + metadata: JSON.stringify({ name }), organization_id, +}); + +const history = (id: string, version: number, organization_id: string | null): Row => ({ + id, type: 'object', name: 'account', version, operation_type: 'update', + recorded_at: `2026-01-0${version}T00:00:00.000Z`, metadata: JSON.stringify({ name: 'account' }), organization_id, +}); + +const SHAPES = [ + { shape: 'v17 shape (legacy organization rows on disk)', legacyShape: true }, + { shape: 'CONTROL: a migrated store', legacyShape: false }, +] as const; + +describe('[#23609] DatabaseLoader counts what it reads: a legacy organization row is not counted', () => { + for (const { shape, legacyShape } of SHAPES) { + describe(shape, () => { + it('exists() agrees with load() for an address only a legacy organization row holds', async () => { + const engine = engineOver({ sys_metadata: [meta('o_1', 'org_only', 'org_a'), meta('e_1', 'account', null)] }, legacyShape); + const loader = new DatabaseLoader({ engine: engine as any }); + expect(await loader.exists('object', 'org_only')).toBe(false); + expect(await loader.load('object', 'org_only')).toMatchObject({ data: null }); + expect(await loader.exists('object', 'account'), 'the environment\'s row').toBe(true); + }); + + it('the history total counts the rows the history page serves', async () => { + const engine = engineOver({ + sys_metadata_history: [history('h_1', 1, null), history('o_h', 2, 'org_a'), history('h_3', 3, null)], + }, legacyShape); + const loader = new DatabaseLoader({ engine: engine as any }); + const { records, total } = await loader.queryHistory('object', 'account'); + expect(records.map((r: { id: string }) => r.id).sort()).toEqual(['h_1', 'h_3']); + expect(total).toBe(2); + }); + }); + } +}); diff --git a/packages/metadata/src/loaders/database-loader.ts b/packages/metadata/src/loaders/database-loader.ts index aac2d91c039..6ea24681a62 100644 --- a/packages/metadata/src/loaders/database-loader.ts +++ b/packages/metadata/src/loaders/database-loader.ts @@ -449,11 +449,21 @@ export class DatabaseLoader implements MetadataLoader { return readLegacyOrganizationRows(driver, table); } + /** + * [ADR-0131 D6/D7, #23609] The count of {@link _find}'s rows: a legacy + * organization's rows are not counted. Counted with them, `exists()` + * answered true for an address only a legacy row held while `load()` + * answered null, and the history `total` counted rows no page serves. The + * count stays the common path; only when it is not zero and the table holds + * legacy rows is it re-counted with them excluded in the query itself. + */ private async _count(table: string, query: DriverQuery): Promise { - if (this.engine) { - return this.engine.count(table, query); - } - return this.driver!.count(table, query); + const count = (q: DriverQuery) => (this.engine ? this.engine.count(table, q) : this.driver!.count(table, q)); + const total = await count(query); + if (total === 0) return total; + const legacy = await this.legacyOrganizationRows(table); + if (legacy.size === 0) return total; + return count({ ...query, where: { $and: [query.where ?? {}, { id: { $nin: [...legacy.keys()] } }] } }); } private async _create(table: string, data: Record): Promise> { From c39c8c8ece87ecdaf86d4b8f8fddee2521d320e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 00:25:57 +0000 Subject: [PATCH 4/8] fix(metadata-protocol): rollbackToPackageCommit refuses a legacy organization commit by name It read the commit through the environment-only seam, which leaves a legacy organization's commit out, and answered COMMIT_NOT_FOUND (404) for a commit that is there. revertCommit and rollbackToPackageCommit now read the commit through one helper, readRevertableCommit, so a legacy commit gets the same NOT_OVERRIDABLE refusal from both doors. A migrated store holds no legacy commit and answers as before (ADR-0131 D6/D7). Claude-Session: https://claude.ai/code/session_01TekAoE8QbLrxFVJsdNuRgg Co-authored-by: Claude --- ...ocol.legacy-organization-read-seam.test.ts | 37 ++++++++ packages/metadata-protocol/src/protocol.ts | 84 +++++++++---------- 2 files changed, 79 insertions(+), 42 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.legacy-organization-read-seam.test.ts b/packages/metadata-protocol/src/protocol.legacy-organization-read-seam.test.ts index 4856f687251..bcab4719ef3 100644 --- a/packages/metadata-protocol/src/protocol.legacy-organization-read-seam.test.ts +++ b/packages/metadata-protocol/src/protocol.legacy-organization-read-seam.test.ts @@ -15,6 +15,10 @@ * `list({ limit })`. Filtered after the cut, the page came back short, and * the probe answered 409 "never been published" for a package whose first * active row was an organization's. + * 2. `rollbackToPackageCommit` reads the commit it rolls back to as + * `revertCommit` reads the commit it reverts: a legacy organization's + * commit is refused by name (`NOT_OVERRIDABLE`), never answered + * `COMMIT_NOT_FOUND` (404) for a commit that is there. * * The real-engine pins over a v17-shape SQLite file are in `@objectstack/cli` * (`meta-stored-legacy-organization.integration.test.ts`). @@ -217,3 +221,36 @@ describe('[#23609] a read that pages leaves the legacy organization rows out bef }); } }); + +describe('[#23609] rollbackToPackageCommit refuses a legacy organization commit by name, as revertCommit does', () => { + for (const { shape, legacyShape } of SHAPES) { + describe(shape, () => { + const ledger = () => store([ + commit('o_c', '2026-01-01T00:00:00.000Z', 'org_a'), + commit('c_env', '2026-01-02T00:00:00.000Z', null), + ], legacyShape); + + it('the legacy commit: one answer from both doors', async () => { + const { protocol } = harness({ sys_metadata_commit: ledger() }, legacyShape); + const revert = await protocol.revertCommit({ commitId: 'o_c' }).then(() => null, (e: unknown) => e); + const rollback = await protocol.rollbackToPackageCommit({ commitId: 'o_c' }).then(() => null, (e: unknown) => e); + // On the control store the ceremony has carried it away: there is no such commit. + expect(revert).toMatchObject(legacyShape + ? { code: 'NOT_OVERRIDABLE', status: 403 } + : { code: 'COMMIT_NOT_FOUND', status: 404 }); + expect(rollback).toMatchObject({ + code: (revert as { code?: unknown }).code, + status: (revert as { status?: unknown }).status, + message: (revert as Error).message, + }); + if (legacyShape) expect((rollback as Error).message).toMatch(/^Commit 'o_c' cannot be written organization-scoped \(organization 'org_a'\)/); + }); + + it('an environment commit still rolls back', async () => { + const { protocol } = harness({ sys_metadata_commit: ledger() }, legacyShape); + await expect(protocol.rollbackToPackageCommit({ commitId: 'c_env' })) + .resolves.toEqual({ success: true, revertedCommits: [], failed: [] }); + }); + }); + } +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 51b68ce66a0..ea9fa58df8f 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -10362,8 +10362,9 @@ export class ObjectStackProtocolImplementation implements * whole answer. * * Two reads deliberately do NOT: {@link migrateStoredMetadata}, which must - * see a legacy row to report it, and {@link revertCommit}'s commit read, - * which refuses a legacy commit by name; each reads the column itself. + * see a legacy row to report it, and the commit read of {@link revertCommit} + * and {@link rollbackToPackageCommit} ({@link readRevertableCommit}), which + * refuses a legacy commit by name; each reads the column itself. */ private async findEnvironmentRows(table: string, query: { where: Record; context?: unknown; [key: string]: unknown }): Promise { const rows = (await this.engine.find(table, query as never)) as any[] | null | undefined; @@ -28413,6 +28414,43 @@ export class ObjectStackProtocolImplementation implements } } + /** + * The commit {@link revertCommit} reverts and {@link rollbackToPackageCommit} + * rolls back to, read by its id: `COMMIT_NOT_FOUND` (404) when there is + * none. + * + * [ADR-0131 D6] A commit recorded in a legacy organization's layer + * describes organization-scoped rows; reverting it, or rolling back to it, + * would write them. [ADR-0131 D7] The commit ledger declares no + * organization column, so the engine row never carries one: the commit's + * organization is read off the physical column, on a database the + * ceremony has not migrated, and such a commit is refused by name + * (`NOT_OVERRIDABLE`, 403). [#23609] Both doors read it here: the rollback + * used to read it through {@link findEnvironmentRow}, which leaves the + * legacy commit out, and answered `COMMIT_NOT_FOUND` for a commit that is + * there. + */ + private async readRevertableCommit(commitId: string): Promise { + // [#21908, ADR-0096 D5] The explicit system opt-in: the commit ledger is a + // platform store the door already authorized; the protocol scopes the + // `where` by organization itself. Principal-less, the engine refuses it. + const row = (await this.engine.findOne('sys_metadata_commit', { where: { id: commitId }, context: { isSystem: true } })) as any; + if (!row) { + const err: any = new Error(`No commit '${commitId}'.`); + err.code = 'COMMIT_NOT_FOUND'; + err.status = 404; + throw err; + } + const legacyCommits = await this.legacyOrganizationRowsOf('sys_metadata_commit'); + const legacy = ObjectStackProtocolImplementation.organizationScopedWriteRefusal( + `Commit '${commitId}'`, + legacyCommits.get(String(row.id)) ?? null, + 'It records a change to that organization\'s legacy layer, which no write reaches any more.', + ); + if (legacy) throw legacy; + return row; + } + /** * Revert a single commit (ADR-0067): undo exactly the artifacts it touched. * A created-by-this-commit artifact is soft-removed (metadata row deleted; @@ -28459,35 +28497,7 @@ export class ObjectStackProtocolImplementation implements `Commit '${request.commitId}'`, request, ); await this.ensureOverlayIndex(); - const where: Record = { id: request.commitId }; - // [#21908, ADR-0096 D5] The explicit system opt-in: the commit ledger is a - // platform store the door already authorized; the protocol scopes the - // `where` by organization itself. Principal-less, the engine refuses it. - const row = (await this.engine.findOne('sys_metadata_commit', { where, context: { isSystem: true } })) as any; - if (!row) { - const err: any = new Error(`No commit '${request.commitId}'.`); - err.code = 'COMMIT_NOT_FOUND'; - err.status = 404; - throw err; - } - // [ADR-0131 D6] A commit recorded in a legacy organization's layer - // describes organization-scoped rows; reverting it would write them. - // [ADR-0131 D7] The commit ledger declares no organization column, so - // the engine row never carries one: the commit's organization is read - // off the physical column, on a database the ceremony has not migrated. - { - const legacyCommits = await readLegacyOrganizationRows( - (this.engine as { getDriverForObject?: (objectName: string) => unknown }) - .getDriverForObject?.('sys_metadata_commit'), - 'sys_metadata_commit', - ); - const legacy = ObjectStackProtocolImplementation.organizationScopedWriteRefusal( - `Commit '${request.commitId}'`, - legacyCommits.get(String(row.id)) ?? null, - 'It records a change to that organization\'s legacy layer, which no write reaches any more.', - ); - if (legacy) throw legacy; - } + const row = await this.readRevertableCommit(request.commitId); const items = this.parseCommitItems(row.items); // #4556 — threaded into repo.put/delete → `recorded_by`; NULL when the // revert carries no human actor. @@ -28975,17 +28985,7 @@ export class ObjectStackProtocolImplementation implements ObjectStackProtocolImplementation.refuseOrganizationScopedWrite( `Commit '${request.commitId}'`, request, ); - const where: Record = { id: request.commitId }; - // [#21908, ADR-0096 D5] The explicit system opt-in: the commit ledger is a - // platform store the door already authorized; the protocol scopes the - // `where` by organization itself. Principal-less, the engine refuses it. - const target = (await this.findEnvironmentRow('sys_metadata_commit', { where, context: { isSystem: true } })) as any; - if (!target) { - const err: any = new Error(`No commit '${request.commitId}'.`); - err.code = 'COMMIT_NOT_FOUND'; - err.status = 404; - throw err; - } + const target = await this.readRevertableCommit(request.commitId); const all = await this.listCommits({ packageId: target.package_id }); // listCommits is newest-first; revert every `apply` commit strictly newer // than the target (by created_at). Revert commits are skipped (their From 8052436abdd70c9743c46eb656aed27368360178 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 00:27:34 +0000 Subject: [PATCH 5/8] refactor(metadata-protocol): drop the legacy-organization read plumbing every row has outgrown Every row that reaches these is the environment's since the family reads leave a legacy organization's rows out (ADR-0131 D6/D7), so they carried nothing: mergePackageAwareOverlay's rowsRead flag (the records' own stored place already says it), ServedOverlayRowCandidate.scope / organizationId, StoredRowPlace.organizationId, and the always-null orgId / itemOrgId locals in applyRemoteMetadataMutation, revertCommit, rollbackMetaItem and deleteMetaItem. Behaviour-neutral. Claude-Session: https://claude.ai/code/session_01TekAoE8QbLrxFVJsdNuRgg Co-authored-by: Claude --- packages/metadata-protocol/src/protocol.ts | 113 +++++++++------------ 1 file changed, 50 insertions(+), 63 deletions(-) diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index ea9fa58df8f..e50761d15bb 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -2556,28 +2556,24 @@ function mutationEventMemberOfBody(type: string, body: unknown): { member?: stri /** * One place a stored `sys_metadata` row of an item can sit, as - * {@link servedOverlayRowCandidates} orders them: a scope (the environment's, - * `organizationId: null`, or — for the anonymous form doors' legacy layer - * alone — a legacy organization's), a spelling of the type + * {@link servedOverlayRowCandidates} orders them: a spelling of the type * (`'canonical'`, or `'other'`, the type's singular/plural twin, pre-#4432 * residue), and the package the row is bound to: the address's package, `null` * for the package-less row, or `undefined` for any row (an address that names - * no package). + * no package). Every row in a candidate's place is the environment's + * (ADR-0131 D6/D7: the reads leave a legacy organization's rows out). */ interface ServedOverlayRowCandidate { - readonly scope: 'env'; - readonly organizationId: string | null; readonly spelling: 'canonical' | 'other'; readonly packageId: string | null | undefined; } /** * [#21804] Where a stored row the list read sits, beside the package it is - * bound to: the scope it was read from (`null` for env-wide) and the type - * spelling it is stored under (the row's own `type` column). + * bound to: the type spelling it is stored under (the row's own `type` + * column). */ interface StoredRowPlace { - readonly organizationId: string | null; readonly type: string; } @@ -2620,12 +2616,11 @@ function servedOverlayRowCandidates(address: { */ readonly packageId: string | null | undefined; }): ServedOverlayRowCandidate[] { - const scopes: Array> = [{ scope: 'env', organizationId: null }]; const packages: Array = address.packageId === null ? [null] : address.packageId ? [address.packageId, null] : [undefined]; - return scopes.flatMap((scope) => (['canonical', 'other'] as const).flatMap((spelling) => - packages.map((packageId) => ({ ...scope, spelling, packageId })))); + return (['canonical', 'other'] as const).flatMap((spelling) => + packages.map((packageId) => ({ spelling, packageId }))); } /** @@ -2698,14 +2693,14 @@ function servedViewExpansion( * disagree on package attribution still collapses (a package-less active * row + its `package_id`-bearing draft resolve to the one package slot, * draft winning). - * • [#21804] When `records` are stored rows the caller read (`rowsRead`, - * each record's `stored` place), the winner among them is the one + * • [#21804] When `records` are stored rows the caller read (each record + * carrying its `stored` place), the winner among them is the one * {@link servedOverlayRowCandidates} picks for `P`: `P`'s own row before * 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), - * the LATEST contribution wins, as before. + * order the store returned the rows in. Records with no `stored` place + * (the MetadataService merge, whose `records` are already-resolved items) + * leave the LATEST contribution the winner, as before. * • A name with NO package-owned row resolves to its latest package-less * contribution — the pre-existing env-wide behaviour, unchanged. * • [#21817] A list scoped to one package passes the package-less rows in @@ -2749,7 +2744,6 @@ function mergePackageAwareOverlay( baseItems: unknown[], records: Array<{ data: unknown; packageId: string | undefined; stored?: StoredRowPlace; standIn?: boolean }>, transform?: (data: any, prev: any) => any, - rowsRead?: Readonly>, unseated?: WeakSet, ): unknown[] { // Per-SLOT, layer-ordered contributions; `pkg: undefined` = package-less. @@ -2775,7 +2769,6 @@ function mergePackageAwareOverlay( const servedStoredRow = (list: readonly Contribution[], real: string | undefined): any => { 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 && (candidate.packageId === undefined || (c.pkg ?? null) === candidate.packageId)); if (hit) return hit.item; @@ -2803,7 +2796,7 @@ function mergePackageAwareOverlay( ?? list.find((c) => c.pkg === undefined)?.item ?? list[0]?.item) : undefined; - push(slot, packageId, transform ? transform(body, prev) : body, rowsRead ? stored : undefined, standIn); + push(slot, packageId, transform ? transform(body, prev) : body, stored, standIn); } const out: unknown[] = []; @@ -2815,7 +2808,7 @@ function mergePackageAwareOverlay( // The stand-in the one candidate order picks among the stored // rows here, else (no stored row: a stand-in held back by an // earlier merge) the latest. - let held = rowsRead ? servedStoredRow(list, undefined) : undefined; + let held = servedStoredRow(list, undefined); if (held === undefined) held = list[list.length - 1].item; unseated?.add(held); out.push(held); @@ -2831,7 +2824,7 @@ function mergePackageAwareOverlay( // chosen by the served-row resolution, whatever order the rows // came back in; with no stored row of `real`'s own or package-less, // the latest lower-layer contribution of either stands, as before. - let chosen: any = rowsRead ? servedStoredRow(list, real) : undefined; + let chosen: any = servedStoredRow(list, real); if (chosen === undefined) { for (const c of list) { if ((c.pkg === real || c.pkg === undefined) && c.stored === undefined) chosen = c.item; @@ -7773,7 +7766,6 @@ export class ObjectStackProtocolImplementation implements // any more, and an organization-scoped row never entered the shared // registry (#6602). So an event a peer stamped with an organization // converges on the environment row — the state the registry holds. - const orgId = null; const repo = this.getOverlayRepo(); const ref = { type, @@ -7797,10 +7789,10 @@ export class ObjectStackProtocolImplementation implements name: evt.name, item: current.body, packageId, - organizationId: orgId, + organizationId: null, }); } else { - await this.restoreArtifactRegistryView(type, evt.name, orgId, evt.member); + await this.restoreArtifactRegistryView(type, evt.name, null, evt.member); } // [#13609] Retire this replica's overlay-row cache at the moment of // convergence — never a direct `@objectstack/objectql` import, see @@ -9764,19 +9756,19 @@ export class ObjectStackProtocolImplementation implements const mergeable = overlays.filter( ({ data }) => !this.declinesStoredRow(request.type, (data as { name?: unknown } | null)?.name), ); - // [#21804] Each row travels with its place (scope and stored + // [#21804] Each row travels with its place (its stored // spelling), so the merge picks a package's slot by the // served-row resolution the by-name read takes // ({@link servedOverlayRowCandidates}), not by row order. - const placed = mergeable.map(({ data, packageId: recPkg, organizationId: recOrg, type: recType }) => ({ - data, packageId: recPkg, stored: { organizationId: recOrg, type: recType }, + const placed = mergeable.map(({ data, packageId: recPkg, type: recType }) => ({ + data, packageId: recPkg, stored: { type: recType }, })); // [#21817] The package-less rows, placed the same way, as // stand-ins: the same parse and the same stored-row rule. const standIns = this.storedOverlayEntries(request, standInRows) .filter(({ data }) => !this.declinesStoredRow(request.type, (data as { name?: unknown } | null)?.name)) - .map(({ data, organizationId: recOrg, type: recType }) => ({ - data, packageId: undefined, stored: { organizationId: recOrg, type: recType }, standIn: true, + .map(({ data, type: recType }) => ({ + data, packageId: undefined, stored: { type: recType }, standIn: true, })); items = mergePackageAwareOverlay(request.type, items, [...placed, ...standIns], (data, prev) => { if (isView && data && typeof data === 'object') { @@ -9796,7 +9788,7 @@ export class ObjectStackProtocolImplementation implements return this.foldObjectExtendersFromRegistry( request.type, (data as { name?: unknown } | null)?.name, data, ); - }, {}, unseated); + }, unseated); // [#13407] Expand any aggregated `defineView` container this // READ just merged in, INLINE into this response's own `items` @@ -10060,10 +10052,7 @@ export class ObjectStackProtocolImplementation implements // by-name read's preview arm serves, in any row order. // [ADR-0131 D6/D7] Every row read here is the // environment's (filtered at the read). - const stored = { - organizationId: null, - type: String(record.type ?? request.type), - }; + const stored = { type: String(record.type ?? request.type) }; return { data, packageId: recPkg, stored }; }; const drafts = [ @@ -10076,7 +10065,7 @@ export class ObjectStackProtocolImplementation implements items = mergePackageAwareOverlay(request.type, items, drafts, (data) => { if (data && typeof data === 'object') (data as any)._draft = true; return data; - }, {}, unseated); + }, unseated); } } catch (error) { // [#5532] Same rule as the active-overlay read above. Serving @@ -20782,8 +20771,9 @@ export class ObjectStackProtocolImplementation implements * the designed per-org overlay leaking out of its org. `organizationId` * is now part of this request and is handed to * {@link hydrateOverlayIntoRegistry}, which owns the row-scope verdict - * for all three hydration paths. Callers pass the SAME `orgId` they wrote - * the row with, so the registry's view cannot disagree with the row's + * for all three hydration paths. Callers pass the SAME organization they + * wrote the row with (`null`: since ADR-0131 D6 every row is the + * environment's), so the registry's view cannot disagree with the row's * scope. * * [#23198] Every caller awaits it: a republish of a view container heals @@ -28636,9 +28626,8 @@ export class ObjectStackProtocolImplementation implements // [ADR-0131 D6] Every item reverts environment-wide: the commit row // is environment-wide (a legacy organization's was refused above). - const itemOrgId = null; const repo = this.getOverlayRepo(); - const ref = { type: it.type, name: it.name, org: itemOrgId ?? 'env' } as unknown as Parameters[0]; + const ref = { type: it.type, name: it.name, org: 'env' } as unknown as Parameters[0]; // [#22877] The plan item's bundle member: every row read, write // and heal below addresses it, so reverting a commit that changed // one locale of a template leaves the other locale's row alone. @@ -28735,8 +28724,8 @@ export class ObjectStackProtocolImplementation implements // // [#6780] The verdict this comment argues now lives INSIDE // {@link restoreArtifactRegistryView} as a REQUIRED - // parameter, so `orgId` is handed over rather than tested - // here: PR #6807's call-site `if (orgId === null)` guarded + // parameter, so the organization is handed over rather than + // tested here: PR #6807's call-site `if (orgId === null)` guarded // this ONE caller while the sibling `deleteMetaItem` — the // caller this limb was modelled on — had the same hole on // all three of its own call sites. The gate moved to the @@ -28746,7 +28735,7 @@ export class ObjectStackProtocolImplementation implements // #6602 gate this parameter carries asks "is this row // env-wide?", and an env-wide row reverted by an org-scoped // caller skipped the heal entirely while answering success. - await this.restoreArtifactRegistryView(it.type, it.name, itemOrgId, member); + await this.restoreArtifactRegistryView(it.type, it.name, null, member); // [#14179] …and announce it on the ONE choke point, // {@link emitMetadataMutation}, exactly as the sibling // delete caller {@link deleteMetaItem} does after its own @@ -28771,13 +28760,13 @@ export class ObjectStackProtocolImplementation implements type: PLURAL_TO_SINGULAR[it.type] ?? it.type, name: it.name, state: 'deleted', - organizationId: itemOrgId, + organizationId: null, }); this.emitMetadataMutation({ type: PLURAL_TO_SINGULAR[it.type] ?? it.type, name: it.name, state: 'deleted', - organizationId: itemOrgId, + organizationId: null, ...mutationEventMember(it.type, member), }); } @@ -28875,7 +28864,7 @@ export class ObjectStackProtocolImplementation implements // [#7559] …and now that is what it actually IS. This line // said "the row's OWN scope" while passing the REQUEST's // org; the resolution above is what makes the comment true. - organizationId: itemOrgId, + organizationId: null, }); // [ADR-0094] Awaited projection BEFORE the // fire-and-forget emit below — the order `saveMetaItem`'s @@ -28887,7 +28876,7 @@ export class ObjectStackProtocolImplementation implements type: PLURAL_TO_SINGULAR[it.type] ?? it.type, name: it.name, state: 'active', - organizationId: itemOrgId, + organizationId: null, body: restored.item.body, }); // [#14179] The restore limb's half of the same repair: a @@ -28900,7 +28889,7 @@ export class ObjectStackProtocolImplementation implements type: PLURAL_TO_SINGULAR[it.type] ?? it.type, name: it.name, state: 'active', - organizationId: itemOrgId, + organizationId: null, ...mutationEventMember(it.type, member), }); reverted.push({ type: it.type, name: it.name, action: 'restored' }); @@ -29156,7 +29145,6 @@ export class ObjectStackProtocolImplementation implements await this.ensureOverlayIndex(); // [ADR-0131 D6] The environment's lineage: an organization-scoped // request was refused above. - const orgId = null; const repo = this.getOverlayRepo(); const artifactBacked = this.isArtifactBacked(singularType, request.name); const intent: 'override-artifact' | 'runtime-only' = artifactBacked @@ -29164,7 +29152,7 @@ export class ObjectStackProtocolImplementation implements const ref = { type: singularType, name: request.name, - org: orgId ?? 'env', + org: 'env', } as Parameters[0]; // [#4636] The ownership key the write-through below needs, read from // the ROW rather than from the request — `rollbackMetaItem` has no @@ -29211,7 +29199,7 @@ export class ObjectStackProtocolImplementation implements packageId: rollbackPackageId, // [#6602] A rollback restores the row IN ITS OWN SCOPE — an // org-scoped restore must not graft the body process-wide. - organizationId: orgId, + organizationId: null, }); // [#7748] ADR-0010 — success audit (best-effort). Same position as // `saveMetaItem`'s: persistence committed, registry write-through @@ -29221,7 +29209,7 @@ export class ObjectStackProtocolImplementation implements await this.recordMetadataAudit({ type: request.type, name: request.name, - organizationId: orgId, + organizationId: null, operation: 'rollback', outcome: 'allowed', code: 'ok', @@ -29242,7 +29230,7 @@ export class ObjectStackProtocolImplementation implements type: singularType, name: request.name, state: 'active', - organizationId: orgId, + organizationId: null, body: result.item.body, }); // [#14179] A rollback is a live write like any other (#4521, the @@ -29258,7 +29246,7 @@ export class ObjectStackProtocolImplementation implements type: singularType, name: request.name, state: 'active', - organizationId: orgId, + organizationId: null, ...mutationEventMember(singularType, member), }); return { @@ -29827,12 +29815,11 @@ export class ObjectStackProtocolImplementation implements // `assertAllowed()` whitelist would 403 those deletes. if (useRepoPath) { // [ADR-0131 D6] Environment-wide: refused above otherwise. - const orgId = null; const repo = this.getOverlayRepo(); const ref = { type: singularTypeForRepo, name: request.name, - org: orgId ?? 'env', + org: 'env', } as Parameters[0]; // [#23165] The refusal of a package-less delete of an item two or // more packages hold: the door's own verdict, rethrown as it is by @@ -29940,7 +29927,7 @@ export class ObjectStackProtocolImplementation implements // before this fix shipped) — drop it so the artifact // view really IS the default we claim below. // - // [#6780] `orgId` is passed, and this branch is where it + // [#6780] The organization is passed, and this branch is where it // matters MOST: with no row to delete, an org that never // customized anything at all could evict the env-wide // plain-key entry with a single no-op DELETE. Measured on @@ -29950,7 +29937,7 @@ export class ObjectStackProtocolImplementation implements // `sys_metadata`. A gate applied only to the delete-ful // branch below would have left this door standing open. if (targetState === 'active') { - await this.restoreArtifactRegistryView(request.type, request.name, orgId, member); + await this.restoreArtifactRegistryView(request.type, request.name, null, member); } return { success: true, @@ -30027,7 +30014,7 @@ export class ObjectStackProtocolImplementation implements // skip this: drafts never hydrate into the registry, and the // still-active overlay (if any) must keep its shadow. // - // [#6780] SCOPED by the same `orgId` the row was deleted with, + // [#6780] SCOPED by the same organization the row was deleted with, // so the registry's view cannot disagree with the row's scope // — the sibling rule the write side states in // {@link applyRegistryWriteThrough}. Org A resetting ITS OWN @@ -30035,7 +30022,7 @@ export class ObjectStackProtocolImplementation implements // ENV-WIDE row, i.e. one tenant's "reset to default" blanked // the item for every other tenant and the control plane. if (targetState === 'active') { - await this.restoreArtifactRegistryView(request.type, request.name, orgId, member); + await this.restoreArtifactRegistryView(request.type, request.name, null, member); } // Storage teardown (opt-in): drop the now-orphaned physical table @@ -30050,7 +30037,7 @@ export class ObjectStackProtocolImplementation implements await this.recordMetadataAudit({ type: request.type, name: request.name, - organizationId: orgId, + organizationId: null, operation: 'delete', outcome: 'allowed', code: 'ok', @@ -30066,13 +30053,13 @@ export class ObjectStackProtocolImplementation implements type: singularTypeForRepo, name: request.name, state: 'deleted', - organizationId: orgId, + organizationId: null, }); this.emitMetadataMutation({ type: singularTypeForRepo, name: request.name, state: 'deleted', - organizationId: orgId, + organizationId: null, ...mutationEventMember(singularTypeForRepo, member), }); const deletedPackage = packageId !== null ? ` (package=${packageId})` : ''; From 6bdaa0e068798b640d9ac580ae2a1bc773c5bca8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 00:38:41 +0000 Subject: [PATCH 6/8] docs(spec,cli): the organization-ownership ceremony's apply names --plan-file; E1's refusal reads straight The four sys_metadata-family ADR-0087 entries and metadata-lifecycle.mdx named os migrate organization-ownership --apply without --plan-file, which the ceremony requires (18.cli-organization-ownership-apply-plan-file-required). They now name the plan run and the reviewed apply. os migrate apply's E1 refusal sentence ("which the organization-ownership ceremony owns that drop") is made grammatical. Prose only: no entry id, category, version or field the migration engine reads changes. Claude-Session: https://claude.ai/code/session_01TekAoE8QbLrxFVJsdNuRgg Co-authored-by: Claude --- content/docs/concepts/metadata-lifecycle.mdx | 2 +- packages/cli/src/commands/migrate/apply.ts | 4 ++-- .../18.sys-metadata-audit-organization-column-retired.ts | 5 +++-- .../18.sys-metadata-commit-organization-column-retired.ts | 5 +++-- .../18.sys-metadata-history-organization-column-retired.ts | 5 +++-- .../semantic/18.sys-metadata-organization-column-retired.ts | 5 +++-- 6 files changed, 15 insertions(+), 11 deletions(-) diff --git a/content/docs/concepts/metadata-lifecycle.mdx b/content/docs/concepts/metadata-lifecycle.mdx index a87c60922b4..ef58eb34490 100644 --- a/content/docs/concepts/metadata-lifecycle.mdx +++ b/content/docs/concepts/metadata-lifecycle.mdx @@ -106,7 +106,7 @@ later ships. See [ADR-0070](https://github.com/objectstack-ai/objectstack/blob/m **Most metadata types must not be overlaid by an environment** — overlaying a packaged item would break the physical schema or the behaviour its package ships. The whitelist lives in **one** place: `MetadataTypeRegistryEntry.allowEnvironmentOverlay` in `packages/spec/src/kernel/metadata-plugin.zod.ts` (spelled `allowOrgOverride` before protocol 18; `GET /api/v1/meta/types` serves it per entry as `allowEnvironmentOverlay`). -> **ADR-0131 D6 — the per-organization overlay axis is retired.** The `/meta` doors (REST and the runtime dispatcher) carry no organization into a metadata write or read: a Studio or `PUT /api/v1/meta/...` write of the five ✅ types below lands **environment-wide** (since ADR-0131 D7 the `sys_metadata` family declares no organization column) and is served to every organization, and an organization admin holding only the retired `manage_org_presentation` capability is refused (`403`). The protocol itself refuses every organization-scoped write, and every read — item, list, layers, cached, history, diff, audit, drafts and the commit timeline — is environment → code, whatever organization a caller names. An overlay row an earlier release stored under an organization — under the `single` posture, every Studio save of these types, filed under the Default Organization — is served by no read, and an 18.x `os serve` refuses to start on a database still holding one until the v18 migration ceremony (ADR-0131 C7, `os migrate organization-ownership --apply`) has carried it to the environment layer and dropped the column; nothing deletes or rewrites it before then. The anonymous form doors read the environment alone too: the ceremony carries every organization-layer public-form withdrawal onto the environment, fail-closed. The table still records which types take an overlay of a shipped item. +> **ADR-0131 D6 — the per-organization overlay axis is retired.** The `/meta` doors (REST and the runtime dispatcher) carry no organization into a metadata write or read: a Studio or `PUT /api/v1/meta/...` write of the five ✅ types below lands **environment-wide** (since ADR-0131 D7 the `sys_metadata` family declares no organization column) and is served to every organization, and an organization admin holding only the retired `manage_org_presentation` capability is refused (`403`). The protocol itself refuses every organization-scoped write, and every read — item, list, layers, cached, history, diff, audit, drafts and the commit timeline — is environment → code, whatever organization a caller names. An overlay row an earlier release stored under an organization — under the `single` posture, every Studio save of these types, filed under the Default Organization — is served by no read, and an 18.x `os serve` refuses to start on a database still holding one until the v18 migration ceremony (ADR-0131 C7: `os migrate organization-ownership --out PLAN`, then `--apply --plan-file PLAN`) has carried it to the environment layer and dropped the column; nothing deletes or rewrites it before then. The anonymous form doors read the environment alone too: the ceremony carries every organization-layer public-form withdrawal onto the environment, fail-closed. The table still records which types take an overlay of a shipped item. | Type | `allowEnvironmentOverlay` | Rationale | | :--- | :---: | :--- | diff --git a/packages/cli/src/commands/migrate/apply.ts b/packages/cli/src/commands/migrate/apply.ts index 2368c4506b9..f6a851f631d 100644 --- a/packages/cli/src/commands/migrate/apply.ts +++ b/packages/cli/src/commands/migrate/apply.ts @@ -512,8 +512,8 @@ export default class MigrateApply extends Command { return; } printError( - `Refusing this --allow-destructive run: it would drop organization_id from ${organizationDrops.join(', ')}, which the organization-ownership ceremony ` - + `owns that drop, and it has not verified on this database. ${detail} Dropping the column first would turn ` + `Refusing this --allow-destructive run: it would drop organization_id from ${organizationDrops.join(', ')}, a drop the organization-ownership ceremony ` + + `owns, and the ceremony has not verified on this database. ${detail} Dropping the column first would turn ` + 'every legacy organization row into an environment row without the ceremony\'s promotion or report. Run ' + '"os migrate organization-ownership --out PLAN" to write and review its plan, then ' + '"os migrate organization-ownership --apply --plan-file PLAN"; it drops these columns itself, in its ' diff --git a/packages/spec/src/migrations/entries/semantic/18.sys-metadata-audit-organization-column-retired.ts b/packages/spec/src/migrations/entries/semantic/18.sys-metadata-audit-organization-column-retired.ts index 0bd460c1986..cf4df3480d2 100644 --- a/packages/spec/src/migrations/entries/semantic/18.sys-metadata-audit-organization-column-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.sys-metadata-audit-organization-column-retired.ts @@ -19,7 +19,8 @@ export const entry: SemanticMigration = { 'nothing on this table — metadata is the environment\'s (ADR-0131 D6), and no organization owns ' + 'a row. Delete any authored filter, list-view column, report grouping, formula or seed key that ' + 'names `organization_id` on `sys_metadata_audit`. Existing databases: run the v18 ceremony, ' - + '`os migrate organization-ownership --apply`, which promotes or reports the legacy ' + + '`os migrate organization-ownership --out PLAN` to write its plan for review, then ' + + '`os migrate organization-ownership --apply --plan-file PLAN`, which promotes or reports the legacy ' + 'organization rows and then drops the physical column', reason: 'ADR-0131 D6 retired the per-organization overlay axis: every metadata write now lands ' @@ -39,6 +40,6 @@ export const entry: SemanticMigration = { acceptanceCriteria: 'No authored metadata names `organization_id` on `sys_metadata_audit`: the field resolver (lint and the data ' + 'door) answers it as an unknown field. A fresh database creates `sys_metadata_audit` with no organization ' - + 'column. On a legacy database, after `os migrate organization-ownership --apply` the column is ' + + 'column. On a legacy database, after `os migrate organization-ownership --apply --plan-file PLAN` the column is ' + 'gone and the boot no longer reports it orphaned.', }; diff --git a/packages/spec/src/migrations/entries/semantic/18.sys-metadata-commit-organization-column-retired.ts b/packages/spec/src/migrations/entries/semantic/18.sys-metadata-commit-organization-column-retired.ts index 771d93ae327..ae4d032c7b1 100644 --- a/packages/spec/src/migrations/entries/semantic/18.sys-metadata-commit-organization-column-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.sys-metadata-commit-organization-column-retired.ts @@ -19,7 +19,8 @@ export const entry: SemanticMigration = { 'nothing on this table — metadata is the environment\'s (ADR-0131 D6), and no organization owns ' + 'a row. Delete any authored filter, list-view column, report grouping, formula or seed key that ' + 'names `organization_id` on `sys_metadata_commit`. Existing databases: run the v18 ceremony, ' - + '`os migrate organization-ownership --apply`, which promotes or reports the legacy ' + + '`os migrate organization-ownership --out PLAN` to write its plan for review, then ' + + '`os migrate organization-ownership --apply --plan-file PLAN`, which promotes or reports the legacy ' + 'organization rows and then drops the physical column', reason: 'ADR-0131 D6 retired the per-organization overlay axis: every metadata write now lands ' @@ -39,6 +40,6 @@ export const entry: SemanticMigration = { acceptanceCriteria: 'No authored metadata names `organization_id` on `sys_metadata_commit`: the field resolver (lint and the data ' + 'door) answers it as an unknown field. A fresh database creates `sys_metadata_commit` with no organization ' - + 'column. On a legacy database, after `os migrate organization-ownership --apply` the column is ' + + 'column. On a legacy database, after `os migrate organization-ownership --apply --plan-file PLAN` the column is ' + 'gone and the boot no longer reports it orphaned.', }; diff --git a/packages/spec/src/migrations/entries/semantic/18.sys-metadata-history-organization-column-retired.ts b/packages/spec/src/migrations/entries/semantic/18.sys-metadata-history-organization-column-retired.ts index 238fe4451ad..c0102fcad61 100644 --- a/packages/spec/src/migrations/entries/semantic/18.sys-metadata-history-organization-column-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.sys-metadata-history-organization-column-retired.ts @@ -19,7 +19,8 @@ export const entry: SemanticMigration = { 'nothing on this table — metadata is the environment\'s (ADR-0131 D6), and no organization owns ' + 'a row. Delete any authored filter, list-view column, report grouping, formula or seed key that ' + 'names `organization_id` on `sys_metadata_history`. Existing databases: run the v18 ceremony, ' - + '`os migrate organization-ownership --apply`, which promotes or reports the legacy ' + + '`os migrate organization-ownership --out PLAN` to write its plan for review, then ' + + '`os migrate organization-ownership --apply --plan-file PLAN`, which promotes or reports the legacy ' + 'organization rows and then drops the physical column', reason: 'ADR-0131 D6 retired the per-organization overlay axis: every metadata write now lands ' @@ -39,6 +40,6 @@ export const entry: SemanticMigration = { acceptanceCriteria: 'No authored metadata names `organization_id` on `sys_metadata_history`: the field resolver (lint and the data ' + 'door) answers it as an unknown field. A fresh database creates `sys_metadata_history` with no organization ' - + 'column. On a legacy database, after `os migrate organization-ownership --apply` the column is ' + + 'column. On a legacy database, after `os migrate organization-ownership --apply --plan-file PLAN` the column is ' + 'gone and the boot no longer reports it orphaned.', }; diff --git a/packages/spec/src/migrations/entries/semantic/18.sys-metadata-organization-column-retired.ts b/packages/spec/src/migrations/entries/semantic/18.sys-metadata-organization-column-retired.ts index cdd6efd734d..fa3ffbaa83f 100644 --- a/packages/spec/src/migrations/entries/semantic/18.sys-metadata-organization-column-retired.ts +++ b/packages/spec/src/migrations/entries/semantic/18.sys-metadata-organization-column-retired.ts @@ -19,7 +19,8 @@ export const entry: SemanticMigration = { 'nothing on this table — metadata is the environment\'s (ADR-0131 D6), and no organization owns ' + 'a row. Delete any authored filter, list-view column, report grouping, formula or seed key that ' + 'names `organization_id` on `sys_metadata`. Existing databases: run the v18 ceremony, ' - + '`os migrate organization-ownership --apply`, which promotes or reports the legacy ' + + '`os migrate organization-ownership --out PLAN` to write its plan for review, then ' + + '`os migrate organization-ownership --apply --plan-file PLAN`, which promotes or reports the legacy ' + 'organization rows and then drops the physical column', reason: 'ADR-0131 D6 retired the per-organization overlay axis: every metadata write now lands ' @@ -39,6 +40,6 @@ export const entry: SemanticMigration = { acceptanceCriteria: 'No authored metadata names `organization_id` on `sys_metadata`: the field resolver (lint and the data ' + 'door) answers it as an unknown field. A fresh database creates `sys_metadata` with no organization ' - + 'column. On a legacy database, after `os migrate organization-ownership --apply` the column is ' + + 'column. On a legacy database, after `os migrate organization-ownership --apply --plan-file PLAN` the column is ' + 'gone and the boot no longer reports it orphaned.', }; From 042ba4c586656577dd8882d029df076829d4bc80 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 00:41:38 +0000 Subject: [PATCH 7/8] chore(changeset): the legacy-organization read seam's loose ends Claude-Session: https://claude.ai/code/session_01TekAoE8QbLrxFVJsdNuRgg Co-authored-by: Claude --- .changeset/23609-legacy-org-read-seam.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .changeset/23609-legacy-org-read-seam.md diff --git a/.changeset/23609-legacy-org-read-seam.md b/.changeset/23609-legacy-org-read-seam.md new file mode 100644 index 00000000000..b3de2b0619f --- /dev/null +++ b/.changeset/23609-legacy-org-read-seam.md @@ -0,0 +1,19 @@ +--- +'@objectstack/metadata-protocol': patch +'@objectstack/metadata': patch +'@objectstack/cli': patch +'@objectstack/spec': patch +--- + +fix(metadata-protocol,metadata): on a database the organization-ownership ceremony has not migrated, the paged family reads, `DatabaseLoader`'s count and the package rollback agree with the environment-only reads; the ceremony's apply is named with `--plan-file` (ADR-0131 D6/D7) + +Clause-②: no + +Every change below matters only where legacy organization rows still sit in the `sys_metadata` family, the database `os serve` refuses to start on until `os migrate organization-ownership` has verified. On a migrated or fresh database every answer is unchanged. + +- **`@objectstack/metadata-protocol`: a paged read leaves the legacy rows out before the page is cut.** `auditMetaItem` (`limit`), `listCommits` (`limit`), `revertStoredPackage`'s first-active-row probe and `SysMetadataRepository.list({ limit })` used to filter a legacy organization's rows out after the limit. A page they sat in came back short, and `revertStoredPackage` answered `RESOURCE_CONFLICT` (409) "has never been published" for a package the environment had published when its first active row was an organization's. When a page holds a legacy row, the read is now repeated with those rows excluded in the query. +- **`@objectstack/metadata-protocol`: `rollbackToPackageCommit` refuses a legacy organization's commit by name.** It answered `COMMIT_NOT_FOUND` (404) for a commit that is there. It now gives `revertCommit`'s refusal: `NOT_OVERRIDABLE` (403), the same sentence, naming the commit and its organization. +- **`@objectstack/metadata`: `DatabaseLoader` counts what it reads.** `exists()` answered true for an address only a legacy organization row held, while `load()` answered null, and `queryHistory`'s `total` counted legacy history rows no page serves. Neither counts them now. +- **`@objectstack/metadata-protocol`: internal cleanup.** The organization plumbing that only ever carried the environment (`null`) is removed from the list merge and the served-row resolution. No export changes. +- **`@objectstack/spec`: wording.** The four ADR-0087 entries `18.sys-metadata{,-audit,-commit,-history}-organization-column-retired` now name the ceremony's reviewed apply, `os migrate organization-ownership --out PLAN` and then `--apply --plan-file PLAN`, which `--plan-file`'s own entry requires. No id, category, version or field the migration engine reads changes. +- **`@objectstack/cli`: wording.** `os migrate apply --allow-destructive`'s refusal on an unmigrated database now reads "a drop the organization-ownership ceremony owns, and the ceremony has not verified on this database". Its `--json` payload is unchanged. From 81aa285d588f9eb7e7a42209bb19c34a3d2cdc97 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 01:09:17 +0000 Subject: [PATCH 8/8] test(metadata,metadata-protocol): the new legacy-organization doubles hold ObjectQL's findOne contract and are pinned The DatabaseLoader count pin's engine double opens findOne with assertEngineFindOnePredicate, and the engine-double ledger records the doubles both new pin files declare (check:engine-double-contract --write). Claude-Session: https://claude.ai/code/session_01TekAoE8QbLrxFVJsdNuRgg Co-authored-by: Claude --- ...e-loader.legacy-organization-count.test.ts | 6 +++++- scripts/engine-double-contract.pinned.json | 20 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/metadata/src/loaders/database-loader.legacy-organization-count.test.ts b/packages/metadata/src/loaders/database-loader.legacy-organization-count.test.ts index 60af6f112fa..dcc92712fe5 100644 --- a/packages/metadata/src/loaders/database-loader.legacy-organization-count.test.ts +++ b/packages/metadata/src/loaders/database-loader.legacy-organization-count.test.ts @@ -11,6 +11,7 @@ * CONTROL), whose answers are the ones the loader always gave. */ import { describe, expect, it } from 'vitest'; +import { assertEngineFindOnePredicate } from '@objectstack/metadata-core'; import { DatabaseLoader } from './database-loader.js'; type Row = Record; @@ -45,7 +46,10 @@ function engineOver(seed: Record, legacyShape: boolean) { }; return { async find(t: string, q?: any) { return select(t, q); }, - async findOne(t: string, q?: any) { return select(t, q)[0] ?? null; }, + async findOne(t: string, q?: any) { + assertEngineFindOnePredicate(t, q); + return select(t, q)[0] ?? null; + }, async count(t: string, q?: any) { return rowsOf(t).filter((r) => matches(r, q?.where)).length; }, getDriverForObject(t: string) { return { diff --git a/scripts/engine-double-contract.pinned.json b/scripts/engine-double-contract.pinned.json index 070f02c4832..b78dea65e8c 100644 --- a/scripts/engine-double-contract.pinned.json +++ b/scripts/engine-double-contract.pinned.json @@ -826,6 +826,21 @@ "verb": "findOne", "pinned": 1 }, + { + "file": "packages/metadata-protocol/src/protocol.legacy-organization-read-seam.test.ts", + "verb": "delete", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.legacy-organization-read-seam.test.ts", + "verb": "findOne", + "pinned": 1 + }, + { + "file": "packages/metadata-protocol/src/protocol.legacy-organization-read-seam.test.ts", + "verb": "update", + "pinned": 1 + }, { "file": "packages/metadata-protocol/src/protocol.legacy-overlay-delete.test.ts", "verb": "delete", @@ -2131,6 +2146,11 @@ "verb": "update", "pinned": 1 }, + { + "file": "packages/metadata/src/loaders/database-loader.legacy-organization-count.test.ts", + "verb": "findOne", + "pinned": 1 + }, { "file": "packages/objectql/src/action-activation.test.ts", "verb": "update",