Repository navigation
Commit 2808069
feat(cli): os migrate organization-ownership --apply — the ADR-0131 D10 ceremony (C7b, PR 2 of 3) (#23034)
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` + #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:**
- admin-authored, customized and organization-stamped template rows are
kept. Their promotion is PR 3's (#22937, the maintainer's Q3 ruling);
- declared sharing-rule mirrors (`sys_sharing_rule` departure) are kept,
because rule-sourced `sys_record_share` rows reference rules by
`source_id`. Their follow-up step rides D2;
- the `sys_setting` global-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.
5. **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 #15211's stage, and it lands before C8
(seat answer 6150126995, pointer 6150140797).
4. **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
- #14570 (`sys_business_unit_member` org-less rows) and #15086 (the
NULL-organization seeded business unit) are absorbed. Both objects are
fate 3 in the inventory (parent anchors `business_unit_id` →
`sys_business_unit` and `parent_business_unit_id`), and both fall back
to the Default Organization under `single`. `--apply` writes those
owners. #14570 and #15086 remain open for their own closure.
- Stage 8 dependency: the ceremony never reads the catalog objects
through the engine, so S8 unregistering them, and S8's `Field.text`
redeclaration of `permission_set_id`, change nothing it does. Both
shapes are covered: the post-S8 shape in the unit fixture, today's
`main` in the smoke run.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DjKf52bcyXa3fbGW723phR)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 9c7cfbf commit 2808069
9 files changed
Lines changed: 1927 additions & 29 deletions
File tree
- .changeset
- packages/cli
- src
- commands/migrate
- utils
- test
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
0 commit comments