Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
23e3a7f
feat(cli,rest,metadata-protocol): the ceremony promotes organization …
claude Oct 14, 2026
7a9c239
test(cli,metadata-protocol): S1 pins, changeset, and the dead helpers…
claude Oct 14, 2026
d026bb2
feat(cli): --metadata-choice resolves a customization conflict; uncho…
claude Oct 14, 2026
035c496
feat(cli): history-renumber step moves into S1; event_seq and version…
claude Oct 14, 2026
c2d595e
fix(cli): the overlay-duplicates category keys the bundle member, as …
claude Oct 14, 2026
f5066d7
fix(cli): renumber history where the ceremony's own environment saves…
claude Oct 14, 2026
b85b091
fix(cli): the overlay-duplicates citation names the overlay index, no…
claude Oct 14, 2026
7878b47
fix(cli,metadata-protocol,rest): S1 review round — 17.x tables plan, …
claude Oct 14, 2026
917e0b8
fix(cli): the history fold moves a lineage's events wholly above its …
claude Oct 14, 2026
b0093a3
chore(changeset): S1's note states how it relates to the --apply 18.0…
claude Oct 14, 2026
ab797b8
Merge remote-tracking branch 'origin/main' into claude/issue-15211-c7…
claude Oct 14, 2026
9058ad1
feat(cli): a --metadata-choice or --template-choice deletes only rows…
claude Oct 14, 2026
f8c44a4
docs(cli): the apply module doc names the choice binding (C7b S1)
claude Oct 14, 2026
0162d47
fix(cli): a resolved conflict's environment row takes the chosen body…
claude Oct 14, 2026
468b74a
chore(changeset): the resolved environment row takes the chosen body …
claude Oct 14, 2026
f5c3ef4
fix(cli): S1 delta review — the fold's base is the organization's who…
claude Oct 14, 2026
d02b699
chore(changeset): S1 note names the door-equivalent write, the unique…
claude Oct 14, 2026
97408a0
Merge remote-tracking branch 'origin/main' into claude/issue-15211-c7…
claude Oct 14, 2026
82bd7e0
fix(cli): S1 merge round — the lock is asked before any write of a re…
claude Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .changeset/15211-ceremony-metadata-promotion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
'@objectstack/cli': minor
---

feat(cli): the organization-ownership ceremony carries the `sys_metadata` organization layer into the environment (ADR-0131 D10)

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.
- **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'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 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.

```bash
os migrate organization-ownership --out plan.json # review plan.json: metadataConflicts lists every conflict
os migrate organization-ownership --apply --backup-taken --plan-file plan.json --metadata-choice ROW_ID # one per listed conflict
```
72 changes: 69 additions & 3 deletions packages/cli/src/commands/migrate/organization-ownership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
import {
applyOrganizationOwnership,
checkpointFromDetails,
MetadataChoiceRefusal,
ORGANIZATION_OWNERSHIP_MIGRATION_ID,
ReviewedPlanRefusal,
reviewedPlanFromFile,
Expand Down Expand Up @@ -95,6 +96,7 @@ async function confirm(question: string): Promise<boolean> {
* 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`,
Expand Down Expand Up @@ -143,7 +145,9 @@ 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 ' +
'(the reviewed plan file in --plan-file, and a backup), only that plan: attribution, the authored catalog conversion, the template promotion, the settings move, the ' +
'(the reviewed plan file in --plan-file, and a backup), only that plan: attribution, the authored catalog ' +
'conversion, the promotion of organization customizations and form withdrawals, the template promotion, the ' +
'settings move, the ' +
'verified id-to-name rewrite, mirror deletion and column drops, in that order, ' +
'resumable from a recorded checkpoint, then the post-check.';

Expand Down Expand Up @@ -192,6 +196,15 @@ 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. 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({
description:
'A JSON file of the fates the host declares for the objects it provides (OrganizationOwnershipSupplementSchema, ' +
Expand Down Expand Up @@ -256,6 +269,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['plan-file']) {
await refuse({ reason: 'flag-without-apply', detail: '--plan-file is only meaningful with --apply' }, '--plan-file is only meaningful with --apply.');
return;
Expand Down Expand Up @@ -358,6 +375,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 chosen with --metadata-choice ROW_ID.',
);
}
}
console.log('');
const { summary } = plan;
Expand All @@ -380,6 +404,7 @@ export default class MigrateOrganizationOwnership extends Command {
json?: boolean;
'backup-taken'?: boolean;
'template-choice'?: string[];
'metadata-choice'?: string[];
'plan-file'?: string;
},
posture: string,
Expand Down Expand Up @@ -468,7 +493,9 @@ export default class MigrateOrganizationOwnership extends Command {
const engine = kernel.getService('objectql') as Parameters<typeof recordDataMigrationRun>[0] & { registry?: unknown };
const protocol = kernel.getService('protocol') as {
saveMetaItem?: (request: Record<string, unknown>) => Promise<unknown>;
getMetaItems?: (request: { type: string }) => Promise<{ items?: unknown[] } | unknown[]>;
getMetaItemsForExecution?: (request: { type: string }) => Promise<{ items?: unknown[] }>;
getMetaItem?: (request: { type: string; name: string; packageId?: string }) => Promise<unknown>;
} | undefined;
const catalog = createSecurityCatalogReader({
registry: engine.registry as never,
Expand Down Expand Up @@ -499,15 +526,50 @@ 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,
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 } : {}),
});
},
},
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 () => {
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<string, unknown> => !!i && typeof i === 'object');
},
},
templates: {
Expand Down Expand Up @@ -570,6 +632,10 @@ export default class MigrateOrganizationOwnership extends Command {
await refuse({ reason: error.reason, ...error.detail, detail: error.message }, `Refused: ${error.message}`);
return;
}
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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,9 @@ async function ceremony(posture: string, db: Database.Database): Promise<Organiz
// The plan the operator reviewed: the one this run computes.
reviewedPlan: { file: 'plan.json', generatedAt: '2026-10-14T00:00:00Z', database: 'v17', inventoryDigest: inventoryDigest(), posture },
catalog,
// No view is served here: the form-withdrawal carry has nothing to judge.
views: { list: async () => [] },
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 (columns) => columns.map((c) => ({ table: c.table, column: c.column, outcome: 'not-proposed' as const })) },
Expand Down
Loading
Loading