Skip to content

Commit 869a381

Browse files
feat(cli): the organization-ownership ceremony carries the sys_metadata organization layer into the environment, and a choice deletes only the rows the reviewed plan listed (ADR-0131 D10, C7b S1) (#23452)
Part of #15211 (stage S1 of C7b; the other stages are this card's S4, #23437, and the split cards #23429 and #23430) Fixes #23398 Clause-②: yes (widening) ## What `os migrate organization-ownership --apply` carries the `sys_metadata` organization layer into the environment (ADR-0131 D10; rulings A on #22011 and #22008; ruling 6163114098, Q1 B and Q6 A; seat answer Q2 A; review rulings in 6165835673). - **The plan gains `metadataConflicts`, and `--apply` gains `--metadata-choice ROW_ID`.** That is the widening, so cli is `minor`. Nothing is removed. This PR changes only `@objectstack/cli`. - **A new step, `metadata-promotion`, after the authored conversion:** - **Form withdrawals are carried first, under every posture, and fail-closed.** Every view of that name the environment serves still open takes an organization's withdrawal, stored or code-served, in every package. It is saved 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 is promoted in place,** with its history. This covers `view`, `dashboard`, `report`, `translation` and `email_template`; the key is type, name, package and bundle member. - **A key held by more than one row is a conflict.** Under `single` it blocks until `--metadata-choice ROW_ID` names one row. The key then ends with **exactly one environment row** defining the chosen body: - where the key has an environment row, that row takes the body in place, by one UPDATE. Its version is the chosen row's after its lineage is folded, so it names that body's snapshot. Not through the door: the door writes a packaged item's customization into the package's slot, never onto a package-less row of the key (measured; see the dogfood); - where it has none, the chosen organization row is promoted in place. Any withdrawal another row of the key holds is kept on that body, and also saved through the door (its history, and the package's slot). The row is **read back**, the choice is **recorded in the checkpoint**, and only then are the key's organization rows deleted, in **one** statement, by id. A rerun after a crash anywhere in this accepts the same `--metadata-choice` and finishes. - **Under a wall:** nothing is promoted unless named, and **nothing is ever deleted** (ruling 6163114098, Q6 A's text, applied by the seat). A conflict nobody chose for is reported, not blocking, and its rows hold the column. - **History (Q2 A): `event_seq` and every `(type, name, version)` unique environment-wide.** The door numbers a save from history in its own scope, so: - **A promoted row takes its history with it.** Its organization's lineage of the item moves above the environment's numbers (wholly above its own range too, because 17.x keeps unique indexes on both counters) and becomes environment history. Every later environment save, the ceremony's or the runtime's, 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: event ranges by one offset each, with their commit ranges; version lineages with the organization's rows. - Under `single`, that history then becomes the environment's too. - Under a wall it stays its organization's: private, renumbered before the column can go. - **A choice deletes only rows the reviewed plan listed.** One mechanism for both flags, on S4's `ReviewedPlan`. The plan file's `metadataConflicts` and `templateConflicts` are read into the reviewed plan. Before anything is written, each chosen conflict must hold exactly the rows the file listed for it, or the apply refuses with `plan-conflict-mismatch`, naming the conflict, the rows added and the rows gone. - **17.x databases plan.** `sys_metadata.bundle_member` first shipped in 18.0.0-next.0, and the ceremony boots with schema changes held. So `environment-overlay-duplicates` groups by `bundle_member` only where the column exists; on a 17.x table the member reads as NULL, the overlay key's folded part. - **The anonymous form doors keep their organization read.** By the seat's ruling, its deletion moves to S2 (#23429), which lands with S5's boot refusal, so no release serves an organization-only withdrawal open. `rest` and `metadata-protocol` are back to `main`. ## Merge round (review 6173411667 PASS at `d02b699d65`; head `82bd7e0b80`, base: `main` `222293f60`, S3 merged) - **Merge.** `main`, now carrying S3 (#23503), is merged through `os-regen-merge.sh`. - Two conflicts: - `organization-ownership-plan.ts`: `TablePlan` gains both `metadataConflicts` and S3's `grants`, and both types are kept; - `organization-ownership-apply.test.ts`: both sides' new describes are kept. - The script's rerun found no generated path to restore. - **The uniqueness gate stands first in S3's rewritten `runColumnDrops`.** The gate pin and the two column-drop doubles now use S3's `{ table, column }` port shape. - The cli dependency closure was rebuilt on the merged tree: S3's plugin-security pin reads its dist. - **Should-fix (S1).** The door's lock verdict is asked once, before the history fold, the promotion and the in-place write, so a refusal leaves nothing written. - Pin: ⭐ `the lock is asked before the history fold and the promotion: under a wall, a refusal leaves history and rows as they were`, with a history table and `isolated`. - Ablation: removing the early check reddens it and the locked-item pin. - **Nit (S2).** The door save after the in-place write sends the row as written, with its stored lock family. - Pin: `the door save after the in-place write sends the row as written, its stored lock family kept`. - Ablation: sending `body` reddens it. - **`history-renumber` keeps blocking on a duplicate**, by the seat's call. Its refusal names the table, each item holding the duplicated number (by row) and the remedy. Both messages are pinned: the `event_seq` one verbatim, the version one by its parts. - **Checks on `82bd7e0b80`:** - cli typecheck exit 0; - the ceremony suites (9 files, S3's included): 146/146; - narrowed lint of the 8 changed `.ts` files: 0 errors, 0 warnings; - `main` merges clean again. - The integration verdict is CI's Test Core on this head. ## Delta round 2 (review 6172666364; head `d02b699d65`) The seat's rulings, each fixed and pinned, each ablation red with the restore verified (blob == HEAD): | Item | Fix | Pin | Ablation → red | |---|---|---|---| | **N1** the fold landed on the organization's own events | each fold's offset (`event_seq` and versions) is based on `max(environment, the same organization's other items)`; excluding the lineage itself keeps a rerun idempotent | ⭐ `N1: folding one item of an organization never lands on that organization's other events, on a rerun too` (the review's smallest case, 17.x's unique indexes, a crash after the first fold's move, then the rerun) | base on the environment alone → the N1 pin | | **N2** checksum | the in-place UPDATE restamps `checksum` with `hashSpec` | ⭐ `restamps checksum from the written body` | restamp removed → that pin | | **N2** stored `_lock` keys | the row's stored `_lock` family is kept, whatever the chosen body carries | ⭐ `keeps the lock family the environment row stores` | carry removed → that pin | | **N2** the door's lock check | a new port asks the item read's `editable` (the save door's own verdict) before the write; a locked item blocks the conflict, nothing written or deleted | ⭐ `a locked item stays locked` | check removed → that pin | | Wall with an environment row (ruled as built) | each organization row left is a report line: "Remaining: … blocks column-drops on sys_metadata … Remedy: …" | ⭐ the wall pin asserts the line | | | Uniqueness under a wall (ruled, on condition) | `history-renumber` verifies `event_seq` and `(type, name, version)` uniqueness environment-wide on every apply and blocks naming any duplicate; `column-drops` refuses unless that verification passed in the same apply | ⭐ `column-drops never runs unless this apply's history-renumber verified uniqueness` (two environment events sharing a number) | verification removed → that pin | | Bundle member in `validateMetadataChoices` | the conflict key carries the member | `two conflicts of one item that differ only by bundle member are two conflicts, each chosen for` | member removed → that pin | | Title and citation | retitled (no rest, metadata-protocol or form doors); the fold fix is `917e0b8cc` | | | Checks on `d02b699d65`: - cli typecheck exit 0. The ceremony suites (`organization-ownership*`, 8 files): 135/135. Narrowed lint of the 8 changed `.ts` files: 0 errors, 0 warnings. - Main merges clean (`git merge-tree`), so it is not merged. - **The integration verdict is CI's Test Core on this head.** The local tier was not re-run. - **The previous head's local runs.** On `468b74a09d`, CI's Test Core passed (all 6 shards). I stopped the local sweep there for this round: 58 of 65 gates had run, all exit 0. ## Verification of the previous round (head `468b74a09d`, base: `main` `f9b421716d`, S4 merged) **Merge.** `main`, carrying S4, is merged through `scripts/pm/os-regen-merge.sh`. - The two textual conflicts (the command's flags, refusals and description, and the test imports and helper) stack both intents. - The script's rerun found no generated path to restore. - `main`'s `served-catalog` test gains the `views` port, which serves no view. - The #23446 pin's resumed-step list gains this PR's two steps. **The binding** (seat option A): | Pin | Ablation → red | |---|---| | ⭐ `--metadata-choice: a row that joined the conflict since the review refuses the apply, naming it; nothing is written`; `a reviewed row gone since refuses too, and a plan file that lists no such conflict`; and the unchanged group resolves | metadata leg removed → both refusal pins | | ⭐ `--template-choice: a row that joined the conflict since the review refuses the apply, naming it; nothing is written`; and the unchanged group resolves | template leg removed → the template pin | | `reviewedPlanFromFile` reads both lists, keyed and sorted | | **The review round (6165835673)** keeps its pins and ablations: | Finding | Pin | Ablation → red | |---|---|---| | F1 17.x refused | `plans a 17.x sys_metadata with no bundle_member column…` | filter removed → the pin and 7 more plan pins | | F2 versions collide | ⭐ organization-only history; ⭐ under a wall; ⭐ two organizations with no environment history | fold removed → 2 ⭐; `single` fold removed → the two-organization pin; seq offset not wholly above → the organization-history pin | | F3 two environment rows / rerun | ⭐ org-over-environment choice (one UPDATE, no organization row cleared); ⭐ the packaged-item pin (below); ⭐ crash, then the same choice; `a write that does not land deletes nothing` | in-place write sent through the door instead → both ⭐; read-back removed → the read-back pin; applied-choice acceptance removed → the crash pin | | Wall ruling | ⭐ a chosen row defines the environment row, nothing deleted; ⭐ two organizations, the other stays | posture branch removed → both | Checks: - cli typecheck: exit 0. The ceremony suites (`organization-ownership*`, 8 files): 129/129. Narrowed lint of the 8 changed `.ts` files: 0 errors, 0 warnings. - **The whole cli integration tier:** CI's Test Core passed on that head (all 6 shards). - **`dispatch-gates`:** the local sweep on that head was stopped at 58 of 65, all exit 0, for the next round. The interim head `b0093a3337` ran 65/65 at exit 0. - **Dogfood as an operator,** with the real `os` binary built from this head. One `objectstack dev --seed-admin` boot of the showcase, then stopped. I made the database a 17.x shape (`bundle_member` dropped; history keeps 17.x's unique `(organization_id, event_seq)` and `(organization_id, type, name, version)` indexes), and planted a conflict on `showcase_inquiry.contact`'s package-less key: the organization's withdrawn copy (with only an organization history, 1–2) beside an open environment copy. 1. The plan exits 0 and lists the conflict (`env_copy`, `legacy_org_withdrawal`). 2. A row joined the conflict after the plan. `--apply --plan-file plan.json --metadata-choice legacy_org_withdrawal` exits 1, `plan-conflict-mismatch`: reviewed `env_copy,legacy_org_withdrawal`, current with `joined_later`. The database file is byte-identical, with no ceremony row. 3. That row removed, the same invocation resolved the conflict. `env_copy` took the chosen body in place, withdrawal kept; its version is 4, the folded `corg2` snapshot. The organization row is deleted, and the package's served copy carries the withdrawal. After the `rewrite` remedy, the resume ran `history-renumber`: no history is left tagged to an organization, and no duplicate exists. `column-drops` blocks on `sys_metadata`'s declared column, which is S2's. 4. On a reboot, both form doors answer 404, nothing landed, and a runtime save took `event_seq` and `version` 5, above everything, with no duplicate. - **The dogfood found two defects this round, both fixed and pinned:** - The fold's `event_seq` offset was smaller than a lineage's own span. 17.x's unique index refused it row by row. Fixed in `917e0b8cc`, and its base corrected in this round's N1. - The door writes a packaged item's customization into the package's slot, so the conflict's package-less environment row never took the body. The read-back caught it and nothing was deleted. Fixed in `0162d47cd`. ## Acceptance notes - **Deviations for the seat to ratify:** 1. Under `single`, when an organization row is chosen over the key's environment row, the environment row keeps its record and takes the chosen body in place, by one UPDATE, doing to the row what the door does (N2). Its version is the chosen row's after its lineage is folded. The chosen organization row is then deleted with the key's other organization rows; its body survives in the environment row. This is the "replace in place" the ruling offered: it keeps one environment row at every point, and any other order leaves zero or two. 2. A promoted row's history is re-tagged to the environment (folded) at promotion. Under `single`, the history left tagged to an organization is folded by the late step. History rows keep their bodies and their order; only `organization_id` and the numbers change. 3. Under a wall, a conflict nobody chose for no longer blocks the step: it is reported in the step's notes, and its rows hold the column. Otherwise a wall's conflicts, whose rows are never deleted, would block every rerun. - **`--apply` shipped in `@objectstack/cli@18.0.0-next.1`.** This PR adds to it (a plan key, a flag, two steps), hence `minor` and `Clause-②: yes (widening)`. It 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. The changeset says so. - **The choice binding (seat item 7) is in this head,** for both flags; see above. It closes the `--template-choice` gap S4 left (6167752569). The changeset's example carries `--plan-file`. - **No MongoDB path** (maintainer ruling 6171293009). - Closing-target claim: 6172270710 on #23398 (branch `claude/issue-15211-c7b-promotions`). - **Not changed, from the review's non-blocking list:** - the conflict key does not read `state`; - identical-body pairs are conflicts; - the two conflict lists in one plan; - the carry skip under `single` for a key an organization row holds. With the doors' organization read kept, no form is open meanwhile. - **An observation, not caused by this PR.** On a freshly seeded showcase database, `rewrite` blocks on the `--seed-admin` grants `admin_full_access` and `organization_admin_no_bypass` (6163356782; escalated in 6165835673). The dogfood removed them as the blocker's remedy says. --- 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01USccGXR13sWyTeEwe8QFdH --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 47d569e commit 869a381

9 files changed

Lines changed: 2352 additions & 32 deletions
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
feat(cli): the organization-ownership ceremony carries the `sys_metadata` organization layer into the environment (ADR-0131 D10)
6+
7+
Clause-②: yes (widening)
8+
9+
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.
10+
11+
`--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.
12+
13+
`--apply` gains a step, `metadata-promotion`, after the authored catalog conversion:
14+
15+
- **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.
16+
- **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.
17+
- **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.
18+
- 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.
19+
- 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`.
20+
- Any public-form withdrawal another row of the key holds is kept on that body.
21+
- The environment row is read back. Only then are the key's organization rows deleted, in one statement, by id.
22+
- A rerun after an interruption accepts the same `--metadata-choice` and finishes.
23+
- **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.
24+
25+
**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.
26+
27+
`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:
28+
29+
- **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.
30+
- **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.
31+
- Under `single`, that history then becomes the environment's too.
32+
- Under a wall it stays its organization's.
33+
34+
`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.
35+
36+
Order within an organization, and the version a rollback resolves, are unchanged. Nothing in history is deleted.
37+
38+
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.
39+
40+
```bash
41+
os migrate organization-ownership --out plan.json # review plan.json: metadataConflicts lists every conflict
42+
os migrate organization-ownership --apply --backup-taken --plan-file plan.json --metadata-choice ROW_ID # one per listed conflict
43+
```

‎packages/cli/src/commands/migrate/organization-ownership.ts‎

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
import {
3434
applyOrganizationOwnership,
3535
checkpointFromDetails,
36+
MetadataChoiceRefusal,
3637
ORGANIZATION_OWNERSHIP_MIGRATION_ID,
3738
ReviewedPlanRefusal,
3839
reviewedPlanFromFile,
@@ -95,6 +96,7 @@ async function confirm(question: string): Promise<boolean> {
9596
* 2): `--backup-taken`, or a yes at the prompt; a non-interactive run without
9697
* the flag is refused before the boot. It then
9798
* performs the fates in order — attribution, the authored catalog conversion,
99+
* the promotion of organization customizations and public-form withdrawals,
98100
* the template promotion, the settings global-rung move, the verified id→name
99101
* rewrite, mirror deletion, column drops — through `../../utils/organization-ownership-apply.ts`,
100102
* checkpointing each finished step in the deployment ledger (`sys_migration`,
@@ -143,7 +145,9 @@ export default class MigrateOrganizationOwnership extends Command {
143145
'The v18 organization-ownership ceremony (ADR-0131 D10). Without --apply, the read-only plan: per table, the fate, ' +
144146
'the row counts, the rows whose owner cannot be derived (by id), and which tables will receive NOT NULL, written ' +
145147
'to a file — never a row or a column changed; it refuses, naming it, any table it cannot enumerate. With --apply ' +
146-
'(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 ' +
148+
'(the reviewed plan file in --plan-file, and a backup), only that plan: attribution, the authored catalog ' +
149+
'conversion, the promotion of organization customizations and form withdrawals, the template promotion, the ' +
150+
'settings move, the ' +
147151
'verified id-to-name rewrite, mirror deletion and column drops, in that order, ' +
148152
'resumable from a recorded checkpoint, then the post-check.';
149153

@@ -192,6 +196,15 @@ export default class MigrateOrganizationOwnership extends Command {
192196
'deleted only after the chosen body is promoted and verified',
193197
multiple: true,
194198
}),
199+
'metadata-choice': Flags.string({
200+
description:
201+
'With --apply: the sys_metadata row id the environment keeps for a listed customization conflict (rows ' +
202+
'of one type, name and package once the organization leaves the key). Repeat once per conflict. Under ' +
203+
'single the key keeps one environment row defining the chosen body, and its organization rows are deleted ' +
204+
'only after that row reads back; under a wall nothing is deleted, and a named organization row of a key it ' +
205+
'alone holds is promoted knowingly',
206+
multiple: true,
207+
}),
195208
'fate-supplement': Flags.string({
196209
description:
197210
'A JSON file of the fates the host declares for the objects it provides (OrganizationOwnershipSupplementSchema, ' +
@@ -256,6 +269,10 @@ export default class MigrateOrganizationOwnership extends Command {
256269
await refuse({ reason: 'flag-without-apply', detail: '--template-choice is only meaningful with --apply' }, '--template-choice is only meaningful with --apply.');
257270
return;
258271
}
272+
if (flags['metadata-choice']?.length) {
273+
await refuse({ reason: 'flag-without-apply', detail: '--metadata-choice is only meaningful with --apply' }, '--metadata-choice is only meaningful with --apply.');
274+
return;
275+
}
259276
if (flags['plan-file']) {
260277
await refuse({ reason: 'flag-without-apply', detail: '--plan-file is only meaningful with --apply' }, '--plan-file is only meaningful with --apply.');
261278
return;
@@ -358,6 +375,13 @@ export default class MigrateOrganizationOwnership extends Command {
358375
' differ. --apply blocks on it until one row is chosen with --template-choice ROW_ID.',
359376
);
360377
}
378+
for (const conflict of table.metadataConflicts ?? []) {
379+
printWarning(
380+
`Customization conflict ${conflict.type} ${conflict.name}${conflict.packageId ? ` (package ${conflict.packageId})` : ''}: ` +
381+
conflict.candidates.map((c) => `${c.id} (${c.organizationId ? `organization ${c.organizationId}` : 'environment'})`).join(', ') +
382+
' hold one key. --apply blocks on it until one row is chosen with --metadata-choice ROW_ID.',
383+
);
384+
}
361385
}
362386
console.log('');
363387
const { summary } = plan;
@@ -380,6 +404,7 @@ export default class MigrateOrganizationOwnership extends Command {
380404
json?: boolean;
381405
'backup-taken'?: boolean;
382406
'template-choice'?: string[];
407+
'metadata-choice'?: string[];
383408
'plan-file'?: string;
384409
},
385410
posture: string,
@@ -468,7 +493,9 @@ export default class MigrateOrganizationOwnership extends Command {
468493
const engine = kernel.getService('objectql') as Parameters<typeof recordDataMigrationRun>[0] & { registry?: unknown };
469494
const protocol = kernel.getService('protocol') as {
470495
saveMetaItem?: (request: Record<string, unknown>) => Promise<unknown>;
496+
getMetaItems?: (request: { type: string }) => Promise<{ items?: unknown[] } | unknown[]>;
471497
getMetaItemsForExecution?: (request: { type: string }) => Promise<{ items?: unknown[] }>;
498+
getMetaItem?: (request: { type: string; name: string; packageId?: string }) => Promise<unknown>;
472499
} | undefined;
473500
const catalog = createSecurityCatalogReader({
474501
registry: engine.registry as never,
@@ -499,15 +526,50 @@ export default class MigrateOrganizationOwnership extends Command {
499526
report = await applyOrganizationOwnership({
500527
executor,
501528
...(flags['template-choice']?.length ? { templateChoices: flags['template-choice'] } : {}),
529+
...(flags['metadata-choice']?.length ? { metadataChoices: flags['metadata-choice'] } : {}),
502530
posture,
503531
database: stack.dbLabel,
504532
catalog,
505533
door: {
506-
save: async (type, name, body) => {
534+
save: async (type, name, body, target) => {
507535
if (typeof protocol?.saveMetaItem !== 'function') {
508536
throw new Error('no metadata protocol is composed on this stack');
509537
}
510-
await protocol.saveMetaItem({ type, name, item: body, actor: CEREMONY_ACTOR });
538+
await protocol.saveMetaItem({
539+
type,
540+
name,
541+
item: body,
542+
actor: CEREMONY_ACTOR,
543+
...(target ? { packageId: target.packageId } : {}),
544+
});
545+
},
546+
},
547+
lock: {
548+
// The save door's own verdict: the item read reports it as `editable`.
549+
verdict: async (type, name, packageId) => {
550+
if (typeof protocol?.getMetaItem !== 'function') {
551+
throw new Error('no metadata protocol is composed on this stack');
552+
}
553+
const read = (await protocol.getMetaItem({ type, name, ...(packageId ? { packageId } : {}) })) as
554+
| { editable?: unknown; lock?: unknown; lockReason?: unknown }
555+
| null
556+
| undefined;
557+
return {
558+
editable: read?.editable !== false,
559+
...(typeof read?.lock === 'string' ? { lock: read.lock } : {}),
560+
...(typeof read?.lockReason === 'string' ? { lockReason: read.lockReason } : {}),
561+
};
562+
},
563+
},
564+
views: {
565+
// The env-wide view list, exactly as the anonymous form doors read it.
566+
list: async () => {
567+
if (typeof protocol?.getMetaItems !== 'function') {
568+
throw new Error('no metadata protocol is composed on this stack');
569+
}
570+
const result = await protocol.getMetaItems({ type: 'view' });
571+
const items = Array.isArray(result) ? result : (result?.items ?? []);
572+
return items.filter((i): i is Record<string, unknown> => !!i && typeof i === 'object');
511573
},
512574
},
513575
templates: {
@@ -570,6 +632,10 @@ export default class MigrateOrganizationOwnership extends Command {
570632
await refuse({ reason: error.reason, ...error.detail, detail: error.message }, `Refused: ${error.message}`);
571633
return;
572634
}
635+
if (error instanceof MetadataChoiceRefusal) {
636+
await refuse({ reason: 'metadata-choice-refused', detail: error.message }, `Refused: ${error.message}`);
637+
return;
638+
}
573639
if (error instanceof TemplateChoiceRefusal) {
574640
await refuse({ reason: 'template-choice-refused', detail: error.message }, `Refused: ${error.message}`);
575641
return;

‎packages/cli/src/utils/organization-ownership-apply.served-catalog.integration.test.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ async function ceremony(posture: string, db: Database.Database): Promise<Organiz
130130
// The plan the operator reviewed: the one this run computes.
131131
reviewedPlan: { file: 'plan.json', generatedAt: '2026-10-14T00:00:00Z', database: 'v17', inventoryDigest: inventoryDigest(), posture },
132132
catalog,
133+
// No view is served here: the form-withdrawal carry has nothing to judge.
134+
views: { list: async () => [] },
135+
lock: { verdict: async () => ({ editable: true }) },
133136
door: { save: async (type, name) => { throw new Error(`the door was asked to save ${type} ${name}`); } },
134137
templates: { find: async () => undefined },
135138
columnDrops: { drop: async (columns) => columns.map((c) => ({ table: c.table, column: c.column, outcome: 'not-proposed' as const })) },

0 commit comments

Comments
 (0)