diff --git a/.changeset/23280-host-fate-supplement.md b/.changeset/23280-host-fate-supplement.md
new file mode 100644
index 00000000000..66488a6a3b9
--- /dev/null
+++ b/.changeset/23280-host-fate-supplement.md
@@ -0,0 +1,21 @@
+---
+'@objectstack/cli': minor
+'@objectstack/spec': minor
+---
+
+feat(cli,spec): `os migrate organization-ownership --fate-supplement FILE`. A host declares the ADR-0131 D10 fates of the objects it provides, and the ceremony plans and applies them as it does the platform's own rows
+
+Clause-②: yes (widening)
+
+The v18 organization-ownership ceremony refuses any platform-prefixed table that carries `organization_id` when its inventory gives that table no fate. That is right for the platform's own objects, but a host's objects have no row in the inventory, so every database a host runs was refused. The host now declares those fates itself, in one JSON file, and passes the file to the command. **Adoption: a host passes its supplement through the flag; with no flag, the ceremony behaves as before.**
+
+- **The file is declared in the spec.** `OrganizationOwnershipSupplementSchema` (`@objectstack/spec/system`) is a JSON array of `OrganizationOwnershipSupplementEntrySchema` rows. Each row gives `object`, `fate` (`column-drop`, `mirror-deletion`, `attribution` or `report`), `citation`, and only the fields its fate reads: `anchors` (required for `attribution`), `mirror`, `departures`, `categories`, `notNullExempt` and `note`. Every shape refuses an unknown key, and a key written on the wrong fate is refused rather than ignored.
+- **Both modes read it.** The plan plans each row on the same terms as a platform row, and `--apply` performs it the same way. A row whose table is not on the database being planned is listed `absent`, so one supplement serves both a control-plane database and a tenant database.
+- **It is refused, never half-read.** The command refuses a missing file, a file that is not JSON, a row the schema refuses, two rows for one object, or a row naming an object the platform inventory already gives a fate. The refusal comes before the database is opened, inside the `--json` envelope (`plan_refused` / `apply_refused`), and names the file, the row index and the object. A row naming one of the cloud-provided objects that the inventory lists with no fate (`cloudCarried`) replaces that placeholder row.
+- **Nothing is read without the flag:** no environment variable and no conventional path. A host table with no declared fate is still refused by name, and the refusal now names `--fate-supplement FILE` as the remedy.
+- The supplement's rows are part of the plan's `inventoryDigest`, so the apply's checkpoint is keyed on the supplement as well.
+
+```bash
+os migrate organization-ownership --fate-supplement host-fates.json --out plan.json
+os migrate organization-ownership --fate-supplement host-fates.json --apply --backup-taken
+```
diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx
index 1b8f6cd0ea9..65cfafca3e4 100644
--- a/content/docs/getting-started/quick-reference.mdx
+++ b/content/docs/getting-started/quick-reference.mdx
@@ -85,7 +85,7 @@ Plugin architecture, manifests, and kernel runtime.
| **[Metadata Loader](/docs/references/kernel/metadata-loader)** | `metadata-loader.zod.ts` | MetadataLoaderContract | Metadata loading |
| **[Package Registry](/docs/references/kernel/package-registry)** | `package-registry.zod.ts` | InstalledPackage, InstallPackageRequest | Package resolution |
-## System Protocol (16 of 35 schemas)
+## System Protocol (16 of 36 schemas)
Runtime environment, logging, jobs, caching, and observability.
diff --git a/content/docs/references/index.mdx b/content/docs/references/index.mdx
index 7c294a038ff..196a6a75967 100644
--- a/content/docs/references/index.mdx
+++ b/content/docs/references/index.mdx
@@ -1,7 +1,7 @@
---
title: Protocol reference — every schema by module
navTitle: Protocol Reference
-description: Every schema published by @objectstack/spec — 1524 schemas across 14 protocol modules
+description: Every schema published by @objectstack/spec — 1531 schemas across 14 protocol modules
---
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -32,9 +32,9 @@ counts are sums of the rows they head. Regenerate with
| [Security Protocol](/docs/references/security) | 5 | 30 | Permission sets, row-level security, sharing rules, tenancy posture. |
| [Shared Protocol](/docs/references/shared) | 10 | 31 | Primitives used across every protocol — identifiers, HTTP, expressions, error maps, enums. |
| [Studio Protocol](/docs/references/studio) | 3 | 35 | Studio designer metadata — the authoring surfaces for the protocols above. |
-| [System Protocol](/docs/references/system) | 35 | 277 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
+| [System Protocol](/docs/references/system) | 36 | 284 | The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance. |
| [UI Protocol](/docs/references/ui) | 16 | 167 | Apps, pages, views, dashboards, reports, actions and themes — the ObjectUI layer. |
-| **Total** | **196** | **1524** | 14 protocol modules |
+| **Total** | **197** | **1531** | 14 protocol modules |
---
@@ -317,7 +317,7 @@ Studio designer metadata — the authoring surfaces for the protocols above.
## System Protocol
-**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **35 pages, 277 schemas**
+**Source:** `packages/spec/src/system/` · **Import:** `@objectstack/spec/system` · **36 pages, 284 schemas**
The runtime environment — logging, jobs, cache, metrics, notifications, i18n and compliance.
@@ -337,6 +337,7 @@ The runtime environment — logging, jobs, cache, metrics, notifications, i18n a
| [`email-template.zod.ts`](/docs/references/system/email-template) | `EmailTemplateDefinition`, `EmailTemplateDefinitionVariable` |
| [`encryption.zod.ts`](/docs/references/system/encryption) | `EncryptionAlgorithm`, `EncryptionConfig`, `FieldEncryption`, `KeyManagementProvider`, `KeyRotationPolicy` |
| [`environment-artifact.zod.ts`](/docs/references/system/environment-artifact) | `Sha256Digest` |
+| [`fate-supplement.zod.ts`](/docs/references/system/fate-supplement) | `OrganizationOwnershipAnchor`, `OrganizationOwnershipCategory`, `OrganizationOwnershipDeparture`, `OrganizationOwnershipFate`, `OrganizationOwnershipRowCondition`, `OrganizationOwnershipSupplement`, `OrganizationOwnershipSupplementEntry` |
| [`http-server.zod.ts`](/docs/references/system/http-server) | `MiddlewareConfig`, `MiddlewareType`, `RouteHandlerMetadata` |
| [`job.zod.ts`](/docs/references/system/job) | `CronSchedule`, `IntervalSchedule`, `Job`, `JobExecution`, `JobExecutionStatus`, `OnceSchedule`, `RetryPolicy`, `Schedule` |
| [`license.zod.ts`](/docs/references/system/license) | `Feature`, `License`, `LicenseMetricType`, `Plan` |
diff --git a/content/docs/references/system/fate-supplement.mdx b/content/docs/references/system/fate-supplement.mdx
new file mode 100644
index 00000000000..ec59e6afe8f
--- /dev/null
+++ b/content/docs/references/system/fate-supplement.mdx
@@ -0,0 +1,474 @@
+---
+title: Fate Supplement — System Protocol reference
+navTitle: Fate Supplement
+description: "Fate Supplement schemas of the ObjectStack System Protocol — each property with its type, default and a TypeScript example."
+---
+
+{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
+
+
+**Source:** `packages/spec/src/system/fate-supplement.zod.ts`
+
+
+## TypeScript Usage
+
+```typescript
+import { OrganizationOwnershipAnchorSchema, OrganizationOwnershipCategorySchema, OrganizationOwnershipDepartureSchema, OrganizationOwnershipFateSchema, OrganizationOwnershipRowConditionSchema, OrganizationOwnershipSupplementSchema, OrganizationOwnershipSupplementEntrySchema } from '@objectstack/spec/system';
+import type { OrganizationOwnershipAnchor, OrganizationOwnershipCategory, OrganizationOwnershipDeparture, OrganizationOwnershipFate, OrganizationOwnershipRowCondition, OrganizationOwnershipSupplement, OrganizationOwnershipSupplementEntry } from '@objectstack/spec/system';
+
+// Validate data
+const result = OrganizationOwnershipAnchorSchema.parse(data);
+```
+
+---
+
+## OrganizationOwnershipAnchor
+
+Fate 3 anchor: parent, subject or holder
+
+### Union Options
+
+This schema accepts one of the following structures:
+
+#### Option 1
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **kind** | `'parent'` | ✅ | |
+| **childKey** | `string` | ✅ | Column of this row naming a row of parentObject |
+| **parentObject** | `string` | ✅ | Object whose row carries the organization |
+
+---
+
+#### Option 2
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **kind** | `'subject'` | ✅ | |
+| **objectColumn** | `string` | ✅ | Column naming the object of the record the row is about |
+| **idColumn** | `string` | ✅ | Column naming that record's id |
+
+---
+
+#### Option 3
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **kind** | `'holder'` | ✅ | |
+| **holderObject** | `string` | ✅ | Object whose rows point at this row |
+| **holderKey** | `string` | ✅ | Column of holderObject naming this row; every stamped holder must agree |
+
+---
+
+
+---
+
+## OrganizationOwnershipCategory
+
+A named row population the ceremony counts on the table
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **id** | `string` | ✅ | Category id (kebab-case), as the plan labels the count |
+| **label** | `string` | ✅ | What the population is, in the operator's terms |
+| **citation** | `string` | ✅ | The ruling or record that names the population |
+| **where** | `({ column: string; equals: string \| number \| boolean } \| { column: string; in: string[] } \| { column: string; notIn: string[] } \| { column: string; isNull: boolean } \| … +1 more)[]` | optional | Rows of the population; absent = every row |
+| **conflictBy** | `{ groupBy: string[]; distinct?: string }` | optional | Count groups holding more than one row instead of rows |
+
+### Nested Shape: `OrganizationOwnershipCategory.where[number][option 1]`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **column** | `string` | ✅ | Column the condition reads |
+| **equals** | `string \| number \| boolean` | ✅ | The column equals this value |
+
+### Nested Shape: `OrganizationOwnershipCategory.where[number][option 2]`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **column** | `string` | ✅ | Column the condition reads |
+| **in** | `string[]` | ✅ | The column holds one of these values |
+
+### Nested Shape: `OrganizationOwnershipCategory.where[number][option 3]`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **column** | `string` | ✅ | Column the condition reads |
+| **notIn** | `string[]` | ✅ | The column is NULL or holds none of these values |
+
+### Nested Shape: `OrganizationOwnershipCategory.where[number][option 4]`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **column** | `string` | ✅ | Column the condition reads |
+| **isNull** | `boolean` | ✅ | true: the column is NULL; false: it is not |
+
+### Nested Shape: `OrganizationOwnershipCategory.where[number][option 5]`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **column** | `string` | ✅ | Column the condition reads |
+| **notTrue** | `true` | ✅ | The column is NULL or holds anything but true |
+
+### Nested Shape: `OrganizationOwnershipCategory.conflictBy`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **groupBy** | `string[]` | ✅ | Columns the rows are grouped by |
+| **distinct** | `string` | optional | Count a group only when it holds more than one distinct value of this column |
+
+
+---
+
+## OrganizationOwnershipDeparture
+
+Rows that leave the table before the NOT NULL gate: neither attributed nor reported
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **id** | `string` | ✅ | Departure id (kebab-case), as the plan labels the count |
+| **label** | `string` | ✅ | Which rows leave, and by which step |
+| **citation** | `string` | ✅ | The ruling or record that names the step |
+| **as** | `Enum<'mirror-deletion' \| 'moved'>` | ✅ | mirror-deletion for mirrors; moved for rows re-homed elsewhere |
+| **where** | `({ column: string; equals: string \| number \| boolean } \| { column: string; in: string[] } \| { column: string; notIn: string[] } \| { column: string; isNull: boolean } \| … +1 more)[]` | ✅ | The rows that leave |
+
+### Nested Shape: `OrganizationOwnershipDeparture.where[number][option 1]`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **column** | `string` | ✅ | Column the condition reads |
+| **equals** | `string \| number \| boolean` | ✅ | The column equals this value |
+
+### Nested Shape: `OrganizationOwnershipDeparture.where[number][option 2]`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **column** | `string` | ✅ | Column the condition reads |
+| **in** | `string[]` | ✅ | The column holds one of these values |
+
+### Nested Shape: `OrganizationOwnershipDeparture.where[number][option 3]`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **column** | `string` | ✅ | Column the condition reads |
+| **notIn** | `string[]` | ✅ | The column is NULL or holds none of these values |
+
+### Nested Shape: `OrganizationOwnershipDeparture.where[number][option 4]`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **column** | `string` | ✅ | Column the condition reads |
+| **isNull** | `boolean` | ✅ | true: the column is NULL; false: it is not |
+
+### Nested Shape: `OrganizationOwnershipDeparture.where[number][option 5]`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **column** | `string` | ✅ | Column the condition reads |
+| **notTrue** | `true` | ✅ | The column is NULL or holds anything but true |
+
+
+---
+
+## OrganizationOwnershipFate
+
+ADR-0131 D10 fate: column-drop, mirror-deletion, attribution or report
+
+### Allowed Values
+
+* `column-drop`
+* `mirror-deletion`
+* `attribution`
+* `report`
+
+
+---
+
+## OrganizationOwnershipRowCondition
+
+One condition on a row: equals, in, notIn, isNull or notTrue on one column
+
+### Union Options
+
+This schema accepts one of the following structures:
+
+#### Option 1
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **column** | `string` | ✅ | Column the condition reads |
+| **equals** | `string \| number \| boolean` | ✅ | The column equals this value |
+
+---
+
+#### Option 2
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **column** | `string` | ✅ | Column the condition reads |
+| **in** | `string[]` | ✅ | The column holds one of these values |
+
+---
+
+#### Option 3
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **column** | `string` | ✅ | Column the condition reads |
+| **notIn** | `string[]` | ✅ | The column is NULL or holds none of these values |
+
+---
+
+#### Option 4
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **column** | `string` | ✅ | Column the condition reads |
+| **isNull** | `boolean` | ✅ | true: the column is NULL; false: it is not |
+
+---
+
+#### Option 5
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **column** | `string` | ✅ | Column the condition reads |
+| **notTrue** | `true` | ✅ | The column is NULL or holds anything but true |
+
+---
+
+
+---
+
+## OrganizationOwnershipSupplement
+
+The host fate supplement: the ADR-0131 D10 fates of the objects a host provides, one row per object
+
+**Type:** `({ object: string; citation: string; categories?: object[]; note?: string; … } | … +3 more)[]`
+
+
+---
+
+## OrganizationOwnershipSupplementEntry
+
+The fate of one object the host provides (ADR-0131 D10)
+
+### Union Options
+
+This schema accepts one of the following structures:
+
+#### Option 1
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **object** | `string` | ✅ | The object (= its table) the host provides |
+| **citation** | `string` | ✅ | The host's source for this fate |
+| **categories** | `{ id: string; label: string; citation: string; where?: (object \| … +4 more)[]; … }[]` | optional | Named row populations the plan counts on the table |
+| **note** | `string` | optional | A note the plan carries beside the table |
+| **fate** | `'column-drop'` | ✅ | Fate 1: the object ends with no organization_id column |
+
+### Nested Shape: `OrganizationOwnershipSupplementEntry[fate='column-drop'].categories[number]`
+
+A named row population the ceremony counts on the table
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **id** | `string` | ✅ | Category id (kebab-case), as the plan labels the count |
+| **label** | `string` | ✅ | What the population is, in the operator's terms |
+| **citation** | `string` | ✅ | The ruling or record that names the population |
+| **where** | `({ column: string; equals: string \| number \| boolean } \| { column: string; in: string[] } \| { column: string; notIn: string[] } \| { column: string; isNull: boolean } \| … +1 more)[]` | optional | Rows of the population; absent = every row |
+| **conflictBy** | `{ groupBy: string[]; distinct?: string }` | optional | Count groups holding more than one row instead of rows |
+
+---
+
+#### Option 2
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **object** | `string` | ✅ | The object (= its table) the host provides |
+| **citation** | `string` | ✅ | The host's source for this fate |
+| **categories** | `{ id: string; label: string; citation: string; where?: (object \| … +4 more)[]; … }[]` | optional | Named row populations the plan counts on the table |
+| **note** | `string` | optional | A note the plan carries beside the table |
+| **fate** | `'mirror-deletion'` | ✅ | Fate 2: mirror rows are deleted after the verified rewrite |
+| **mirror** | `({ column: string; equals: string \| number \| boolean } \| { column: string; in: string[] } \| { column: string; notIn: string[] } \| { column: string; isNull: boolean } \| … +1 more)[]` | optional | Which rows are mirrors; absent = every row |
+
+### Nested Shape: `OrganizationOwnershipSupplementEntry[fate='mirror-deletion'].categories[number]`
+
+A named row population the ceremony counts on the table
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **id** | `string` | ✅ | Category id (kebab-case), as the plan labels the count |
+| **label** | `string` | ✅ | What the population is, in the operator's terms |
+| **citation** | `string` | ✅ | The ruling or record that names the population |
+| **where** | `({ column: string; equals: string \| number \| boolean } \| { column: string; in: string[] } \| { column: string; notIn: string[] } \| { column: string; isNull: boolean } \| … +1 more)[]` | optional | Rows of the population; absent = every row |
+| **conflictBy** | `{ groupBy: string[]; distinct?: string }` | optional | Count groups holding more than one row instead of rows |
+
+### Nested Shape: `OrganizationOwnershipSupplementEntry[fate='mirror-deletion'].mirror[number][option 1]`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **column** | `string` | ✅ | Column the condition reads |
+| **equals** | `string \| number \| boolean` | ✅ | The column equals this value |
+
+### Nested Shape: `OrganizationOwnershipSupplementEntry[fate='mirror-deletion'].mirror[number][option 2]`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **column** | `string` | ✅ | Column the condition reads |
+| **in** | `string[]` | ✅ | The column holds one of these values |
+
+### Nested Shape: `OrganizationOwnershipSupplementEntry[fate='mirror-deletion'].mirror[number][option 3]`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **column** | `string` | ✅ | Column the condition reads |
+| **notIn** | `string[]` | ✅ | The column is NULL or holds none of these values |
+
+### Nested Shape: `OrganizationOwnershipSupplementEntry[fate='mirror-deletion'].mirror[number][option 4]`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **column** | `string` | ✅ | Column the condition reads |
+| **isNull** | `boolean` | ✅ | true: the column is NULL; false: it is not |
+
+### Nested Shape: `OrganizationOwnershipSupplementEntry[fate='mirror-deletion'].mirror[number][option 5]`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **column** | `string` | ✅ | Column the condition reads |
+| **notTrue** | `true` | ✅ | The column is NULL or holds anything but true |
+
+---
+
+#### Option 3
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **object** | `string` | ✅ | The object (= its table) the host provides |
+| **citation** | `string` | ✅ | The host's source for this fate |
+| **categories** | `{ id: string; label: string; citation: string; where?: (object \| … +4 more)[]; … }[]` | optional | Named row populations the plan counts on the table |
+| **note** | `string` | optional | A note the plan carries beside the table |
+| **fate** | `'attribution'` | ✅ | Fate 3: NULL rows take their organization from an anchor |
+| **anchors** | `({ kind: 'parent'; childKey: string; parentObject: string } \| { kind: 'subject'; objectColumn: string; idColumn: string } \| { kind: 'holder'; holderObject: string; holderKey: string })[]` | ✅ | The anchors, in order; empty = the Default Organization under single, otherwise reported |
+| **departures** | `{ id: string; label: string; citation: string; as: Enum<'mirror-deletion' \| 'moved'>; … }[]` | optional | Rows that leave the table before the NOT NULL gate |
+
+### Nested Shape: `OrganizationOwnershipSupplementEntry[fate='attribution'].categories[number]`
+
+A named row population the ceremony counts on the table
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **id** | `string` | ✅ | Category id (kebab-case), as the plan labels the count |
+| **label** | `string` | ✅ | What the population is, in the operator's terms |
+| **citation** | `string` | ✅ | The ruling or record that names the population |
+| **where** | `({ column: string; equals: string \| number \| boolean } \| { column: string; in: string[] } \| { column: string; notIn: string[] } \| { column: string; isNull: boolean } \| … +1 more)[]` | optional | Rows of the population; absent = every row |
+| **conflictBy** | `{ groupBy: string[]; distinct?: string }` | optional | Count groups holding more than one row instead of rows |
+
+### Nested Shape: `OrganizationOwnershipSupplementEntry[fate='attribution'].anchors[number][kind='parent']`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **kind** | `'parent'` | ✅ | |
+| **childKey** | `string` | ✅ | Column of this row naming a row of parentObject |
+| **parentObject** | `string` | ✅ | Object whose row carries the organization |
+
+### Nested Shape: `OrganizationOwnershipSupplementEntry[fate='attribution'].anchors[number][kind='subject']`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **kind** | `'subject'` | ✅ | |
+| **objectColumn** | `string` | ✅ | Column naming the object of the record the row is about |
+| **idColumn** | `string` | ✅ | Column naming that record's id |
+
+### Nested Shape: `OrganizationOwnershipSupplementEntry[fate='attribution'].anchors[number][kind='holder']`
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **kind** | `'holder'` | ✅ | |
+| **holderObject** | `string` | ✅ | Object whose rows point at this row |
+| **holderKey** | `string` | ✅ | Column of holderObject naming this row; every stamped holder must agree |
+
+### Nested Shape: `OrganizationOwnershipSupplementEntry[fate='attribution'].departures[number]`
+
+Rows that leave the table before the NOT NULL gate: neither attributed nor reported
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **id** | `string` | ✅ | Departure id (kebab-case), as the plan labels the count |
+| **label** | `string` | ✅ | Which rows leave, and by which step |
+| **citation** | `string` | ✅ | The ruling or record that names the step |
+| **as** | `Enum<'mirror-deletion' \| 'moved'>` | ✅ | mirror-deletion for mirrors; moved for rows re-homed elsewhere |
+| **where** | `({ column: string; equals: string \| number \| boolean } \| { column: string; in: string[] } \| { column: string; notIn: string[] } \| { column: string; isNull: boolean } \| … +1 more)[]` | ✅ | The rows that leave |
+
+---
+
+#### Option 4
+
+### Properties
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **object** | `string` | ✅ | The object (= its table) the host provides |
+| **citation** | `string` | ✅ | The host's source for this fate |
+| **categories** | `{ id: string; label: string; citation: string; where?: (object \| … +4 more)[]; … }[]` | optional | Named row populations the plan counts on the table |
+| **note** | `string` | optional | A note the plan carries beside the table |
+| **fate** | `'report'` | ✅ | Fate 4: NULL rows are listed, never guessed, never deleted |
+| **departures** | `{ id: string; label: string; citation: string; as: Enum<'mirror-deletion' \| 'moved'>; … }[]` | optional | Rows that leave the table before the NOT NULL gate |
+| **notNullExempt** | `string` | optional | The column is not the tenancy anchor, so it never receives NOT NULL: the reason |
+
+### Nested Shape: `OrganizationOwnershipSupplementEntry[fate='report'].categories[number]`
+
+A named row population the ceremony counts on the table
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **id** | `string` | ✅ | Category id (kebab-case), as the plan labels the count |
+| **label** | `string` | ✅ | What the population is, in the operator's terms |
+| **citation** | `string` | ✅ | The ruling or record that names the population |
+| **where** | `({ column: string; equals: string \| number \| boolean } \| { column: string; in: string[] } \| { column: string; notIn: string[] } \| { column: string; isNull: boolean } \| … +1 more)[]` | optional | Rows of the population; absent = every row |
+| **conflictBy** | `{ groupBy: string[]; distinct?: string }` | optional | Count groups holding more than one row instead of rows |
+
+### Nested Shape: `OrganizationOwnershipSupplementEntry[fate='report'].departures[number]`
+
+Rows that leave the table before the NOT NULL gate: neither attributed nor reported
+
+| Property | Type | Required | Description |
+| :--- | :--- | :--- | :--- |
+| **id** | `string` | ✅ | Departure id (kebab-case), as the plan labels the count |
+| **label** | `string` | ✅ | Which rows leave, and by which step |
+| **citation** | `string` | ✅ | The ruling or record that names the step |
+| **as** | `Enum<'mirror-deletion' \| 'moved'>` | ✅ | mirror-deletion for mirrors; moved for rows re-homed elsewhere |
+| **where** | `({ column: string; equals: string \| number \| boolean } \| { column: string; in: string[] } \| { column: string; notIn: string[] } \| { column: string; isNull: boolean } \| … +1 more)[]` | ✅ | The rows that leave |
+
+---
+
+
+---
+
diff --git a/content/docs/references/system/index.mdx b/content/docs/references/system/index.mdx
index f3132db8961..7faeb0273fe 100644
--- a/content/docs/references/system/index.mdx
+++ b/content/docs/references/system/index.mdx
@@ -1,7 +1,7 @@
---
title: System Protocol — complete schema reference
navTitle: System Protocol
-description: "The ObjectStack System Protocol in 35 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example."
+description: "The ObjectStack System Protocol in 36 reference pages: every schema in @objectstack/spec with its properties, types, defaults and a TypeScript example."
---
{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */}
@@ -23,6 +23,7 @@ This section contains all protocol schemas for the system layer of ObjectStack.
+
diff --git a/content/docs/references/system/meta.json b/content/docs/references/system/meta.json
index 257fa318569..7e05cd254ad 100644
--- a/content/docs/references/system/meta.json
+++ b/content/docs/references/system/meta.json
@@ -40,6 +40,7 @@
"doc",
"---More---",
"dev-login",
+ "fate-supplement",
"sms-config",
"stack-server"
]
diff --git a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md
index 773b28aef3e..5449560ed02 100644
--- a/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md
+++ b/docs/audits/2026-07-unknown-key-strictness-ledger.counts/system.md
@@ -19,4 +19,4 @@ hand-patch a number here** — fix the code or the verdict and regenerate.
| Dir | Sites |
|---|---|
-| `system/` | 358 |
+| `system/` | 373 |
diff --git a/packages/cli/src/commands/migrate/organization-ownership.fate-supplement.integration.test.ts b/packages/cli/src/commands/migrate/organization-ownership.fate-supplement.integration.test.ts
new file mode 100644
index 00000000000..d042c190688
--- /dev/null
+++ b/packages/cli/src/commands/migrate/organization-ownership.fate-supplement.integration.test.ts
@@ -0,0 +1,185 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * `os migrate organization-ownership --fate-supplement FILE` — the host's
+ * declared fates reach the ceremony through this flag and nowhere else (ADR-0131
+ * D10, the inventory's C10 stage). Both modes read it: the plan plans its rows
+ * against a real SQLite database, and `--apply` hands the merged inventory to
+ * the apply step. A file that cannot be used is refused before the database is
+ * opened, inside the `--json` envelope, naming the file and the row.
+ *
+ * The boot is replaced by a stack over a fixture database; the apply step is
+ * replaced by a recorder (its own suite pins what it does with an inventory).
+ */
+
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
+import Database from 'better-sqlite3';
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+const boot = vi.hoisted(() => vi.fn());
+vi.mock('../../utils/schema-migrate.js', () => ({ bootSchemaStack: boot }));
+
+const apply = vi.hoisted(() => vi.fn());
+vi.mock('../../utils/organization-ownership-apply.js', async (importOriginal) => ({
+ ...(await importOriginal()),
+ applyOrganizationOwnership: apply,
+}));
+
+type Command = (typeof import('./organization-ownership.js'))['default'];
+let MigrateOrganizationOwnership: Command;
+
+// The command's module graph is large, and oclif reads the package's config on
+// the first run: both are paid outside the 5 s default (CASE_TIMEOUT_MS).
+beforeAll(async () => {
+ ({ default: MigrateOrganizationOwnership } = await import('./organization-ownership.js'));
+}, 60_000);
+
+const CASE_TIMEOUT_MS = 60_000;
+const CLI_ROOT = fileURLToPath(new URL('../../..', import.meta.url));
+const CITE = 'fixture: a host-declared fate (test data, not any host\'s declaration)';
+
+let dir: string;
+let db: Database.Database;
+
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'os-fate-supplement-cmd-'));
+ db = new Database(join(dir, 'fixture.db'));
+ db.exec(`
+ CREATE TABLE sys_organization (id TEXT PRIMARY KEY, slug TEXT);
+ INSERT INTO sys_organization VALUES ('org_default', 'default');
+ CREATE TABLE sys_domain (id TEXT PRIMARY KEY, organization_id TEXT, hostname TEXT);
+ INSERT INTO sys_domain VALUES ('dom_1', 'org_default', 'a.example.com'), ('dom_2', NULL, 'b.example.com');
+ `);
+ boot.mockImplementation(async () => ({
+ driver: {
+ config: { client: 'better-sqlite3' },
+ execute: async (sql: string, params: unknown[] = []) => db.prepare(sql).all(...params),
+ },
+ dbLabel: 'fixture.db',
+ kernel: {
+ getService: (name: string) =>
+ name === 'objectql'
+ ? { registry: { getItem: () => undefined, listItems: () => [], isPackageDisabled: () => false } }
+ : name === 'metadata'
+ ? { get: async () => undefined, list: async () => [] }
+ : undefined,
+ },
+ tableAbsent: () => false,
+ shutdown: async () => {},
+ }));
+ apply.mockImplementation(async () => ({
+ kind: 'organization-ownership-apply',
+ generatedAt: '2026-10-13T00:00:00.000Z',
+ steps: [],
+ tables: [],
+ complete: true,
+ blocking: 0,
+ advisory: 0,
+ }));
+});
+
+afterEach(() => {
+ boot.mockReset();
+ apply.mockReset();
+ db.close();
+ rmSync(dir, { recursive: true, force: true });
+});
+
+function writeSupplement(rows: unknown): string {
+ const file = join(dir, 'host-fates.json');
+ writeFileSync(file, JSON.stringify(rows));
+ return file;
+}
+
+async function runJson(argv: string[]): Promise<{ payload: Record; exitCode: unknown }> {
+ const chunks: string[] = [];
+ const savedExit = process.exitCode;
+ vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: unknown, ...rest: unknown[]) => {
+ chunks.push(String(chunk));
+ const cb = rest.find((a) => typeof a === 'function') as (() => void) | undefined;
+ if (cb) cb();
+ return true;
+ }) as typeof process.stdout.write);
+ vi.spyOn(process.stderr, 'write').mockImplementation((() => true) as typeof process.stderr.write);
+ try {
+ await MigrateOrganizationOwnership.run(argv, { root: CLI_ROOT });
+ } catch (error) {
+ if ((error as { oclif?: unknown }).oclif === undefined) throw error;
+ }
+ const exitCode = process.exitCode;
+ process.exitCode = savedExit;
+ vi.restoreAllMocks();
+ return { payload: JSON.parse(chunks.join('').trim()) as Record, exitCode };
+}
+
+const out = (name: string): string[] => ['--out', join(dir, name), '--json'];
+
+describe('the plan reads the host fate supplement through --fate-supplement', () => {
+ it('without it, a host table carrying the column is refused by name, and the refusal names the flag', async () => {
+ const { payload, exitCode } = await runJson(out('plan.json'));
+ expect(payload).toMatchObject({ error: 'plan_refused', reason: 'uninventoried-platform-table', table: 'sys_domain' });
+ expect(String(payload.detail)).toContain('--fate-supplement FILE');
+ expect(exitCode).toBe(1);
+ }, CASE_TIMEOUT_MS);
+
+ it('with it, the host-declared fate is planned like a platform row', async () => {
+ const file = writeSupplement([{ object: 'sys_domain', fate: 'report', citation: CITE }]);
+ const { payload } = await runJson(['--fate-supplement', file, ...out('plan.json')]);
+ const plan = payload.plan as { tables: Array> };
+ expect(plan.tables.find((t) => t.object === 'sys_domain')).toMatchObject({
+ source: 'inventory',
+ census: ['host-supplement'],
+ fate: 'report',
+ citation: CITE,
+ physical: 'present',
+ rows: { total: 2, organizationNull: 1, stamped: 1 },
+ unattributable: [{ id: 'dom_2', reason: 'fate 4: reported, never guessed' }],
+ });
+ }, CASE_TIMEOUT_MS);
+});
+
+describe('--apply hands the merged inventory to the apply step', () => {
+ it('with --fate-supplement, the apply step receives the host row', async () => {
+ const file = writeSupplement([{ object: 'sys_domain', fate: 'report', citation: CITE }]);
+ const { payload } = await runJson(['--apply', '--backup-taken', '--fate-supplement', file, ...out('apply.json')]);
+ expect(payload).toMatchObject({ report: { kind: 'organization-ownership-apply' } });
+ expect(apply).toHaveBeenCalledTimes(1);
+ const inventory = (apply.mock.calls[0]![0] as { inventory?: Array> }).inventory;
+ expect(inventory?.find((e) => e.object === 'sys_domain')).toMatchObject({ census: ['host-supplement'], fate: 'report' });
+ }, CASE_TIMEOUT_MS);
+
+ it('control: without it, the apply step is handed no inventory and reads the platform\'s', async () => {
+ await runJson(['--apply', '--backup-taken', ...out('apply.json')]);
+ expect(apply).toHaveBeenCalledTimes(1);
+ expect(apply.mock.calls[0]![0]).not.toHaveProperty('inventory');
+ }, CASE_TIMEOUT_MS);
+});
+
+describe('a supplement it cannot use is refused before the database is opened, inside the --json envelope', () => {
+ it('a missing file (plan)', async () => {
+ const file = join(dir, 'absent.json');
+ const { payload, exitCode } = await runJson(['--fate-supplement', file, ...out('plan.json')]);
+ expect(payload).toMatchObject({ error: 'plan_refused', reason: 'fate-supplement-unreadable', file });
+ expect(exitCode).toBe(1);
+ expect(boot).not.toHaveBeenCalled();
+ }, CASE_TIMEOUT_MS);
+
+ it('a row naming a platform object (--apply)', async () => {
+ const file = writeSupplement([{ object: 'sys_file', fate: 'report', citation: CITE }]);
+ const { payload, exitCode } = await runJson(['--apply', '--backup-taken', '--fate-supplement', file, ...out('apply.json')]);
+ expect(payload).toMatchObject({ error: 'apply_refused', reason: 'fate-supplement-platform-object', file, row: 0, object: 'sys_file' });
+ expect(exitCode).toBe(1);
+ expect(boot).not.toHaveBeenCalled();
+ expect(apply).not.toHaveBeenCalled();
+ }, CASE_TIMEOUT_MS);
+
+ it('a row the schema refuses (plan)', async () => {
+ const file = writeSupplement([{ object: 'sys_domain', fate: 'attribution', citation: CITE }]);
+ const { payload } = await runJson(['--fate-supplement', file, ...out('plan.json')]);
+ expect(payload).toMatchObject({ error: 'plan_refused', reason: 'fate-supplement-invalid', file, row: 0, object: 'sys_domain' });
+ expect(boot).not.toHaveBeenCalled();
+ }, CASE_TIMEOUT_MS);
+});
diff --git a/packages/cli/src/commands/migrate/organization-ownership.ts b/packages/cli/src/commands/migrate/organization-ownership.ts
index e2a7b80c05e..3db1862da26 100644
--- a/packages/cli/src/commands/migrate/organization-ownership.ts
+++ b/packages/cli/src/commands/migrate/organization-ownership.ts
@@ -25,6 +25,11 @@ import {
OrganizationOwnershipPlanRefusal,
type OrganizationOwnershipPlan,
} from '../../utils/organization-ownership-plan.js';
+import {
+ HostFateSupplementRefusal,
+ readHostFateSupplement,
+ type InventoryEntry,
+} from '../../utils/organization-ownership-inventory.js';
import {
applyOrganizationOwnership,
checkpointFromDetails,
@@ -95,6 +100,21 @@ async function confirm(question: string): Promise {
* `verified_at` when the post-check also reads zero discrepancies. A report
* file is written beside the plan's, never over an existing one.
*
+ * ## `--fate-supplement FILE` (the host's own objects, ADR-0131 D10)
+ *
+ * The inventory holds the platform's fates. An object a HOST provides — a
+ * platform-prefixed table carrying `organization_id` that no platform row
+ * names — is refused, because no fate may be guessed for it. The host declares
+ * those fates in one JSON file (the spec's `OrganizationOwnershipSupplementSchema`,
+ * `@objectstack/spec/system`) and hands it to this flag; both modes read it and
+ * plan, or apply, its rows on the same terms as the platform's
+ * (`mergeHostFateSupplement`). A file that cannot be used — missing, not JSON,
+ * a row the schema refuses, a duplicate row, a row naming a platform object —
+ * is refused before the database is opened, inside the `--json` envelope. Its
+ * rows enter the plan's `inventoryDigest`, so the apply's checkpoint is keyed
+ * on the supplement too. Nothing is read without the flag: no environment
+ * variable, no conventional path.
+ *
* The apply boot composes the deployment's served object set
* (`composeHostStack`, with the auth-gated security declarations and the
* served platform) so the security catalog it verifies names against is the
@@ -117,6 +137,7 @@ export default class MigrateOrganizationOwnership extends Command {
'$ os migrate organization-ownership --out plans/v18-ownership.json',
'$ os migrate organization-ownership --database-url postgres://… --json',
'$ os migrate organization-ownership --apply --backup-taken',
+ '$ os migrate organization-ownership --fate-supplement host-fates.json',
];
static override flags = {
@@ -150,6 +171,12 @@ export default class MigrateOrganizationOwnership extends Command {
'deleted only after the chosen body is promoted and verified',
multiple: true,
}),
+ 'fate-supplement': Flags.string({
+ description:
+ 'A JSON file of the fates the host declares for the objects it provides (OrganizationOwnershipSupplementSchema, ' +
+ '@objectstack/spec/system), read in both modes and planned on the same terms as the platform inventory. ' +
+ 'A row naming a platform object, a duplicate row, or a row the schema refuses is refused',
+ }),
};
async run(): Promise {
@@ -178,8 +205,30 @@ export default class MigrateOrganizationOwnership extends Command {
return;
}
+ // The host's fate supplement, read before the database is opened in either
+ // mode: a supplement that cannot be used refuses the whole run.
+ let inventory: readonly InventoryEntry[] | undefined;
+ if (flags['fate-supplement']) {
+ try {
+ inventory = await readHostFateSupplement(resolve(flags['fate-supplement']));
+ } catch (error) {
+ if (!(error instanceof HostFateSupplementRefusal)) throw error;
+ await refuse(
+ {
+ reason: error.reason,
+ file: error.file,
+ ...(error.row !== undefined ? { row: error.row } : {}),
+ ...(error.object !== undefined ? { object: error.object } : {}),
+ detail: error.message,
+ },
+ `Refused: ${error.message}`,
+ );
+ return;
+ }
+ }
+
if (flags.apply) {
- await this.runApply(flags, posture, refuse);
+ await this.runApply(flags, posture, refuse, inventory);
return;
}
if (flags['template-choice']?.length) {
@@ -228,6 +277,7 @@ export default class MigrateOrganizationOwnership extends Command {
},
posture,
database: stack.dbLabel,
+ ...(inventory ? { inventory } : {}),
});
} catch (error: unknown) {
if (isExitSignal(error)) throw error;
@@ -302,6 +352,7 @@ export default class MigrateOrganizationOwnership extends Command {
flags: { 'database-url'?: string; out?: string; json?: boolean; 'backup-taken'?: boolean; 'template-choice'?: string[] },
posture: string,
refuse: (payload: Record, message: string) => Promise,
+ inventory: readonly InventoryEntry[] | undefined,
): Promise {
const json = Boolean(flags.json);
const timer = createTimer();
@@ -455,6 +506,7 @@ export default class MigrateOrganizationOwnership extends Command {
},
},
ledger,
+ ...(inventory ? { inventory } : {}),
...(json ? {} : { onProgress: (message: string) => printStep(message) }),
});
} catch (error: unknown) {
diff --git a/packages/cli/src/utils/organization-ownership-host-supplement.integration.test.ts b/packages/cli/src/utils/organization-ownership-host-supplement.integration.test.ts
new file mode 100644
index 00000000000..5f62929516d
--- /dev/null
+++ b/packages/cli/src/utils/organization-ownership-host-supplement.integration.test.ts
@@ -0,0 +1,244 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The ADR-0131 D10 plan reading a host fate supplement (the inventory's C10
+ * stage): a host declares the fates of the objects it provides, and the plan
+ * reads them the way it reads the platform's own rows.
+ *
+ * The two fixtures are SHAPED like a host's control-plane database and its
+ * tenant database. They carry the tables the card names (seven on the control
+ * database, three on a tenant database) and labelled placeholders for the two
+ * wildcard families (`sys_billing_fixture_N`, `ai_fixture_N`). ⛔ They are not
+ * any host's real table list, and the supplement below is test data, not any
+ * host's declaration: the real list and its fates are the host's to declare in
+ * its own supplement.
+ */
+
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import Database from 'better-sqlite3';
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import {
+ buildOrganizationOwnershipPlan,
+ inventoryDigest,
+ OrganizationOwnershipPlanRefusal,
+ type OrganizationOwnershipPlan,
+ type PlanReader,
+ type TablePlan,
+} from './organization-ownership-plan.js';
+import { readHostFateSupplement, type InventoryEntry } from './organization-ownership-inventory.js';
+
+const CITE = 'fixture: a host-declared fate (test data, not any host\'s declaration)';
+
+/** The card's seven control-database tables, and its three tenant-database tables. */
+const CONTROL_NAMED = [
+ 'sys_quota_usage', 'sys_domain', 'sys_slug_reservation', 'sys_cloud_connection', 'sys_ai_credit_grant',
+ 'sys_environment_usage', 'sys_environment_revision',
+];
+const TENANT_NAMED = ['sys_env_artifact_cache', 'sys_seed_replay', 'sys_seed_replay_claim'];
+/** Labelled placeholders for the two wildcard families (×7 and ×8). */
+const BILLING = Array.from({ length: 7 }, (_, i) => `sys_billing_fixture_${i + 1}`);
+const AI = Array.from({ length: 8 }, (_, i) => `ai_fixture_${i + 1}`);
+
+const CONTROL_HOST_TABLES = [...BILLING, ...CONTROL_NAMED, ...AI];
+const TENANT_HOST_TABLES = [...AI, ...TENANT_NAMED];
+
+/**
+ * ONE supplement for both database kinds: every host object, whichever
+ * database holds it. The named tables take fate 4 (listed, nothing guessed);
+ * two placeholders exercise fates 3 and 1.
+ */
+const SUPPLEMENT = [
+ ...[...CONTROL_NAMED, ...TENANT_NAMED, ...BILLING.slice(1), ...AI.slice(1)].map((object) => ({ object, fate: 'report', citation: CITE })),
+ { object: BILLING[0], fate: 'attribution', citation: CITE, anchors: [{ kind: 'parent', childKey: 'account_id', parentObject: BILLING[1] }] },
+ { object: AI[0], fate: 'column-drop', citation: CITE },
+];
+
+let dir: string;
+const dbs: Database.Database[] = [];
+
+beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'os-host-supplement-plan-'));
+});
+
+afterEach(() => {
+ while (dbs.length) dbs.pop()!.close();
+ rmSync(dir, { recursive: true, force: true });
+});
+
+function open(name: string, ddl: string): { db: Database.Database; file: string; reader: PlanReader } {
+ const file = join(dir, `${name}.db`);
+ const db = new Database(file);
+ dbs.push(db);
+ db.exec(ddl);
+ return {
+ db,
+ file,
+ reader: { client: 'better-sqlite3', query: async (sql, params = []) => db.prepare(sql).all(...(params as unknown[])) as Record[] },
+ };
+}
+
+/** A host table carrying the anchor, one stamped row and one NULL row. */
+const hostTable = (name: string, extra = ''): string =>
+ `CREATE TABLE ${name} (id TEXT PRIMARY KEY, organization_id TEXT${extra});
+ INSERT INTO ${name} (id, organization_id) VALUES ('${name}_1', 'org_b'), ('${name}_2', NULL);`;
+
+const PLATFORM = `
+ CREATE TABLE sys_organization (id TEXT PRIMARY KEY, slug TEXT);
+ INSERT INTO sys_organization VALUES ('org_default', 'default'), ('org_b', 'b');
+ CREATE TABLE sys_job (id TEXT PRIMARY KEY, organization_id TEXT);
+ INSERT INTO sys_job VALUES ('job_1', NULL);
+ CREATE TABLE _objectstack_sequences (name TEXT PRIMARY KEY, value INTEGER);
+`;
+
+const controlPlane = (omit: readonly string[] = []) =>
+ open('control', [
+ PLATFORM,
+ ...CONTROL_HOST_TABLES.filter((t) => !omit.includes(t)).map((t) => hostTable(t, t === BILLING[0] ? ', account_id TEXT' : '')),
+ `UPDATE ${BILLING[0]} SET account_id = '${BILLING[1]}_1' WHERE id = '${BILLING[0]}_2';`,
+ ].join('\n'));
+
+const tenant = (omit: readonly string[] = []) =>
+ open('tenant', [PLATFORM, ...TENANT_HOST_TABLES.filter((t) => !omit.includes(t)).map((t) => hostTable(t))].join('\n'));
+
+async function supplement(rows: readonly unknown[] = SUPPLEMENT): Promise {
+ const file = join(dir, `fates-${Math.random().toString(36).slice(2)}.json`);
+ writeFileSync(file, JSON.stringify(rows));
+ return readHostFateSupplement(file);
+}
+
+const plan = (reader: PlanReader, database: string, inventory?: readonly InventoryEntry[]): Promise =>
+ buildOrganizationOwnershipPlan({ reader, posture: 'isolated', database, now: new Date('2026-10-13T00:00:00Z'), ...(inventory ? { inventory } : {}) });
+
+const tableOf = (p: OrganizationOwnershipPlan, object: string): TablePlan => {
+ const found = p.tables.find((t) => t.object === object);
+ if (!found) throw new Error(`no ${object} in the plan`);
+ return found;
+};
+
+async function refusal(promise: Promise): Promise {
+ try {
+ await promise;
+ } catch (error) {
+ if (error instanceof OrganizationOwnershipPlanRefusal) return error;
+ throw error;
+ }
+ throw new Error('the plan did not refuse');
+}
+
+describe('pin 1 — a host-declared fate is planned like a platform row', () => {
+ it('a host row with a platform row\'s fate and anchor plans to the platform row\'s answer, field for field', async () => {
+ // The platform's own sys_approval_action (fate 3, parent request_id -> sys_approval_request)
+ // beside a host table holding the same rows under the same declared fate.
+ const rows = "('act_1', NULL, 'req_1'), ('act_2', NULL, 'req_2'), ('act_3', 'org_b', 'req_1')";
+ const { reader, file } = open('pin1', `
+ ${PLATFORM}
+ CREATE TABLE sys_approval_request (id TEXT PRIMARY KEY, organization_id TEXT, object_name TEXT, record_id TEXT);
+ INSERT INTO sys_approval_request VALUES ('req_1', 'org_b', NULL, NULL), ('req_2', NULL, NULL, NULL);
+ CREATE TABLE sys_approval_action (id TEXT PRIMARY KEY, organization_id TEXT, request_id TEXT);
+ INSERT INTO sys_approval_action VALUES ${rows};
+ CREATE TABLE sys_billing_fixture_1 (id TEXT PRIMARY KEY, organization_id TEXT, request_id TEXT);
+ INSERT INTO sys_billing_fixture_1 VALUES ${rows};
+ `);
+ const inventory = await supplement([
+ { object: 'sys_billing_fixture_1', fate: 'attribution', citation: CITE, anchors: [{ kind: 'parent', childKey: 'request_id', parentObject: 'sys_approval_request' }] },
+ ]);
+ const p = await plan(reader, file, inventory);
+
+ const strip = ({ object: _o, citation: _c, census: _s, tables: _t, ...rest }: TablePlan) => rest;
+ const platform = tableOf(p, 'sys_approval_action');
+ const declared = tableOf(p, 'sys_billing_fixture_1');
+ expect(declared).toMatchObject({ source: 'inventory', census: ['host-supplement'], fate: 'attribution', citation: CITE });
+ expect(strip(declared)).toEqual(strip(platform));
+ // The answer is a real one, not two empty plans agreeing.
+ expect(declared.attribution).toEqual({ 'parent:request_id->sys_approval_request': 1 });
+ expect(declared.unattributable).toEqual([{ id: 'act_2', reason: 'sys_approval_request row req_2 has no organization' }]);
+
+ // Control: without the supplement the same database is refused at that table.
+ expect(await refusal(plan(reader, file))).toMatchObject({ reason: 'uninventoried-platform-table', table: 'sys_billing_fixture_1' });
+ });
+
+ it('the plan\'s inventoryDigest covers the merged list, so the apply checkpoint is keyed on the supplement', async () => {
+ const { reader, file } = controlPlane();
+ const inventory = await supplement();
+ const p = await plan(reader, file, inventory);
+ expect(p.inventoryDigest).toBe(inventoryDigest(inventory));
+ // Control: the platform inventory alone has another digest.
+ expect(p.inventoryDigest).not.toBe(inventoryDigest());
+ });
+});
+
+describe('pin 2 — an undeclared host table that holds data is still refused, by name, naming the flag', () => {
+ it('a supplement that omits one table refuses the plan at that table', async () => {
+ const { reader, file } = controlPlane();
+ const inventory = await supplement(SUPPLEMENT.filter((row) => row.object !== 'sys_domain'));
+ const r = await refusal(plan(reader, file, inventory));
+ expect(r).toMatchObject({ reason: 'uninventoried-platform-table', table: 'sys_domain' });
+ expect(r.message).toContain('--fate-supplement FILE');
+ // Control: the full supplement plans the same database.
+ await expect(plan(reader, file, await supplement())).resolves.toMatchObject({ kind: 'organization-ownership-plan' });
+ });
+});
+
+describe('pin 4 — a supplement row whose table this database lacks is listed absent', () => {
+ it('the control-plane rows are absent on a tenant database and present on the control database', async () => {
+ const inventory = await supplement();
+ const t = tenant();
+ const onTenant = await plan(t.reader, t.file, inventory);
+ for (const object of CONTROL_NAMED) {
+ expect(tableOf(onTenant, object), object).toMatchObject({ physical: 'absent', rows: null, tables: [], census: ['host-supplement'] });
+ }
+ // Control: the same rows, on the database that holds their tables.
+ const c = controlPlane();
+ const onControl = await plan(c.reader, c.file, inventory);
+ for (const object of CONTROL_NAMED) {
+ expect(tableOf(onControl, object).physical, object).toBe('present');
+ }
+ });
+});
+
+describe('pin 5 — the plan names every table, on a control-plane-shaped and a tenant-shaped database', () => {
+ for (const [kind, build, hostTables] of [
+ ['control-plane', controlPlane, CONTROL_HOST_TABLES],
+ ['tenant', tenant, TENANT_HOST_TABLES],
+ ] as const) {
+ it(`${kind}: every table carrying the column is planned, every other one counted outside the ceremony`, async () => {
+ const { db, reader, file } = build();
+ const p = await plan(reader, file, await supplement());
+ const all = (db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all() as Array<{ name: string }>).map((r) => r.name);
+ const withColumn = all.filter((t) =>
+ (db.prepare('SELECT name FROM pragma_table_info(?)').all(t) as Array<{ name: string }>).some((c) => c.name === 'organization_id'),
+ );
+ const present = p.tables.filter((t) => t.physical === 'present').map((t) => t.object);
+
+ expect(withColumn.length).toBe(hostTables.length + 1); // + sys_job
+ expect(present).toEqual(expect.arrayContaining(withColumn));
+ expect(all).toEqual(expect.arrayContaining(present));
+ // sys_organization is inventoried (no column); _objectstack_sequences is outside.
+ expect(present).toContain('sys_organization');
+ expect(p.summary.outsideCeremony).toBe(1);
+ for (const object of hostTables) {
+ expect(tableOf(p, object), object).toMatchObject({ source: 'inventory', census: ['host-supplement'] });
+ }
+ expect(tableOf(p, 'sys_job')).toMatchObject({ source: 'inventory', fate: 'column-drop' });
+
+ // Control: the platform inventory alone refuses this database, naming a host table.
+ const r = await refusal(plan(reader, file));
+ expect(r.reason).toBe('uninventoried-platform-table');
+ expect(hostTables).toContain(r.table);
+ });
+ }
+
+ it('the declared fates are the plan\'s: fate 3 derives through its anchor, fate 1 counts its column, fate 4 lists its NULL rows', async () => {
+ const { reader, file } = controlPlane();
+ const p = await plan(reader, file, await supplement());
+ expect(tableOf(p, BILLING[0]!)).toMatchObject({ fate: 'attribution', attribution: { [`parent:account_id->${BILLING[1]}`]: 1 }, unattributable: [] });
+ expect(tableOf(p, AI[0]!)).toMatchObject({ fate: 'column-drop', fateCounts: { columnPresent: true, rowsLosingTheirStamp: 1 } });
+ expect(tableOf(p, 'sys_domain')).toMatchObject({
+ fate: 'report',
+ unattributable: [{ id: 'sys_domain_2', reason: 'fate 4: reported, never guessed' }],
+ notNull: { willReceive: false },
+ });
+ });
+});
diff --git a/packages/cli/src/utils/organization-ownership-inventory.test.ts b/packages/cli/src/utils/organization-ownership-inventory.test.ts
index 41955902457..c01b37cd2b9 100644
--- a/packages/cli/src/utils/organization-ownership-inventory.test.ts
+++ b/packages/cli/src/utils/organization-ownership-inventory.test.ts
@@ -8,12 +8,24 @@
* table on a real database, and this says so before any database does.
*/
-import { describe, expect, it } from 'vitest';
-import { CLOUD_PROVIDED_OBJECT_NAMES, PLATFORM_OBJECTS_BY_PACKAGE } from '@objectstack/spec/system';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
import {
+ CLOUD_PROVIDED_OBJECT_NAMES,
+ OrganizationOwnershipSupplementEntrySchema,
+ PLATFORM_OBJECTS_BY_PACKAGE,
+ type OrganizationOwnershipSupplementEntry,
+} from '@objectstack/spec/system';
+import {
+ HostFateSupplementRefusal,
ORGANIZATION_OWNERSHIP_FATES,
ORGANIZATION_OWNERSHIP_INVENTORY,
inventoryEntryFor,
+ mergeHostFateSupplement,
+ readHostFateSupplement,
+ type InventoryEntry,
} from './organization-ownership-inventory.js';
/**
@@ -136,3 +148,165 @@ describe('ADR-0131 D10 inventory — every censused object has one fate and a ci
}
});
});
+
+// ── The host fate supplement (ADR-0131 D10, the inventory's C10 stage) ────────
+
+/**
+ * The keys a host authors are the inventory row's keys minus what only the
+ * platform states. Compile-time: both directions of `extends` must hold, so a
+ * key added to either side without the other fails `tsc` on this file.
+ */
+type HostAuthoredKeys = Exclude;
+type SpecRowKeys = OrganizationOwnershipSupplementEntry extends infer Row ? (Row extends unknown ? keyof Row : never) : never;
+const KEYS_AGREE: [HostAuthoredKeys] extends [SpecRowKeys] ? ([SpecRowKeys] extends [HostAuthoredKeys] ? true : false) : false = true;
+
+const FILE = 'host-fates.json';
+const CITE = 'fixture: a host-declared fate (test data, not any host\'s declaration)';
+/** A supplement row; fate 4 unless `extra` says otherwise. */
+const host = (object: string, extra: Record = {}): OrganizationOwnershipSupplementEntry =>
+ ({ object, fate: 'report', citation: CITE, ...extra }) as OrganizationOwnershipSupplementEntry;
+
+function refusalOf(run: () => unknown): HostFateSupplementRefusal {
+ try {
+ run();
+ } catch (error) {
+ if (error instanceof HostFateSupplementRefusal) return error;
+ throw error;
+ }
+ throw new Error('the supplement was not refused');
+}
+
+async function refusalOfAsync(run: () => Promise): Promise {
+ try {
+ await run();
+ } catch (error) {
+ if (error instanceof HostFateSupplementRefusal) return error;
+ throw error;
+ }
+ throw new Error('the supplement was not refused');
+}
+
+describe('the inventory row and the spec\'s supplement row are one shape', () => {
+ it('the host-authored keys equal the spec row\'s keys (compile-time)', () => {
+ expect(KEYS_AGREE).toBe(true);
+ });
+
+ it('every platform row, less what only the platform states, is a row the spec admits', () => {
+ // The CLI's vocabulary as the platform actually uses it (every condition,
+ // anchor, category and departure form) is declared in the spec: a host can
+ // author anything the platform's own rows say.
+ const refused: string[] = [];
+ for (const { census: _census, cloudCarried: _cloud, external: _external, ...row } of ORGANIZATION_OWNERSHIP_INVENTORY) {
+ const result = OrganizationOwnershipSupplementEntrySchema.safeParse(row);
+ if (!result.success) refused.push(`${row.object}: ${result.error.issues[0]?.message}`);
+ }
+ expect(refused).toEqual([]);
+ });
+});
+
+describe('mergeHostFateSupplement — host rows join the inventory on the platform\'s terms', () => {
+ it('a host row joins with census host-supplement and the fields it declared', () => {
+ const merged = mergeHostFateSupplement(
+ [host('sys_fixture_quota', { fate: 'attribution', anchors: [{ kind: 'parent', childKey: 'environment_id', parentObject: 'sys_environment' }] })],
+ FILE,
+ );
+ expect(merged).toHaveLength(ORGANIZATION_OWNERSHIP_INVENTORY.length + 1);
+ expect(merged.find((e) => e.object === 'sys_fixture_quota')).toEqual({
+ object: 'sys_fixture_quota',
+ census: ['host-supplement'],
+ fate: 'attribution',
+ citation: CITE,
+ anchors: [{ kind: 'parent', childKey: 'environment_id', parentObject: 'sys_environment' }],
+ });
+ // Control: the platform's own inventory is untouched.
+ expect(inventoryEntryFor('sys_fixture_quota')).toBeUndefined();
+ });
+
+ it('without a supplement the inventory is exactly the platform\'s', () => {
+ expect(mergeHostFateSupplement([], FILE)).toEqual(ORGANIZATION_OWNERSHIP_INVENTORY);
+ });
+
+ it('a row naming a platform object is refused, naming the row and the object', () => {
+ const r = refusalOf(() => mergeHostFateSupplement([host('sys_fixture_ok'), host('sys_file')], FILE));
+ expect(r).toMatchObject({ reason: 'fate-supplement-platform-object', file: FILE, row: 1, object: 'sys_file' });
+ // An example object the inventory gives a fate is a platform row too.
+ expect(refusalOf(() => mergeHostFateSupplement([host('crm_account')], FILE)).reason).toBe('fate-supplement-platform-object');
+ // Control: the same row naming an object the inventory does not hold joins.
+ expect(() => mergeHostFateSupplement([host('sys_fixture_ok'), host('sys_fixture_file')], FILE)).not.toThrow();
+ });
+
+ it('a row naming a cloud-provided placeholder replaces it in place; without one the placeholder stands', () => {
+ const at = ORGANIZATION_OWNERSHIP_INVENTORY.findIndex((e) => e.object === 'sys_environment');
+ expect(ORGANIZATION_OWNERSHIP_INVENTORY[at]).toMatchObject({ census: ['cloud-supplement'], fate: 'report', cloudCarried: true });
+ const merged = mergeHostFateSupplement([host('sys_environment', { fate: 'column-drop' })], FILE);
+ expect(merged).toHaveLength(ORGANIZATION_OWNERSHIP_INVENTORY.length);
+ expect(merged[at]).toEqual({ object: 'sys_environment', census: ['host-supplement'], fate: 'column-drop', citation: CITE });
+ // Control: every other placeholder still carries no declared fate.
+ for (const name of CLOUD_PROVIDED_OBJECT_NAMES.filter((n) => n !== 'sys_environment')) {
+ expect(merged.find((e) => e.object === name), name).toMatchObject({ fate: 'report', cloudCarried: true });
+ }
+ });
+
+ it('two rows naming one object are refused', () => {
+ const r = refusalOf(() => mergeHostFateSupplement([host('sys_fixture_a'), host('sys_fixture_b'), host('sys_fixture_a')], FILE));
+ expect(r).toMatchObject({ reason: 'fate-supplement-duplicate-object', row: 2, object: 'sys_fixture_a' });
+ expect(r.message).toContain('[0]');
+ });
+
+ it('new rows are appended in object order, so the merged list names the content, not the file order', () => {
+ const forward = mergeHostFateSupplement([host('sys_fixture_a'), host('sys_fixture_b')], FILE);
+ const backward = mergeHostFateSupplement([host('sys_fixture_b'), host('sys_fixture_a')], FILE);
+ expect(JSON.stringify(forward)).toBe(JSON.stringify(backward));
+ });
+});
+
+describe('readHostFateSupplement — a file it cannot use is refused, naming the file and the row', () => {
+ let dir: string;
+ const write = (name: string, body: string): string => {
+ const path = join(dir, name);
+ writeFileSync(path, body);
+ return path;
+ };
+
+ beforeEach(() => {
+ dir = mkdtempSync(join(tmpdir(), 'os-fate-supplement-'));
+ });
+ afterEach(() => {
+ rmSync(dir, { recursive: true, force: true });
+ });
+
+ it('reads a valid file into the merged inventory', async () => {
+ const file = write('ok.json', JSON.stringify([host('sys_fixture_a')]));
+ const merged = await readHostFateSupplement(file);
+ expect(merged.find((e) => e.object === 'sys_fixture_a')).toMatchObject({ census: ['host-supplement'], fate: 'report' });
+ });
+
+ it('a missing file', async () => {
+ const file = join(dir, 'absent.json');
+ expect(await refusalOfAsync(() => readHostFateSupplement(file))).toMatchObject({ reason: 'fate-supplement-unreadable', file });
+ });
+
+ it('a file that is not JSON', async () => {
+ const file = write('bad.json', '[{ "object": ');
+ expect(await refusalOfAsync(() => readHostFateSupplement(file))).toMatchObject({ reason: 'fate-supplement-unparsable', file });
+ });
+
+ it('a file that is not an array of rows', async () => {
+ const file = write('object.json', JSON.stringify({ rows: [host('sys_fixture_a')] }));
+ const r = await refusalOfAsync(() => readHostFateSupplement(file));
+ expect(r).toMatchObject({ reason: 'fate-supplement-invalid', file });
+ expect(r.row).toBeUndefined();
+ });
+
+ it('a row the spec refuses, by index and object, with where in the row', async () => {
+ const file = write('row.json', JSON.stringify([host('sys_fixture_a'), { object: 'sys_fixture_b', fate: 'attribution', citation: CITE }]));
+ const r = await refusalOfAsync(() => readHostFateSupplement(file));
+ expect(r).toMatchObject({ reason: 'fate-supplement-invalid', file, row: 1, object: 'sys_fixture_b' });
+ expect(r.message).toContain('anchors');
+ });
+
+ it('the merge refusals reach the caller from the file too', async () => {
+ const file = write('platform.json', JSON.stringify([host('sys_member')]));
+ expect(await refusalOfAsync(() => readHostFateSupplement(file))).toMatchObject({ reason: 'fate-supplement-platform-object', file, row: 0, object: 'sys_member' });
+ });
+});
diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts
index e3dc4afa7bd..042f650ac33 100644
--- a/packages/cli/src/utils/organization-ownership-inventory.ts
+++ b/packages/cli/src/utils/organization-ownership-inventory.ts
@@ -30,9 +30,16 @@
* - `cloud-supplement` — cloud-declared objects. The 2026-09-03 cloud-side
* supplement is not readable from this repository, so the rows are the
* names this repository records as cloud-provided
- * (`CLOUD_PROVIDED_OBJECT_NAMES`). Cloud stays on v17 (ruling 6094175435 on
- * #15211), so no v18 ceremony runs against a cloud database: each carries
- * fate 4, `cloudCarried`, never a guessed fate.
+ * (`CLOUD_PROVIDED_OBJECT_NAMES`). Cloud's tenant databases run this
+ * ceremony (cloud ruling record 6146394952, A), and a cloud-provided
+ * object's fate is the host's to declare: each row carries fate 4,
+ * `cloudCarried`, never a guessed fate, until a host fate supplement
+ * replaces it (C10).
+ * - `host-supplement` — rows a host declares for the objects it provides,
+ * read from the file `os migrate organization-ownership --fate-supplement`
+ * names ({@link readHostFateSupplement}). They join this table on the same
+ * terms as the platform's rows; the row shape is the spec's
+ * `OrganizationOwnershipSupplementEntrySchema` (`@objectstack/spec/system`).
*
* Any other table carrying `organization_id` that a deployment holds is an
* application object (a customer's own app): {@link APPLICATION_OBJECT_FATE}
@@ -56,81 +63,80 @@
* the plan that reads it never change a row or a column.
*/
+import { readFile } from 'node:fs/promises';
+import {
+ OrganizationOwnershipFateSchema,
+ OrganizationOwnershipSupplementSchema,
+ type OrganizationOwnershipAnchor,
+ type OrganizationOwnershipCategory,
+ type OrganizationOwnershipDeparture,
+ type OrganizationOwnershipFate as SpecOrganizationOwnershipFate,
+ type OrganizationOwnershipRowCondition,
+ type OrganizationOwnershipSupplement,
+} from '@objectstack/spec/system';
+
+// ── The row vocabulary, declared once in the spec ────────────────────────────
+//
+// Every shape a row is built from is the spec's
+// (`@objectstack/spec`, `system/fate-supplement.zod.ts`): a host authors its rows against
+// that schema, and the platform's rows below are typed by the same
+// declarations, so the two cannot drift into two dialects. The top-level row
+// keys are the one list kept here (`InventoryEntry` adds what only the platform
+// states); `organization-ownership-inventory.test.ts` pins them equal to the
+// spec's and parses every platform row through the spec's row schema.
+
/** The four D10 fates. */
-export type OrganizationOwnershipFate = 'column-drop' | 'mirror-deletion' | 'attribution' | 'report';
+export type OrganizationOwnershipFate = SpecOrganizationOwnershipFate;
-export const ORGANIZATION_OWNERSHIP_FATES: readonly OrganizationOwnershipFate[] = [
- 'column-drop',
- 'mirror-deletion',
- 'attribution',
- 'report',
-];
+export const ORGANIZATION_OWNERSHIP_FATES: readonly OrganizationOwnershipFate[] = OrganizationOwnershipFateSchema.options;
/** Where an inventory row's object was counted. */
export type InventoryCensus =
| 'platform-census-2026-08-31'
| 'platform-census-main'
| 'example-census-2026-08-31'
- | 'cloud-supplement';
+ | 'cloud-supplement'
+ | 'host-supplement';
+
+/** Each arm of `T` with its list-valued fields read-only. */
+type ReadonlyLists = T extends unknown ? { [K in keyof T]: T[K] extends ReadonlyArray ? readonly U[] : T[K] } : never;
/**
* One condition on a row. A {@link RowPredicate} ANDs them. Spelled as data,
* never as SQL text, so the plan binds every value and quotes every column.
+ * The spec's arms, with `in` / `notIn` read-only: the platform's rows and the
+ * ceremony's own selectors share constant value lists, and a spec-parsed row
+ * (mutable lists) is assignable to it unchanged.
*/
-export type RowCondition =
- | { column: string; equals: string | number | boolean }
- | { column: string; in: readonly string[] }
- | { column: string; notIn: readonly string[] }
- | { column: string; isNull: boolean }
- /** The column is NULL or holds anything but `true`. */
- | { column: string; notTrue: true };
+export type RowCondition = ReadonlyLists;
export type RowPredicate = readonly RowCondition[];
/**
* Where a NULL row's organization comes from (D10 fate 3). Tried in order;
- * the first that answers wins.
+ * the first that answers wins: `parent` (`childKey` names a row of
+ * `parentObject`, whose `organization_id` the row takes), `subject`
+ * (`objectColumn` / `idColumn` name ANY record; that record's
+ * `organization_id` is the row's), `holder` (rows of `holderObject` point at
+ * this row through `holderKey`; when every stamped holder agrees on one
+ * organization, the row takes it).
*/
-export type Anchor =
- /** `childKey` names a row of `parentObject`, whose `organization_id` the row takes. */
- | { kind: 'parent'; childKey: string; parentObject: string }
- /** `objectColumn` / `idColumn` name ANY record; that record's `organization_id` is the row's. */
- | { kind: 'subject'; objectColumn: string; idColumn: string }
- /**
- * Rows of `holderObject` point at this row through `holderKey`; when every
- * stamped holder agrees on one organization, the row takes it.
- */
- | { kind: 'holder'; holderObject: string; holderKey: string };
+export type Anchor = OrganizationOwnershipAnchor;
-/** A named row population the plan counts on a table. */
-export interface InventoryCategory {
- id: string;
- label: string;
- /** The ruling or record that names the population. */
- citation: string;
- /** Rows of the population — counted over the whole table. */
- where?: RowPredicate;
- /**
- * Count GROUPS instead: rows matching `where`, grouped by these columns, in
- * groups holding more than one row (or, with `distinct`, more than one
- * distinct value of that column). The plan reports the group count and the
- * rows in them.
- */
- conflictBy?: { groupBy: readonly string[]; distinct?: string };
-}
+/**
+ * A named row population the plan counts on a table: rows matching `where`
+ * over the whole table, or with `conflictBy` the GROUPS of those rows holding
+ * more than one row (or, with `distinct`, more than one distinct value of that
+ * column) — the plan reports the group count and the rows in them.
+ */
+export type InventoryCategory = OrganizationOwnershipCategory;
/**
* Rows that leave the table by another step of the ceremony before its NOT
- * NULL gate is read: they are neither attributed nor reported.
+ * NULL gate is read: they are neither attributed nor reported. `as` is fate 2
+ * for mirrors, `moved` for rows a ceremony step re-homes elsewhere.
*/
-export interface InventoryDeparture {
- id: string;
- label: string;
- citation: string;
- /** Fate 2 for mirrors; `moved` for rows a ceremony step re-homes elsewhere. */
- as: 'mirror-deletion' | 'moved';
- where: RowPredicate;
-}
+export type InventoryDeparture = OrganizationOwnershipDeparture;
export interface InventoryEntry {
object: string;
@@ -150,7 +156,7 @@ export interface InventoryEntry {
* NOT NULL whatever its rows hold. The reason, in the operator's terms.
*/
notNullExempt?: string;
- /** Fate 4 only: a cloud-declared object; cloud stays on v17. */
+ /** Fate 4 only: a cloud-provided object no host fate supplement has declared yet (C10). */
cloudCarried?: boolean;
/** Federated from another datasource; the planned database holds no table for it. */
external?: boolean;
@@ -175,8 +181,9 @@ const D3_CATALOG = 'ADR-0131 D3/D13: the catalog object retires; definitions liv
const D10_SINGLE = 'ADR-0131 D10 fate 3: under single, the Default Organization; otherwise reported';
const CLOUD =
'cloud-provided (CLOUD_PROVIDED_OBJECT_NAMES, @objectstack/spec/system); the 2026-09-03 cloud-side supplement is ' +
- 'not readable here. Cloud stays on v17 (ruling record 6094175435): no v18 ceremony runs against a cloud ' +
- 'database, so the row is listed, never given a guessed fate; C10 assigns it when cloud moves';
+ 'not readable here. Cloud\'s tenant databases run this ceremony (cloud ruling record 6146394952, A), and the fate ' +
+ 'of an object the host provides is the host\'s to declare: a host fate supplement (--fate-supplement) replaces ' +
+ 'this row; without one it is listed, never given a guessed fate (C10)';
const EXAMPLE =
`${CENSUS_1}: non-platform object, column auto-injected, presumptive accidental (no citable writer fact); ` +
D10_SINGLE;
@@ -187,8 +194,8 @@ const PMAIN: readonly InventoryCensus[] = ['platform-census-main'];
const EX: readonly InventoryCensus[] = ['example-census-2026-08-31'];
/** Rows a package or the platform seeded — the D10 fate-2 mirror selector on the catalog objects. */
-const SEEDED: RowPredicate = [{ column: 'managed_by', in: ['package', 'platform', 'system'] }];
-const AUTHORED: RowPredicate = [{ column: 'managed_by', notIn: ['package', 'platform', 'system'] }];
+const SEEDED = [{ column: 'managed_by', in: ['package', 'platform', 'system'] }] satisfies RowPredicate;
+const AUTHORED = [{ column: 'managed_by', notIn: ['package', 'platform', 'system'] }] satisfies RowPredicate;
const parent = (childKey: string, parentObject: string): Anchor => ({ kind: 'parent', childKey, parentObject });
const subject = (objectColumn: string, idColumn: string): Anchor => ({ kind: 'subject', objectColumn, idColumn });
@@ -567,3 +574,170 @@ const BY_OBJECT: ReadonlyMap = new Map(
export function inventoryEntryFor(object: string): InventoryEntry | undefined {
return BY_OBJECT.get(object);
}
+
+// ── The host fate supplement ──────────────────────────────────────────────────
+
+/** Why a host fate supplement cannot join the inventory. */
+export type HostFateSupplementRefusalReason =
+ /** The file is missing or cannot be read. */
+ | 'fate-supplement-unreadable'
+ /** The file is not JSON. */
+ | 'fate-supplement-unparsable'
+ /** The file is not an array of rows, or a row is one the spec's schema refuses. */
+ | 'fate-supplement-invalid'
+ /** Two rows name one object. */
+ | 'fate-supplement-duplicate-object'
+ /** A row names an object the platform inventory already gives a fate. */
+ | 'fate-supplement-platform-object';
+
+/**
+ * The supplement cannot join the inventory. Thrown before anything is read
+ * from the database, so nothing is planned or applied; the command answers it
+ * as a refusal inside its `--json` envelope, naming the file and the row.
+ */
+export class HostFateSupplementRefusal extends Error {
+ constructor(
+ readonly reason: HostFateSupplementRefusalReason,
+ message: string,
+ readonly file: string,
+ /** The refused row's zero-based index in the file's array. */
+ readonly row?: number,
+ readonly object?: string,
+ ) {
+ super(message);
+ this.name = 'HostFateSupplementRefusal';
+ }
+}
+
+const HOST: readonly InventoryCensus[] = ['host-supplement'];
+
+/**
+ * The inventory with a host's supplement rows joined to it, on the same terms
+ * as the platform's rows: each takes census `host-supplement` and is planned,
+ * and applied, exactly as a platform row with its fate is. ADR-0131 D10 drives
+ * the ceremony from "the two censuses … cloud's supplement for cloud-declared
+ * ones"; this is that supplement, declared by the host instead of guessed here.
+ *
+ * - A row naming an object the platform inventory already gives a fate is
+ * refused: the platform's row stands, and a host declares only the objects
+ * it provides.
+ * - A row naming a cloud-provided placeholder (census `cloud-supplement`,
+ * `cloudCarried`, fate 4 with no declared fate) replaces it, in place. Those
+ * rows are C10's placeholder for exactly this declaration.
+ * - Any other row joins as a new row. A row whose table the planned database
+ * lacks is listed `absent` by the plan, as an inventoried object is, so one
+ * supplement serves every database kind a host runs.
+ * - Two rows naming one object are refused.
+ *
+ * The result is deterministic: a replaced row keeps its place and new rows are
+ * appended in object-name order, so the plan's `inventoryDigest` — and the
+ * apply's checkpoint, keyed on it — names the content, not the file's order.
+ */
+export function mergeHostFateSupplement(
+ supplement: OrganizationOwnershipSupplement,
+ file: string,
+ inventory: readonly InventoryEntry[] = ORGANIZATION_OWNERSHIP_INVENTORY,
+): readonly InventoryEntry[] {
+ const at = new Map(inventory.map((entry, index) => [entry.object, index]));
+ const seen = new Map();
+ const merged = [...inventory];
+ const added: InventoryEntry[] = [];
+ supplement.forEach((row, index) => {
+ const first = seen.get(row.object);
+ if (first !== undefined) {
+ throw new HostFateSupplementRefusal(
+ 'fate-supplement-duplicate-object',
+ `The host fate supplement ${file} declares ${row.object} twice (rows [${first}] and [${index}]); one object takes ` +
+ 'one fate.',
+ file,
+ index,
+ row.object,
+ );
+ }
+ seen.set(row.object, index);
+ const { object, ...declared } = row;
+ const entry: InventoryEntry = { object, census: HOST, ...declared };
+ const existing = at.get(object);
+ if (existing === undefined) {
+ added.push(entry);
+ return;
+ }
+ const platform = inventory[existing]!;
+ if (!platform.census.includes('cloud-supplement')) {
+ throw new HostFateSupplementRefusal(
+ 'fate-supplement-platform-object',
+ `The host fate supplement ${file} row [${index}] names ${object}, which the platform inventory already gives ` +
+ `a fate (${platform.fate}). A host declares fates only for the objects it provides; the platform's row ` +
+ 'stands, so remove this one.',
+ file,
+ index,
+ object,
+ );
+ }
+ merged[existing] = entry;
+ });
+ added.sort((a, b) => (a.object < b.object ? -1 : a.object > b.object ? 1 : 0));
+ return [...merged, ...added];
+}
+
+/** `[3].anchors[0].kind` for a zod issue path. */
+function issuePath(path: readonly PropertyKey[]): string {
+ return path.map((key) => (typeof key === 'number' ? `[${key}]` : `.${String(key)}`)).join('').replace(/^\./, '');
+}
+
+/**
+ * Read the host fate supplement at `file` — a JSON array of rows the spec's
+ * `OrganizationOwnershipSupplementSchema` admits — and return the inventory
+ * with it merged in ({@link mergeHostFateSupplement}). Called by the command
+ * only when it is handed the file: the inventory has no ambient input.
+ *
+ * Every way of not using the file throws {@link HostFateSupplementRefusal},
+ * naming the file and, for a row, its index and object: ⛔ a row is never
+ * skipped and a file is never half-read.
+ */
+export async function readHostFateSupplement(
+ file: string,
+ inventory: readonly InventoryEntry[] = ORGANIZATION_OWNERSHIP_INVENTORY,
+): Promise {
+ let text: string;
+ try {
+ text = await readFile(file, 'utf8');
+ } catch (error) {
+ throw new HostFateSupplementRefusal(
+ 'fate-supplement-unreadable',
+ `The host fate supplement ${file} could not be read: ${error instanceof Error ? error.message : String(error)}.`,
+ file,
+ );
+ }
+ let json: unknown;
+ try {
+ json = JSON.parse(text);
+ } catch (error) {
+ throw new HostFateSupplementRefusal(
+ 'fate-supplement-unparsable',
+ `The host fate supplement ${file} is not JSON: ${error instanceof Error ? error.message : String(error)}.`,
+ file,
+ );
+ }
+ const parsed = OrganizationOwnershipSupplementSchema.safeParse(json);
+ if (!parsed.success) {
+ const [issue, ...more] = parsed.error.issues;
+ const row = typeof issue!.path[0] === 'number' ? issue!.path[0] : undefined;
+ const named = row === undefined ? undefined : (json as Array<{ object?: unknown }>)[row]?.object;
+ const object = typeof named === 'string' ? named : undefined;
+ const where = issue!.path.length > 1 ? ` at ${issuePath(issue!.path.slice(1))}` : '';
+ const others = more.length > 0 ? ` (and ${more.length} more issue(s))` : '';
+ throw new HostFateSupplementRefusal(
+ 'fate-supplement-invalid',
+ row === undefined
+ ? `The host fate supplement ${file} must be a JSON array of fate rows (OrganizationOwnershipSupplementSchema, ` +
+ `@objectstack/spec/system): ${issue!.message}${others}.`
+ : `The host fate supplement ${file} row [${row}]${object ? ` (${object})` : ''} is refused${where}: ` +
+ `${issue!.message}${others}.`,
+ file,
+ row,
+ object,
+ );
+ }
+ return mergeHostFateSupplement(parsed.data, file, inventory);
+}
diff --git a/packages/cli/src/utils/organization-ownership-plan.ts b/packages/cli/src/utils/organization-ownership-plan.ts
index 0a3a60ac918..2fc20598904 100644
--- a/packages/cli/src/utils/organization-ownership-plan.ts
+++ b/packages/cli/src/utils/organization-ownership-plan.ts
@@ -919,7 +919,9 @@ export async function buildOrganizationOwnershipPlan(opts: {
throw new OrganizationOwnershipPlanRefusal(
'uninventoried-platform-table',
`${base} carries ${ORG} but the ADR-0131 D10 inventory gives it no fate. The plan refuses rather than ` +
- 'guess one: the inventory (packages/cli/src/utils/organization-ownership-inventory.ts) owes it a row.',
+ 'guess one: the inventory (packages/cli/src/utils/organization-ownership-inventory.ts) 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.',
base,
);
}
diff --git a/packages/spec/api-surface/system.json b/packages/spec/api-surface/system.json
index 840f19e9b0c..9bae0980883 100644
--- a/packages/spec/api-surface/system.json
+++ b/packages/spec/api-surface/system.json
@@ -478,6 +478,20 @@
"OpenTelemetryCompatibility (type)",
"OpenTelemetryCompatibilityParsed (type)",
"OpenTelemetryCompatibilitySchema (const)",
+ "OrganizationOwnershipAnchor (type)",
+ "OrganizationOwnershipAnchorSchema (const)",
+ "OrganizationOwnershipCategory (type)",
+ "OrganizationOwnershipCategorySchema (const)",
+ "OrganizationOwnershipDeparture (type)",
+ "OrganizationOwnershipDepartureSchema (const)",
+ "OrganizationOwnershipFate (type)",
+ "OrganizationOwnershipFateSchema (const)",
+ "OrganizationOwnershipRowCondition (type)",
+ "OrganizationOwnershipRowConditionSchema (const)",
+ "OrganizationOwnershipSupplement (type)",
+ "OrganizationOwnershipSupplementEntry (type)",
+ "OrganizationOwnershipSupplementEntrySchema (const)",
+ "OrganizationOwnershipSupplementSchema (const)",
"OtelExporterType (const)",
"OtelExporterType (type)",
"PAGE_COMPONENT_COPY_KEYS (const)",
diff --git a/packages/spec/authorable-surface/system.json b/packages/spec/authorable-surface/system.json
index 0ac7ccec4ba..3f1b7f05bfb 100644
--- a/packages/spec/authorable-surface/system.json
+++ b/packages/spec/authorable-surface/system.json
@@ -865,6 +865,16 @@
"system/OpenTelemetryCompatibility:resource",
"system/OpenTelemetryCompatibility:sdkVersion",
"system/OpenTelemetryCompatibility:semanticConventionsVersion",
+ "system/OrganizationOwnershipCategory:citation",
+ "system/OrganizationOwnershipCategory:conflictBy",
+ "system/OrganizationOwnershipCategory:id",
+ "system/OrganizationOwnershipCategory:label",
+ "system/OrganizationOwnershipCategory:where",
+ "system/OrganizationOwnershipDeparture:as",
+ "system/OrganizationOwnershipDeparture:citation",
+ "system/OrganizationOwnershipDeparture:id",
+ "system/OrganizationOwnershipDeparture:label",
+ "system/OrganizationOwnershipDeparture:where",
"system/PNCounter:negative",
"system/PNCounter:positive",
"system/PNCounter:type",
diff --git a/packages/spec/declaration-map/system.json b/packages/spec/declaration-map/system.json
index bb2f8859d71..748819da1b7 100644
--- a/packages/spec/declaration-map/system.json
+++ b/packages/spec/declaration-map/system.json
@@ -317,6 +317,20 @@
"OnceScheduleSchema": "system/OnceSchedule",
"OpenTelemetryCompatibility": "system/OpenTelemetryCompatibility",
"OpenTelemetryCompatibilitySchema": "system/OpenTelemetryCompatibility",
+ "OrganizationOwnershipAnchor": "system/OrganizationOwnershipAnchor",
+ "OrganizationOwnershipAnchorSchema": "system/OrganizationOwnershipAnchor",
+ "OrganizationOwnershipCategory": "system/OrganizationOwnershipCategory",
+ "OrganizationOwnershipCategorySchema": "system/OrganizationOwnershipCategory",
+ "OrganizationOwnershipDeparture": "system/OrganizationOwnershipDeparture",
+ "OrganizationOwnershipDepartureSchema": "system/OrganizationOwnershipDeparture",
+ "OrganizationOwnershipFate": "system/OrganizationOwnershipFate",
+ "OrganizationOwnershipFateSchema": "system/OrganizationOwnershipFate",
+ "OrganizationOwnershipRowCondition": "system/OrganizationOwnershipRowCondition",
+ "OrganizationOwnershipRowConditionSchema": "system/OrganizationOwnershipRowCondition",
+ "OrganizationOwnershipSupplement": "system/OrganizationOwnershipSupplement",
+ "OrganizationOwnershipSupplementEntry": "system/OrganizationOwnershipSupplementEntry",
+ "OrganizationOwnershipSupplementEntrySchema": "system/OrganizationOwnershipSupplementEntry",
+ "OrganizationOwnershipSupplementSchema": "system/OrganizationOwnershipSupplement",
"OtelExporterType": "system/OtelExporterType",
"PNCounter": "system/PNCounter",
"PNCounterSchema": "system/PNCounter",
diff --git a/packages/spec/export-origins/system.json b/packages/spec/export-origins/system.json
index 183d397e158..b6f920e6b93 100644
--- a/packages/spec/export-origins/system.json
+++ b/packages/spec/export-origins/system.json
@@ -458,6 +458,20 @@
"OpenTelemetryCompatibility": "src/system/tracing.zod.ts#OpenTelemetryCompatibility (type)",
"OpenTelemetryCompatibilityParsed": "src/system/tracing.zod.ts#OpenTelemetryCompatibilityParsed (type)",
"OpenTelemetryCompatibilitySchema": "src/system/tracing.zod.ts#OpenTelemetryCompatibilitySchema (const)",
+ "OrganizationOwnershipAnchor": "src/system/fate-supplement.zod.ts#OrganizationOwnershipAnchor (type)",
+ "OrganizationOwnershipAnchorSchema": "src/system/fate-supplement.zod.ts#OrganizationOwnershipAnchorSchema (const)",
+ "OrganizationOwnershipCategory": "src/system/fate-supplement.zod.ts#OrganizationOwnershipCategory (type)",
+ "OrganizationOwnershipCategorySchema": "src/system/fate-supplement.zod.ts#OrganizationOwnershipCategorySchema (const)",
+ "OrganizationOwnershipDeparture": "src/system/fate-supplement.zod.ts#OrganizationOwnershipDeparture (type)",
+ "OrganizationOwnershipDepartureSchema": "src/system/fate-supplement.zod.ts#OrganizationOwnershipDepartureSchema (const)",
+ "OrganizationOwnershipFate": "src/system/fate-supplement.zod.ts#OrganizationOwnershipFate (type)",
+ "OrganizationOwnershipFateSchema": "src/system/fate-supplement.zod.ts#OrganizationOwnershipFateSchema (const)",
+ "OrganizationOwnershipRowCondition": "src/system/fate-supplement.zod.ts#OrganizationOwnershipRowCondition (type)",
+ "OrganizationOwnershipRowConditionSchema": "src/system/fate-supplement.zod.ts#OrganizationOwnershipRowConditionSchema (const)",
+ "OrganizationOwnershipSupplement": "src/system/fate-supplement.zod.ts#OrganizationOwnershipSupplement (type)",
+ "OrganizationOwnershipSupplementEntry": "src/system/fate-supplement.zod.ts#OrganizationOwnershipSupplementEntry (type)",
+ "OrganizationOwnershipSupplementEntrySchema": "src/system/fate-supplement.zod.ts#OrganizationOwnershipSupplementEntrySchema (const)",
+ "OrganizationOwnershipSupplementSchema": "src/system/fate-supplement.zod.ts#OrganizationOwnershipSupplementSchema (const)",
"OtelExporterType": "src/system/tracing.zod.ts#OtelExporterType (type)",
"PAGE_COMPONENT_COPY_KEYS": "src/system/i18n-resolver.ts#PAGE_COMPONENT_COPY_KEYS (const)",
"PKG_CONVENTIONS": "src/system/constants/paths.ts#PKG_CONVENTIONS (const)",
diff --git a/packages/spec/json-schema.manifest/system.json b/packages/spec/json-schema.manifest/system.json
index 6e431524388..4efde58da7d 100644
--- a/packages/spec/json-schema.manifest/system.json
+++ b/packages/spec/json-schema.manifest/system.json
@@ -173,6 +173,13 @@
"system/OidcProvidersConfig",
"system/OnceSchedule",
"system/OpenTelemetryCompatibility",
+ "system/OrganizationOwnershipAnchor",
+ "system/OrganizationOwnershipCategory",
+ "system/OrganizationOwnershipDeparture",
+ "system/OrganizationOwnershipFate",
+ "system/OrganizationOwnershipRowCondition",
+ "system/OrganizationOwnershipSupplement",
+ "system/OrganizationOwnershipSupplementEntry",
"system/OtelExporterType",
"system/PNCounter",
"system/PackagePublishResult",
diff --git a/packages/spec/llms.txt b/packages/spec/llms.txt
index e9b7f56c5fb..0294c6a9cdd 100644
--- a/packages/spec/llms.txt
+++ b/packages/spec/llms.txt
@@ -77,7 +77,7 @@ const query = {
---
-## 3. Schema Inventory by Domain (204 schemas)
+## 3. Schema Inventory by Domain (205 schemas)
Counted as `*.zod.ts` modules under `packages/spec/src//` — the sources
that ship in this tarball (`files` includes `src/**/*.zod.ts`), so every number
@@ -85,7 +85,7 @@ here is verifiable from the installed package.
| Domain | Count | Key Schemas |
|--------|-------|-------------|
-| system | 35 | Auth, Cache, Dev Login, Email / SMS Config, Encryption, HTTP Server, License, Logging, Metrics |
+| system | 36 | Auth, Cache, Dev Login, Email / SMS Config, Encryption, Fate Supplement (organization ownership), HTTP Server, License, Logging, Metrics |
| kernel | 31 | Plugin, Manifest, Events (6 sub-modules), Feature, Context, Package Registry |
| data | 30 | Object, Field, Picklist, Query, Filter, Driver (SQL/NoSQL/Memory/Mongo/Postgres), Cube |
| api | 31 | Endpoint, REST Server, Discovery, OData, Batch, WebSocket, Response Envelope, Package Lifecycle, Package API (assembled stage) |
diff --git a/packages/spec/scripts/liveness/empty-state-registry.mts b/packages/spec/scripts/liveness/empty-state-registry.mts
index 697b49fa4dd..2ee12df958e 100644
--- a/packages/spec/scripts/liveness/empty-state-registry.mts
+++ b/packages/spec/scripts/liveness/empty-state-registry.mts
@@ -172,6 +172,22 @@ export const EMPTY_STATE_REGISTRY: EmptyStateEntry[] = [
semantics: 'scope',
rationale: 'Which metadata types a Studio action offers itself for. An authoring-surface affordance, not an access decision.',
},
+ {
+ file: 'packages/spec/src/system/fate-supplement.zod.ts',
+ property: 'where',
+ semantics: 'scope',
+ rationale:
+ 'A host fate supplement row\'s counted category: which rows of the table the organization-ownership plan COUNTS under that label. Absent = the whole table, the same reading as the platform inventory\'s own categories. It selects a population to report, and grants and deletes nothing.',
+ evidence: 'packages/cli/src/utils/organization-ownership-plan.ts (planCategory — `category.where ?? []`)',
+ },
+ {
+ file: 'packages/spec/src/system/fate-supplement.zod.ts',
+ property: 'mirror',
+ semantics: 'scope',
+ rationale:
+ 'Which rows of a `mirror-deletion` (ADR-0131 D10 fate 2) table are copies of code, as the platform inventory\'s own `mirror` reads: absent = every row. It selects work for the ceremony\'s mirror step, not access, and the fate itself is the declaration that the rows are re-derivable. The step deletes nothing before the verified id-to-name rewrite, and it always keeps every row a named category claims and, on a table that records provenance, every row no seeder wrote.',
+ evidence: 'packages/cli/src/utils/organization-ownership-apply.ts (deletableWhere — the mirror selector minus every claimed row)',
+ },
{
file: 'packages/spec/src/automation/flow.zod.ts',
property: 'errorCode',
diff --git a/packages/spec/src/system/fate-supplement.test.ts b/packages/spec/src/system/fate-supplement.test.ts
new file mode 100644
index 00000000000..2ba528e3b85
--- /dev/null
+++ b/packages/spec/src/system/fate-supplement.test.ts
@@ -0,0 +1,113 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * The host fate supplement's declared shape (ADR-0131 D10): each fate admits
+ * the fields that fate reads and no others, every shape refuses an unknown
+ * key, and the file is an array of rows. The ceremony-side rules (a row naming
+ * a platform object, two rows for one object) are the CLI's and are pinned
+ * there, against the inventory they are judged by.
+ */
+
+import { describe, expect, it } from 'vitest';
+import {
+ OrganizationOwnershipFateSchema,
+ OrganizationOwnershipSupplementEntrySchema,
+ OrganizationOwnershipSupplementSchema,
+} from './fate-supplement.zod';
+
+const CITE = 'host record: fixture citation';
+
+const codes = (input: unknown): string[] => {
+ const result = OrganizationOwnershipSupplementEntrySchema.safeParse(input);
+ return result.success ? [] : result.error.issues.map((issue) => issue.code);
+};
+
+describe('OrganizationOwnershipSupplementEntrySchema — one row per fate', () => {
+ it('admits each of the four fates with the fields it reads', () => {
+ const rows = [
+ { object: 'sys_fixture_drop', fate: 'column-drop', citation: CITE, note: 'deployment-level state' },
+ {
+ object: 'sys_fixture_mirror',
+ fate: 'mirror-deletion',
+ citation: CITE,
+ mirror: [{ column: 'managed_by', in: ['package'] }],
+ categories: [{ id: 'authored', label: 'authored rows', citation: CITE, where: [{ column: 'managed_by', notIn: ['package'] }] }],
+ },
+ {
+ object: 'sys_fixture_attr',
+ fate: 'attribution',
+ citation: CITE,
+ anchors: [
+ { kind: 'parent', childKey: 'account_id', parentObject: 'sys_fixture_account' },
+ { kind: 'subject', objectColumn: 'ref_object', idColumn: 'ref_id' },
+ { kind: 'holder', holderObject: 'sys_fixture_holder', holderKey: 'attr_id' },
+ ],
+ departures: [{ id: 'global-move', label: 'global rows move', citation: CITE, as: 'moved', where: [{ column: 'scope', equals: 'global' }] }],
+ },
+ {
+ object: 'sys_fixture_report',
+ fate: 'report',
+ citation: CITE,
+ notNullExempt: 'the column is a relation, not the tenancy anchor',
+ categories: [{ id: 'conflicts', label: 'conflicting rows', citation: CITE, conflictBy: { groupBy: ['name'], distinct: 'organization_id' } }],
+ },
+ ];
+ for (const row of rows) {
+ expect(OrganizationOwnershipSupplementEntrySchema.safeParse(row).success, row.object).toBe(true);
+ }
+ expect(OrganizationOwnershipSupplementSchema.safeParse(rows).success).toBe(true);
+ });
+
+ it('a fate names its fields: anchors are required on fate 3, an empty list being the declared Default Organization', () => {
+ expect(codes({ object: 'sys_fixture_attr', fate: 'attribution', citation: CITE, anchors: [] })).toEqual([]);
+ // Control: the same row without `anchors` is refused, so the pass above is the list, not the fate.
+ expect(codes({ object: 'sys_fixture_attr', fate: 'attribution', citation: CITE })).toEqual(['invalid_type']);
+ });
+
+ it('a key written on the wrong fate is refused, never read by nothing', () => {
+ const anchors = [{ kind: 'parent', childKey: 'account_id', parentObject: 'sys_fixture_account' }];
+ expect(codes({ object: 'sys_fixture_row', fate: 'report', citation: CITE, anchors })).toEqual(['unrecognized_keys']);
+ expect(codes({ object: 'sys_fixture_row', fate: 'attribution', citation: CITE, anchors: [], mirror: [] })).toEqual(['unrecognized_keys']);
+ expect(codes({ object: 'sys_fixture_row', fate: 'column-drop', citation: CITE, notNullExempt: 'x' })).toEqual(['unrecognized_keys']);
+ expect(codes({ object: 'sys_fixture_row', fate: 'mirror-deletion', citation: CITE, departures: [] })).toEqual(['unrecognized_keys']);
+ // Control: each key is admitted on its own fate.
+ expect(codes({ object: 'sys_fixture_row', fate: 'attribution', citation: CITE, anchors })).toEqual([]);
+ expect(codes({ object: 'sys_fixture_row', fate: 'mirror-deletion', citation: CITE, mirror: [] })).toEqual([]);
+ expect(codes({ object: 'sys_fixture_row', fate: 'report', citation: CITE, notNullExempt: 'x' })).toEqual([]);
+ });
+
+ it('the platform-only markers are not authorable: the ceremony stamps the census itself', () => {
+ for (const key of ['census', 'cloudCarried', 'external']) {
+ expect(codes({ object: 'sys_fixture_row', fate: 'report', citation: CITE, [key]: true }), key).toEqual(['unrecognized_keys']);
+ }
+ });
+
+ it('an unknown key is refused at every depth', () => {
+ const base = { object: 'sys_fixture_row', fate: 'attribution', citation: CITE };
+ expect(codes({ ...base, anchors: [{ kind: 'parent', childKey: 'a_id', parentObject: 'sys_a', parentOrgColumn: 'organization_id' }] })).toContain('unrecognized_keys');
+ expect(codes({ ...base, anchors: [], categories: [{ id: 'c', label: 'l', citation: CITE, filter: [] }] })).toContain('unrecognized_keys');
+ expect(codes({ ...base, anchors: [], departures: [{ id: 'd', label: 'l', citation: CITE, as: 'moved', where: [], why: 'x' }] })).toContain('unrecognized_keys');
+ // A row condition is one of five closed shapes; a misspelled operator matches none.
+ expect(codes({ ...base, anchors: [], departures: [{ id: 'd', label: 'l', citation: CITE, as: 'moved', where: [{ column: 'scope', equal: 'global' }] }] })).toEqual(['invalid_union']);
+ });
+
+ it('names are snake_case identifiers and prose is not blank', () => {
+ expect(codes({ object: 'Sys-Fixture', fate: 'column-drop', citation: CITE })).toEqual(['invalid_format']);
+ expect(codes({ object: 'sys_fixture_row', fate: 'column-drop', citation: ' ' })).toEqual(['invalid_format']);
+ expect(codes({ object: 'sys_fixture_row', fate: 'report', citation: CITE, categories: [{ id: 'c', label: 'l', citation: CITE, where: [{ column: 'scope', in: [] }] }] })).toEqual(['too_small']);
+ // Control.
+ expect(codes({ object: 'sys_fixture_row', fate: 'column-drop', citation: CITE })).toEqual([]);
+ });
+
+ it('a fate outside the four is refused', () => {
+ expect(OrganizationOwnershipFateSchema.options).toEqual(['column-drop', 'mirror-deletion', 'attribution', 'report']);
+ expect(codes({ object: 'sys_fixture_row', fate: 'delete', citation: CITE })).toEqual(['invalid_union']);
+ });
+});
+
+describe('OrganizationOwnershipSupplementSchema — the file', () => {
+ it('is an array of rows; an object holding rows is not the file', () => {
+ expect(OrganizationOwnershipSupplementSchema.safeParse([]).success).toBe(true);
+ expect(OrganizationOwnershipSupplementSchema.safeParse({ rows: [] }).success).toBe(false);
+ });
+});
diff --git a/packages/spec/src/system/fate-supplement.zod.ts b/packages/spec/src/system/fate-supplement.zod.ts
new file mode 100644
index 00000000000..78dd8b3d471
--- /dev/null
+++ b/packages/spec/src/system/fate-supplement.zod.ts
@@ -0,0 +1,237 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+import { z } from 'zod';
+import { lazySchema } from '../shared/lazy-schema';
+import { strictObject } from '../shared/strict-object';
+import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod';
+
+/**
+ * The host fate supplement — the fates a HOST declares for the objects it
+ * provides, read by the v18 organization-ownership ceremony
+ * (`os migrate organization-ownership --fate-supplement FILE`, ADR-0131 D10).
+ *
+ * ## Why a host declares anything
+ *
+ * ADR-0131 D10 drives the ceremony from one inventory: one fate, with its
+ * citation, for every object that carries `organization_id` — "the two
+ * censuses seed it … cloud's supplement for cloud-declared ones". The
+ * platform's rows live with the ceremony (`@objectstack/cli`). An object a
+ * host provides is not the platform's to give a fate: the ceremony refuses a
+ * platform-prefixed table it holds no fate for rather than guess one. This
+ * file is the declared channel through which the host supplies those rows. It
+ * is a JSON array of {@link OrganizationOwnershipSupplementEntrySchema} rows,
+ * and nothing reads it unless the command is handed it: no environment
+ * variable, no conventional path.
+ *
+ * ## The same terms as the platform's rows
+ *
+ * Each row is the platform inventory's entry shape, minus what only the
+ * platform states (the census that counted the object, and its own
+ * markers). The ceremony stamps the row's census itself and plans it exactly
+ * as it plans a platform row: the same fates, anchors, mirror selector,
+ * departures and counted categories. What it refuses beyond this schema is
+ * the ceremony's to say: a row naming an object the platform inventory
+ * already gives a fate, and a second row for one object.
+ *
+ * ## The four fates (ADR-0131 D10)
+ *
+ * 1. `column-drop` — the object ends with no `organization_id` column.
+ * 2. `mirror-deletion` — rows re-derivable from code; deleted only after the
+ * verified id-to-name rewrite. `mirror` says which rows are mirrors.
+ * 3. `attribution` — NULL rows take their organization from an anchor, in
+ * order; under the `single` posture, the Default Organization.
+ * 4. `report` — rows are listed, never guessed and never deleted.
+ *
+ * Each fate admits only the keys that fate reads, so a key written on the
+ * wrong fate is refused instead of read by nothing.
+ */
+
+const SUPPLEMENT_HISTORY =
+ 'The host fate supplement is strict from birth: no earlier spelling of this file existed, so an unknown key '
+ + 'was never accepted.';
+
+/**
+ * A physical column a row predicate, anchor or category reads. The ceremony
+ * binds every value and quotes every identifier, so a column is a plain
+ * snake_case name (object name = table name; machine names are snake_case).
+ */
+const ColumnNameSchema = SnakeCaseIdentifierSchema;
+
+/** Prose the ceremony prints beside a count; at least one non-whitespace character. */
+const ProseSchema = z.string().min(1).regex(/\S/);
+
+/** The four ADR-0131 D10 fates. */
+export const OrganizationOwnershipFateSchema = lazySchema(() => z
+ .enum(['column-drop', 'mirror-deletion', 'attribution', 'report'])
+ .describe('ADR-0131 D10 fate: column-drop, mirror-deletion, attribution or report'));
+export type OrganizationOwnershipFate = z.input;
+
+/**
+ * One condition on a row. A predicate ANDs them. Data, never SQL text: the
+ * ceremony binds every value and quotes the column.
+ */
+export const OrganizationOwnershipRowConditionSchema = lazySchema(() => z
+ .union([
+ strictObject(
+ { surface: 'an `equals` row condition', history: SUPPLEMENT_HISTORY },
+ {
+ column: ColumnNameSchema.describe('Column the condition reads'),
+ equals: z.union([z.string(), z.number(), z.boolean()]).describe('The column equals this value'),
+ },
+ ),
+ strictObject(
+ { surface: 'an `in` row condition', history: SUPPLEMENT_HISTORY },
+ {
+ column: ColumnNameSchema.describe('Column the condition reads'),
+ in: z.array(z.string()).min(1).describe('The column holds one of these values'),
+ },
+ ),
+ strictObject(
+ { surface: 'a `notIn` row condition', history: SUPPLEMENT_HISTORY },
+ {
+ column: ColumnNameSchema.describe('Column the condition reads'),
+ notIn: z.array(z.string()).min(1).describe('The column is NULL or holds none of these values'),
+ },
+ ),
+ strictObject(
+ { surface: 'an `isNull` row condition', history: SUPPLEMENT_HISTORY },
+ {
+ column: ColumnNameSchema.describe('Column the condition reads'),
+ isNull: z.boolean().describe('true: the column is NULL; false: it is not'),
+ },
+ ),
+ strictObject(
+ { surface: 'a `notTrue` row condition', history: SUPPLEMENT_HISTORY },
+ {
+ column: ColumnNameSchema.describe('Column the condition reads'),
+ notTrue: z.literal(true).describe('The column is NULL or holds anything but true'),
+ },
+ ),
+ ])
+ .describe('One condition on a row: equals, in, notIn, isNull or notTrue on one column'));
+export type OrganizationOwnershipRowCondition = z.input;
+
+const RowPredicateSchema = z.array(OrganizationOwnershipRowConditionSchema);
+
+/**
+ * Where a NULL row's organization comes from (fate 3). Tried in order; the
+ * first that answers wins.
+ */
+export const OrganizationOwnershipAnchorSchema = lazySchema(() => z
+ .discriminatedUnion('kind', [
+ strictObject(
+ { surface: 'a `parent` anchor', history: SUPPLEMENT_HISTORY },
+ {
+ kind: z.literal('parent'),
+ childKey: ColumnNameSchema.describe('Column of this row naming a row of parentObject'),
+ parentObject: SnakeCaseIdentifierSchema.describe('Object whose row carries the organization'),
+ },
+ ),
+ strictObject(
+ { surface: 'a `subject` anchor', history: SUPPLEMENT_HISTORY },
+ {
+ kind: z.literal('subject'),
+ objectColumn: ColumnNameSchema.describe('Column naming the object of the record the row is about'),
+ idColumn: ColumnNameSchema.describe('Column naming that record\'s id'),
+ },
+ ),
+ strictObject(
+ { surface: 'a `holder` anchor', history: SUPPLEMENT_HISTORY },
+ {
+ kind: z.literal('holder'),
+ holderObject: SnakeCaseIdentifierSchema.describe('Object whose rows point at this row'),
+ holderKey: ColumnNameSchema.describe('Column of holderObject naming this row; every stamped holder must agree'),
+ },
+ ),
+ ])
+ .describe('Fate 3 anchor: parent, subject or holder'));
+export type OrganizationOwnershipAnchor = z.input;
+
+/** A named row population the ceremony counts on the table. */
+export const OrganizationOwnershipCategorySchema = lazySchema(() => strictObject(
+ { surface: 'a counted category', history: SUPPLEMENT_HISTORY },
+ {
+ id: z.string().regex(/^[a-z][a-z0-9-]*$/).describe('Category id (kebab-case), as the plan labels the count'),
+ label: ProseSchema.describe('What the population is, in the operator\'s terms'),
+ citation: ProseSchema.describe('The ruling or record that names the population'),
+ where: RowPredicateSchema.optional().describe('Rows of the population; absent = every row'),
+ conflictBy: strictObject(
+ { surface: 'a category `conflictBy`', history: SUPPLEMENT_HISTORY },
+ {
+ groupBy: z.array(ColumnNameSchema).min(1).describe('Columns the rows are grouped by'),
+ distinct: ColumnNameSchema.optional()
+ .describe('Count a group only when it holds more than one distinct value of this column'),
+ },
+ ).optional().describe('Count groups holding more than one row instead of rows'),
+ },
+).describe('A named row population the ceremony counts on the table'));
+export type OrganizationOwnershipCategory = z.input;
+
+/** Rows that leave the table by another step before its NOT NULL gate is read. */
+export const OrganizationOwnershipDepartureSchema = lazySchema(() => strictObject(
+ { surface: 'a departure', history: SUPPLEMENT_HISTORY },
+ {
+ id: z.string().regex(/^[a-z][a-z0-9-]*$/).describe('Departure id (kebab-case), as the plan labels the count'),
+ label: ProseSchema.describe('Which rows leave, and by which step'),
+ citation: ProseSchema.describe('The ruling or record that names the step'),
+ as: z.enum(['mirror-deletion', 'moved']).describe('mirror-deletion for mirrors; moved for rows re-homed elsewhere'),
+ where: RowPredicateSchema.describe('The rows that leave'),
+ },
+).describe('Rows that leave the table before the NOT NULL gate: neither attributed nor reported'));
+export type OrganizationOwnershipDeparture = z.input;
+
+/** The keys every fate admits. */
+const commonShape = () => ({
+ object: SnakeCaseIdentifierSchema.describe('The object (= its table) the host provides'),
+ citation: ProseSchema.describe('The host\'s source for this fate'),
+ categories: z.array(OrganizationOwnershipCategorySchema).optional()
+ .describe('Named row populations the plan counts on the table'),
+ note: z.string().optional().describe('A note the plan carries beside the table'),
+});
+
+const entrySurface = (fate: string) => ({ surface: `a \`${fate}\` supplement row`, history: SUPPLEMENT_HISTORY });
+
+/**
+ * One row of the host fate supplement: the fate of one object the host
+ * provides, with the fields that fate reads and no others.
+ */
+export const OrganizationOwnershipSupplementEntrySchema = lazySchema(() => z
+ .discriminatedUnion('fate', [
+ strictObject(entrySurface('column-drop'), {
+ ...commonShape(),
+ fate: z.literal('column-drop').describe('Fate 1: the object ends with no organization_id column'),
+ }),
+ strictObject(entrySurface('mirror-deletion'), {
+ ...commonShape(),
+ fate: z.literal('mirror-deletion').describe('Fate 2: mirror rows are deleted after the verified rewrite'),
+ mirror: RowPredicateSchema.optional().describe('Which rows are mirrors; absent = every row'),
+ }),
+ strictObject(entrySurface('attribution'), {
+ ...commonShape(),
+ fate: z.literal('attribution').describe('Fate 3: NULL rows take their organization from an anchor'),
+ anchors: z.array(OrganizationOwnershipAnchorSchema)
+ .describe('The anchors, in order; empty = the Default Organization under single, otherwise reported'),
+ departures: z.array(OrganizationOwnershipDepartureSchema).optional()
+ .describe('Rows that leave the table before the NOT NULL gate'),
+ }),
+ strictObject(entrySurface('report'), {
+ ...commonShape(),
+ fate: z.literal('report').describe('Fate 4: NULL rows are listed, never guessed, never deleted'),
+ departures: z.array(OrganizationOwnershipDepartureSchema).optional()
+ .describe('Rows that leave the table before the NOT NULL gate'),
+ notNullExempt: ProseSchema.optional()
+ .describe('The column is not the tenancy anchor, so it never receives NOT NULL: the reason'),
+ }),
+ ])
+ .describe('The fate of one object the host provides (ADR-0131 D10)'));
+export type OrganizationOwnershipSupplementEntry = z.input;
+
+/**
+ * The host fate supplement file: a JSON array of rows, one per object the
+ * host provides. `os migrate organization-ownership --fate-supplement FILE`
+ * reads it in both its plan and apply modes.
+ */
+export const OrganizationOwnershipSupplementSchema = lazySchema(() => z
+ .array(OrganizationOwnershipSupplementEntrySchema)
+ .describe('The host fate supplement: the ADR-0131 D10 fates of the objects a host provides, one row per object'));
+export type OrganizationOwnershipSupplement = z.input;
diff --git a/packages/spec/src/system/index.ts b/packages/spec/src/system/index.ts
index 3297f5d2cba..1d921f1f035 100644
--- a/packages/spec/src/system/index.ts
+++ b/packages/spec/src/system/index.ts
@@ -64,6 +64,9 @@ export * from './tracing.zod';
// change tracking is `sys_metadata` history and the package model (ADR-0126).
// See the D3 record `change-management-family-retired`.
export * from './migration.zod';
+// The host fate supplement `os migrate organization-ownership --fate-supplement`
+// reads (ADR-0131 D10): the fates a host declares for the objects it provides.
+export * from './fate-supplement.zod';
// Security & Compliance
export * from './auth-config.zod';
diff --git a/packages/spec/src/type-alias-convention.pin.test.ts b/packages/spec/src/type-alias-convention.pin.test.ts
index 279e493f6ce..db32c9d8292 100644
--- a/packages/spec/src/type-alias-convention.pin.test.ts
+++ b/packages/spec/src/type-alias-convention.pin.test.ts
@@ -271,9 +271,11 @@ import type * as M185 from './shared/epoch.zod.js';
import type * as M187 from './shared/duration.zod.js';
// [#18451] The build-progress PHASE vocabulary -- new module, next free index.
import type * as M188 from './ai/build-progress.zod.js';
+// The host fate supplement (ADR-0131 D10's C10 stage) -- new module, next free index.
+import type * as M189 from './system/fate-supplement.zod.js';
// ---------------------------------------------------------------------------
-// 776 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared.
+// 783 isomorphic aliases: `z.input` === `z.infer`, so no `XParsed` is declared.
//
// That number is machine-checked, not hand-kept. The runtime companion at the
// bottom of this file recomputes the pin count from the source and asserts that
@@ -1279,6 +1281,15 @@ export type Iso_system_encryption__KeyManagementProviderSchema = Assert, z.infer< typeof M131.Sha256DigestSchema > >>;
+// system/fate-supplement.zod.ts
+export type Iso_system_fateSupplement__OrganizationOwnershipAnchorSchema = Assert, z.infer< typeof M189.OrganizationOwnershipAnchorSchema > >>;
+export type Iso_system_fateSupplement__OrganizationOwnershipCategorySchema = Assert, z.infer< typeof M189.OrganizationOwnershipCategorySchema > >>;
+export type Iso_system_fateSupplement__OrganizationOwnershipDepartureSchema = Assert, z.infer< typeof M189.OrganizationOwnershipDepartureSchema > >>;
+export type Iso_system_fateSupplement__OrganizationOwnershipFateSchema = Assert, z.infer< typeof M189.OrganizationOwnershipFateSchema > >>;
+export type Iso_system_fateSupplement__OrganizationOwnershipRowConditionSchema = Assert, z.infer< typeof M189.OrganizationOwnershipRowConditionSchema > >>;
+export type Iso_system_fateSupplement__OrganizationOwnershipSupplementEntrySchema = Assert, z.infer< typeof M189.OrganizationOwnershipSupplementEntrySchema > >>;
+export type Iso_system_fateSupplement__OrganizationOwnershipSupplementSchema = Assert, z.infer< typeof M189.OrganizationOwnershipSupplementSchema > >>;
+
// system/http-server.zod.ts
export type Iso_system_httpServer__MiddlewareType = Assert, z.infer< typeof M132.MiddlewareType > >>;
@@ -1678,7 +1689,7 @@ describe('ADR-0122 type-alias convention', () => {
// this title and the section header above the pin list — are now asserted
// against the recomputed count below, so neither can go stale without a red
// test naming it.
- it('still declares all 776 isomorphic pins', () => {
+ it('still declares all 783 isomorphic pins', () => {
// The truth of each pin is proved by tsc, not here — an `Assert>`
// that stops holding is a compile error with the alias named. What tsc
// cannot notice is a pin that was DELETED: removing the assertion removes
@@ -2484,7 +2495,13 @@ describe('ADR-0122 type-alias convention', () => {
// strings and optional `method` literal carry no default and no transform, so
// it is pinned here (Iso_ui_page__ElementApiDataSourceSchema) rather than
// given an `ElementApiDataSourceParsed` synonym. +1 added.
- expect(pins).toHaveLength(776);
+ //
+ // 776 -> 783 is the host fate supplement (ADR-0131 D10's C10 stage): the
+ // seven exported schemas of the new `system/fate-supplement.zod.ts` are
+ // closed objects, unions and enums with no default and no transform in
+ // their trees, so each is pinned here (Iso_system_fateSupplement__*)
+ // rather than given an `XParsed` synonym. +7 added.
+ expect(pins).toHaveLength(783);
// The count is stated in PROSE twice as well — this case's title and the
// section header above the pin list — and until commit c6b05c76a nothing read either
diff --git a/packages/spec/variant-docs.json b/packages/spec/variant-docs.json
index 1c44135d2fa..a0d2e34b2ff 100644
--- a/packages/spec/variant-docs.json
+++ b/packages/spec/variant-docs.json
@@ -158,6 +158,18 @@
"content/docs/ui/forms.mdx"
],
"note": "Newly VISIBLE to this gate at #4001 批 18, not newly authorable: the four variants were always authored on `FormView.submitBehavior`, but the shape was a plain `z.union`, which this walk cannot recognise as discriminated. Batch 18 made it `z.discriminatedUnion('kind')` so a bad key inside one arm reports `unrecognized_keys` at the key's own path instead of an `invalid_union` whose prescription the renderers flatten away (#5014) — and that turned an already-authorable vocabulary into a governed one. GOVERNED rather than exempt: ui/forms.mdx names all four (line 20 lists them together, and the `thank-you` and `redirect` shapes have worked examples)."
+ },
+ {
+ "key": "fate:attribution|column-drop|mirror-deletion|report",
+ "label": "host fate supplement row (ADR-0131 D10 fate)",
+ "exempt": "generated-reference-only",
+ "reason": "Authored by a host operator for one command, `os migrate organization-ownership --fate-supplement FILE` (ADR-0131 D10, the inventory's host supplement); the file is the spec's OrganizationOwnershipSupplementSchema. The gap, stated plainly: no hand-written page documents the v18 ceremony's inputs at all (measured when this entry was added: no file under content/docs names `--fate-supplement` or `--backup-taken`), so the generated content/docs/references/system/fate-supplement.mdx and the command's own --help are the reference until the ceremony's operator guide is written."
+ },
+ {
+ "key": "kind:holder|parent|subject",
+ "label": "host fate supplement attribution anchor",
+ "exempt": "generated-reference-only",
+ "reason": "The fate-3 anchor inside a host fate supplement row; see the `fate:` entry for the same command, the same file and the same documentation gap."
}
]
}