Skip to content

feat(cli,core): the organization-ownership ceremony promotes customized email templates and moves the settings global rung (C7b, PR 3 of 3) - #23329

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-22937-ceremony-promotions
Oct 13, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-22937-ceremony-promotions

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

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 (feat(spec,services): deployment-level state has no organization column — settings global rung, plumbing objects, the audit ledger, #12699 made total (ADR-0131 D7) #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


Generated by Claude Code

…10 ceremony's apply step and post-check

The read-only plan gains its writing mode. --apply asks about the backup
first (--backup-taken, or a yes at the prompt; refused non-interactively),
then performs the fates in order, stopping at the first blocker:

1. attribution — the owners the plan's own walk derives (anchors, else the
   Default Organization under single), to a fixed point; an unattributable
   row is never assigned and stays listed by id;
2. authored catalog rows — permission sets and positions become environment
   metadata through the metadata door via core's row-to-definition mappers,
   with position bindings carried into permissionSets (single only);
3. the verified id-to-name rewrite of sys_user_permission_set.permission_set,
   then every grant verified to name a set the security catalog resolves
   (permission_set_id stays);
4. mirror deletion, gated on that verification read again before the first
   delete; only mirror rows no named category claims;
5. column drops through the schema-drift ceremony's drop_column entries.

Every write is conditioned on the state it changes, and each finished step
is checkpointed in sys_migration (adr-0131-organization-ownership), so an
interrupted run resumes at its step. The post-check re-reads the plan; the
ledger row's applied_at / verified_at are the ceremony's marker.

The acceptance fixture carries every fate: effective access identical, with
a positive control; mirrors gone; attributed rows stamped; the orphan row
present and reported; row counts reconciling.

Claude-Session: https://claude.ai/code/session_01DjKf52bcyXa3fbGW723phR
Co-authored-by: Claude <noreply@anthropic.com>
… as the email_template definition it carries

The pure row-to-definition mapper beside permissionSetDefinitionFromRow and
positionDefinitionFromRow, for the v18 ceremony's promotion of customized
template rows (ADR-0131 D10, ruling C on section 6 Q1). No import, so the
lean entry carries it. Pinned against EmailTemplateDefinitionSchema.

Claude-Session: https://claude.ai/code/session_01DjKf52bcyXa3fbGW723phR
Co-Authored-By: Claude <noreply@anthropic.com>
… the settings global rung

Two steps of `os migrate organization-ownership --apply`, per the
maintainer's ruling on #22937 (Q3, Q4 → A), checkpointed and resumable
like the others:

- template-promotion: customized and admin-authored sys_email_template
  rows become environment email_template items through the metadata
  door (core's emailTemplateDefinitionFromRow) before mirror deletion;
  never over an environment template that differs; reported under a wall.
- settings-move: the inventory's global-rung-move departure, executed:
  sys_setting scope = global rows copied into sys_platform_setting by
  INSERT ... SELECT, then removed; a key already served with a different
  value is a blocker.

The sys_sharing_rule declared-rule mirrors are measured and not removed:
the declared-rule seeder is still live and rule grants key on the row id.

Claude-Session: https://claude.ai/code/session_01DjKf52bcyXa3fbGW723phR
Co-Authored-By: Claude <noreply@anthropic.com>
…remony-promotions

Conflicts in the apply module, its test and the command resolved so that
PR 2's landed behaviour holds exactly (B1's provenance keep, the
NULL-claim-keeps rule, B2's departure count), with PR 3 layered on top:

- the template promotion carries exactly the rows B1 keeps (customized or
  customized-unknown rows, and every row no seeder wrote, with or without
  an organization); under single, once it has run, those claims are
  discharged and the rows are mirrors;
- under a wall no promotion runs: the rows stay, count as remaining and
  are named as a template-promotion departure, so verified_at stays null;
- the global-rung departure is executed by settings-move, so it reads 0;
  the declared-rule mirrors stay counted until D2.

Claude-Session: https://claude.ai/code/session_01DjKf52bcyXa3fbGW723phR
Co-Authored-By: Claude <noreply@anthropic.com>
… keeps; the executed departure stops counting

Claude-Session: https://claude.ai/code/session_01DjKf52bcyXa3fbGW723phR
Co-Authored-By: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 13, 2026
@github-actions

github-actions Bot commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/cli, @objectstack/core, touching 54 documentable anchor(s).

11 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/email-templates.mdx (via email_template (literal, a string literal in runApply; a string literal in runTemplatePromotion; a string literal in save), sys_email_template (literal, a string literal in TEMPLATE_OBJECT))
  • content/docs/concepts/metadata-lifecycle.mdx (via email_template (literal, a string literal in runApply; a string literal in runTemplatePromotion; a string literal in save))
  • content/docs/data-modeling/drivers.mdx (via sys_setting (literal, a string literal in SETTINGS))
  • content/docs/deployment/cli.mdx (via sys_setting (literal, a string literal in SETTINGS))
  • content/docs/deployment/environment-variables.mdx (via email_template (literal, a string literal in runApply; a string literal in runTemplatePromotion; a string literal in save), sys_setting (literal, a string literal in SETTINGS))
  • content/docs/deployment/install-modes.mdx (via email_template (literal, a string literal in runApply; a string literal in runTemplatePromotion; a string literal in save))
  • content/docs/permissions/authorization.mdx (via managed_by (literal, a string literal in templatePromotionSql))
  • content/docs/permissions/capabilities.mdx (via os migrate organization-ownership (command, read off packages/cli/src/commands/migrate/organization-ownership.ts))
  • content/docs/permissions/permission-sets.mdx (via os migrate organization-ownership (command, read off packages/cli/src/commands/migrate/organization-ownership.ts))
  • content/docs/permissions/system-context.mdx (via sys_email_template (literal, a string literal in TEMPLATE_OBJECT), sys_platform_setting (literal, a string literal in PLATFORM_SETTINGS), sys_setting (literal, a string literal in SETTINGS))
  • content/docs/protocol/kernel/config-resolution.mdx (via sys_setting (literal, a string literal in SETTINGS))

⛔ 5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v12.mdx (via managed_by (literal, a string literal in templatePromotionSql))
  • content/docs/releases/v15.mdx (via managed_by (literal, a string literal in templatePromotionSql))
  • content/docs/releases/v17/17-1.mdx (via sys_email_template (literal, a string literal in TEMPLATE_OBJECT))
  • content/docs/releases/v17/17-3.mdx (via sys_setting (literal, a string literal in SETTINGS))
  • content/docs/releases/v17/17-5.mdx (via body_text (symbol, a field of interface EmailTemplateRowForDefinition))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • 1 anchor(s) matched too much of the corpus to be a work list: organization_id (literal, 35 pages)
  • 16 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 46 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 614fdec83280704418266aba83fb33dce1e5fb45 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from dd7d87df9e2ed32326c1bf69920c8b0aa618c697 — the merge of head db2088648cc2933d87102a010c2c89c25ea338fe into base 614fdec83280704418266aba83fb33dce1e5fb45, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin dd7d87df9e2ed32326c1bf69920c8b0aa618c697 && git checkout dd7d87df9e2ed32326c1bf69920c8b0aa618c697
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 614fdec83280704418266aba83fb33dce1e5fb45 db2088648cc2933d87102a010c2c89c25ea338fe && git checkout -B drift-repro 614fdec83280704418266aba83fb33dce1e5fb45 && git merge --no-ff db2088648cc2933d87102a010c2c89c25ea338fe

node scripts/docs-audit/affected-docs.mjs --json 614fdec83280704418266aba83fb33dce1e5fb45

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 614fdec83280704418266aba83fb33dce1e5fb45 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 34989ec72ec0ed045fc09d80bc6fafbe0021c709
Local-runs: none

What was reviewed. PR #23329 (feat(cli,core): the organization-ownership ceremony promotes customized email templates and moves the settings global rung (C7b, PR 3 of 3), draft, base main, head repo = base repo) at head 34989ec72e, the head the brief named; it had not moved at 2026-10-13T20:05Z. Seven commits over origin/main 4d4040ccb6, which is also the merge-base: 0555b80a and 5a05cb3d (the PR 2 build and an early merge), 691bb957 (core mapper), 49d85fc6 (the two steps), f36a3a34 (the merge that resolved against PR 2's squash 2808069121), 636f149b (changeset), 34989ec7 (merge of two main commits touching none of this PR's files). Three-dot diff: 8 files, +704 / −41, under packages/cli, packages/core/src/security and two changesets; the merge ref 06d8ebaf (parents 4d4040cc, 34989ec7) has a tree byte-equal to the head's, so every check-run below ran on the diff as it would land. The apply module, its test and the command are unchanged on main since 2808069121, so git diff 2808069121 head on those three files is PR 3's layer exactly. Inputs: card #22937's body, the claim 6122368393, the ruling 6138591940, the seat's answers 6140351542, the landing record 6151058104, the dispatch 6151180611, the dev's report 6153947869; PR 2's records 6147223040 and 6150575317; the PR body, file list, diff and merge ref; ADR-0131 on origin/main (1.4, D7, D10, D13, section 6 Q1); the head's check-runs. For the door and the schema: EmailTemplateDefinitionSchema (packages/spec/src/system/email-template.zod.ts), itemDiscriminator / ITEM_KEY_DISCRIMINATORS (packages/metadata-core/src/item-key-discriminators.ts), saveMetaItem and readFlattenedMetaItems in packages/metadata-protocol/src/protocol.ts, sys-metadata-repository.ts, the three object definitions, the inventory entries, predicateSql / Sql / walkNullRows in the plan module, scripts/check-closing-target-claim.mjs and claimedBranches.

Check-runs on 34989ec7 at 2026-10-13T20:05Z: 38 runs. 24 success (among them Build Core, Check Changeset, Check PR Size, Type Check · source gates, Type Check · consumer gates, Type Check · debt ledger, Lint · ESLint, Lint · PM dispatch-gates, Lint · docs & code guards, Lint · repo & tooling gates, Lint · CI, release & engine gates, Lint & Repo Gates, Governed Surface Queue Guard, Part-of PR must not also close its card, No other open PR may claim the same issue, No other open PR may claim the same single-writer path). 6 skipped (Build Docs, Console Pin Gate, the Dogfood Regression Gate matrix row, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance). 7 still running: Test Core (1/6) through (6/6) and Type Check · workspace. 1 failure: The card this PR closes must claim this branch (job 115507157834, step 4 red; the log download is refused by the session's proxy, so the cause is read from the script and the card, not the log — see ③ F1).

① Derived judgments

  1. The merge resolution — PR 2's landed behaviour holds, with one named semantic change, the dev's own. Read off git diff 2808069121 head -- organization-ownership-apply.ts:

    • B1's provenance keep: deletableWhere still adds the managed_by IS NULL OR NOT IN (package, platform, system) keep on every table carrying managed_by — except, under single, on sys_email_template after the promotion (the discharge, below). The CASE WHEN claim THEN 1 WHEN NOT (claim) THEN 0 ELSE 1 END = 0 keep is byte-identical, so a NULL-evaluating claim still keeps its row.
    • B2's departure count: remainingFor for attribution and report tables is unchanged (rowsToAttribute + departingRows); TableOutcome.departures is still filtered to rows above zero; notNull is overridden from the same list (now a local departures array, equal to the old one unless the walled template entry is pushed).
    • The gate re-read: runMirrorDeletion still calls verifyReferences before any DELETE and returns blocked without deleting. Keyset paging: readAll (id greater than the last page's id, bound, LIMIT PAGE) is unchanged and both new steps read through it. Checkpoint and marker: the loop, the per-step ledger.write({ complete: false }), the digest-bound checkpoint, the final passed: complete && blocking === 0 are untouched; the two new steps are checkpointed like the others. Sql.q on every identifier and every value bound: holds on the old code and on the new (①7).
    • Changes to PR 2's lines that are not pure additions: CEREMONY_STEPS gains two members between authored-conversion and rewrite; MetadataDoorPort.save widens its type union; ApplyOptions gains a required templates port; TableOutcome gains movedOut / movedIn; SEEDED_BY moves up the file unchanged; and the one behavioural change: under single, for sys_email_template only, deletableWhere skips the template-promotion and organization-stamped category keeps and the provenance keep (DISCHARGED_BY_PROMOTION). The dev named it; its consequences are in ①2 and ③ F2. The test moved the "killed mid-step" trigger from /^DELETE/ to /^DELETE FROM "sys_capability"/ and excluded sys_setting / sys_platform_setting from the no-delete pin because the settings move now deletes before the rewrite; both test-only and honest.
  2. The promotion population — B1's kept set minus the seeded uncustomized organization-stamped copies, which is the dispatch's set; one deletion path escapes it. templatePromotionSql selects customized = true OR customized IS NULL OR managed_by IS NULL OR managed_by NOT IN (package, platform, system) (values bound), with no organization clause. B1's kept set on this table is that same predicate OR organization_id IS NOT NULL. The difference — seeded, uncustomized, organization-stamped rows — is not promoted and, under single, is deleted as a mirror because the organization-stamped keep is discharged: the seat's answer 6140351542, applied as written. No row PR 2 deletes (seeded, uncustomized, NULL organization) is in the population. The customized IS NULL rows the dev added are right: B1's ELSE arm keeps them, so they are in the kept set; promoting an unknown is the safe direction, and when the body equals the served template the save is skipped (alreadyPromoted) before the row goes. On a table without managed_by the predicate is 1 = 1 (everything promoted, then under single everything deleted) — the safe direction, unmeasured. Where a row can be deleted that should have been promoted: under single the template DELETE is WHERE 1 = 1 — the discharge removes every keep and the inventory names no mirror selector — and promoted is derived from the posture alone, not from a verified promotion of each row. In the same run every population row was promoted, alreadyPromoted or blocked (a blocker stops the ceremony before the DELETE), with two exceptions: a row with no name is "reported, never promoted" and the step still ends done, so the DELETE takes it; and on a resumed run (template-promotion checkpointed, mirror-deletion not) the promotion is skipped, run.saved is empty, nothing re-reads the table, and every row present is deleted — including one a 17.x admin customized or authored between the runs. PR 2 built the grant re-read for exactly this window and pinned it; the template step has no equivalent. ③ F2.

  3. What promotion writes. Door: protocol.saveMetaItem({ type: 'email_template', name, item: body, actor: 'os migrate organization-ownership' }), the same door as the authored conversion. Mode defaults to publish (protocol.ts: "Default is 'publish'"), which the repository writes as state: 'active'; the request carries no organization and the door refuses organization-scoped writes for every type, so the row is environment-level (organization_id NULL); bundle_member comes from itemDiscriminator('email_template', body) — the body's locale, canonical en-US stored as NULL — so a second locale of one name is a second row under the (type, name, organization_id, package_id, bundle_member) key, and "one per (name, locale)" holds through the door. Owner: whatever the door stamps for the actor string; the mapper sends no provenance key. Posture: single only; under a wall nothing is written. Idempotent: the command's find reads sys_metadata (type, name, state = 'active', organization_id IS NULL, bundle_member = locale OR (NULL AND locale = 'en-US'), all bound), then getMetaItemsForExecution({ type: 'email_template' }) (returns { type, items }, matched on name and locale with the en-US fallback); an equal body is alreadyPromoted, a differing environment row is a blocker ("never overwritten"), a differing served package item is overridden (D6: the environment row replaces the managed item whole), a door refusal is a blocker. Equal to the served template: no save, the row is then deleted — nothing lost. Caveat, unmeasured: bodyKey compares the body's own keys with ?? null; if the real door normalizes a stored body so any of those keys differ, a re-run with rows still present (interrupted before deletion, checkpoint discarded by a digest change) reads a false "differs" blocker — loud, not lossy. Deleted afterwards, and only after the write is verified: deleted afterwards, yes; the verification is the door's acceptance in the same run and nothing on a resumed run (①2, F2).

  4. Counting until promotion. Under single: a blocked promotion stops the ceremony, complete: false, blocking = blocker count, the ledger write has passed: false so verified_at stays null, and the blocker names the template (name, locale) and the row ids — pinned. After a completed promotion the discharge makes every remaining row deletable, mirror deletion removes them, and remainingFor reads 0 — pinned (the acceptance case ends sys_email_template empty, complete: true, blocking: 0). Under a wall: remainingFor adds the unpromoted count, the post-check pushes a synthetic template-promotion departure (as: 'moved', label naming the posture, rows), notNull.willReceive reads false with that label, blocking is non-zero, verified_at null — pinned (isolated, tpl_admin counted, tpl_seeded deleted). The dev's two ablations (count dropped: the walled case red; population dropped: three cases red) are reported with restored blobs; weighed, not repeated. Honest in the JSON: yes — tables[].departures[] carries id, label, as, rows; movedOut / movedIn reconcile before − deleted − movedOut + movedIn = after, pinned per table. In the human face: the departure line prints through the existing loop; but the table filter still reads attributed + deleted + unattributable + remaining + departures.length, so sys_setting (movedOut 2) and sys_platform_setting (movedIn 2) print no table line — the move is visible only in the step line's summary {"moved":2,"alreadyMoved":0} and the progress line. Face nit, not blocking. Nit: as: 'moved' on a promotion stretches the inventory's two-word vocabulary.

  5. The global-rung move. Destination sys_platform_setting (D7; inventory label "moved to sys_platform_setting (value_enc copied verbatim, one row per key)"). Population: the inventory departure's where (scope = 'global'), read through predicateSql, keyed (namespace, key), compared on value, value_enc, encrypted, locked, locked_reason. Conflict rule: differing source rows for one key — blocker, nothing touched; target holds the key with a different value — blocker, nothing moved or deleted for that key (pinned, mail.transport with "ses"); target holds it equal — the source row is deleted only (alreadyMoved); target absent — INSERT INTO to (shared cols) SELECT shared cols FROM from WHERE id = ? (the columns are the intersection of the two physical column lists, each through Sql.q; the id bound), then DELETE FROM from WHERE (scope = ?) AND id IN (?…). Target table absent — blocker with the remedy (boot once, re-run). Verified before delete: by the driver accepting the INSERT; no read-back of the target by key before the DELETE (the mirror step counts after its DELETE; this step does not). Idempotent and interruption-safe: the step is checkpointed only when done; interrupted between INSERT and DELETE, the next run finds the target equal and only deletes; a run after completion reads zero rows. After the step the plan's recount of the departure reads 0 (pinned: sys_setting departures []), and declared-rule-mirrors still counts and is named (pinned, rule_declared). Two notes, not blocking: (a) the move takes every scope = 'global' row whatever its organization_id, while the departure it executes is counted by walkNullRows over NULL rows only; the settings service wrote global rows with organization_id: null (settings-service.ts line 2087), so the sets coincide today, but a stamped global row, if one existed, would be copied into a deployment-wide table — one organization's value becoming everyone's, the exact thing ①2 refuses under a wall. One bound clause (organization_id IS NULL) and a reported residue closes it. (b) The id is copied verbatim; a PK collision in the target throws through execute and aborts the run resumably, with the driver's message. The move runs before the rewrite gate: it is a departure, not fate 2, so D10's "mirror deletion behind the verified rewrite" does not reach it; the running 17.x service already serves the rung from the new table, so a later blocker leaves nothing broken.

  6. Core's emailTemplateDefinitionFromRow. Against the strict schema (strictObject): the body carries name, label, locale, subject, bodyHtml, variables, active always and bodyText, fromOverride, replyTo, description when filled — every one a declared key; protection (no column), the retired category / isSystem and every state or provenance column are left out. fromOverride is { name?, address }, the shape of EmailAddressInlineSchema; label falls back to the name (required string); locale falls back to 'en-US', equal to EMAIL_TEMPLATE_FLOOR_LOCALE and the discriminator's canonical, but hard-coded rather than imported (the module keeps itself import-free; the pin parses the result but does not compare the fallback to the constant — a drift the spec test stack-email-template-locale-floor.test.ts does not watch for this file). Conventions match the two siblings: the RowForDefinition interface with an index signature, pure, deterministic key order, the "not validation" note, the boolean | 0 | '0' | 'false' reading inverted from positionDefinitionFromRow's truthy test and documented. It does not filter through the schema's shape as permissionSetDefinitionFromRow does; the position mapper does not either, and the pin holds the body to a full safeParse. The export sits in packages/core/src/security/index.ts beside the other two with its type, and core's root index re-exports ./security, so it is on the . surface the changeset grades. The test's @objectstack/spec/system subpath is exported by spec's package.json. The file lives under security/ because the card put the mappers there; semantically it is a template, not a security object — nit.

  7. Data safety. No write precedes the backup acknowledgement and the boot: the command's flow is unchanged and both steps run inside applyOrganizationOwnership after the plan. No SQL is built from data: templatePromotionSql quotes through Sql.q and binds true and the three provenance words; the move quotes every column it copies (names from the driver's column listing, each refused by SAFE_IDENTIFIER if unsafe) and binds ids and the where's values; the command's find is a constant statement with six bound values. Destructive steps: the template DELETE is fate 2 after the promotion (with F2's hole); the settings DELETE is the moved departure the inventory names under D7 and feat(spec,services): deployment-level state has no organization column — settings global rung, plumbing objects, the audit ledger, #12699 made total (ADR-0131 D7) #15207's ruling; the sharing-rule mirrors are not touched.

② Semver level

.changeset/22937-core-email-template-row-definition.md grades @objectstack/core minor and .changeset/22937-cli-ceremony-promotions.md grades @objectstack/cli minor; both carry Clause-②: yes (widening), as does line 2 of the PR body. Under the WHICH LEVEL rule in .github/workflows/pr-automation.yml, a new exported symbol on an index (core: emailTemplateDefinitionFromRow and its row type, reachable from the package root) and a new accepted key on a published payload (cli: tables[].movedOut, tables[].movedIn, two new steps[].step values, the template-promotion departure id in the report file and --json stdout) each take at least minor; the commit type feat( agrees. Check Changeset is success, so the level axis accepted a declared clause ② with both moved packages at minor. Hidden removal or narrowing: none on a public surface — ApplyOptions.templates is a new required option, but organization-ownership-apply.ts is not on the CLI's exports (., ./console, ./hook-body) and is imported only by the command and two tests in the package; no flag, exit code or key is removed; the one behavioural tightening (under single, template rows PR 2 kept are deleted after promotion) is the ceremony completing, stated in the cli changeset. Both changeset bodies match the code on every clause read, including the walled posture and the alreadyMoved rule. No BREAKING banner and no ADR-0087 entry is owed by this PR; the sys_email_template table's own retirement under D13 is C8's entry, not this one.

③ Boundary flags

  • F1 — blocking on this head, the seat's remedy. The closing-target claim gate is red. scripts/check-closing-target-claim.mjs exits 1 when no Claim: comment on the card this PR closes carries a Branch: directive naming the PR's head branch (claimedBranches reads Branch: lines only). The only claim on C7b build-ahead: os migrate organization-ownership --apply and E5's pure row→definition mappers in core (drafts from main; the --apply PR lands after #15204 stage 8) #22937 is 6122368393, Branch: claude/issue-22937-organization-ownership-apply; this PR's branch is claude/issue-22937-ceremony-promotions, and the dispatch 6151180611 said "The claim 6122368393 stands". The script forbids the dispatched executor from posting a second claim ("the dispatch's claim is its identity"), so the seat posts a Claim: on C7b build-ahead: os migrate organization-ownership --apply and E5's pure row→definition mappers in core (drafts from main; the --apply PR lands after #15204 stage 8) #22937 naming this branch and re-runs the job; the gate reads the card live, so no new head is needed for it. The job log itself is NOT MEASURED (the proxy refuses the log host); the cause is read from the script and the thread.
  • F2 — blocking-grade, the dev's remedy; the seat decides whether it lands in this PR. Under single the template DELETE is WHERE 1 = 1 and nothing before it checks that each row has its environment copy (①2, ①3): a nameless row is reported and then deleted, and on a resumed run every row present is deleted with no promotion re-read, including a row customized or authored between the runs. This is the B1 class (a customer row deleted without promotion) through a narrow window — the same window PR 2 closed for grants with the gate re-read, on this card, and pinned. Remedy inside the surface: before the first template DELETE under single, run the promotion pass again (it is idempotent: equal bodies are alreadyPromoted) and block if it promotes nothing new while a population row lacks a copy — or delete by id (the promoted ids plus the seeded mirrors) and block on any other row; make the nameless-row arm a blocker; add a pin that inserts a customized row between the killed run and the resume and asserts it survives.
  • Dev deviations (6153947869), answered: the customized NULL population — right (①2); the two merge commits and the default message — accepted, as 6140351542 accepted the same shape. No open questions and no out-of-scope findings were reported.
  • Walled posture, escalated. ADR-0131 section 6 Q1 as ruled (C) says the ceremony promotes customer-edited templates and, on a deployment with more than one organization, lists a name conflict for the operator to choose per row (fate 4). PR 3 promotes nothing under a wall and counts every population row as remaining forever (the dispatch's item 2 asked for exactly that count). As built, a walled deployment holding any customized or authored template cannot reach verified_at through this ceremony, and no PR or card in the inputs carries the walled promotion with the per-row choice. The seat names a carrier (or rules that walled deployments resolve the rows by hand, which the face names).
  • The second ruling-C population — organization-scoped email_template overlays Studio saved under single — is the inventory's organization-presentational-promotion category on sys_metadata, carried by the family item in 6150126995 and the feat(objectql,cli): inventory + migration — four fates per object, mirrors deleted only after the id→name rewrite is verified, per-table boot report (ADR-0131 D10) #15211 pointer, not this PR; the PR body says so. Right.
  • Line 1 is Fixes #22937, line 2 the clause; Part-of PR must not also close its card and No other open PR may claim the same issue are success. Left open on the card by this PR: the card body's own C7b sentence — "C7b drops the column and both indexes, and judges whether the unique index still means anything once new grants write NULL there" (sys_user_permission_set.permission_set_id) — is done by none of the three PRs; the head's test still says "the id column is still there (C7b's later drop)". Closing the card leaves that drop without a carrier in the inputs: the seat names one (C8 or a new card) before readying. Also carried elsewhere, with carriers named in the inputs: the sys_sharing_rule declared-rule mirrors (D2, 6140351542); the sys_metadata family drop (6150126995); the boot refusal (feat(objectql,cli): inventory + migration — four fates per object, mirrors deleted only after the id→name rewrite is verified, per-table boot report (ADR-0131 D10) #15211, C8). The "sys_business_unit_member is unadjudicated in PLATFORM_OBJECT_TENANCY, so seed-replayed and system-written membership rows land organization-less #14570 / plugin-sharing: after the #15030 revert, 17.x still cannot reach a NULL-org-seeded business unit from an org-stamped rule — and #14547, its only tracker, is closed #15086 absorbed" and "pointer on refactor(plugin-security,platform-objects,spec): retire the catalog seeders, the per-organization catalog machinery and the four catalog objects; Setup creation is an environment write under single and refused under a wall (ADR-0131 D2/D3/D5/D13) #15204" items of the card body are outside this record's inputs — NOT MEASURED.
  • Remedy-quality notes, not blocking: the move's missing organization_id IS NULL clause and the absent read-back before its DELETE (①5); the human face never prints movedOut / movedIn (①4); 'en-US' hard-coded in the mapper (①6); the SEEDED_BY duplicate of the inventory's vocabulary and the module-header nit carried from 6150575317 remain.
  • NOT MEASURED: Test Core (1/6)–(6/6) and Type Check · workspace, running at 2026-10-13T20:05Z — the verdict's gate reading is conditional on them; the claim gate's log; the real metadata door end to end (the stored body's shape against bodyKey, a second locale's bundle_member row, the active state) — the fixture's door is a map; the PostgreSQL and MySQL paths of INSERT … SELECT and the new predicates (SQLite fixture only); a sys_email_template without managed_by or customized (the 1 = 1 arm); the nameless-row arm; the between-runs template write (F2); dogfood, because no suite exercises the ceremony (the dev's finding, as before); the derived gates on 34989ec7 itself (the dev's 67/67 was on 636f149b, the same 8-file diff; the head's two extra main commits touch none of the files, and CI's lint and type jobs are green on the head).

Adopted by domain:engine seat 2 at 2026-10-13T20:13Z, after a tool-use audit of the isolated reviewer's transcript. The reviewer made reads, git fetch into review refs, REST GETs, and record writes only. It made no GitHub write and ran no build, test or gate. The seat takes the verdict as FAIL.

Implemented-by: claude/issue-22937-ceremony-promotions
Reviewed-by: session_01ADzJtzYTLUfgrRZHxkagkX

VERDICT: FAIL

…d 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
@github-actions github-actions Bot added size/xl and removed size/l labels Oct 13, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: db2088648cc2933d87102a010c2c89c25ea338fe
Local-runs: none

What was reviewed. PR #23329 (feat(cli,core): the organization-ownership ceremony promotes customized email templates and moves the settings global rung (C7b, PR 3 of 3), draft, base main, head repo = base repo) at head db208864, the head the brief named; it had not moved at 2026-10-13T22:06Z. This is the delta record answering FAIL 6154293191 at 34989ec72e. Since that head: the round's own commit 1bede353 (fix(cli): the template delete stays inside fate 2 on every run; walled promotion with operator-chosen conflicts), between two merges of main (6e963587, db208864). git diff 34989ec7 db208864 on the nine PR files equals the 1bede353 diff on eight of them; the ninth, packages/core/src/security/index.ts, differs only by main's own 25 lines (#23321, carried by 6e963587), and the net diff on it against main is the PR's four export lines alone. The final merge db208864 touched no PR file. The merge-base with origin/main is main's tip 614fdec8, so the three-dot diff is the two-dot diff: 9 files, +1,025 / −47. The merge ref dd7d87df (parents 614fdec8, db208864) has a tree byte-equal to the head's (1f1493da), so every check-run below ran on the diff as it would land. Inputs: the previous record 6154293191; on #22937 the ruling 6138591940, the seat's answers 6140351542, the dispatch 6151180611, the claim 6154303731, the patch round 6154321875 and the dev's report 6156054460; the #15211 pointer 6154334043; ADR-0131 on origin/main (1.4, D10, §6 Q1 as ruled C); the PR body, file list, net diff, delta and merge ref; the head's check-runs. Read on the head for the judgments: the apply module whole (deletableWhere, runTemplatePromotion, validateTemplateChoices, runSettingsMove, runMirrorDeletion, deletePromotedTemplates, remainingFor, the step loop and the final verdict), the plan module's new exports and readTemplateConflicts, Sql.read and walkNullRows, the inventory entry for sys_email_template, the command's flag, faces and catch, the test file's 17 cases and fixtures, the mapper and its test, both changesets, EMAIL_TEMPLATE_FLOOR_LOCALE in packages/spec/src/system/email-template.zod.ts.

Check-runs on db208864 at 2026-10-13T22:12Z: 39 runs. 28 success, among them Build Core, TypeScript Type Check (the required context) with Type Check · workspace, Type Check · source gates, Type Check · consumer gates and Type Check · debt ledger, Lint & Repo Gates with every Lint · row, Check Changeset, Check PR Size, Governed Surface Queue Guard, Test Core (1/6), Part-of PR must not also close its card, No other open PR may claim the same issue, No other open PR may claim the same single-writer path, and The card this PR closes must claim this branch, now success (F1 closed by the seat's claim 6154303731). 6 skipped (Build Docs, Console Pin Gate, the Dogfood Regression Gate matrix row, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance). 5 still running: Test Core (2/6) through (6/6). No failure.

① Derived judgments

  1. F2 — closed. Every template DELETE is now confined to the seeded uncustomized mirrors plus the ids this run verified. Two statements delete from sys_email_template, both in runMirrorDeletion: (a) the selector DELETE, whose WHERE for this object is now one keep over the whole promotion population — deletableWhere returns, for sys_email_template in every posture, (CASE WHEN population THEN 1 WHEN NOT (population) THEN 0 ELSE 1 END) = 0 with templatePromotionSql (customized = ? OR customized IS NULL OR managed_by IS NULL OR managed_by NOT IN (?, ?, ?), values bound), the inventory names no mirror selector, and the categories and the provenance keep are bypassed because the population keep subsumes them; a row whose claim evaluates NULL is kept (the ELSE arm), as in PR 2. So the selector deletes exactly the complement of the population: seeded, uncustomized rows, organization-stamped or not, which is D10 fate 2's text and the seat's answer 6140351542. (b) deletePromotedTemplates, which deletes WHERE id IN (?, …) in IN_CHUNK slices over run.verifiedTemplateIds, with a count before and a count read-back after each slice that throws if a row is left. verifiedTemplateIds is cleared and rebuilt by the promotion pass that runMirrorDeletion runs again after the reference gate and before any DELETE, so the set is this run's, never a checkpoint's. The DISCHARGED_BY_PROMOTION posture switch and the WHERE 1 = 1 arm are gone. A resumed run re-promotes before deleting: yes — the pass runs inside mirror deletion whatever the template-promotion checkpoint says; a pass that is not done blocks the step with its blockers and nothing on any table is deleted (the gate's read-back precedes it, as before). The nameless row is a blocker that is never deleted: groupTemplateRows lists every population row whose mapped name is not a non-empty string; runTemplatePromotion pushes one blocker naming the ids and returns blocked, so step 2b stops the ceremony, and the re-run inside mirror deletion blocks that step; a nameless row is population, so the selector keeps it, and it is never in verifiedTemplateIds. Do the two pins redden on the old code? By reading, yes: on 34989ec7 the between-runs case ends with tpl_late deleted under WHERE 1 = 1 and c.templates lacking crm.late (two assertions red), and the nameless case ends with the step done, not blocked, and the row gone (three assertions red). The dev's ablations A1 and A2 are the forward direction on the new code; weighed, not repeated. A7 (the keep in the delete selector replaced by a no-op, 17 green). The dev's measured cause is right: the pass returns done only when every population row it read is verified (each group ends promoted, already promoted, or a blocker; nameless rows are a blocker), so when the selector runs there is no unverified population row for the keep to hold, and deleting by selector or by id is the same set. The keep guards one window the fixture never opens: a population row that appears, or a seeded row whose customized flips, between the pass's read and the selector DELETE in the same run (a 17.x write during the ceremony). There it is the only thing between that row and an unpromoted delete, and today no pin reddens if someone removes it. Not a hole in the code; a hole in the pins. The pin that closes it: through the fixture's executor hook (the failOnceOn shape, as an onBefore), insert a customized sys_email_template row immediately before the first DELETE FROM "sys_email_template" and assert it survives the run, is counted as remaining, and the run ends blocking above zero — the keep alone decides that case. A cheaper companion, in the shape the acceptance case already uses for sys_setting: assert the template selector DELETE's text carries the population keep, so a WHERE 1 = 1 can never return silently. Two notes, not blocking: the between-runs pin covers the inserted variant; a seeded row flipped to customized between runs follows the same predicate and the same path, read, not pinned. And "verified" means the door accepted the save in this process or templates.find read the copy back equal from an earlier run: in the re-run pass, current = saved ?? existing.definition, so a row promoted earlier in the same run is verified against the in-memory run.saved, not against a read of the door; a templates.find after each door.save, before the id is marked verified, would make the changeset's "verified" literal. The settings move got exactly that read-back this round; the promotion did not.

  2. The walled promotion — built as the patch round ruled, and inside §6 Q1 as ruled. (a) Grouping by (name, locale): groupTemplateRows keys by the mapped body's name and String(body.locale); the mapper fills an empty or absent locale with EMAIL_TEMPLATE_FLOOR_LOCALE, so a NULL-locale row and an en-US row of one name are one group, which is the discriminator's canonical reading. Right. (b) "Identical" is decided on the promoted body: definitionKey over the union of the bodies' own keys, each read with ?? null; the body is the mapper's output, which carries no organization_id, managed_by, customized or timestamp, so stamps cannot split a group and a difference in label, active, variables or fromOverride does. Right. (c) A conflict is listed with each candidate's row id and organization, counted, and blocks verified_at: the plan (TablePlan.templateConflicts, built by readTemplateConflicts in planPresentTable for this object, printed by --plan as a warning and in the JSON), the step blocker (tpl_a (organization org_a), tpl_b (organization org_b) and the flag's name), the report (TableOutcome.templateConflicts from the after-plan, printed on the human face under the table line), remainingFor (population rows count until gone), the synthetic template-promotion departure (notNull.willReceive false), and the ledger write (step 2b blocked so complete false, passed false). Pinned (conflicts: 1, promoted: 0, both rows kept, c.templates empty). (d) --template-choice: validateTemplateChoices runs after planFor and before the step loop, so before any write; an id that is a candidate of no listed conflict is refused with the conflicts spelled out (none when there are none); two ids of one group are refused as ambiguous; a stale choice (the conflict a finished run already removed) is the first case, loud, and a re-run without the flag is a no-op — the dev's deviation 3, accepted. The command refuses the flag without --apply, maps TemplateChoiceRefusal to reason: 'template-choice-refused', and the flag is multiple: true. The chosen body is promoted through the same door; verifiedTemplateIds gains every row of the group only when that save resolves or the copy reads back equal, and deletePromotedTemplates runs after the pass, so the losing rows leave only after the chosen body's promotion is verified in the sense of ①1. Pinned (From B promoted, deleted 2) and the refusal pinned with writes empty. Nit: the typo is refused after the backup acknowledgement and the boot, because the plan needs the catalog; loud, no write. (e) Can the promotion leak one organization's template where the ruling did not sanction it? Judged against §6 Q1 as ruled — C: every customer-edited template is promoted to an environment-level template; on a deployment with more than one organization a name conflict is listed and the operator chooses per row, never guessed — no. The code never chooses between differing bodies: group.bodies[0] is used only when every body in the group is identical, otherwise the operator's row or a blocker. A lone customer edit becoming the environment's template is the ruling's own sentence, and under 17.x it already was deployment-wide: sendTemplate resolved by (name, locale) with no organization in the key (ADR-0131 1.4), so the promotion changes no audience. The one residual the wording accepts and the operator is not shown: another organization's seeded, uncustomized copy of the same name is a mirror, so after the ceremony that organization is served the lone edit instead of the code template — exactly what 17.x's key already did. Note, not blocking: the inventory's organization-stamped category label still says "promoted with the customized population", while apply treats seeded, uncustomized organization-stamped rows as mirrors in every posture; the plan prints that label to the operator.

  3. The settings move. The selector is now ((scope = ?) AND organization_id IS NULL) when the column exists, through Sql.q with the value bound, so the step's population is the departure's (walkNullRows counts NULL rows); pinned by the acceptance case's DELETE allow-list. After each INSERT … SELECT, readTarget() re-reads the destination by key and the source leaves only when exactly one row comes back and same holds on value, value_enc, encrypted, locked, locked_reason; a mismatch throws, mid-write, with the kept source ids named. Is throwing right? Yes. The INSERT has landed, so a per-key blocker would carry on past a database that did not copy what it was told, and deleting the wrong destination row would be a write the ceremony does not sanction. The throw lands in the command's generic catch as reason: 'apply-failed' with the existing resume text; the step is not checkpointed; the next run reads the destination as differing and blocks that key, moving and deleting nothing — loud, nothing lost. Two notes, not blocking: the message names the kept source rows but not that the destination now holds a differing copy that the running service serves; and no pin reaches the arm (SQLite's INSERT … SELECT always reads back equal) — NOT MEASURED, as the dev says.

  4. The mapper. locale: filled(row.locale) ?? EMAIL_TEMPLATE_FLOOR_LOCALE, imported from @objectstack/spec/system, which exports it (email-template.zod.ts line 54, the schema's own default); the pin compares to the constant. The module doc and both changesets now say the one import. Two sibling files under core/src/security already import spec subpaths, Lint & Repo Gates and every Lint · row are green on the head, so the "lean entry" sentence the old doc carried is not a gate this import trips.

  5. PR 2's landed behaviour holds, and the previous record's "right" findings are not regressed. The gate re-read: runMirrorDeletion still calls verifyReferences first and returns blocked before the promotion pass and before any DELETE; pinned, unchanged. Keyset paging: readAll (id greater than the last page's id, bound, ORDER BY id LIMIT 500) is unchanged and both the pass and the move read through it. Checkpoint and marker: the loop, the per-step ledger write, inventoryDigest over the inventory alone (so templateConflicts cannot discard a checkpoint), passed: complete && blocking === 0 in the command — untouched. Sql.q and bound values on every new statement: the id-chunk DELETE, the organization_id IS NULL clause, the plan's SELECT * … WHERE population ORDER BY id. B1's provenance keep on every other managed_by table and the NULL-evaluating claim keep are byte-identical. One divergence, not blocking: readTemplateConflicts reads the whole population in one unpaged SELECT *, where walkNullRows and readAll page; the population is the customized and authored rows, so small, but it is the plan's one full-row read without a LIMIT. The face nit from 6154293191 is closed: the table filter counts movedOut and movedIn, and the line prints them. The nameless arm, previously "reported, never promoted", is now the blocker ①1 describes.

② Semver level

.changeset/22937-core-email-template-row-definition.md grades @objectstack/core minor and .changeset/22937-cli-ceremony-promotions.md grades @objectstack/cli minor; both carry Clause-②: yes (widening), as does line 2 of the PR body; Check Changeset is success on this head. --template-choice is a new public flag inside the declared widening, and it is named in the changeset: the cli changeset's conflict bullet says "the new repeatable flag --template-choice ROW_ID", its validation and its deletion rule; the flag is additive (multiple: true, no default), refused outside --apply, and takes nothing away. Also inside the widening and stated: the plan's and the report's new optional templateConflicts, the step summary's conflicts in place of reported, the refusal reason template-choice-refused, and the settings selector's narrowing to NULL-organization rows, which is the departure's own population and is written in the changeset. Core: the one new import is a constant from spec, which core already depends on; the export set is unchanged from the previous head. No public removal or narrowing; TemplateChoiceRefusal is exported from a module that is not on the CLI's exports. No BREAKING banner and no ADR-0087 entry is owed by this PR.

③ Boundary flags

  • F1 and F2 of 6154293191 are closed on this head (the gate success; ①1). The walled escalation is answered in this PR (①2), under the maintainer's Q3 ruling 「本卡补做提升」 as the seat applied it in 6154321875.
  • Dev deviations (6156054460), answered: (1) A7 green with no pin isolating the keep — accepted as measured; the pin that closes it is named in ①1 and is the seat's call whether it lands here or as a follow-up pin; (2) the settings read-back throws rather than blocks — right (①3); (3) a stale choice is refused as naming no candidate — accepted (①2d). No open questions and no out-of-scope findings were reported.
  • The PR body is stale, and the seat updates it before readying. The lines that must change: the heading ## What it adds (8 files, +704 / −41; 745 changed lines, none generated) reads 9 files, +1,025 / −47 (1,072 changed lines, none generated); item 1 It is a pure mapper that imports nothing. reads that its one import is the spec's EMAIL_TEMPLATE_FLOOR_LOCALE; item 2 Under single, after it has run, PR 2's template claims are discharged and every remaining template row is a mirror. is removed; item 2 When it stops, keeping every row: rows of one (name, locale) that differ; becomes a conflict, listed with each candidate's row id and organization and resolved by the new repeatable --template-choice ROW_ID, plus a new arm for a customized or authored row with no name; item 2 Under a wall no promotion runs. The rows stay, count as remaining, and are named as a template-promotion departure, so verified_at stays null (consequence 2 of review 6150575317). reads that the promotion runs under every posture and that conflicts count as remaining and block until chosen; item 3 Each sys_setting row with scope = 'global' is copied into sys_platform_setting by INSERT … SELECT gains "and no organization" and "read back equal before the source row leaves"; the ## Merge with PR 2's landed form paragraph The only change to a B1/B2 line: under single, once the promotion has run, the provenance keep and the template categories no longer apply to sys_email_template, because those rows now have their environment copy. reads that for sys_email_template, in every posture, the selector deletes only the complement of the promotion population, mirror deletion re-runs the promotion first, and promoted rows leave by id after verification; the ## Pins (organization-ownership-apply.test.ts, 12 cases) heading reads 17 cases, its Under a wall bullet is replaced, and the six new pins are listed; Ablations lists A1–A7 with A7's measured cause; ## Gates carries the readings at 1bede353 and db208864 from 6156054460 in place of 636f149b and 34989ec7; ## Not in this PR gains the line the patch round asked for, naming feat(objectql,cli): inventory + migration — four fates per object, mirrors deleted only after the id→name rewrite is verified, per-table boot report (ADR-0131 D10) #15211 for the sys_user_permission_set.permission_set_id column and index drop; and the --template-choice flag, absent from the body today, is named once as a new public flag of --apply. The footer stays the session-URL form.
  • Card closure. Line 1 is Fixes #22937, line 2 the clause; the three closing-target gates are success on this head. The card's C7b sentence (drop sys_user_permission_set.permission_set_id, its unique index and its (permission_set_id) index) is carried by feat(objectql,cli): inventory + migration — four fates per object, mirrors deleted only after the id→name rewrite is verified, per-table boot report (ADR-0131 D10) #15211, open, by the seat's pointer 6154334043, which names it as a precondition of the ceremony certifying before C8 — so Fixes #22937 leaves nothing unowned once the body names it. Carried elsewhere, as before: the sys_sharing_rule declared-rule mirrors (D2, 6140351542); the sys_metadata family drop (6150126995, feat(objectql,cli): inventory + migration — four fates per object, mirrors deleted only after the id→name rewrite is verified, per-table boot report (ADR-0131 D10) #15211); the boot refusal (feat(objectql,cli): inventory + migration — four fates per object, mirrors deleted only after the id→name rewrite is verified, per-table boot report (ADR-0131 D10) #15211, C8).
  • Remedy-quality notes, not blocking, in one place: the keep's pin (①1); the read-back after door.save (①1); the plan's unpaged conflict read (①5); the organization-stamped category label (①2e); the read-back throw's message (①3); the --template-choice typo refused after the boot (①2d); the inventory's SEEDED_BY vocabulary now exported from the plan module beside TEMPLATE_OBJECT, which is the right home.
  • NOT MEASURED: Test Core (2/6)–(6/6), running at 2026-10-13T22:12Z — the verdict's gate reading is conditional on them, as in 6154293191 (the round's core change is the mapper's import and its test, and Test Core (1/6) is green); the real metadata door end to end (the fixture's door is a map with no environment flag, so the "environment differs" arm and the stored body's shape are unexercised); the PostgreSQL and MySQL paths of INSERT … SELECT, the read-back's same across column types, and the id-chunk DELETE (SQLite fixture only); the settings read-back mismatch arm; the A7 window (a row appearing between the pre-delete pass and the selector DELETE); the flipped-customized variant of the between-runs case; a sys_email_template without managed_by (the 1 = 1 population arm, which now keeps every row); dogfood, because no suite exercises the ceremony (the dev's finding, as before); the derived gates on db208864 itself (the dev's 67/67 and the full CLI suite were at 1bede353, with a scoped re-check at the head; the final merge touched no PR file, and CI's lint and type jobs are green on the head).

Adopted by domain:engine seat 2 at 2026-10-13T22:14Z, after a tool-use audit of the isolated reviewer's transcript. The reviewer made reads, git fetch into review refs, REST GETs, record writes and one helper script in its own scratch directory. It made no GitHub write and ran no build, test or gate. The seat takes the verdict as PASS, conditional on Test Core (2/6)–(6/6), which the seat reads before readying. The PR body is rewritten by the seat to the record's ③ list before readying. That includes --template-choice as a new flag of --apply, and #15211 for the column drop. The non-blocking notes go to the ACCEPT as Acceptance notes: the A7 keep pin, a literal read-back after door.save, the plan's unpaged conflict read, the inventory's organization-stamped label, and the read-back throw's message.

Implemented-by: claude/issue-22937-ceremony-promotions
Reviewed-by: session_01ADzJtzYTLUfgrRZHxkagkX

VERDICT: PASS

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 13, 2026 22:25
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 13, 2026 22:25
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 13, 2026
Merged via the queue into main with commit ed03834 Oct 13, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-22937-ceremony-promotions branch October 13, 2026 22:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

2 participants