Repository navigation
feat(cli): os migrate organization-ownership --apply — the ADR-0131 D10 ceremony (C7b, PR 2 of 3) - #23034
Conversation
…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>
📓 Docs Drift CheckThis PR changes 1 package(s): 23 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 14 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 28 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # 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
|
…ganization-ownership-apply
…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>
Contract reviewServed-tier: What was reviewed. PR #23034 ( Check-runs on ① Derived judgments
② Semver levelThe changeset ③ Boundary flags
Adopted by Implemented-by: 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>
Contract reviewServed-tier: What was reviewed. Delta record on PR #23034 ( Check-runs on ① Derived judgments
② Semver levelThe changeset ③ Boundary flags
Adopted by
Implemented-by: VERDICT: PASS |
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 onmain. PR 3 (the template promotion and thesys_settingglobal-rung move, the maintainer's Q3 and Q4 rulings) opens offmainafter 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 onsys_email_template; B2, a post-check that certified while departures were unexecuted). Both are fixed in8b615d0f9c, and the delta review is PASS (6150575317).What it does
os migrate organization-ownershipkeeps its read-only plan as the default.--applyis the new writing mode:Backup first (D10 item 2). It needs
--backup-takenor a yes at the prompt. A non-interactive run without the flag is refused before the boot.--backup-takenwithout--applyis 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.
attribution(fate 3). The plan's own walk derives the owners: parent, subject or holder anchors, and undersinglethe 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.authored-conversion. The inventory'sauthoredrows ofsys_permission_setandsys_positionbecome environment metadata through the metadata door (protocol.saveMetaItem). The bodies come from core'spermissionSetDefinitionFromRow/positionDefinitionFromRow, and the position bindings are carried intopermissionSets(convertPositionBindingRows). This runs undersingleonly (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. Authoredsys_capabilityrows are fate 4, reported (seat answer 6110777865).rewrite(D4), verified. Each unnamedsys_user_permission_setgrant gets the name of the set row itspermission_set_idpoints 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_idand its indexes stay (C7b's later drop, E3).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 amirror-deletionobject, matches the object's mirror selector, and no named category of the object claims it. On a table that carriesmanaged_by, a row whose provenance is notpackage,platformorsystemis 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.column-drops(fate 1).organization_idleaves the D7 objects through the schema-drift ceremony: thedrop_columnentriesdetectManagedDrift()proposes, applied withapplyMigrationEntries(…, { allowDestructive: true }), which isos migrate apply's own machinery and never a handALTER. A column-drop table with rows in a named category (thesys_metadatapromotion 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 = '');Each finished step is checkpointed in
sys_migration(rowadr-0131-organization-ownership,details.checkpoint, throughrecordDataMigrationRun). 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_atonce every step has finished, andverified_atonce the post-check also reads zero discrepancies. A departure whose step has not run yet (thesys_settingglobal-rung rows, the declared sharing-rule mirrors) counts as remaining. Each table's report names it with its row count, andverified_atstays 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 exportsSql,predicateSql,requireColumn,columnListSqlandcolumnNameOffor 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--applywrite 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/climinor,Clause-②: yes (widening).⛔ Untouched:
packages/core/src/security/(read only),packages/plugins/plugin-security/**,organization-ownership-inventory.tsand 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.tscarries every fate the card lists, on the post-stage-8 physical shape: the catalog tables are read and written through raw SQL only, andpermission_set_idis a plain text column. It holds:sys_filerows with asys_attachmentholder;sys_saved_report, fate 4).After
--apply:permissionSets).org_bthrough the holder, not the Default Organization.Guarantee cases, measured:
UPDATE;Ablations, through
scripts/ablation-replace.mjs, each restored to the HEAD blob withgit diff HEADempty:End to end on today's
maincomposition. A smoke run used the built CLI against a SQLite database whose schemaos migrate applyhad created, plus 17.x rows:sys_metadata, and it resolved within the run;sys_job.organization_idwas dropped through the drift machinery;sys_metadatafamily was correctly reported as not proposed, because its declarations onmainstill carry the column;sys_migrationcheckpoint.Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 66 derived. All were run with exit codes captured before any pipe.--ranreconciliation: "66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN (a DERIVED zero)".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.0555b80a, then ataa6bc6d9after the merge ofmain, whereapply-flagsloads the command inbeforeAllwith 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.aa6bc6d9+ feat(plugin-security)!: retire the four security-catalog row objects (#15204 stage 8) #23222's head23e450a6, never pushed), report 6150092087: the full@objectstack/clisuite, unit and integration, as CI shards it; the 66 gates; and an end-to-end--applyfromexamples/app-crmagainst the four unregistered catalog tables. All green.8b615d0f9c(B1, B2): CI's full Test Core on the merge ref withmainis green.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)The boot refusal is not built here. Its parts live outside
packages/cli:CREATION_ATTESTED_MIGRATION_IDSin@objectstack/spec/system, plusattestFreshDatastorein@objectstack/platform-objects;serve.ts, and the runtime for hosts that do not boot through it).Landing it before C8 would refuse every existing database on
mainbefore the ceremony can complete there. This PR writes the marker the refusal will read: thesys_migrationrow, through the existingreadDataMigrationFlag/isDataMigrationFlagVerifiedcontract. The refusal belongs with C8 (or a follow-up C7b card) and names that row.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.
Not performed here, reported instead:
os migrate organization-ownership --applyand E5's pure row→definition mappers in core (drafts from main; the --apply PR lands after #15204 stage 8) #22937, the maintainer's Q3 ruling);sys_sharing_ruledeparture) are kept, because rule-sourcedsys_record_sharerows reference rules bysource_id. Their follow-up step rides D2;sys_settingglobal-rung move is not performed here. It is PR 3's (Q4).None of these is deleted or attributed. The two departures count as remaining, so the marker is not verified until they have left.
Known limit on today's
main: column-drops stops on thesys_metadatafamily.sys_metadata,sys_metadata_audit,sys_metadata_commitandsys_metadata_historystill declareorganization_id, so the drift differ proposes no drop.--applyexits 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).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
sys_business_unit_memberis unadjudicated inPLATFORM_OBJECT_TENANCY, so seed-replayed and system-written membership rows land organization-less #14570 (sys_business_unit_memberorg-less rows) and 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 (the NULL-organization seeded business unit) are absorbed. Both objects are fate 3 in the inventory (parent anchorsbusiness_unit_id→sys_business_unitandparent_business_unit_id), and both fall back to the Default Organization undersingle.--applywrites those owners.sys_business_unit_memberis unadjudicated inPLATFORM_OBJECT_TENANCY, so seed-replayed and system-written membership rows land organization-less #14570 and 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 remain open for their own closure.Field.textredeclaration ofpermission_set_id, change nothing it does. Both shapes are covered: the post-S8 shape in the unit fixture, today'smainin the smoke run.Generated by Claude Code