Skip to content

Commit ed03834

Browse files
feat(cli,core): the organization-ownership ceremony promotes customized email templates and moves the settings global rung (C7b, PR 3 of 3) (#23329)
Fixes #22937 Clause-②: yes (widening) PR 3 of 3 for the C7b card: the template promotion and the settings global-rung move, per the maintainer's ruling 6138591940 (Q3 「本卡补做提升」, Q4 「本卡单开 PR 3」) and dispatch 6151180611. PR 2 (#23034) landed as `2808069121`. **Predecessor:** the closed stacked draft #23195. Its branch carries this work, and it now merges `main` (`f36a3a34`, `34989ec7`, `db208864`), so this diff reads off `main` alone. The contract review went FAIL first (6154293191). The patch round (6154321875) fixed the template delete on resumed runs and brought in the walled promotion. #15211 remains open for the boot refusal, which lands with C8. ## What it adds (9 files, +1,025 / −47; 1,072 changed lines, none generated) 1. **`emailTemplateDefinitionFromRow`**, in `packages/core/src/security/` beside the two catalog mappers. It is a pure mapper whose one import is the spec's `EMAIL_TEMPLATE_FLOOR_LOCALE`, the fallback locale. Its pin holds the body to `EmailTemplateDefinitionSchema`: no undeclared key, and a full `safeParse` passes. 2. **`template-promotion`**, a ceremony step (ADR-0131 §6 Q1, ruling C), which runs under every posture. - **What it promotes:** exactly the rows PR 2's mirror deletion keeps for it (B1). That is every customized row, every row whose `customized` is unknown, and every row no seeder wrote (`managed_by` NULL or outside package / platform / system), with or without an organization. - **How:** grouped by `(name, locale)`. A single candidate, or identical candidates (judged on the mapped body), becomes one environment `email_template` item through the real metadata door. - **What it does not promote:** seeded, uncustomized copies, organization-stamped or not. They stay mirrors (the seat's answer 6140351542). - **Conflicts:** differing bodies for one `(name, locale)` are listed in the plan and the `--apply` report (`templateConflicts`), each candidate with its row id and organization. They count as remaining and block `verified_at`, so nothing is guessed and no candidate is deleted. - The operator chooses with the new repeatable flag **`--template-choice ROW_ID`** on `--apply`. The chosen body is promoted, and the group's rows are deleted by id only after that promotion is verified. - A choice that names no candidate, or two candidates of one conflict, is refused before any write (`template-choice-refused`). - `--template-choice` without `--apply` is refused. - **It also stops, keeping every row,** on: - a customized or authored row with no name, which is named; - an environment template that already exists and differs, because the environment's edit is never overwritten; - a door refusal. 3. **`settings-move`**, a ceremony step that executes the inventory's `global-rung-move` departure (#15207's ruling 6043540291). - Each `sys_setting` row with `scope = 'global'` and no organization is copied into `sys_platform_setting` by `INSERT … SELECT`, so `value` and the `value_enc` handle move verbatim. - The destination row is read back equal before the source row leaves `sys_setting`. A mismatch throws mid-write, and the checkpoint holds, so a re-run resumes. - **It stops when** a key `sys_platform_setting` already serves has a different value, or when source rows differ. - After it runs, B2's count for this departure reads 0. The `declared-rule-mirrors` departure still counts until D2. 4. **Step order:** attribution → authored-conversion → **template-promotion** → **settings-move** → rewrite → mirror-deletion → column-drops. 5. **The report** gains `movedOut` / `movedIn` per table, and the human face prints the moves and the template conflicts. Counts reconcile as before − deleted − moved out + moved in. ## Merge with PR 2's landed form `organization-ownership-apply.ts`, its test and the command conflicted with `2808069121`. PR 2's behaviour is kept exactly: - B1's provenance keep; - a claim that evaluates NULL keeps its row; - B2's departure count in `remainingFor`, and departures on the report; - the fate-2 gate's re-read before the first DELETE. PR 3 is layered on top. For `sys_email_template`, in every posture, mirror deletion first re-runs the idempotent promotion after the fate-2 gate; if that pass is not done, the step is blocked and nothing is deleted. Then the selector deletes only the complement of the promotion population, which means the seeded uncustomized mirrors. Promoted rows leave by id, in chunks with a count read-back, and only once this run has verified their environment copy. ## Pins (`organization-ownership-apply.test.ts`, 17 cases) - **One `--apply`, complete and verified.** The acceptance fixture holds four templates to promote (customized, admin-authored with an organization, admin-authored without one, unknown provenance), a seeded mirror, an organization's seeded copy, and the global rung with a secret handle. One run ends `{ complete: true, blocking: 0 }`. All four are promoted, every template row is gone, the rung is in `sys_platform_setting`, and effective access is identical (the PR 2 pin, unchanged). The DELETE allow-list pins the settings selector with `organization_id IS NULL`. - **A blocked promotion certifies nothing:** with a differing environment template, the ceremony stops at the promotion, ends `complete: false`, `verified_at` stays null, and the blocker names the template. - **Between runs:** killed at a fate-2 DELETE with `template-promotion` checkpointed, then a customized row inserted. The resume promotes it and ends complete; no row is deleted unpromoted. - **A nameless customized row** blocks the promotion, stays, and is named. - **Walled, no conflict:** complete, `blocking: 0`; the template is promoted and its rows are deleted. - **Walled, a conflict:** blocked, with the blocker naming both candidates with their organizations and `--template-choice`. Both rows are kept and nothing is promoted. - **A chosen conflict:** the chosen body is promoted and verified, then both rows are deleted. - **A bad choice:** an unknown row id and an ambiguous pair are each refused with a located message, and nothing is written. - **The declared-rule mirror departure** still blocks `verified_at` and is named; the moved rung reads 0. - **Settings conflict:** nothing is moved or deleted for that key. **Ablations** through `scripts/ablation-replace.mjs`, each restored to the HEAD blob with `git diff HEAD` empty: - **A1:** the pre-delete promotion re-run disabled. The between-runs pin goes red. - **A2:** the nameless blocker disabled. The nameless pin goes red. - **A3:** the walled-promotion wall restored. Four walled cases go red. - **A4:** the conflict detection disabled. The conflict and chosen pins go red. - **A5:** the chosen body replaced by the first candidate. The chosen pin goes red. - **A6:** the choice validation disabled. The refusal pin goes red. - **A7:** the delete selector's template keep replaced by a no-op claim. Still green. The measured cause: the re-run promotion verifies every population row whenever it completes, so the id-only delete never meets an unverified row that the keep would hold. The keep is a second line of defence behind A1's gate, which is red under its own ablation. ## Gates - **At `1bede353`** (the patch round, on `main` `cc14657e`): - `@objectstack/core` and `@objectstack/cli` typecheck, both exit 0; - core `src/security/`: 39 files, 887 tests passed; - the full `@objectstack/cli` suite, CI-sharded: unit 2/2 (287 files, 4,292 tests) and integration 8/8 (121 files, 1,067 passed, 2 skipped: the live-Postgres cells, which skip without `OS_TEST_POSTGRES_URL`, as in CI); - `dispatch-gates --commands` derived 67, all exit 0 on the first run. `--ran`: "67 derived, 67 run, 0 NOT-MEASURED (a DERIVED zero)". - **At `db208864`,** after merging `main` (six commits, none of this PR's files), a scoped re-check: closure rebuilt; core and cli typecheck exit 0; core `src/security/` 887 passed; the ceremony suites, 4 files and 39 tests, passed; the derivation is the same 67. - **Dogfood:** no dogfood suite exercises the ceremony. NOT MEASURED, because none exists. - **Contract review:** FAIL 6154293191 at `34989ec7`, then the delta review at `db208864` (on the PR). ## Not in this PR - The `sys_sharing_rule` declared-rule mirrors ride D2 (6140351542). - The `sys_metadata`-family column drop is a precondition of certifying the ceremony (6150126995, #15211). - The `sys_user_permission_set.permission_set_id` column and its two indexes are dropped by #15211, not by this PR (pointer 6154334043). --- _Generated by [Claude Code](https://claude.ai/code/session_01DjKf52bcyXa3fbGW723phR)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3490bf5 commit ed03834

9 files changed

Lines changed: 1025 additions & 47 deletions
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
feat(cli): `os migrate organization-ownership --apply` promotes customized email templates and moves the settings global rung (ADR-0131 D10)
6+
7+
Clause-②: yes (widening)
8+
9+
Two steps join the ceremony, after the authored catalog conversion and before the verified id→name rewrite. Each is checkpointed and resumable like the others.
10+
11+
- **`template-promotion`** (ruling C on ADR-0131 §6 Q1). Every `sys_email_template` row the mirror deletion keeps for it (customized, or with `customized` unknown, and every row no seeder wrote: `managed_by` NULL or other than package / platform / system, with or without an organization) becomes an environment `email_template` item through the metadata door, one per `(name, locale)`, before mirror deletion removes the rows. It runs under every posture. Seeded, uncustomized copies are not promoted. A promotion that already landed is skipped. The step stops, keeping every row of the template, in four cases:
12+
- a conflict: rows of one `(name, locale)` that differ. Each conflict is listed in the plan and in the `--apply` report, with every candidate's row id and organization. It counts as remaining and holds back `verified_at` until the operator chooses one row with the new repeatable flag `--template-choice ROW_ID`. A choice that names no candidate, or two candidates of one conflict, is refused before anything is written. The chosen body is promoted, and the other candidates are deleted only after that promotion is verified;
13+
- a customized or authored row with no name;
14+
- an environment template that already exists and differs, since the environment's edit is never overwritten;
15+
- a door refusal.
16+
- **`settings-move`** (ADR-0131 D7). Every `sys_setting` row with `scope = 'global'` and no organization is copied into `sys_platform_setting` by the database itself (`INSERT … SELECT`), so `value` and the `value_enc` secret handle move verbatim. The destination row is read back, and only when it equals the source does the row leave `sys_setting`. A key `sys_platform_setting` already holds with the same value is only removed from `sys_setting`. The step stops, moving nothing for that key, in two cases:
17+
- `sys_platform_setting` already holds the key with a different value (the one the runtime serves);
18+
- `sys_setting` holds differing rows for the key.
19+
20+
The `global-rung-move` departure is now executed, so it reads zero after the run and no longer holds back `verified_at`. The `declared-rule-mirrors` departure still counts until the declared-rule seeder is retired.
21+
22+
Mirror deletion now also deletes the promoted template rows, and only those. Before its first template delete it runs the promotion pass again, so a row customized or authored between runs (on a resumed ceremony) is promoted, not deleted. It then deletes the seeded, uncustomized mirrors and, by id, the rows whose environment copy that pass verified. Any other promotion row stays and blocks. The apply report gains `movedOut` / `movedIn` per table, and row counts reconcile as before − deleted − moved out + moved in.
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/core': minor
3+
---
4+
5+
feat(core): `emailTemplateDefinitionFromRow` — a `sys_email_template` row as the `email_template` definition it carries (ADR-0131 D10, ruling C on §6 Q1)
6+
7+
Clause-②: yes (widening)
8+
9+
A new pure export beside `permissionSetDefinitionFromRow` and `positionDefinitionFromRow`: one row in, one body out, with no engine and no I/O. Its one import is the spec's floor-locale constant. The upgrade ceremony (`os migrate organization-ownership --apply`) uses it to promote a customized or admin-authored template row to an environment `email_template` item through the metadata door before the row is deleted. It runs only in that ceremony, never at boot.
10+
11+
The mapping: `body_html` → `bodyHtml`, `body_text` → `bodyText`, `from_address` / `from_name` → `fromOverride`, `reply_to` → `replyTo`, `variables_json` → `variables`, and `active` keeps its stored meaning. `label` falls back to the name and `locale` to `EMAIL_TEMPLATE_FLOOR_LOCALE` (`en-US`). The retired `category` / `is_system` columns and every provenance or state column are left out. It does not validate; the door does.
12+
13+
Nothing existing changes.

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

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
applyOrganizationOwnership,
3030
checkpointFromDetails,
3131
ORGANIZATION_OWNERSHIP_MIGRATION_ID,
32+
TemplateChoiceRefusal,
3233
type ApplyExecutor,
3334
type CeremonyLedgerPort,
3435
type ColumnDropOutcome,
@@ -84,8 +85,9 @@ async function confirm(question: string): Promise<boolean> {
8485
* The writing mode. It asks about the backup first — the operator's act and
8586
* the precondition (D10 item 2): `--backup-taken`, or a yes at the prompt; a
8687
* non-interactive run without the flag is refused before the boot. It then
87-
* performs the fates in order — attribution, the verified id→name rewrite,
88-
* mirror deletion, column drops — through `../../utils/organization-ownership-apply.ts`,
88+
* performs the fates in order — attribution, the authored catalog conversion,
89+
* the template promotion, the settings global-rung move, the verified id→name
90+
* rewrite, mirror deletion, column drops — through `../../utils/organization-ownership-apply.ts`,
8991
* checkpointing each finished step in the deployment ledger (`sys_migration`,
9092
* row {@link ORGANIZATION_OWNERSHIP_MIGRATION_ID}) so an interrupted run
9193
* resumes where it stopped, and ends with the post-check. The ledger row is
@@ -106,7 +108,8 @@ export default class MigrateOrganizationOwnership extends Command {
106108
'The v18 organization-ownership ceremony (ADR-0131 D10). Without --apply, the read-only plan: per table, the fate, ' +
107109
'the row counts, the rows whose owner cannot be derived (by id), and which tables will receive NOT NULL, written ' +
108110
'to a file — never a row or a column changed; it refuses, naming it, any table it cannot enumerate. With --apply ' +
109-
'(and a backup): attribution, the verified id-to-name rewrite, mirror deletion and column drops, in that order, ' +
111+
'(and a backup): attribution, the authored catalog conversion, the template promotion, the settings move, the ' +
112+
'verified id-to-name rewrite, mirror deletion and column drops, in that order, ' +
110113
'resumable from a recorded checkpoint, then the post-check.';
111114

112115
static override examples = [
@@ -140,6 +143,13 @@ export default class MigrateOrganizationOwnership extends Command {
140143
'none; without this flag the run asks, and a non-interactive run is refused',
141144
default: false,
142145
}),
146+
'template-choice': Flags.string({
147+
description:
148+
'With --apply: the sys_email_template row id the environment keeps for a listed template conflict ' +
149+
'(rows of one name and locale that differ). Repeat once per conflict; the other rows of that conflict are ' +
150+
'deleted only after the chosen body is promoted and verified',
151+
multiple: true,
152+
}),
143153
};
144154

145155
async run(): Promise<void> {
@@ -172,6 +182,10 @@ export default class MigrateOrganizationOwnership extends Command {
172182
await this.runApply(flags, posture, refuse);
173183
return;
174184
}
185+
if (flags['template-choice']?.length) {
186+
await refuse({ reason: 'flag-without-apply', detail: '--template-choice is only meaningful with --apply' }, '--template-choice is only meaningful with --apply.');
187+
return;
188+
}
175189
if (flags['backup-taken']) {
176190
await refuse({ reason: 'flag-without-apply', detail: '--backup-taken is only meaningful with --apply' }, '--backup-taken is only meaningful with --apply.');
177191
return;
@@ -261,6 +275,15 @@ export default class MigrateOrganizationOwnership extends Command {
261275
`${table.notNull.willReceive ? chalk.green(' NOT NULL') : ''}`,
262276
);
263277
}
278+
for (const table of plan.tables) {
279+
for (const conflict of table.templateConflicts ?? []) {
280+
printWarning(
281+
`Template conflict ${conflict.name} (${conflict.locale}): ` +
282+
conflict.candidates.map((c) => `${c.id} (organization ${c.organizationId ?? 'none'})`).join(', ') +
283+
' differ. --apply blocks on it until one row is chosen with --template-choice ROW_ID.',
284+
);
285+
}
286+
}
264287
console.log('');
265288
const { summary } = plan;
266289
printInfo(
@@ -276,7 +299,7 @@ export default class MigrateOrganizationOwnership extends Command {
276299

277300
/** `--apply`: the backup precondition, the boot, the ceremony, the report file. */
278301
private async runApply(
279-
flags: { 'database-url'?: string; out?: string; json?: boolean; 'backup-taken'?: boolean },
302+
flags: { 'database-url'?: string; out?: string; json?: boolean; 'backup-taken'?: boolean; 'template-choice'?: string[] },
280303
posture: string,
281304
refuse: (payload: Record<string, unknown>, message: string) => Promise<void>,
282305
): Promise<void> {
@@ -342,6 +365,7 @@ export default class MigrateOrganizationOwnership extends Command {
342365
const engine = kernel.getService('objectql') as Parameters<typeof recordDataMigrationRun>[0] & { registry?: unknown };
343366
const protocol = kernel.getService('protocol') as {
344367
saveMetaItem?: (request: Record<string, unknown>) => Promise<unknown>;
368+
getMetaItemsForExecution?: (request: { type: string }) => Promise<{ items?: unknown[] }>;
345369
} | undefined;
346370
const catalog = createSecurityCatalogReader({
347371
registry: engine.registry as never,
@@ -371,6 +395,7 @@ export default class MigrateOrganizationOwnership extends Command {
371395
const liveStack = stack;
372396
report = await applyOrganizationOwnership({
373397
executor,
398+
...(flags['template-choice']?.length ? { templateChoices: flags['template-choice'] } : {}),
374399
posture,
375400
database: stack.dbLabel,
376401
catalog,
@@ -382,6 +407,29 @@ export default class MigrateOrganizationOwnership extends Command {
382407
await protocol.saveMetaItem({ type, name, item: body, actor: CEREMONY_ACTOR });
383408
},
384409
},
410+
templates: {
411+
// The environment's own row first (it is what a promotion must never
412+
// overwrite), then what the registry serves for the bundle member.
413+
find: async (name, locale) => {
414+
const stored = await executor.query(
415+
'SELECT metadata FROM sys_metadata WHERE type = ? AND name = ? AND state = ? AND organization_id IS NULL ' +
416+
'AND (bundle_member = ? OR (bundle_member IS NULL AND ? = ?))',
417+
['email_template', name, 'active', locale, locale, 'en-US'],
418+
);
419+
if (stored.length > 0) {
420+
const raw = stored[0]!.metadata;
421+
const definition = (typeof raw === 'string' ? JSON.parse(raw) : raw) as Record<string, unknown>;
422+
return { definition, environment: true };
423+
}
424+
if (typeof protocol?.getMetaItemsForExecution !== 'function') return undefined;
425+
const listed = await protocol.getMetaItemsForExecution({ type: 'email_template' });
426+
const served = (listed?.items ?? []).find((item) => {
427+
const i = item as Record<string, unknown> | null;
428+
return !!i && i.name === name && (typeof i.locale === 'string' && i.locale ? i.locale : 'en-US') === locale;
429+
}) as Record<string, unknown> | undefined;
430+
return served ? { definition: served, environment: false } : undefined;
431+
},
432+
},
385433
columnDrops: {
386434
drop: async (tables) => {
387435
const drift = await liveStack.detectManagedDrift();
@@ -411,6 +459,10 @@ export default class MigrateOrganizationOwnership extends Command {
411459
});
412460
} catch (error: unknown) {
413461
if (isExitSignal(error)) throw error;
462+
if (error instanceof TemplateChoiceRefusal) {
463+
await refuse({ reason: 'template-choice-refused', detail: error.message }, `Refused: ${error.message}`);
464+
return;
465+
}
414466
if (error instanceof OrganizationOwnershipPlanRefusal) {
415467
await refuse(
416468
{ reason: error.reason, ...(error.table ? { table: error.table } : {}), detail: error.message },
@@ -454,16 +506,25 @@ export default class MigrateOrganizationOwnership extends Command {
454506
console.log('');
455507
for (const table of report.tables) {
456508
if (!table.before) continue;
457-
if (table.attributed + table.deleted + table.unattributable.length + table.remaining + table.departures.length === 0 && !table.columnDropped) continue;
509+
if (table.attributed + table.deleted + table.movedOut + table.movedIn + table.unattributable.length + table.remaining + table.departures.length === 0 && !table.columnDropped) continue;
458510
console.log(
459511
` ${chalk.white(table.object.padEnd(34))} ${table.fate.padEnd(16)} attributed ${String(table.attributed).padStart(6)} ` +
460-
`deleted ${String(table.deleted).padStart(6)} ${table.columnDropped ? chalk.green('column dropped ') : ''}` +
512+
`deleted ${String(table.deleted).padStart(6)} ` +
513+
`${table.movedOut ? `moved out ${table.movedOut} ` : ''}${table.movedIn ? `moved in ${table.movedIn} ` : ''}` +
514+
`${table.columnDropped ? chalk.green('column dropped ') : ''}` +
461515
`${table.unattributable.length ? chalk.yellow(`reported ${table.unattributable.length} `) : ''}` +
462516
`${table.remaining ? chalk.red(`remaining ${table.remaining}`) : ''}`,
463517
);
464518
for (const departure of table.departures) {
465519
console.log(chalk.yellow(` ${departure.rows} row(s) still to leave by a step not yet run — ${departure.label}`));
466520
}
521+
for (const conflict of table.templateConflicts ?? []) {
522+
console.log(chalk.yellow(
523+
` template conflict ${conflict.name} (${conflict.locale}): ` +
524+
conflict.candidates.map((c) => `${c.id} (organization ${c.organizationId ?? 'none'})`).join(', ') +
525+
' — choose one with --template-choice ROW_ID',
526+
));
527+
}
467528
}
468529
console.log('');
469530
if (report.advisory > 0) {

0 commit comments

Comments
 (0)