Skip to content

feat(cli): os migrate organization-ownership --apply — the ADR-0131 D10 ceremony (C7b, PR 2 of 3) - #23034

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22937-organization-ownership-apply
Oct 13, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/issue-22937-organization-ownership-apply

Conversation

@objectstack-fleet

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

Copy link
Copy Markdown
Contributor

Part of #22937
Clause-②: yes

PR 2 of 3 for the C7b build-ahead card: os migrate organization-ownership --apply, the operator ceremony of ADR-0131 D10 (ceremony items 2 to 4). PR 1 (the core mappers, #22959) is on main. PR 3 (the template promotion and the sys_setting global-rung move, the maintainer's Q3 and Q4 rulings) opens off main after this one lands, and closes the card. #15211 remains open: its boot refusal is not built here (see "Decisions" below).

Stage 8 is on main (66949b7729), and CI here checks the merge ref that carries it. The contract-tier review went FAIL first (6147223040: B1, a deletion outside fate 2 on sys_email_template; B2, a post-check that certified while departures were unexecuted). Both are fixed in 8b615d0f9c, and the delta review is PASS (6150575317).

What it does

os migrate organization-ownership keeps its read-only plan as the default. --apply is the new writing mode:

  • Backup first (D10 item 2). It needs --backup-taken or a yes at the prompt. A non-interactive run without the flag is refused before the boot. --backup-taken without --apply is refused.

  • The fates, in order. The run stops at the first step that meets a blocker, and the report names the blocker and its remedy.

    1. attribution (fate 3). The plan's own walk derives the owners: parent, subject or holder anchors, and under single the Default Organization. The apply hands the walk a sink (AttributionSink), so it writes exactly the owners the plan reports and never runs a second derivation. It repeats to a fixed point, so a child whose parent was NULL in pass 1 takes the parent's owner in pass 2. ⛔ No other owner is ever written: an unattributable row stays NULL and stays listed by id.
    2. authored-conversion. The inventory's authored rows of sys_permission_set and sys_position become environment metadata through the metadata door (protocol.saveMetaItem). The bodies come from core's permissionSetDefinitionFromRow / positionDefinitionFromRow, and the position bindings are carried into permissionSets (convertPositionBindingRows). This runs under single only (ADR-0131 D3). Under a wall the rows are reported. The step blocks on: two rows of one name that define it differently; a name the catalog already holds with a different body; a door refusal; organizations binding different sets to one position. Deactivated authored rows are reported and not converted: converting one would make it grant. Authored sys_capability rows are fate 4, reported (seat answer 6110777865).
    3. rewrite (D4), verified. Each unnamed sys_user_permission_set grant gets the name of the set row its permission_set_id points at. Three cases are never named: a grant pointing at another organization's set row (a name carries no organization), a dangling id, and a name the catalog does not resolve. Then EVERY grant is verified: it carries a name, the security catalog resolves that name, and the name agrees with the row its id points at. Every binding row's sets are verified to be in its position's definition. permission_set_id and its indexes stay (C7b's later drop, E3).
    4. mirror-deletion (fate 2). It is gated on that verification, which is read again just before the first delete, so a grant written between runs keeps every mirror. A row is deleted only when it is in a mirror-deletion object, matches the object's mirror selector, and no named category of the object claims it. On a table that carries managed_by, a row whose provenance is not package, platform or system is also kept: that is the catalog objects' authored boundary, and it holds for templates too, NULL provenance included. So the seeded catalog copies (per organization and NULL residue), all binding rows, seeded capabilities and seeded uncustomized templates go. Authored rows stay, and so do admin-authored, customized and organization-stamped templates.
    5. column-drops (fate 1). organization_id leaves the D7 objects through the schema-drift ceremony: the drop_column entries detectManagedDrift() proposes, applied with applyMigrationEntries(…, { allowDestructive: true }), which is os migrate apply's own machinery and never a hand ALTER. A column-drop table with rows in a named category (the sys_metadata promotion categories) keeps its column, and the run says so. A table the drift differ does not propose is reported as "the declaration still carries the column".
  • Idempotent and resumable. Every write is conditioned on the state it changes:

    • … WHERE organization_id IS NULL AND id IN (…);
    • … AND (permission_set IS NULL OR permission_set = '');
    • the deletes select only mirrors.

    Each finished step is checkpointed in sys_migration (row adr-0131-organization-ownership, details.checkpoint, through recordDataMigrationRun). A re-run skips finished steps and resumes at the interrupted one. A checkpoint from a different inventory digest is discarded, and every step runs again; each step is idempotent.

  • Post-check. The plan is read again, and each table reports what remains for its fate. The ledger row is the ceremony's marker: applied_at once every step has finished, and verified_at once the post-check also reads zero discrepancies. A departure whose step has not run yet (the sys_setting global-rung rows, the declared sharing-rule mirrors) counts as remaining. Each table's report names it with its row count, and verified_at stays null until it has left. The command exits 1 while the ceremony is unfinished or has a discrepancy. A JSON report file is written (--out, never overwritten).

  • Boot. The apply boot composes the served object set (composeHostStack + composeServedPlatform + composeAuthGatedSecurity), so the catalog it verifies against is the one the deployment serves, and the column drops are that set's drift. It still holds the boot's own DDL back (deferSchemaDdl).

Files (9, +1,927 / −29; 1,956 changed lines, generated included: none)

  • packages/cli/src/utils/organization-ownership-apply.ts: new, the ceremony.
  • packages/cli/src/utils/organization-ownership-plan.ts: the attribution sink (AttributionAssignment, AttributionSink, onAttribution). It also exports Sql, predicateSql, requireColumn, columnListSql and columnNameOf for the apply. The plan's own run passes no sink and stays SELECT-only.
  • packages/cli/src/commands/migrate/organization-ownership.ts: --apply, --backup-taken, the ports and the report.
  • packages/cli/src/utils/organization-ownership-apply.test.ts: new, the acceptance fixture (9 cases).
  • packages/cli/src/commands/migrate/organization-ownership.apply-flags.test.ts: new, the backup precondition (3 cases).
  • packages/cli/src/utils/schema-migrate.one-shot-family.integration.test.ts: the family pin gains the --apply write mode, which it previously recorded as missing.
  • packages/cli/src/utils/schema-migrate.ts, packages/cli/test/json-stdout-purity.e2e.test.ts: doc comments naming the new caller.
  • .changeset/22937-cli-organization-ownership-apply.md: @objectstack/cli minor, Clause-②: yes (widening).

⛔ Untouched: packages/core/src/security/ (read only), packages/plugins/plugin-security/**, organization-ownership-inventory.ts and its test (PR #22993 and PR #22962 edit them), and every D / S8-T / S8 file.

Acceptance (#15211), measured

The fixture database organization-ownership-apply.test.ts carries every fate the card lists, on the post-stage-8 physical shape: the catalog tables are read and written through raw SQL only, and permission_set_id is a plain text column. It holds:

  • per-organization mirrors and NULL residue of one declared set and one declared position, with grants (by id) and bindings pointing at them;
  • Setup-authored sets, positions and capabilities;
  • seeded, customized and organization-stamped templates;
  • NULL sys_file rows with a sys_attachment holder;
  • an application object's NULL row;
  • a D7 table with its column;
  • one genuinely unattributable row (sys_saved_report, fate 4).

After --apply:

  • Effective access is identical. It is read the 17.x way before (grant → set row by id, position → binding rows) and the v18 way after (grant → name → catalog, position → definition permissionSets).
  • Positive control: after the run, un-naming one grant reddens the comparison, naming exactly that grant's user and set.
  • Mirrors are gone. Authored, customized and organization-stamped rows stay.
  • Attributed rows carry their organization. The anchor's organization wins: org_b through the holder, not the Default Organization.
  • The unattributable row is still present, still NULL, and reported by id.
  • Row counts reconcile: for every table, before − deleted = after, and only fate-2 objects delete.

Guarantee cases, measured:

  • a second run writes nothing, and the database file is byte-identical;
  • a run killed inside mirror deletion resumes there and issues no UPDATE;
  • a cross-organization grant blocks the rewrite, and nothing is deleted;
  • a grant written between runs blocks the re-read gate, and nothing is deleted;
  • under a wall, an anchorless row stays NULL and authored rows are not converted;
  • a column-drop table with promotion-category rows keeps its column.

Ablations, through scripts/ablation-replace.mjs, each restored to the HEAD blob with git diff HEAD empty:

  • Dropping the category exclusion from the delete predicate reddens the acceptance case: the customized template is deleted.
  • Disabling the pre-delete gate re-read reddens the between-runs case.

End to end on today's main composition. A smoke run used the built CLI against a SQLite database whose schema os migrate apply had created, plus 17.x rows:

  • the door wrote the authored set to sys_metadata, and it resolved within the run;
  • both grants were named and verified;
  • sys_job.organization_id was dropped through the drift machinery;
  • the sys_metadata family was correctly reported as not proposed, because its declarations on main still carry the column;
  • the second run resumed from the sys_migration checkpoint.

Gates

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 66 derived. All were run with exit codes captured before any pipe. --ran reconciliation: "66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero)".
    • Three first runs exited 3 on prerequisites, and each was re-run to exit 0 after the prerequisite was met:
      • check-plugin-teardown-shape --self-test: shallow clone; the pinned fixture commit was fetched.
      • check:dual-build-cjs-loads: unbuilt sibling dists; they were built under the verify lock.
      • check:i18n-coverage: re-run green.
  • pnpm --filter @objectstack/cli typecheck (tsc + test-typecheck): exit 0.
  • vitest. First at HEAD 0555b80a, then at aa6bc6d9 after the merge of main, where apply-flags loads the command in beforeAll with 60 s per case (on the merged tree its first case took 5.4 s). 5 files, 52 tests passed:
    • organization-ownership-apply.test.ts;
    • organization-ownership.apply-flags.test.ts;
    • organization-ownership-plan.integration.test.ts;
    • organization-ownership-inventory.test.ts;
    • vitest-tiers-partition.test.ts.
  • schema-migrate.one-shot-family.integration.test.ts -t "organization-ownership|every module": 6 passed.
  • On a scratch tree with S8 merged (aa6bc6d9 + feat(plugin-security)!: retire the four security-catalog row objects (#15204 stage 8) #23222's head 23e450a6, never pushed), report 6150092087: the full @objectstack/cli suite, unit and integration, as CI shards it; the 66 gates; and an end-to-end --apply from examples/app-crm against the four unregistered catalog tables. All green.
  • 8b615d0f9c (B1, B2): CI's full Test Core on the merge ref with main is green.
  • NOT MEASURED: test/json-stdout-purity.e2e.test.ts. The edit there is a comment only, and the file is in the nightly e2e tier, outside both local projects.

Decisions (settled by the four axes; also in the report's open_questions)

  1. The boot refusal is not built here. Its parts live outside packages/cli:

    • the fresh-database attestation is CREATION_ATTESTED_MIGRATION_IDS in @objectstack/spec/system, plus attestFreshDatastore in @objectstack/platform-objects;
    • the refusal itself would sit at the serving boot (serve.ts, and the runtime for hosts that do not boot through it).

    Landing it before C8 would refuse every existing database on main before the ceremony can complete there. This PR writes the marker the refusal will read: the sys_migration row, through the existing readDataMigrationFlag / isDataMigrationFlagVerified contract. The refusal belongs with C8 (or a follow-up C7b card) and names that row.

  2. The fate-2 gate is literal: every grant must name a set the catalog resolves. A named grant whose set no longer resolves blocks mirror deletion too, even though deleting mirrors would not change what it grants. The remedy is printed per name.

  3. Not performed here, reported instead:

    None of these is deleted or attributed. The two departures count as remaining, so the marker is not verified until they have left.

  4. Known limit on today's main: column-drops stops on the sys_metadata family. sys_metadata, sys_metadata_audit, sys_metadata_commit and sys_metadata_history still declare organization_id, so the drift differ proposes no drop. --apply exits 1 there, names the remedy, and leaves the marker unapplied. The family's declared no-column schema is 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's stage, and it lands before C8 (seat answer 6150126995, pointer 6150140797).

  5. One checkpoint and marker row. It reuses the deployment ledger (sys_migration, recordDataMigrationRun) rather than ADR-0119's chunk journal. The journal is engine-bound, and stage 8 unregisters the catalog objects this ceremony must still read and delete.

Acceptance notes


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>
@github-actions

github-actions Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/cli, touching 99 documentable anchor(s).

23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 026391d5d8d8e876377d19fb08a0afc038088f76.

⛔ 14 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

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)
  • 53 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 — 28 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 026391d5d8d8e876377d19fb08a0afc038088f76 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 21885d74715b15221125f6150ae986028bf29e2b — the merge of head 8b615d0f9c8d803812f57534f74d3f6e7edc49bc into base 026391d5d8d8e876377d19fb08a0afc038088f76, 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 21885d74715b15221125f6150ae986028bf29e2b && git checkout 21885d74715b15221125f6150ae986028bf29e2b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 026391d5d8d8e876377d19fb08a0afc038088f76 8b615d0f9c8d803812f57534f74d3f6e7edc49bc && git checkout -B drift-repro 026391d5d8d8e876377d19fb08a0afc038088f76 && git merge --no-ff 8b615d0f9c8d803812f57534f74d3f6e7edc49bc

node scripts/docs-audit/affected-docs.mjs --json 026391d5d8d8e876377d19fb08a0afc038088f76

⚠️ 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 026391d5d8d8e876377d19fb08a0afc038088f76 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

…rst load outside the per-case clock

On the merged tree the first case took 5.4 s, past vitest's 5 s default,
and the timed-out case's output then broke the next case's stdout capture.
The command is imported in beforeAll and each case gets 60 s.

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

Copy link
Copy Markdown
Contributor Author

Contract review

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

What was reviewed. PR #23034 (feat(cli): os migrate organization-ownership --apply, draft, base main, head repo = base repo) at head aa6bc6d91f — the head the brief named; it had not moved. Three commits: 0555b80a (the build), 3f68f15e (merge of origin/main 6d7f4df3), aa6bc6d9 (the apply-flags test's first load moved to beforeAll, 14/−5 in one test file). Net three-dot diff against origin/main 09ef77c7: 9 files, +1,872 / −28, all under packages/cli plus one changeset; no governed path (Governed Surface Queue Guard success). Inputs: card #22937 body and all 17 comments (claim 6122368393, report 6123371167, seat review 6124036468, correction 6138559307, ruling 6138591940, PR 3 report 6140232596, PR 3 correction 6140351542, pre-landing dispatch 6146422537; no newer os-dev-report at 2026-10-13T13:52Z); card #15211 body; ADR-0131 D3/D4/D10 on origin/main; the PR body, file list, diff, its one bot comment (docs drift, advisory), zero reviews; the head's check-runs; for context organization-ownership-inventory.ts, the pre-PR command, core's permissionSetDefinitionFromRow / positionDefinitionFromRow / convertPositionBindingRows, recordDataMigrationRun / isDataMigrationFlagVerified, SaveMetaItemRequestSchema, sys-email-template.object.ts, and S8's branch claude/issue-15204-s8-catalog-objects at 23e450a6 (same merge base 6d7f4df3).

Check-runs on aa6bc6d9 at 2026-10-13T13:53Z: 39 runs — 32 success, 6 skipped (Build Docs, Console Pin Gate, Dogfood Regression Gate matrix, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance), 1 still running: Test Core (3/6). None failed. Check Changeset, Lint · PM dispatch-gates, Type Check · workspace, Build Core are among the successes.

① Derived judgments

  1. CLI surface widening (Clause-② yes) — right. --apply (boolean, default false) and --backup-taken (boolean, default false) are added to os migrate organization-ownership; the pre-PR plan path is untouched code, so the bare run stays the read-only plan. Order in run(): posture resolution, then --apply branch, then --backup-taken without --apply refused (flag-without-apply, exit 1) — all before any boot. In runApply, the backup acknowledgement is read before bootSchemaStack: --backup-taken, else a y/N prompt only when stdin is a TTY and --json is off; a non-interactive or --json run without the flag is refused (backup-unacknowledged, exit 1) with the platform-takes-no-backup message (D10 item 2). JSON faces: apply_refused / apply_failed / boot_failed keyed off reason. The three apply-flags cases pin exactly these (boot mock not called on both refusals; called once with deferSchemaDdl: true, composeHostStack: true on the accepted path). Nit, not blocking: the --out flag's description still reads "Where to write the plan" though --apply reuses it for the report file.

  2. D10 conformance of the executor — right on the fate order, the gate and the resume; wrong on two counts (B1, B2 in ③).

    • Attribution (fate 3) — right. The apply passes an AttributionSink into the plan's own walkNullRows; owners come only from the inventory's anchors and, under single, the Default Organization; the walk is keyset-paged (id greater than the last page's id, bound as a parameter), so the per-page UPDATE … SET organization_id = ? WHERE organization_id IS NULL AND id IN (…) cannot shift later pages. Repeats to a fixed point with a NULL-count-must-decrease guard (MAX_ATTRIBUTION_PASSES 8) and blocks loudly if writes do not land. Departure rows (sys_setting scope=global, sys_sharing_rule declared-rule mirrors) are skipped by the walk and are therefore never stamped — right for PR 3's move.
    • Authored conversion — right under single (D3): authored rows (managed_by not in package/platform/system) of sys_permission_set / sys_position go through protocol.saveMetaItem({ type, name, item, actor }) — the spec's SaveMetaItemRequestSchema carries exactly type, name, item, optional actor — with bodies from core's mappers and binding rows carried into permissionSets via convertPositionBindingRows. Blockers for: two rows of one name with differing bodies, a name the catalog holds with a different body, a door refusal, conflicting per-organization bindings, bindings on an undeclared position. Deactivated authored rows are reported, not converted. Authored sys_capability reported (fate 4). Under a wall everything is reported. Right.
    • Rewrite (D4) — right. Unnamed grants are named from the set row their permission_set_id points at, except the three never-named cases (another organization's set row, a dangling id, a name the catalog does not resolve), with … AND (permission_set IS NULL OR permission_set = ''). Then EVERY grant is verified (named, resolves, agrees with its id row) and every binding row's sets are verified against the position definition. permission_set_id stays.
    • Mirror deletion (fate 2) — the gate is right: verifyReferences is read again immediately before the first DELETE (the between-runs case pins it, and the dev's ablation reddened it). Binding rows are deleted only after their sets are in the position definition (declared fate), which is "mirrors deleted only after their conversion is verified" for the one object whose rows carry information. Catalog mirrors are selected by managed_by IN (package, platform, system) minus the authored category. Wrong for sys_email_template: see B1 — the inventory gives that object no mirror selector, so the delete predicate is "every row" minus customized = true minus organization_id IS NOT NULL; a managed_by = 'admin' row (the object declares admin as its authored provenance) with NULL organization and customized false is deleted as a mirror. It is not re-derivable from code; D10 fate 2 does not sanction it; 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 says stop and report any deletion outside fate 2. PR 3's own report (6140232596) measured this exact row under PR feat(cli): os migrate organization-ownership --apply — the ADR-0131 D10 ceremony (C7b, PR 2 of 3) #23034 alone, and the seat's reading (6140351542) classes admin-authored rows with the promoted population. Also: the doc comment on deletableWhere says CASE … END = 0 makes a NULL-evaluating category still claim the row; the SQL does the opposite (NULL → ELSE 0 → deletable). For the notIn categories this is harmless because predicateSql spells notIn as IS NULL OR NOT IN; for an equals: true category it is the less safe direction.
    • Column drops (fate 1) — right. Only drop_column organization_id entries that detectManagedDrift() proposes for the due tables, applied through applyMigrationEntries(…, { allowDestructive: true }); a table with rows in a named category (the sys_metadata promotion categories) keeps its column and is reported; "not proposed" is reported as the declaration still carrying the column. Post-S8 the four unmanaged catalog tables are never in due (fate 2), and the filter ignores any drop_table the differ might propose for them.
    • Checkpoint and marker — the ledger mechanics are right: each finished step writes recordDataMigrationRun({ passed: false, applied: false, details: { checkpoint } }), which keeps an existing applied_at and clears verified_at (the ledger's own contract); the final write sets applied: complete and passed: complete && blocking === 0, so applied_at is first written only when all five steps have finished and verified_at only when the post-check reads zero discrepancies; a blocker leaves both as they were. The checkpoint is bound to the inventory digest and discarded on a digest change; every step is conditioned on the state it changes, so a resumed or repeated run writes nothing (the byte-identical second-run case pins it). Wrong on what "complete" certifies: see B2 — remainingFor counts only rowsToAttribute, which excludes departure rows, so a database whose sys_setting still holds NULL global-rung rows (and whose sys_sharing_rule still holds declared-rule mirrors) ends complete: true, blocking: 0, verified_at set, notNull.willReceive: true, "every NULL row is attributed or departs" — while nothing has departed. TableOutcome has no departures field, and the human face prints a table only when attributed + deleted + unattributable + remaining is non-zero, so those rows appear nowhere in the apply report except as an unlabelled after.organizationNull count.
    • Transactions — no step runs in a transaction; each UPDATE/DELETE is one statement (atomic per statement), a mirror delete that leaves rows behind throws before the next table, and the checkpoint is written only after a step finishes. That is the resumable shape D10 names, not a transactional one; D10 asks for idempotent and resumable, so right. One consequence is unmeasured: an attribution UPDATE that collides with a unique index over organization_id (a NULL-organization grant duplicating a stamped one) fails the whole 200-id chunk and aborts the run with the driver's message and no row ids or remedy.
  3. Ruling conformance.

    • Q2 → A: right as built — a named grant whose set the catalog does not resolve blocks the rewrite and, re-read, the deletion, with one blocker line per name naming the ids and the remedy ("Declare or author the set, or remove the grants, then re-run").
    • Q1 → A: right — no boot refusal is built; the marker is the sys_migration row adr-0131-organization-ownership written through recordDataMigrationRun, read through readDataMigrationFlag / isDataMigrationFlagVerified (verified_at non-null and blocking === 0); CREATION_ATTESTED_MIGRATION_IDS untouched.
    • Q3 / Q4 → A (PR 3's): the PR half-implements neither — no template promotion, no settings move, no sharing-rule deletion (departures are never executed; only fate === 'mirror-deletion' tables are). Customized and organization-stamped templates are kept and reported through the mirror step's "N row(s) are not mirrors (a named category claims them) and stay" note — right. The global-rung rows and the declared-rule mirrors it does not move are not reported in the apply report and are counted as done by the post-check — wrong (B2). The admin-authored uncustomized NULL-organization template is dropped, not reported — wrong (B1).
  4. Data safety. No write before the backup acknowledgement and the boot. Every identifier passes Sql.q (SAFE_IDENTIFIER ^[A-Za-z_][A-Za-z0-9_]*$, refused otherwise) and every value is bound, IN lists included; no SQL is built by concatenation from data. The only destructive steps are the gated mirror delete and the drift-machinery column drop; both are reachable on a partially migrated database only through the re-read gate and the held-category check respectively. The exception is B1: a deletion of a non-mirror population without a conversion.

  5. Post-S8 correctness — right, by reading; not yet measured on the S8 tree. The inventory, plan and apply never read the four catalog objects through the engine: the plan enumerates physical tables from the driver's catalog statement, reads columns from pragma_table_info / information_schema, and classifies by inventory row; the apply reads and writes them through the raw-SQL seam (resolvePlannedDriverExec). permission_set_id is read as text (text(grant.permission_set_id)), which S8's Field.text redeclaration does not change. The composeHostStack + composeAuthGatedSecurity boot under S8 registers the remaining security objects, and createSecurityCatalogReader({ registry: engine.registry, metadata }) reads registry plus metadata service as its sources require. S8 touches no organization-ownership file; its unmanaged-tables sweep listing the four tables is os migrate plan's face, not this command's. The acceptance fixture is already the post-S8 shape. The pre-landing dispatch's scratch run of the suites on this branch plus S8 (6146422537 item 2) has no report yet: NOT MEASURED.

② Semver level

The changeset .changeset/22937-cli-organization-ownership-apply.md grades @objectstack/cli minor and carries Clause-②: yes (widening); the PR body's line 2 is Clause-②: yes. Under the WHICH LEVEL rule in .github/workflows/pr-automation.yml an additive widening of a published package's public surface takes at least minor; two new accepted flags on a published CLI command are that act, so minor is right and patch would have been refused by the level axis. Nothing is removed or renamed: no BREAKING banner and no ADR-0087 marker is owed. scripts/pm/clause2-line.mjs reads yes (widening) as a declared yes with the widening arm and the body's bare yes as a declaration; the two agree. Check Changeset is success on this head. The changeset body matches the code on every clause I read (fate order, the three never-named cases, the gate re-read, the drift machinery, the applied_at / verified_at rule, the wx report file) — with the one overstatement that it says rows "still need another step" are reported, which B2 shows the apply report does not do for departures.

③ Boundary flags

  • B1 — blocking. Deletion outside fate 2 on sys_email_template. deletableWhere with no inventory mirror selector deletes every row not claimed by customized = true or organization_id IS NOT NULL; a managed_by = 'admin' (the object's authored provenance), NULL-organization, uncustomized row is deleted without promotion and without a report line. Confirmed by the PR 3 report 6140232596 ("would have been deleted as a mirror without being promoted") and by the seat's reading 6140351542 (admin-authored rows are the promoted population). The acceptance fixture has no such row (its three templates are platform-managed), so the pin is silent. Remedy inside this PR's own surface, no inventory edit: in runMirrorDeletion / deletableWhere, when the physical table carries managed_by, add (managed_by IS NULL OR managed_by NOT IN ('package','platform','system')) to the claimed set — the same AUTHORED boundary the catalog objects use — so those rows stay and are counted in the "not mirrors … stay" note for PR 3 to promote; add a tpl_admin (NULL, managed_by 'admin', customized 0) row to the fixture and assert it survives with counts reconciling.
  • B2 — blocking. The post-check certifies a ceremony that has not finished, and the apply report does not name what it skipped. With departures excluded from remainingFor, every database holding sys_setting global-rung rows ends complete: true, blocking: 0 and verified_at set, notNull.willReceive: true for sys_setting, and the rows appear in no step note, no table line and no advisory count. D10 item 4 wants tables still holding NULL rows named with the remedy and left unconstrained; item 5's refusal (C8, Q1 → A) will read this very row. Remedy, also small and inside the surface: count fateCounts.departingRows into remainingFor for attribution (and report) tables so blocking stays non-zero and verified_at null until a step performs them (PR 3's settings-move; the sharing-rule mirrors ride D2), and carry each departure's label and row count on TableOutcome and the human face as its remedy. After PR 3 the same code reads zero and the marker becomes true.
  • Form, seat decision before readying: the PR title says "PR 2 of 2" and line 1 is Fixes #22937, but the maintainer ruled a PR 3 of this card (6138591940, 「本卡单开 PR 3」; feat(cli,core): the organization-ownership ceremony promotes customized email templates and moves the settings global rung (C7b, PR 3) #23195 closed pending re-open). Landing PR 2 as written closes the card while PR 3 is still owed on it. Either line 1 becomes Part of #22937 (a PR-body edit the role file reserves to the seat) or the seat records that the card closes at PR 2 and PR 3 is carried otherwise.
  • Dev deviations in 6123371167 — answered, none blocking: three files beyond the claim (the one-shot family pin now drives --apply --backup-taken --json as a write mode under the "no seed write" and "no lifecycle sweep" pins; schema-migrate.ts doc comments; one e2e comment) are mechanical and inside packages/cli; the direct git push and the one lease-named force-push before the PR existed follow the role file; the model-free trailers follow AGENTS.md; the boot refusal's location was reported and ruled Q1 → A. The aa6bc6d9 timing fix is the one the PR 3 run named as its carrier and is test-only.
  • Open questions Q1–Q4 — ruled all A (6138591940); the code conforms except B2's reporting half. PR 3's two questions (sharing-rule mirrors ride D2; seeded uncustomized organization-stamped copies are mirrors) are the seat's answers in 6140351542 and change nothing here; note PR 2 keeps organization-stamped seeded copies (the safe direction) that PR 3 will delete after promotion.
  • Remedy-quality notes, not blocking: (a) an authored position that is deactivated, or any authored position under a wall, still has binding rows, and verifyReferences then blocks with "The authored-conversion step carries them; resolve its blocker" although that step only reported — the operator's real remedy (remove the binding rows, or activate the position) is not said; (b) the attribution unique-index collision described in ①2 aborts with the driver's error and no ids; (c) the CASE … END = 0 comment contradicts the SQL; (d) the --out description.
  • Out of scope, named: a sys_metadata table with organization-scoped presentational rows holds its column-drop until a promotion step that lives outside this ceremony exists, so the ceremony cannot complete there; the blocker says "resolve them, then re-run" without naming the command. The sys_metadata family's declarations on main still carry the column, so on today's main those drops are reported "not proposed" (the dev's smoke run saw this).
  • NOT MEASURED: test/json-stdout-purity.e2e.test.ts (comment-only edit, nightly tier — the dev said so); the full @objectstack/cli suite and the derived gates on the branch-plus-S8 scratch tree (dispatched 6146422537, unreported); the PostgreSQL and MySQL dialect paths of the apply (the fixture is SQLite; the dev's smoke was SQLite); the real metadata door accepting a position whose permissionSets names a set saved in the same run; the unique-collision case; Test Core (3/6) still running at 2026-10-13T13:53Z.

Adopted by domain:engine seat 2 at 2026-10-13T13:56Z, after a tool-use audit of the isolated reviewer's transcript: reads, git fetch into a review ref, and record writes only. It made no GitHub write and ran no build, test or gate. The seat takes B1 and B2 as blocking, and the dev fixes both in PR 2. Form, decided by the seat: PR 2 does not close the card while PR 3 (ruling 6138591940, 「本卡单开 PR 3」) is owed. Line 1 becomes Part of #22937, the title says PR 2 of 3, and PR 3 carries Fixes #22937. A delta record runs on the fixed head.

Implemented-by: claude/issue-22937-organization-ownership-apply
Reviewed-by: session_01ADzJtzYTLUfgrRZHxkagkX

VERDICT: FAIL

…ts unrun departures as remaining

Contract review 6147223040 on PR #23034 (B1, B2):

- B1: mirror deletion also keeps, on a table recording provenance, every
  row no seeder wrote (managed_by NULL or not package / platform /
  system). A sys_email_template row an admin authored with no
  organization and customized false was deleted as a mirror; it is now
  kept and reported for the promotion step. A claim that evaluates NULL
  now keeps the row, as the doc comment always said.
- B2: the post-check counts each departure's rows (the settings global
  rung, the declared sharing-rule mirrors) as remaining, so verified_at
  stays null until a step carries them, and the report names each
  departure with its count; NOT NULL readiness says why it waits.

Also: --out names the apply report it writes.

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

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 8b615d0f9c8d803812f57534f74d3f6e7edc49bc
Local-runs: none

What was reviewed. Delta record on PR #23034 (feat(cli): os migrate organization-ownership --apply, draft, base main, head repo = base repo, title now "PR 2 of 3", line 1 Part of #22937) at head 8b615d0f9c, the head the brief named; it had not moved. The delta since the FAIL head aa6bc6d91f is ONE commit, 8b615d0f9c ("the ceremony keeps every admin-authored fate-2 row and counts unrun departures as remaining"), 3 files, +68 / −14, no merge of main in between (the merge-base with origin/main is still 6d7f4df3c, so the head does not carry S8 66949b7729). The PR's merge ref 21885d7471 (parents 026391d5d8 = origin/main, 8b615d0f9c) carries S8, merges clean (no conflict markers in the PR's files), and its diff against origin/main is byte-equal to the three-dot diff: 9 files, +1,927 / −29 (1,956 changed lines, under 3,000), all under packages/cli plus one changeset; no governed path (Governed Surface Queue Guard success). Inputs: the FAIL record 6147223040 in full; on card #22937 the ruling 6138591940, the correction 6140351542, the dispatch 6146422537, the pre-landing report 6150092087, the collection 6150126995 and the services pointer 6147942984; the PR body, file list, net diff and delta, its two comments (the docs-drift bot, the FAIL record) and zero reviews; docs/adr/0131-*.md on origin/main (D10 and the fates); the inventory on origin/main (blob e3dc4afa, identical on head); for the S8 question packages/core/src/security/index.ts, security-catalog.ts, migration-flag.ts, sys-email-template.object.ts on origin/main, sys-position-permission-set.object.ts at the merge-base, and the 18 commits main gained since 6d7f4df3c; the head's check-runs. The B1/B2 round's own report is not posted; the check-runs stand in for it.

Check-runs on 8b615d0f9c at 2026-10-13T16:45Z: 39 — 30 success, 6 skipped (Build Docs, Console Pin Gate, the Dogfood Regression Gate matrix row, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance), 3 still running: Test Core (2/6), Test Core (3/6), Test Core (4/6). None failed. Among the successes: Build Core, Check Changeset, Check PR Size, Type Check · workspace, Type Check · consumer gates, Type Check · source gates, Lint · ESLint, Lint · docs & code guards, Lint · repo & tooling gates, Lint · CI, release & engine gates, Lint · PM dispatch-gates, Test Core (1/6), Test Core (5/6), Test Core (6/6), Part-of PR must not also close its card, The card this PR closes must claim this branch. The typecheck and build successes are on the merge ref, so the apply's four core imports (createSecurityCatalogReader, permissionSetDefinitionFromRow, positionDefinitionFromRow, convertPositionBindingRows) resolve on post-S8 main; I also read them exported at packages/core/src/security/index.ts lines 248, 264, 281 and 285. The verdict below is conditional on the three running shards: a red one voids it.

① Derived judgments

  1. B1 — fixed, and inside fate 2. Right.

    • deletableWhere is now: the inventory's mirror selector (every row when it names none), AND one keep per named category, AND, when the physical table carries managed_by, a keep on managed_by IS NULL OR managed_by NOT IN ('package', 'platform', 'system') (predicateSql's notIn spelling; the three values are bound parameters). Each keep is CASE WHEN claim THEN 1 WHEN NOT (claim) THEN 0 ELSE 1 END = 0, with the claim's parameters pushed twice to match the two placeholders, so a claim that evaluates NULL now keeps the row — the previous record's note (c) is closed in the safe direction.
    • On sys_email_template (no mirror selector; managed_by is a select over platform, package, admin with default admin; customized boolean) the only deletable population is now seeded platform or package, uncustomized, NULL-organization rows. Admin rows, NULL-provenance rows, customized rows and organization-stamped rows all stay. That is fate 2 as D10 words it ("rows that are re-derivable from code and nothing else") and the seat's Q-templates answer (seeded uncustomized copies are mirrors; customized or admin-authored rows are the promoted population).
    • On the catalog objects the new keep repeats the authored category word for word (same three values), so nothing changes there. sys_position_permission_set has no managed_by field (merge-base object: id, display_title, position_id, permission_set_id, created_at, updated_at; the object-level managedBy: 'system-data' is not a column) and managed_by is not an injected system field, so every binding row still goes after the verification gate — the PR body's "all binding rows" claim still holds.
    • remainingFor (mirror-deletion) counts the same deletableWhere, so the post-check and the delete agree.
    • The pin: the fixture gains tpl_admin (NULL organization, managed_by 'admin', customized 0) and the acceptance case asserts sys_email_template ends as tpl_admin, tpl_customized, tpl_org, with counts reconciling and every DELETE on one of the five fate-2 tables. Remove the provenance keep and tpl_admin is deleted — the case reddens. So yes, the old selector cannot come back silently.
    • Test gaps, not blocking: no row with customized NULL exercises the new ELSE arm, and the mirror step's "stay" note is not asserted.
  2. B2 — fixed on the gate, as the previous record's remedy asked. Right, with two consequences to carry.

    • remainingFor now adds departingRows (the sum of the after-plan's departures[].rows, counted by walkNullRows over NULL rows only) for attribution tables, and returns it for report tables (no report entry carries departures today — inert, harmless). With every step finished, blocking = discrepancies, which is now non-zero while a departure is unexecuted; the ledger write is passed: complete && blocking === 0 → false, so recordDataMigrationRun writes verified_at: null; applied: complete → true, so applied_at is set. The gate the C8 refusal will read (Q1 → A) is isDataMigrationFlagVerified: verified_at set AND blocking === 0 — it reads false. Exit code 1. No reader gates on applied_at alone (every other ledger writes it the same way; the gate reads verified_at), so complete: true with blocking non-zero is the shape the remedy named, not the brief's looser "no complete: true"; the report's own definition of complete is "every step finished", which is true. If the seat wants complete false too, that is a one-line choice, not a defect.
    • The report names what it skipped and why: TableOutcome.departures[] carries id, label, as and rows for each departure with rows left (global-rung-move / moved; declared-rule-mirrors / mirror-deletion), remaining includes them, and notNull is overridden to willReceive: false with "N row(s) still wait for a step not yet run: label". The human face prints a table with departures even when nothing else moved, one yellow line per departure: "N row(s) still to leave by a step not yet run — label", where the label is the inventory's ("scope = global rows — moved to sys_platform_setting …"; "uncustomized declared sharing rules — seeded copies of code (D2 retires bootstrapDeclaredSharingRules)"). It does not point to PR 3, correctly: a runtime string carries no tracker number (AGENTS.md, check:doc-authoring); the PR 3 pointer belongs in the PR body (③).
    • The pin: sys_setting with one scope = 'global' NULL row and one stamped tenant row; asserted complete: true, blocking: 1, the ledger's last verdict { complete: true, blocking: 1 }, remaining 1, departures = one global-rung-move / moved / rows 1, willReceive false, both rows still present. Drop departingRows from remainingFor and blocking reads 0 — the case reddens.
    • Consequence 1, for the seat to carry: 17.x bootstrapDeclaredSharingRules under single seeds organization_id NULL rules (findOrganizationLessRules exists for exactly that residue), stamped managed_by 'package', customized false. Those rows match the declared-rule-mirrors departure, so on every deployment that ever seeded declared sharing rules the ceremony now ends blocking non-zero and verified_at null until the follow-up step that rides D2 (6140351542). This is what the previous record's remedy and the seat's own answer chose, and it is the right side of the false-complete line; but it makes D2 a precondition of certifying the ceremony on such deployments, which the ordering pointer to the epic seat (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, with the sys_metadata family item) should say.
    • Consequence 2, not re-ruled here: the fate-2 rows the B1 fix now keeps (tpl_customized, tpl_admin: NULL organization, awaiting PR 3's promotion) are not counted as remaining — sys_email_template reads remaining 0, and the acceptance case certifies complete: true, blocking: 0 with them present. The previous record judged the template handling "right" (the table retires under D13; notNull.willReceive is false for it; the step note carries the count) and scoped B2 to departures, so this record does not reverse it. Named because the note is one number over three populations (customized, organization-stamped, non-seeded provenance), carries no ids, and the marker certifies while the promotion step is unrun. PR 3 (feat(cli,core): the organization-ownership ceremony promotes customized email templates and moves the settings global rung (C7b, PR 3) #23195) owns the promotion: its dispatch should make those rows a departure, or count them, so the same remainingFor holds the marker until they are promoted.
  3. Everything else the delta touches.

    • --out description names the apply report (the previous nit (d)): text only, right.
    • Human face: the skip condition now includes departures.length, so a table with only departures prints; right.
    • TableOutcome.departures is filtered to departures with at least one row; right. notNull in the apply report is overridden only when departures remain; the plan command's own notNull ("every NULL row is attributed or departs") is unchanged and forward-looking; right.
    • SEEDED_BY in the apply module is a second copy of the inventory's unexported SEEDED / AUTHORED vocabulary. The inventory file is claimed by feat(service-mailbox): sys_mail_message, its record associations and the restricted unassociated pool — the inbound-mail capability (manage_mailboxes, requires: mailbox) #22993 and test: the stage-8 test half — suites stop depending on the four catalog row objects (#15204 stage 8, S8-T) #22962, so this PR could not export it; a later PR should import the one list so the two cannot drift. Remedy-quality, not blocking.
    • The module header (the step 4 bullet) still says "none of its named categories" without the provenance clause; deletableWhere's own doc is correct. Nit.
    • The previous record's "right" findings, re-read on this head and untouched by the delta: the fate order (CEREMONY_STEPS, stop at the first step not done); the gate re-read (runMirrorDeletion calls verifyReferences before any DELETE and returns blocked without deleting); keyset paging (readAll and walkNullRows page by id greater than the last page's id, bound, LIMIT 500; attribution UPDATEs in 200-id bound IN chunks); the checkpoint and marker (per-step ledger.write with complete: false; the final write with applied: complete, passed: complete && blocking === 0; digest-bound checkpoint discarded on change); Sql.q on every identifier (SAFE_IDENTIFIER, refused otherwise) and every value bound, the new keep's parameters included. No SQL is built from data.
  4. The merge of main with S8.

    • Of the 18 commits main gained since the merge-base, two touch the ceremony's dependency paths: S8 (packages/core/src/security/index.ts −5, row-active.ts removed, resolve-user-grants-cache.ts −3; none of the four imported symbols) and 01d503432b (assemble-execution-context.ts +19, ADR-0138 anonymous-caller denial, not on the ceremony's path). schema-migrate.ts, unmanaged-tables.ts, the plan and the inventory are unchanged on main since the merge-base.
    • Engine reads or writes of sys_position, sys_permission_set, sys_position_permission_set, sys_capability: none. The apply reaches them only through Sql.read and executor.execute on the raw-SQL seam (resolvePlannedDriverExec). The command's engine uses are readDataMigrationFlag / recordDataMigrationRun (sys_migration), protocol.saveMetaItem (sys_metadata), createSecurityCatalogReader({ registry, metadata }) (post-S8 it reads the engine registry then the metadata service, by name, never a row object), and detectManagedDrift + applyMigrationEntries on entries filtered to drop_column organization_id of the due tables — a drop_table the differ might now propose for the four unmanaged tables is filtered out, and due never holds a fate-2 table.
    • Absent-table behaviour: columnsOf returns null for a table the plan did not enumerate, and every reader guards it (readCatalogRows, authoredRows, verifyReferences on GRANTS and BINDINGS, runRewrite on SETS); runMirrorDeletion and remainingFor iterate only physical === 'present' tables; columnListSql is issued only for present tables. By reading, a fresh post-S8 database without the four tables runs the ceremony with those steps reporting nothing.
    • Measured, not by this record: the dev's scratch run at aa6bc6d9 + feat(plugin-security)!: retire the four security-catalog row objects (#15204 stage 8) #23222's head 23e450a6 — the exact head squash-merged as 66949b7729 — ran the full @objectstack/cli suite, 66/66 derived gates and an end-to-end --apply from examples/app-crm (6150092087, accepted 6150126995). The delta touches neither plan nor inventory, so that measurement still answers the S8 question; what it does not cover is the delta's own 68 lines on the merge tree, which is the running Test Core shards.
  5. Ruling conformance. Unchanged: Q1–Q4 → A hold as the previous record read them, now including B2's reporting half. The form decision is applied: title "PR 2 of 3", line 1 Part of #22937, Part-of PR must not also close its card success.

② Semver level

The changeset .changeset/22937-cli-organization-ownership-apply.md is byte-identical to the one at aa6bc6d91f: @objectstack/cli minor, Clause-②: yes (widening); the PR body's line 2 is Clause-②: yes. The delta adds no flag and no exit code. It adds one key to the published JSON report payload (tables[].departures, with id, label, as, rows), overrides notNull on that payload when departures remain, and changes two human-face strings and the --out description. A new key on a published payload is the clause ② act — already declared, already graded minor; under the WHICH LEVEL rule in .github/workflows/pr-automation.yml the widening takes at least minor, so the grade still fits and patch across the board would be refused by the level axis. Nothing is removed or renamed: no BREAKING banner, no ADR-0087 marker owed. The package's . export does not re-export the report type; the surface is the CLI's stdout and report file. Check Changeset is success on this head. The changeset's "a table whose rows still need another step keeps its column, and the run says why" and "reports, per table, what remains" are now true for departures, closing the previous record's overstatement; it does not name the departures key by name, which is acceptable for a behaviour description.

③ Boundary flags

  • B1 — closed. Deletion on sys_email_template stays inside fate 2; admin-authored and NULL-provenance NULL-organization rows are kept and counted in the step note; the pin reddens on the old selector.
  • B2 — closed on the gate. verified_at stays null and exit is 1 while a departure is unexecuted; each departure is named per table with its count and the remedy label. Two carried items, not blocking: the D2 precondition on deployments with seeded declared sharing rules (①2, consequence 1) and the held fate-2 template rows not counted (①2, consequence 2) — the first to the epic seat's ordering pointer, the second to PR 3's dispatch.
  • The B1/B2 round's dev report is not posted. Weighed instead: the diff, the two changed pins, and the head's check-runs above. Three Test Core shards were still running at 2026-10-13T16:45Z; the shard holding organization-ownership-apply.test.ts and the one-shot family pin is not identifiable from the roster, so the verdict stands only once all three are green.
  • Dev deviations in the delta: none beyond the brief — three files inside packages/cli, no inventory edit, no change to the plan, a commit message with model-free trailers. The --out fix was the previous record's nit; accepted.
  • Open question (column-drops on the sys_metadata family) → A, answered by the seat (6150126995). The code matches A: a not-proposed outcome blocks with the remedy, the marker stays unapplied; the delta does not touch it. The ordering pointer to 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 is the seat's, with consequence 1 above added to it.
  • Services seat pointer 6147942984 (plugin-approvals expandPositionUsers filters organization_id strictly, while plugin-security's holdingTakesEffectIn admits an organization-less position holding in every organization: an upgraded 17.x holder never reaches an approval slate #23256): sys_user_position keeps fate attribution, anchor business_unit_id → sys_business_unit, Default Organization fallback under single; the delta does not change it, so nothing is owed to plugin-approvals expandPositionUsers filters organization_id strictly, while plugin-security's holdingTakesEffectIn admits an organization-less position holding in every organization: an upgraded 17.x holder never reaches an approval slate #23256. One caveat the services seat should know when it verifies: under a walled posture an organization-less holder row with no business unit stays NULL and is reported, so the slate fix the maintainer ruled arrives through this ceremony under single only.
  • PR body, for the seat's reserved update (6150126995): paragraph 1 still says "PR 2 of 2"; the Files heading reads "+1,863 / −28; 1,891" and should read 9 files, +1,927 / −29, 1,956 changed lines; the Gates section names "HEAD 0555b80"; "the acceptance fixture (8 cases)" is now 9 (plus the 3 apply-flags cases); "What it does" item 4 should say admin-authored and NULL-provenance template rows stay; "Decisions 3" should say the unexecuted departures are counted as remaining (exit 1, verified_at null) and name PR 3 (feat(cli,core): the organization-ownership ceremony promotes customized email templates and moves the settings global rung (C7b, PR 3) #23195) as the owner of the settings move and the template promotion, and D2 as the carrier of the sharing-rule mirrors; the sys_metadata-family known limit (the seat's own item) stands.
  • Remedy-quality, carried from the previous record, unchanged: the binding-rows blocker text under a wall or on a deactivated authored position; the attribution unique-index collision aborting with the driver's error and no ids; the SEEDED_BY duplicate above.
  • NOT MEASURED: the delta's own tests on the merge tree (Test Core (2/6), (3/6) and (4/6) running); the PostgreSQL and MySQL paths of the new CASE … END = 0 keep (SQLite fixture only; the predicate is dialect-neutral by reading and binds each claim's parameters twice); a customized NULL row (the ELSE arm); a real sys_sharing_rule NULL-row departure end to end (same code path as the pinned sys_setting case); test/json-stdout-purity.e2e.test.ts (comment-only edit, nightly tier); dogfood (no suite exercises the ceremony — the dev's finding, accepted by the seat); the derived gates on this exact head (CI's Lint and Type Check jobs are green; the dev's 66/66 was on aa6bc6d9 and on the S8 scratch tree).

Adopted by domain:engine seat 2 at 2026-10-13T16:46Z, after a tool-use audit of the isolated reviewer's transcript. The reviewer made reads, git fetch into review refs (refs/review/pr-23034-delta, refs/review/pr-23034-merge), REST GETs, and record writes only. It made no GitHub write and ran no build, test or gate. The seat takes the verdict as PASS conditional on Test Core (3/6) and (4/6), which were still running when this was adopted ((2/6) has since gone green); a red one voids it. The carried items:

Implemented-by: claude/issue-22937-organization-ownership-apply
Reviewed-by: session_01ADzJtzYTLUfgrRZHxkagkX

VERDICT: PASS

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

Development

Successfully merging this pull request may close these issues.

2 participants