Skip to content

Commit 1bede35

Browse files
committed
fix(cli): the template delete stays inside fate 2 on every run; walled promotion with operator-chosen conflicts (#22937)
- Mirror deletion re-runs the idempotent template promotion before its first template delete, then deletes only the seeded, uncustomized mirrors and, by id, the rows whose environment copy that pass verified. A resumed run can no longer delete a row customized or authored between runs. - A customized or authored template row with no name blocks: kept, named. - The promotion runs under every posture. Rows of one (name, locale) that differ are a conflict: listed in the plan and the --apply report with each candidate's row id and organization, counted as remaining, and blocking until the operator chooses one row with the repeatable --template-choice ROW_ID. An unknown or ambiguous choice is refused before anything is written. - The settings move selects organization_id IS NULL and reads each destination row back before deleting its source; the human face prints the moves. - emailTemplateDefinitionFromRow falls back to EMAIL_TEMPLATE_FLOOR_LOCALE. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DjKf52bcyXa3fbGW723phR
1 parent 6e96358 commit 1bede35

8 files changed

Lines changed: 455 additions & 140 deletions

‎.changeset/22937-cli-ceremony-promotions.md‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,15 @@ Clause-②: yes (widening)
88

99
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.
1010

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. Seeded, uncustomized copies are not promoted. A promotion that already landed is skipped. The step stops, keeping every row, in three cases:
12-
- two rows of one `(name, locale)` that differ;
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;
1314
- an environment template that already exists and differs, since the environment's edit is never overwritten;
1415
- a door refusal.
15-
16-
Under a walled posture the rows are kept and not promoted: promoting them would apply one organization's edit to every organization. They count as remaining and are named as a `template-promotion` departure, so the ceremony's `verified_at` stays null.
17-
- **`settings-move`** (ADR-0131 D7). Every `sys_setting` row with `scope = 'global'` is copied into `sys_platform_setting` by the database itself (`INSERT … SELECT`), so `value` and the `value_enc` secret handle move verbatim. The row then leaves `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:
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:
1817
- `sys_platform_setting` already holds the key with a different value (the one the runtime serves);
1918
- `sys_setting` holds differing rows for the key.
2019

2120
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.
2221

23-
Mirror deletion now also deletes the promoted template rows: under `single`, once the promotion step has finished, every remaining `sys_email_template` row is a mirror. The apply report gains `movedOut` / `movedIn` per table, and row counts reconcile as before − deleted − moved out + moved in.
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.

‎.changeset/22937-core-email-template-row-definition.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ feat(core): `emailTemplateDefinitionFromRow` — a `sys_email_template` row as t
66

77
Clause-②: yes (widening)
88

9-
A new pure export beside `permissionSetDefinitionFromRow` and `positionDefinitionFromRow`: one row in, one body out, with no engine, no I/O and no import. 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.
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.
1010

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 `en-US`. The retired `category` / `is_system` columns and every provenance or state column are left out. It does not validate; the door does.
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.
1212

1313
Nothing existing changes.

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

Lines changed: 38 additions & 3 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,
@@ -142,6 +143,13 @@ export default class MigrateOrganizationOwnership extends Command {
142143
'none; without this flag the run asks, and a non-interactive run is refused',
143144
default: false,
144145
}),
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+
}),
145153
};
146154

147155
async run(): Promise<void> {
@@ -174,6 +182,10 @@ export default class MigrateOrganizationOwnership extends Command {
174182
await this.runApply(flags, posture, refuse);
175183
return;
176184
}
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+
}
177189
if (flags['backup-taken']) {
178190
await refuse({ reason: 'flag-without-apply', detail: '--backup-taken is only meaningful with --apply' }, '--backup-taken is only meaningful with --apply.');
179191
return;
@@ -263,6 +275,15 @@ export default class MigrateOrganizationOwnership extends Command {
263275
`${table.notNull.willReceive ? chalk.green(' NOT NULL') : ''}`,
264276
);
265277
}
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+
}
266287
console.log('');
267288
const { summary } = plan;
268289
printInfo(
@@ -278,7 +299,7 @@ export default class MigrateOrganizationOwnership extends Command {
278299

279300
/** `--apply`: the backup precondition, the boot, the ceremony, the report file. */
280301
private async runApply(
281-
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[] },
282303
posture: string,
283304
refuse: (payload: Record<string, unknown>, message: string) => Promise<void>,
284305
): Promise<void> {
@@ -374,6 +395,7 @@ export default class MigrateOrganizationOwnership extends Command {
374395
const liveStack = stack;
375396
report = await applyOrganizationOwnership({
376397
executor,
398+
...(flags['template-choice']?.length ? { templateChoices: flags['template-choice'] } : {}),
377399
posture,
378400
database: stack.dbLabel,
379401
catalog,
@@ -437,6 +459,10 @@ export default class MigrateOrganizationOwnership extends Command {
437459
});
438460
} catch (error: unknown) {
439461
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+
}
440466
if (error instanceof OrganizationOwnershipPlanRefusal) {
441467
await refuse(
442468
{ reason: error.reason, ...(error.table ? { table: error.table } : {}), detail: error.message },
@@ -480,16 +506,25 @@ export default class MigrateOrganizationOwnership extends Command {
480506
console.log('');
481507
for (const table of report.tables) {
482508
if (!table.before) continue;
483-
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;
484510
console.log(
485511
` ${chalk.white(table.object.padEnd(34))} ${table.fate.padEnd(16)} attributed ${String(table.attributed).padStart(6)} ` +
486-
`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 ') : ''}` +
487515
`${table.unattributable.length ? chalk.yellow(`reported ${table.unattributable.length} `) : ''}` +
488516
`${table.remaining ? chalk.red(`remaining ${table.remaining}`) : ''}`,
489517
);
490518
for (const departure of table.departures) {
491519
console.log(chalk.yellow(` ${departure.rows} row(s) still to leave by a step not yet run — ${departure.label}`));
492520
}
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+
}
493528
}
494529
console.log('');
495530
if (report.advisory > 0) {

0 commit comments

Comments
 (0)