Repository navigation
feat(cli): os migrate organization-ownership — the ADR-0131 D10 inventory and the read-only ceremony plan (C7a) - #22643
Conversation
… plan Claude-Session: https://claude.ai/code/session_01AB6Nvx7Ue6yzJypw7VTvkj Co-authored-by: Claude <noreply@anthropic.com>
…only plan, its inventory and pins Claude-Session: https://claude.ai/code/session_01AB6Nvx7Ue6yzJypw7VTvkj Co-authored-by: Claude <noreply@anthropic.com>
…ed without the auth family Claude-Session: https://claude.ai/code/session_01AB6Nvx7Ue6yzJypw7VTvkj Co-authored-by: Claude <noreply@anthropic.com>
…ot tracker numbers; the plan refusal carries its reason, not an unregistered code Claude-Session: https://claude.ai/code/session_01AB6Nvx7Ue6yzJypw7VTvkj Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 93 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 16 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 99fb429a23dbf7f3df833df218fc3b32b7eb347f && git checkout 99fb429a23dbf7f3df833df218fc3b32b7eb347f
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 1b99388505d33c940757e85685ca925d6cd57f04 a3f7ab26ad07cc5d5b087e58ef9d4ade1c8ffbe7 && git checkout -B drift-repro 1b99388505d33c940757e85685ca925d6cd57f04 && git merge --no-ff a3f7ab26ad07cc5d5b087e58ef9d4ade1c8ffbe7
node scripts/docs-audit/affected-docs.mjs --json 1b99388505d33c940757e85685ca925d6cd57f04
|
Contract reviewServed-tier: PR #22643 (card #22617, C7a of #15211) · net diff against ① Derived judgmentsPublic-surface and accept-set changes the diff implies — each named right or wrong:
Governance: the file list touches no governed path ( ② Semver level
③ Boundary flagsDev deviations (report 6095493258):
Items the owning seat acts on (none is a FAIL ground for this diff):
Check-runs on the head (required contexts per the
Implemented-by: VERDICT: PASS |
…t scaffold with the workspace protocol (nightly tiers) (#22699) Fixes #22622 Clause-②: no Two nightly-tier CLI test files change and nothing else: no accepted input, key, export or error code moves, and nothing publishes. Two independent reds on the nightly tiers, both in `domain:cli`. **Both are fixed inside the nightly tests**, following the seat's review 6099406666 on #22622. Nothing else changes: - the family-roster check; - the noise budget and every assertion in that file; - the `create-objectstack` template, the version-time stamp (`scripts/sync-template-versions.mjs`) and their tests, which are byte-for-byte as `main` has them. ## (a) Both reds, reproduced before any fix Base `9646991283`, `OS_TEST_TIERS=nightly`, `--project integration`, through the verify lock: `Tests 2 failed | 51 passed (53)`. - `json-stdout-purity.e2e.test.ts`, `is exactly the set listed here`: `expected [ 'meta resync', …(17) ] to deeply equal [ 'meta resync', …(15) ]`, with `+ "migrate organization-ownership"` and `+ "migrate security-catalog-overlays"`. `migrate organization-ownership` came from PR #22643, after the nightly that filed the card. The members were taken from `discoverFamily()`'s output. - `serve-boot-diagnostics-noise-budget.e2e.test.ts`, `highlights the dead button once…`: `expected 0 to be greater than 0` at `:176`. The boot printed `✗ package 'com.example.support-desk' targets protocol ^17 (engines.protocol) but this runtime is protocol 18.0.0. This is a major-version break. Run: objectstack migrate meta --from 17`. That is a refusal, so no *Boot diagnostics* block is printed at all. After, on `3d63ed7db5`: `Test Files 2 passed (2)`, `Tests 59 passed (59)`. That is 53, plus 3 cases for each of the two new members. ## Failure 1: the two new `--json` members are driven `FAMILY` gains both members, each in its bare form. The CLI was first run against the purity fixture to see each one's face: - `migrate organization-ownership` shows its refusal face. Over a database with none of the platform tables it prints `{"error":"plan_refused","reason":"not-an-objectstack-database",…}` and exits 1. The JSON is emitted after `stack.shutdown()`, and no plan file is written. - `migrate security-catalog-overlays` shows its read-only preview: `listed: 0`, exit 0. There is no `sys_metadata` hydration and nothing is deleted. Both print all three boot diagnostics on stderr. The discovery and the `toEqual` roster check are untouched. ## Failure 2: the noise-budget e2e aligns the pair it boots ### (b) What the version-time stamp writes Measured read-only at `9646991283`: - `changeset status --output` (`pre.json` is `{"mode":"pre","tag":"next"}`) gives `@objectstack/spec major 17.7.0 -> 18.0.0-next.0` and `create-objectstack major 17.7.0 -> 18.0.0-next.0` (the `fixed` group). - `loadScaffolderVersion()` on `18.0.0-next.0` gives `{"version":"18.0.0-next.0","major":"18","range":"^18.0.0"}`. So the version pass stamps `engines: { protocol: '^18' }` into the released template. **Triage's "every project that v18's `create-objectstack` scaffolds would boot with the protocol-gap warning" is false.** No released v18 scaffold carries the gap. PR #22215's changeset says the same ("the template keeps `'^17'` until the version pass stamps it"). The red is `main`'s pre-mode window: packages at `17.7.0`, `PROTOCOL_VERSION` at `18.0.0`. Only an in-repo pairing sees it: a published-line scaffold booted on the workspace runtime with nothing installed. ### Round 1 changed the product side, and CI falsified that Round 1 (`bff640d98`) stamped the template's `engines.protocol` from `PROTOCOL_VERSION`'s major. On that head, `Scaffold with repo dist` (`scaffold-e2e.yml`, job 1) went red: "package 'com.example.e2e-app' targets protocol ^18 (engines.protocol) but this runtime is protocol 17.0.0". That gate guards the path a user takes. It scaffolds with the repo-built scaffolder and installs `@objectstack/*` from the registry (`^17.0.0`, so protocol 17). On that path a template's range must match the protocol of the packages it installs, which is the package major. The sync script and the `template-consistency` ratchet already enforce exactly that on `main`. Commit `60fa01c7a7` removes round 1's product-side change: - `templates/blank/objectstack.config.ts`, `template-consistency.test.ts`, `template-version-stamps.test.ts` and `scripts/sync-template-versions.mjs` are back at their `main` blobs (`6308f29e`, `d32c819c`, `48ba146e`, `f7704db8`, all equal at base and `origin/main`); - the `create-objectstack` changeset is deleted. ### This round: the setup aligns the pair it boots This follows the seat's verification-strategy ruling in 6099406666. After scaffolding, and before the fixture files are written, `alignProtocolRange()` rewrites the scaffold's `engines.protocol` to `'^' + PROTOCOL_MAJOR`. `PROTOCOL_MAJOR` is read from `@objectstack/spec/kernel`, the workspace spec this file boots, the same import other CLI e2e files use. - **Only the major's digits are rewritten.** When the majors agree, the scaffold is left byte-for-byte as written, and the helper does not write the file at all. - Measured on the template: at `'^17'` (the window) the hash moves `684e38ed0d1a` → `5d6e6468255e`, and only the `engines` line differs. - At `'^18'` (majors agree) the hash stays `5d6e6468255e` → `5d6e6468255e`, byte-identical. - **A missing stamp throws in `beforeAll`.** A template that stops declaring the key fails this file loudly instead of skipping the alignment. - **Why a direct rewrite of the one key, and not `objectstack migrate meta --from 17 --write`.** The `--write` command is the step the refusal prescribes, and it was measured first. On a scaffold under `packages/cli/node_modules/` it exited 0 with `not written [outside-project]` and left the config unchanged. The codemod refuses any file whose real path has a `node_modules` segment (`authored-source-codemod.ts`, the `outside-project` refusal). This project has to live under this package's `node_modules` so that its imports resolve to workspace copies. - **What stays the same:** the noise budget, all three assertions, and the ticket object and actions. Once the version pass lands, the window closes and the alignment becomes a no-op. ## Ablations Both ran through `scripts/ablation-replace.mjs` in wrap mode on committed trees, under the verify lock. Each restore was proven by blob equal to HEAD and an empty `git diff HEAD`. Neither subject resolves through `dist/`: the FAMILY and the setup live in the test files. | Mutation | Anchor | Result | |---|---|---| | `alignProtocolRange(join(dir, 'objectstack.config.ts'));` deleted from the setup | x1 → x0, blob `058fbecec7dc` → `bfedb0522aa5` | noise-budget red with the original refusal: `✗ package 'com.example.support-desk' targets protocol ^17 … this runtime is protocol 18.0.0 …`, `expected 0 to be greater than 0`, 1 failed and 2 passed. After the restore to blob `058fbecec7dc`: 3 passed. | | `'migrate security-catalog-overlays': [],` deleted from `FAMILY` (round 1, same blob as now) | x1 → x0, blob `698034ba9bcb` → `5dfe8780897a` | roster pin red: `expected [ 'meta resync', …(17) ] to deeply equal [ 'meta resync', …(16) ]`, `+ "migrate security-catalog-overlays"` | ## Tests (on `3d63ed7db5`) - **Nightly tier** (`OS_TEST_TIERS=nightly`, `--project integration`, verify lock): the two card files, 59 passed. - **`create-objectstack`:** suite 17 files and 254 tests passed. `pnpm check:template-version-sync` is green with 40 assertions, the same count as on `main`. Both are unchanged, because this diff does not touch the package or the script. - **`@objectstack/cli`:** - unit layer: 279 files and 4126 tests passed; - `typecheck` (`tsc --noEmit` plus `check:test-typecheck`) is OK, and `tsconfig.test.json`'s `--listFiles` includes both edited files. - **Changeset:** `node scripts/check-empty-changeset.mjs --base origin/main` exits 0. It reports no empty-frontmatter changeset added and no merge-base changeset modified or deleted. - **Gates:** `dispatch-gates --commands` (no paths) derived 50 commands from the 2-file change set. `--ran` reports `50 derived, 50 run, 0 NOT-MEASURED, 0 UNRUN`, a derived zero: every command recorded `exit 0`. `check:type-check-debt` and `check:dual-build-cjs-loads` ran last, after every locked suite. - `dispatch-gates` noted that the tree is behind `origin/main`: 6 derived-from files changed there (`ci.yml`, `check-shard-attestation.mjs` and the fleet-write relay scripts). - No conflict exists, so per the round's direction there was no merge. CI judges the merge ref. - **Lint, proven narrowing:** 1. Population is read from ESLint's own config: both changed files return results with no "File ignored". 2. File count is from `--format json`: 2 results, 0 errors, 0 warnings. 3. Invariance: `eslint.config.mjs` enables no type-aware linting, and this diff touches neither the config nor a baseline it reads. ## Changeset Nothing publishes: `@objectstack/cli` ships `dist`, `README.md` and `CHANGELOG.md`, and this diff is two `test/` files. By the repo's rule this PR takes the `skip-changeset` label. An empty-frontmatter changeset would be the risky path that `check:empty-changeset` refuses. The seat applies the label. ## Acceptance notes - `objectstack migrate meta --write` on a project whose path has a `node_modules` segment says `the literal is in …/objectstack.config.ts, outside the project at …`, although the file is inside the project. The real cause is the `node_modules` segment rule. Only a test layout reaches it, so this is a wording polish, not a finding. No carrier. - Version Packages PR #21988 was last refreshed on 2026-10-08T06:19Z, before pre mode was entered (`a87d8be29`). This is release-lane state, recorded as a reading, ⛔ not acted on. --- _Generated by [Claude Code](https://claude.ai/code/session_01BmsuLyUeuG5CNpZFMH1jzS)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #22617
Part of #15211
Clause-②: yes (widening)
The Clause-② widening is a new read-only command surface. No existing command or verdict changes.
What this adds (C7a of the ADR-0131 D10 ceremony)
The inventory:
packages/cli/src/utils/organization-ownership-inventory.ts. It gives each object one D10 fate (column drop, mirror deletion, attribution through a parent anchor, or report) with its citation. 128 objects:00d8f6541b)main)sys_business_unit_memberis unadjudicated inPLATFORM_OBJECT_TENANCY, so seed-replayed and system-written membership rows land organization-less #14570 (sys_business_unit_member) is read in: attribution throughbusiness_unit_id→sys_business_unit, citing 5536484221 and 6067123924. 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 business unit seeded with a NULL organization) is read in: attribution onsys_business_unit(D3; pointer 5536478573).sys_metadatapresentational promotion with its conflict list (6020279837, records 6020163868 and 6020151485), the overlay duplicates (6071418113), the email-template promotion (6020178017), and thesys_settingglobal rung moving out (6051723395).sys_metadatafamily's schema change (6068052798) is fate 1 on all four objects.organization_idand has no inventory row takes the application-object default: attribution, which is the Default Organization undersingleand a report otherwise.os migrate organization-ownership: the read-only plan (D10 ceremony item 1). For each table it gives:notNull: { willReceive, reason }.It writes the plan to a file (
--out, defaultorganization-ownership-plan-TIMESTAMP.json) and never overwrites one.--jsonalso prints it.The completion-marker design: below. It is design only, with no code.
Why
os migrate organization-ownershipand notos migrate --planos migrateis the schema-drift plan (Schema sync is additive-only: non-additive metadata changes (required→optional, type, drop, rename) silently diverge from existing DBs; need drift detection + os migrate #2186), and this PR leaves it unchanged.summary-nulls,files-to-references,security-catalog-overlays, …) is a named subcommand. Its bare run is the read-only preview and--applyis its only writing mode. The ADR's--plannames this mode.--applyand the post-check to the same command. Until then it has no writing mode at all.Read-only, and refusal
deferSchemaDdl+readOnlyProbe). It reads the physical database through the planned driver's raw seam, with SELECT statements only.--database-url).physical: 'absent'withrows: null. It is never reported as a table of zero rows.sys_activityshards are folded onto the base object.os serveboot, then the next release's artifact) plans 20 tables: 9 column-drop, 11 attribution, andsys_activityfolded from its shard.Where it lives, measured
The inventory and the planner sit under
packages/cli/src/utils/. oclif'spatternstrategy turns every module undersrc/commands/**into a command, so a data module there would become one. The command issrc/commands/migrate/organization-ownership.ts. The inventory is a typed TypeScript table rather than a JSON file, so the CLI ships it indistand the planner and the pin read one spelling.The completion marker — design only (C7b builds it)
⛔ This PR contains no code that writes or reads the marker. This section is the design that C7b's last step writes and the v18 boot refusal reads.
Where it lives. One row in
sys_migration, the deployment-level migration-flag table (platform-objects/src/system/migration-flag.ts, #3617). The row's primary key is a new spec constant,ORGANIZATION_OWNERSHIP_MIGRATION_ID = 'adr-0131-organization-ownership', which sits besideFILE_REFERENCES_MIGRATION_IDandVALUE_SHAPES_MIGRATION_ID. No new table.sys_migrationis itself a D7 column-drop object. Its drop runs inside the ceremony before the marker is written, so the marker is always written to the table's final, tenant-less shape.What it records. It reuses the existing columns:
applied_at: when the apply finished.verified_at: when the post-check passed.blocking: the number of tables whose post-check did not complete. This is not the count of reported rows. Reported rows are D10 fate 4: they are named at boot and do not block it.details: one JSON document:ceremonyVersion: the integer1, the sameceremonyVersionthis plan prints;inventoryDigest: the sha256 the plan prints, which ties the marker to the inventory it executed;posture;tables: per table,{ object, fate, remainingNull, notNull: 'applied' | 'withheld', reason }.verified_atis set only by a post-check that re-ran the plan and found zero tables left mid-fate.When it is written. Only as the ceremony's last statement, after the post-check. An interrupted apply leaves no marker; its checkpoint lives in the ADR-0119 journal (
os migrate resume). A fresh v18 database gets the marker when it is created, the wayattestFreshDatastoreattests the creation-attested migration ids. A database born on v18 has nothing to migrate.How a v18 boot reads it. This has the same fail-fast shape as ADR-0093 D5, with no escape hatch.
start(). Additive sync could otherwise create tables on a database the boot is about to refuse.sys_organizationbut no marker row is refused, and the refusal namesos migrate organization-ownership --apply.ceremonyVersionbelow the runtime's required version, missingverified_at, or withblocking > 0is refused.readDataMigrationFlag.OS_ALLOW_*/OS_SKIP_*variable and no flag that skips the read (ADR-0131 D10 item 5). A marker with tables still reporting NULL rows boots. Those tables are listed at boot with counts and the remedy (fate 4), and they stay unconstrained.Verification (head
a3f7ab26)pnpm --filter @objectstack/cli typecheck && pnpm --filter @objectstack/cli exec vitest run --project unit→Test Files 278 passed (278),Tests 4115 passed (4115), VERDICT command-exit 0. This includes the neworganization-ownership-inventory.test.ts, the enumeration pin:PLATFORM_OBJECTS_BY_PACKAGEregistry, which reds by name for an object with no row;CLOUD_PROVIDED_OBJECT_NAMES, each pinned as fate 4 andcloudCarried;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 each read in.vitest run --project integration src/utils/organization-ownership-plan.integration.test.ts src/utils/schema-migrate.one-shot-family.integration.test.ts -t 'organization-ownership|ADR-0131|missing from CALLERS'→ 14 passed. That covers:isolatedand undersingle;SELECT;scripts/ablation-replace.mjs,if (hasPlatformObjectPrefix(base)) {was changed to… && false) {. The mutation landed (anchor 1→0) and the uninventoried-table pin went red (1 failed, 9 passed). The restore was verified: blob equals HEAD andgit diff HEADis empty.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderives 65 commands. All 65 exit 0 ona3f7ab26, and--ranreconciles them: 65 derived, 65 run, 0 NOT-MEASURED, all with exit codes.check:doc-authoring(tracker numbers in citation strings, now ADR sections and ruling-record ids) andcheck:dispatcher-error-vocabulary(an unregisteredPLAN_REFUSEDcode, dropped because the refusal carries itsreason).eslint --no-inline-configon the changed files exits 0. The repo-wide lint is CI's.Acceptance notes
cloud, which this session cannot reach. Both are NOT MEASURED. The cloud rows are the names this repository records as cloud-provided. Cloud stays on v17 by ruling 6094175435, so they carry fate 4 andcloudCarried, and C10 assigns them their fates.parent row … has no organization. C7b's apply runs attribution parents-first and its post-check re-plans. Undersingle, the Default Organization covers those rows anyway.sys_notification_templatestays on attribution. It is the conservative fate: no column tells a seeded row from an authored one, so no row is selected as a mirror on a guess.🤖 Generated with Claude Code
https://claude.ai/code/session_01AB6Nvx7Ue6yzJypw7VTvkj
Generated by Claude Code