Repository navigation
Commit 707b3cd
feat(cli,spec): a host declares the ADR-0131 D10 fates of its own objects through os migrate organization-ownership --fate-supplement (#23348)
Fixes #23280
Clause-②: yes (a host-declared fate supplement becomes a published input
of `os migrate organization-ownership`)
## What this does
The v18 organization-ownership ceremony (ADR-0131 D10) refuses every
platform-prefixed table that carries `organization_id` and has no fate
in its inventory. That stays right for the platform's own objects. But
no inventory row covers a host's own objects (cloud's control-plane and
tenant tables, for example), so the plan refused every host database.
This PR adds one declared channel through which a host states those
fates, the inventory's C10 stage:
- **The input is declared in the spec.**
`packages/spec/src/system/fate-supplement.zod.ts` exports
`OrganizationOwnershipSupplementSchema`, a JSON array of
`OrganizationOwnershipSupplementEntrySchema` rows. It also exports the
row's building blocks: `OrganizationOwnershipFateSchema`,
`...RowConditionSchema`, `...AnchorSchema`, `...CategorySchema` and
`...DepartureSchema`.
- A row carries `object`, `fate`, `citation`, `categories?`, `note?`,
plus only the fields its fate reads: `mirror?` on fate 2; `anchors`
(required) and `departures?` on fate 3; `departures?` and
`notNullExempt?` on fate 4.
- Every shape is strict. A key written on the wrong fate is refused by
name, and the platform-only markers (`census`, `cloudCarried`,
`external`) are not authorable.
- **One flag, read in both modes:** `os migrate organization-ownership
--fate-supplement FILE`.
- The command reads the file before the database is opened. It passes
the merged inventory as `inventory` to `buildOrganizationOwnershipPlan`
(plan mode) and to `applyOrganizationOwnership` (`--apply`).
- The apply path's semantics are unchanged beyond receiving `inventory`.
- **The merge joins host rows on the platform's terms**
(`mergeHostFateSupplement` in `organization-ownership-inventory.ts`).
Each host row takes census `host-supplement` and is planned and applied
exactly as a platform row with that fate would be. Per `domain:spec#2`'s
answer 3:
- a row naming an object the platform inventory already gives a fate is
refused;
- a row whose table this database lacks is listed `absent`, so one
supplement serves both the control-plane and the tenant database;
- an undeclared table that holds data is still refused by name.
- **Refusals, never a half-read:**
- The file may be missing, not JSON, not an array, hold a row the schema
refuses, declare one object twice, or name a platform object.
- Each case is a `HostFateSupplementRefusal` with a reason code, mapped
through `refuse()` inside the `--json` envelope (`plan_refused` /
`apply_refused`).
- The envelope names `file`, plus `row` (zero-based index) and `object`
for a row refusal.
- Reason codes: `fate-supplement-unreadable`, `-unparsable`, `-invalid`,
`-duplicate-object`, `-platform-object`.
- **The undeclared-table refusal gains one remedy clause** (answer 4).
It now ends: "…owes it a row, or, when the host provides the object, the
host declares its fate in a fate supplement passed with
--fate-supplement FILE." Its condition is unchanged, and this is the
only edit in `organization-ownership-plan.ts`.
- **The `CLOUD` premise sentence**
(`organization-ownership-inventory.ts`, formerly `:177-179`) no longer
says "Cloud stays on v17 (ruling record 6094175435)". It now cites cloud
ruling record `6146394952` (objectstack-ai/cloud#2866 A: cloud's tenant
databases run this ceremony), and says a host fate supplement replaces
the row.
- The citation is spelled as a record id rather than a tracker number,
because it is a runtime string (`check:doc-authoring`).
- The header comment now says the same.
- ⛔ No fate is guessed inside objectstack for any cloud table. The 8
hard-coded `cloud(...)` placeholder rows are kept, unchanged and
fateless, until a supplement replaces one.
- ⛔ No ambient input: no environment variable and no conventional path.
The flag declares no `env:`.
### Why `--fate-supplement`
"Supplement" is ADR-0131 D10's own word for this input ("cloud's
supplement for cloud-declared ones"), and "fate" names what it
supplements. A bare `--fates` could be read as a filter over the plan's
fates, and `--supplement` alone does not say what it supplements. The
spec module is named after the flag (`fate-supplement.zod.ts`). The
fuller "organization-ownership-supplement" made its generated reference
page title 51 characters, outside the docs title band (36 to 46), and no
ladder rung fits it. Renaming the module was the in-scope fix; widening
the docs tooling was not.
### One shape, not two hand-kept copies (H1)
The CLI already depends on `@objectstack/spec` and imports its schemas
(`ObjectStackDefinitionSchema.safeParse` in `validate`, `ManifestSchema`
in `plugin build`).
- **Derived:** the CLI's row vocabulary now comes from the spec.
`OrganizationOwnershipFate`, `RowCondition`, `Anchor`,
`InventoryCategory` and `InventoryDeparture` are aliases of the spec
types, and `ORGANIZATION_OWNERSHIP_FATES` is
`OrganizationOwnershipFateSchema.options`.
- **Pinned equal:** only the top-level row key list stays in the CLI
(`InventoryEntry` also carries `census`, `cloudCarried` and `external`).
It is held equal to the spec three ways:
- compile-time spec→CLI: `mergeHostFateSupplement` assigns a parsed spec
row into `InventoryEntry`;
- compile-time key equality: `KEYS_AGREE` in
`organization-ownership-inventory.test.ts`. Reverse-verified below;
- runtime CLI→spec: every platform inventory row, minus `census` /
`cloudCarried` / `external`, parses under
`OrganizationOwnershipSupplementEntrySchema`. The platform rows use all
5 condition forms, all 3 anchor kinds, both category forms and both
departure forms, so this check covers the whole vocabulary in use today.
## Measurements (dispatch Zone 2)
- **H1 (row shape):** measured as stated. A row is the inventory entry
minus `census` (the merge stamps `host-supplement`), minus the platform
markers `cloudCarried` / `external`. These are output markers only: the
plan copies them onto `TablePlan` and nothing branches on them.
- **H2 (format, flag):** JSON only. No migrate sibling reads a YAML
input, so YAML was not added.
- **H3 (`cloudCarried`):** the plan copies it to the table plan
(`organization-ownership-plan.ts`, `planPresentTable` / `absentTable`),
and apply reads it nowhere (`git grep cloudCarried packages/cli/src`:
plan, inventory and the inventory test only).
- So the 8 `cloud(...)` rows are fate-4 placeholders with a marker. As
the PM read it, a supplement row naming one of them is not "a platform
object": it replaces the placeholder in place.
- Without a supplement those rows are byte-identical in shape to before.
Their citation text changed, per the claim.
- **H4 (digest):** `inventoryDigest` hashes the merged list, so a plan's
digest names its supplement (pinned in the integration test).
- `--apply` run with a different supplement (or none) than the
checkpoint it resumes is **not refused**. `applyOrganizationOwnership`
compares the stored checkpoint's digest with the new plan's, discards a
mismatched checkpoint, prints "the inventory changed since the recorded
checkpoint, so every step runs again (each is idempotent)", and re-runs
every step.
- The kept plan file is not an input of `--apply`, so planning with
supplement A and applying with supplement B is not refused either.
- With no supplement, `--apply` refuses at its own plan stage, because
the host tables are undeclared. No new mechanism was built; see
Acceptance notes.
- **H5 (duplicates):** two rows naming one object are refused
(`fate-supplement-duplicate-object`), naming both row indexes.
- The check lives in the CLI merge, not the spec. A uniqueness `.refine`
would be a dropped refinement in the published JSON Schema, and
`dropped-refinements.baseline.json` is shrink-only.
- **H6 (fixtures):** reused the existing plan harness, a
`better-sqlite3` file database behind a `PlanReader`, as in
`organization-ownership-plan.integration.test.ts`.
## Pins (each with a negative control)
| # | Pin | Where | Negative control |
|---|---|---|---|
| 1 | A host-declared fate is planned like a platform row: a host table
with `sys_approval_action`'s fate and anchor plans to
`sys_approval_action`'s exact `TablePlan`, except `object` / `citation`
/ `census` / `tables` |
`organization-ownership-host-supplement.integration.test.ts` | Same
database without the supplement: refused at that table |
| 1 (command) | `--fate-supplement` reaches the plan (real SQLite) and
`--apply` (the merged inventory reaches the apply step) |
`organization-ownership.fate-supplement.integration.test.ts` | Without
the flag: refused (plan), and no `inventory` passed (apply) |
| 2 | An undeclared host table holding data is still refused, by name,
and the refusal names `--fate-supplement FILE` | both integration files
| The full supplement plans the same database |
| 3 | A row naming a platform object is refused
(`fate-supplement-platform-object`, with row and object) | inventory
unit test, command test (`--apply`) | The same row naming an
uninventoried object joins; a cloud placeholder is replaced |
| 4 | A row whose table this database lacks is listed `absent` |
host-supplement integration test | The same rows on the database that
holds them are `present` |
| 5 | The plan names every table on a control-plane-shaped and a
tenant-shaped fixture | host-supplement integration test | The platform
inventory alone refuses each fixture, naming a host table |
Fixtures use the card's 7 control-database and 3 tenant-database tables,
plus labelled placeholders (`sys_billing_fixture_1..7`,
`ai_fixture_1..8`), per answer 2. The real names take fate 4. Their
citation says "test data, not any host's declaration".
## Ablations and reverse verification
All three ran through `scripts/ablation-replace.mjs` in wrap mode, under
`os-verify-lock`. Each also had an EXIT/INT/TERM trap restoring from
`HEAD` by absolute path, and each restore was proven as blob == `HEAD`
with `git diff HEAD` empty.
1. **The command does not pass `inventory`.** Both `...(inventory ? {
inventory } : {})` sites were replaced (anchor 2→0, blob `b5aa7463` →
`901af2d0`).
- Red: "with it, the host-declared fate is planned like a platform row"
and "with --fate-supplement, the apply step receives the host row" (2
failed / 27 passed).
- Restored to `b5aa7463` == HEAD.
2. **No platform-object refusal.** The guard was set to `if (false …)`
(anchor 1→0, blob `c9791d01` → `e0e81f8c`).
- Red: "a row naming a platform object is refused…", "the merge refusals
reach the caller from the file too", and the command's "a row naming a
platform object (--apply)" (3 failed / 26 passed).
- Restored to `c9791d01` == HEAD.
3. **Reverse verification of `KEYS_AGREE`:** a key `reverseProbe?:
string` was added to `InventoryEntry`, then `tsc --noEmit` was run on
`packages/cli`.
- Exactly one error: `organization-ownership-inventory.test.ts(161,7):
error TS2322: Type 'true' is not assignable to type 'false'`. Restored
to `c9791d01` == HEAD.
- The first attempt was a no-op: the tool refused because the
replacement contained its anchor, so tsc never ran. It was re-anchored
and re-run.
## Verification
The final head is `a0cbcf9cf`. `origin/main` was merged twice through
`scripts/pm/os-regen-merge.sh`: at `cc14657ed` (`2da5e78d6`), then at
`bbb975771` (`a133014ac`), which resolved one conflict with PR #23329 in
the command's flags (both flags kept byte-identical). One type-only
commit (`a0cbcf9cf`) keeps the derived row condition's lists read-only,
so the merged `SEEDED_BY` typechecks. Against `bbb975771`: 25 files,
+1861 / -75.
- **Derived gates:** `node scripts/pm/dispatch-gates.mjs --commands
--repo objectstack-ai/objectstack` gives 114 commands at `a0cbcf9cf`.
- All 114 ran on that head, with each exit code captured before any
pipe. All exited 0, none exited 3.
- Reconciled with `--ran`: "114 derived, 114 run, 0 NOT-MEASURED, 0
UNRUN (a DERIVED zero)".
- `check:dts-closure` and `check:dual-build-cjs-loads` first failed on
one partial `@objectstack/organizations` dist. My own stray build had
left it, and the files were not in this diff. Both were re-run green
after a turbo rebuild under the lock.
- `check:type-check-debt` ran under the lock, because it builds the
workspace first.
- **Spec:**
- `pnpm --filter @objectstack/spec check:generated`: all 14 artifacts up
to date on the merged tree.
- `pnpm --filter @objectstack/spec test` (local): 670 files, 20284
passed, at `11764ed1f`, before the merge. On the merged head,
`fate-supplement.test.ts` (8) and `type-alias-convention.pin.test.ts`
(3) re-ran green.
- `test:repo`, in 3 shards on the merged head: 58 of 59 files fully
green (shard 1: 20 files / 307; shard 2: 20 files / 280; shard 3: 18
complete files green).
- **Declared narrowing:** `scripts/build-schemas-check-mode.test.ts` ran
20 of its 88 cases, all green. Each case re-runs `build-schemas` (13–28
s), so the file alone is about 30 minutes of serial lock time on this
shared box. The remaining 68 cases are NOT MEASURED locally and left to
CI's repo project. The diff does not touch `build-schemas.ts` or
`authorable-surface.base.json`, and `check:authorable-surface` is green.
- **CLI:**
- The six ceremony files: `organization-ownership-inventory` 22,
`-plan.integration` 11, `-apply` 9, `-host-supplement.integration` 7,
`organization-ownership.apply-flags` 3, `.fate-supplement.integration` 7
— 59 passed on the merged head.
- `pnpm --filter @objectstack/cli typecheck`: green (tsc, and
`check:test-typecheck` OK).
- The whole `unit` layer in 4 shards: 287 files, 4306 tests passed.
- The integration tier was run for the touched files only (above); the
rest is declared to CI.
- **Lint:** `eslint --no-inline-config` over the 10 touched TypeScript
files: exit 0.
- **Not caused by this diff:**
`packages/cli/test/hook-timeout-override-refusal.test.ts` timed out once
in each of its two CONTROL legs, on two runs out of three, under load
(`load average` 4 to 9). Each leg spawns `vitest list` against the 5 s
default timeout and measured 3.9–5.1 s. The third run passed 4 of 4, and
the file is not in this diff.
## Acceptance notes
- **The apply checkpoint across supplements (H4):** a changed supplement
discards the recorded checkpoint, and every step re-runs idempotently.
It is never refused, and the kept plan file is not read by `--apply`.
That is the existing #23034 behaviour; this PR only feeds the supplement
into the digest. Noted, not filed: it is neither a defect nor a contract
violation.
- **The default inventory's digest moves once.** The corrected `CLOUD`
citation is part of the hashed rows, so an apply checkpoint recorded on
a pre-merge build re-runs its steps once (each is idempotent). v18 is
unreleased (pre mode), so no released checkpoint is affected.
- **No hand-written docs page** documents the ceremony's flags.
Measured: no file under `content/docs` names `--fate-supplement` or
`--backup-taken`. So no docs page was edited, and `variant-docs.json`
exempts the two new unions as `generated-reference-only`, stating that
gap.
- **Spec-gate registrations a new exported schema requires**, beyond the
generated projections:
- `llms.txt`'s inventory count (`check:llms-txt`);
- two `scope` rows in `empty-state-registry.mts` (`check:empty-state`);
- two `variant-docs.json` rows (`check:variant-docs`);
- seven isomorphic pins in `type-alias-convention.pin.test.ts`
(`check:spec-parsed-alias`, ADR-0122, instead of seven `XParsed`
synonyms).
- At landing, the seat records the mechanism (a host supplement read
through the command's flag) on #15211, as its release `6148865508` says.
---
_Generated by [Claude
Code](https://claude.ai/code/session_012uFegdfR6K6uqvLzt7XsdZ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent e790cfc commit 707b3cd
25 files changed
Lines changed: 1861 additions & 75 deletions
File tree
- .changeset
- content/docs
- getting-started
- references
- system
- docs/audits/2026-07-unknown-key-strictness-ledger.counts
- packages
- cli/src
- commands/migrate
- utils
- spec
- api-surface
- authorable-surface
- declaration-map
- export-origins
- json-schema.manifest
- scripts/liveness
- src
- system
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
85 | 85 | | |
86 | 86 | | |
87 | 87 | | |
88 | | - | |
| 88 | + | |
89 | 89 | | |
90 | 90 | | |
91 | 91 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | 3 | | |
4 | | - | |
| 4 | + | |
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| |||
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
35 | | - | |
| 35 | + | |
36 | 36 | | |
37 | | - | |
| 37 | + | |
38 | 38 | | |
39 | 39 | | |
40 | 40 | | |
| |||
317 | 317 | | |
318 | 318 | | |
319 | 319 | | |
320 | | - | |
| 320 | + | |
321 | 321 | | |
322 | 322 | | |
323 | 323 | | |
| |||
337 | 337 | | |
338 | 338 | | |
339 | 339 | | |
| 340 | + | |
340 | 341 | | |
341 | 342 | | |
342 | 343 | | |
| |||
0 commit comments