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." } ] }