Skip to content

Commit cc305a3

Browse files
feat(cli): os migrate organization-ownership — the ADR-0131 D10 inventory and the read-only ceremony plan (C7a) (#22643)
Fixes #22617 Part of #15211 Clause-②: yes (widening) The Clause-② widening is a new read-only command surface. No existing command or verdict changes. ## What this adds (C7a of the ADR-0131 D10 ceremony) 1. **The inventory**: `packages/cli/src/utils/organization-ownership-inventory.ts`. It gives each object one D10 fate (column drop, mirror deletion, attribution through a parent anchor, or report) with its citation. 128 objects: | source | objects | column-drop | mirror-deletion | attribution | report | |---|---|---|---|---|---| | platform, first census (5479883784, at `00d8f6541b`) | 84 | 37 | 5 | 38 | 4 | | platform, added since (gated census on `main`) | 3 | 1 | 0 | 2 | 0 | | examples (the census's "28" = 28 files declaring 33 objects) | 33 | 0 | 0 | 31 | 2 | | cloud supplement (cloud-carried) | 8 | 0 | 0 | 0 | 8 | | **total** | **128** | 38 | 5 | 71 | 14 | - **#14570** (`sys_business_unit_member`) is read in: attribution through `business_unit_id` → `sys_business_unit`, citing 5536484221 and 6067123924. **#15086** (the business unit seeded with a NULL organization) is read in: attribution on `sys_business_unit` (D3; pointer 5536478573). - The ruled categories are counted per table by name: the `sys_metadata` presentational promotion with its conflict list (6020279837, records 6020163868 and 6020151485), the overlay duplicates (6071418113), the email-template promotion (6020178017), and the `sys_setting` global rung moving out (6051723395). - The `sys_metadata` family's schema change (6068052798) is fate 1 on all four objects. - A non-platform table that carries `organization_id` and has no inventory row takes the application-object default: attribution, which is the Default Organization under `single` and a report otherwise. 2. **`os migrate organization-ownership`**: the read-only plan (D10 ceremony item 1). For each table it gives: - the fate and its citation; - the row counts (total, NULL organization, stamped) and what the fate touches; - the ruled categories; - the derivable rows per anchor; - the rows whose owner cannot be derived, listed by id with the reason; - `notNull: { willReceive, reason }`. It writes the plan to a file (`--out`, default `organization-ownership-plan-TIMESTAMP.json`) and never overwrites one. `--json` also prints it. 3. **The completion-marker design**: below. It is design only, with no code. ### Why `os migrate organization-ownership` and not `os migrate --plan` - Bare `os migrate` is the schema-drift plan (#2186), and this PR leaves it unchanged. - Every data step in the family (`summary-nulls`, `files-to-references`, `security-catalog-overlays`, …) is a named subcommand. Its bare run is the read-only preview and `--apply` is its only writing mode. The ADR's `--plan` names this mode. - C7b adds `--apply` and the post-check to the same command. Until then it has no writing mode at all. ### Read-only, and refusal - **Read-only boot.** It uses the family's read-only boot (`deferSchemaDdl` + `readOnlyProbe`). It reads the physical database through the planned driver's raw seam, with SELECT statements only. - **Refusals.** It refuses the whole plan, names the table, and exits 1 with no file written when: - the dialect has no catalog statement; - the catalog or a table read fails; - a table lacks a column its fate reads; - a platform-prefixed table carries the column but has no inventory row; - a table name cannot be quoted; - the database holds none of the inventoried platform tables (the wrong `--database-url`). - **Absent tables.** An inventoried object with no table on this database is listed as `physical: 'absent'` with `rows: null`. It is never reported as a table of zero rows. - **Rotation shards.** `sys_activity` shards are folded onto the base object. - **Measured on a real database.** The family pin's served database (an `os serve` boot, then the next release's artifact) plans 20 tables: 9 column-drop, 11 attribution, and `sys_activity` folded from its shard. ### Where it lives, measured The inventory and the planner sit under `packages/cli/src/utils/`. oclif's `pattern` strategy turns every module under `src/commands/**` into a command, so a data module there would become one. The command is `src/commands/migrate/organization-ownership.ts`. The inventory is a typed TypeScript table rather than a JSON file, so the CLI ships it in `dist` and the planner and the pin read one spelling. ## The completion marker — design only (C7b builds it) ⛔ This PR contains no code that writes or reads the marker. This section is the design that C7b's last step writes and the v18 boot refusal reads. **Where it lives.** One row in `sys_migration`, the deployment-level migration-flag table (`platform-objects/src/system/migration-flag.ts`, #3617). The row's primary key is a new spec constant, `ORGANIZATION_OWNERSHIP_MIGRATION_ID = 'adr-0131-organization-ownership'`, which sits beside `FILE_REFERENCES_MIGRATION_ID` and `VALUE_SHAPES_MIGRATION_ID`. No new table. `sys_migration` is itself a D7 column-drop object. Its drop runs inside the ceremony before the marker is written, so the marker is always written to the table's final, tenant-less shape. **What it records.** It reuses the existing columns: - `applied_at`: when the apply finished. - `verified_at`: when the post-check passed. - `blocking`: the number of tables whose post-check did not complete. This is not the count of reported rows. Reported rows are D10 fate 4: they are named at boot and do not block it. - `details`: one JSON document: - `ceremonyVersion`: the integer `1`, the same `ceremonyVersion` this plan prints; - `inventoryDigest`: the sha256 the plan prints, which ties the marker to the inventory it executed; - `posture`; - `tables`: per table, `{ object, fate, remainingNull, notNull: 'applied' | 'withheld', reason }`. `verified_at` is set only by a post-check that re-ran the plan and found zero tables left mid-fate. **When it is written.** Only as the ceremony's last statement, after the post-check. An interrupted apply leaves no marker; its checkpoint lives in the ADR-0119 journal (`os migrate resume`). A fresh v18 database gets the marker when it is created, the way `attestFreshDatastore` attests the creation-attested migration ids. A database born on v18 has nothing to migrate. **How a v18 boot reads it.** This has the same fail-fast shape as ADR-0093 D5, with no escape hatch. - **When:** before schema sync and before any plugin `start()`. Additive sync could otherwise create tables on a database the boot is about to refuse. - **How:** a primary-key read of that one row, through the raw read seam, in the same pre-boot gate as the tenancy-posture refusal. - **What it decides:** - A database with no ObjectStack tables is fresh, and is attested. - A database that holds `sys_organization` but no marker row is refused, and the refusal names `os migrate organization-ownership --apply`. - A marker that is unreadable, malformed, at a `ceremonyVersion` below the runtime's required version, missing `verified_at`, or with `blocking > 0` is refused. - **Reads fail toward refused:** the same asymmetry as `readDataMigrationFlag`. - **The refusal text** says what was found, that the server is refusing to start, the command to run, and that a 17.x runtime is the way to keep 17.x semantics. - ⛔ There is no `OS_ALLOW_*` / `OS_SKIP_*` variable and no flag that skips the read (ADR-0131 D10 item 5). A marker with tables still reporting NULL rows boots. Those tables are listed at boot with counts and the remedy (fate 4), and they stay unconstrained. ## Verification (head `a3f7ab26`) - **Unit tier.** `pnpm --filter @objectstack/cli typecheck && pnpm --filter @objectstack/cli exec vitest run --project unit` → `Test Files 278 passed (278)`, `Tests 4115 passed (4115)`, VERDICT command-exit 0. This includes the new `organization-ownership-inventory.test.ts`, the enumeration pin: - the first census's 59 + 25 platform objects and 33 example objects, frozen as counted; - the live `PLATFORM_OBJECTS_BY_PACKAGE` registry, which reds by name for an object with no row; - `CLOUD_PROVIDED_OBJECT_NAMES`, each pinned as fate 4 and `cloudCarried`; - #14570 and #15086 each read in. - **Integration tier, the files this diff touches.** `vitest run --project integration src/utils/organization-ownership-plan.integration.test.ts src/utils/schema-migrate.one-shot-family.integration.test.ts -t 'organization-ownership|ADR-0131|missing from CALLERS'` → 14 passed. That covers: - the per-table plan against a fixture carrying each fate, under `isolated` and under `single`; - six refusals, each naming its table (uninventoried platform table, failing read, missing column, unsupported driver, not an ObjectStack database, unquotable name); - **the control:** across two runs the database file's sha256 and a full schema-and-row dump are byte-equal, and every statement issued starts with `SELECT`; - the family pin's no-write cases, now including this command (the database stays byte-identical and no missing SQLite file is created). - **Ablation.** Through `scripts/ablation-replace.mjs`, `if (hasPlatformObjectPrefix(base)) {` was changed to `… && false) {`. The mutation landed (anchor 1→0) and the uninventoried-table pin went red (1 failed, 9 passed). The restore was verified: blob equals HEAD and `git diff HEAD` is empty. - **Gates.** `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derives 65 commands. All 65 exit 0 on `a3f7ab26`, and `--ran` reconciles them: 65 derived, 65 run, 0 NOT-MEASURED, all with exit codes. - Before the fix commit, two were real findings on this diff and are fixed: `check:doc-authoring` (tracker numbers in citation strings, now ADR sections and ruling-record ids) and `check:dispatcher-error-vocabulary` (an unregistered `PLAN_REFUSED` code, dropped because the refusal carries its `reason`). - Three were unmet prerequisites (a shallow clone, unbuilt packages); after the clone was deepened and the packages built, all three ran green. - **Lint.** `eslint --no-inline-config` on the changed files exits 0. The repo-wide lint is CI's. ## Acceptance notes - **The second census.** #13564's 2026-09-02 ledger (5507087600) answers 404, and the 2026-09-03 cloud-side supplement lives in `cloud`, which this session cannot reach. Both are NOT MEASURED. The cloud rows are the names this repository records as cloud-provided. Cloud stays on v17 by ruling 6094175435, so they carry fate 4 and `cloudCarried`, and C10 assigns them their fates. - **Parent chains are one level.** A child whose parent is NULL now but attributable in the same apply is listed as unattributable, with the reason `parent row … has no organization`. C7b's apply runs attribution parents-first and its post-check re-plans. Under `single`, the Default Organization covers those rows anyway. - **`sys_notification_template` stays on attribution.** It is the conservative fate: no column tells a seeded row from an authored one, so no row is selected as a mirror on a guess. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01AB6Nvx7Ue6yzJypw7VTvkj --- _Generated by [Claude Code](https://claude.ai/code/session_01AB6Nvx7Ue6yzJypw7VTvkj)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d1d42ec commit cc305a3

7 files changed

Lines changed: 2011 additions & 0 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/cli': minor
3+
---
4+
5+
feat(cli): `os migrate organization-ownership` — the read-only plan of the v18 organization-ownership ceremony (ADR-0131 D10, ceremony item 1)
6+
7+
Clause-②: yes (widening)
8+
9+
A new command beside the `os migrate` family. It reads the database and writes a plan file the operator keeps; it never changes a row or a column, and it has no writing mode yet.
10+
11+
- **Per table:** the object's fate from the ADR-0131 D10 inventory (column drop, mirror deletion, attribution through a parent anchor, or report) with its citation, the row counts that fate touches, the rows whose owner cannot be derived (listed by id, with the reason), and whether the table will receive the `NOT NULL` constraint.
12+
- **Ruled populations are counted by name:** the organization-scoped `sys_metadata` rows that the ceremony promotes, with their conflict list; the customized email templates; and the global settings rung that moves to `sys_platform_setting`.
13+
- **Refuses, naming the table:** a table it cannot enumerate. That covers an unsupported driver, a read that fails, a column the fate needs that the table lacks, a platform table the inventory gives no fate, a name it cannot quote, and a database with no `sys_organization`. ⛔ It never reports such a table as empty. An inventoried object with no table on the database is listed as `absent`.
14+
- **Under the `single` posture,** a NULL row that no anchor derives is attributed to the Default Organization (slug `default`). Under a walled posture it is reported.
15+
16+
```bash
17+
os migrate organization-ownership # writes organization-ownership-plan-TIMESTAMP.json
18+
os migrate organization-ownership --out plan.json --json
19+
```
20+
21+
The plan file is never overwritten. Nothing about bare `os migrate` (the schema-drift plan) or any other subcommand changes.
Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { writeFile } from 'node:fs/promises';
4+
import { resolve } from 'node:path';
5+
import { Command, Flags } from '@oclif/core';
6+
import chalk from 'chalk';
7+
import { resolveTenancyPosture } from '@objectstack/types';
8+
import {
9+
printHeader,
10+
printSuccess,
11+
printError,
12+
printInfo,
13+
printStep,
14+
printWarning,
15+
createTimer,
16+
emitJson,
17+
errorCodeFields,
18+
isExitSignal,
19+
} from '../../utils/format.js';
20+
import { bootSchemaStack } from '../../utils/schema-migrate.js';
21+
import { resolvePlannedDriverExec } from '../../utils/unmanaged-tables.js';
22+
import {
23+
buildOrganizationOwnershipPlan,
24+
OrganizationOwnershipPlanRefusal,
25+
type OrganizationOwnershipPlan,
26+
} from '../../utils/organization-ownership-plan.js';
27+
28+
/**
29+
* `os migrate organization-ownership` — the v18 organization-ownership
30+
* ceremony's preflight (ADR-0131 D10, ceremony item 1): per table, the fate,
31+
* the row counts each fate will touch, the rows whose owner cannot be derived
32+
* (listed by id), and the tables that will and will not receive NOT NULL.
33+
*
34+
* ## Read-only, by construction
35+
*
36+
* The boot is the family's read-only boot (`deferSchemaDdl` + `readOnlyProbe`:
37+
* no DDL, no seed, no database file brought into existence), and the plan
38+
* itself issues SELECT statements only (`../../utils/organization-ownership-plan.ts`).
39+
* The one thing it writes is the plan FILE, on the operator's disk, which the
40+
* ADR asks for: "the plan is written to a file the operator keeps". It never
41+
* overwrites one — a kept plan is evidence.
42+
*
43+
* ## Refusal
44+
*
45+
* A table the plan cannot enumerate (a dialect with no catalog statement, a
46+
* read that fails, a column its fate reads that the table lacks, a
47+
* platform table the inventory names no fate for, a database that is not an
48+
* ObjectStack one) refuses the whole plan, naming it — exit 1 and no file. ⛔
49+
* Never a table reported as empty.
50+
*
51+
* ## Why a subcommand, not a flag on `os migrate`
52+
*
53+
* Bare `os migrate` is the schema-drift plan (#2186) and stays exactly that.
54+
* Every data step in this family is a named subcommand whose bare run is the
55+
* read-only preview and whose `--apply` is the only writing mode; the ADR's
56+
* `--plan` names this mode. C7b adds `--apply` (fate order: attribution, the
57+
* verified id-to-name rewrite, mirror deletion, column drops) and the
58+
* post-check to THIS command; until then it has no writing mode at all.
59+
*/
60+
export default class MigrateOrganizationOwnership extends Command {
61+
static override description =
62+
'The v18 organization-ownership ceremony\'s read-only plan (ADR-0131 D10): per table, the fate, the row counts, the ' +
63+
'rows whose owner cannot be derived (by id), and which tables will receive NOT NULL. Writes the plan to a file; ' +
64+
'never changes a row or a column. Refuses — naming it — any table it cannot enumerate.';
65+
66+
static override examples = [
67+
'$ os migrate organization-ownership',
68+
'$ os migrate organization-ownership --out plans/v18-ownership.json',
69+
'$ os migrate organization-ownership --database-url postgres://… --json',
70+
];
71+
72+
static override flags = {
73+
'database-url': Flags.string({
74+
description: 'Database URL to plan (defaults to $OS_DATABASE_URL / the project DB)',
75+
env: 'OS_DATABASE_URL',
76+
}),
77+
out: Flags.string({
78+
description:
79+
'Where to write the plan (default: organization-ownership-plan-TIMESTAMP.json in the current directory). ' +
80+
'An existing file is never overwritten',
81+
}),
82+
json: Flags.boolean({ description: 'Also print the plan document on stdout' }),
83+
};
84+
85+
async run(): Promise<void> {
86+
const { flags } = await this.parse(MigrateOrganizationOwnership);
87+
const timer = createTimer();
88+
const json = Boolean(flags.json);
89+
const refuse = async (payload: Record<string, unknown>, message: string): Promise<void> => {
90+
if (json) {
91+
await emitJson({ error: payload.reason === 'boot-failed' ? 'boot_failed' : 'plan_refused', ...payload }, 1, { compact: true });
92+
return;
93+
}
94+
printError(message);
95+
this.exit(1);
96+
};
97+
98+
let posture: string;
99+
try {
100+
posture = resolveTenancyPosture();
101+
} catch (error) {
102+
await refuse({ reason: 'posture-unresolved', detail: String((error as Error).message) }, (error as Error).message);
103+
return;
104+
}
105+
106+
if (!json) {
107+
printHeader('Migrate · organization-ownership (plan)');
108+
printStep('Booting data stack (read-only)…');
109+
}
110+
111+
let stack;
112+
try {
113+
stack = await bootSchemaStack({
114+
jsonOutput: json,
115+
...(flags['database-url'] ? { databaseUrl: flags['database-url'] } : {}),
116+
deferSchemaDdl: true,
117+
readOnlyProbe: true,
118+
});
119+
} catch (error: unknown) {
120+
await refuse({ reason: 'boot-failed', detail: String((error as Error)?.message ?? error) }, String((error as Error)?.message ?? error));
121+
return;
122+
}
123+
124+
let plan: OrganizationOwnershipPlan;
125+
try {
126+
const exec = resolvePlannedDriverExec(stack.driver);
127+
if (!stack.driver || !exec) {
128+
await refuse(
129+
{ reason: 'driver-unsupported', detail: 'no SQL driver with a raw-SQL seam is active' },
130+
'No SQL driver with a raw-SQL seam is active, so no table can be enumerated. The ceremony supports SQLite, PostgreSQL and MySQL.',
131+
);
132+
return;
133+
}
134+
const { normalizeRows } = await import('@objectstack/metadata-protocol');
135+
const client = (stack.driver.config as { client?: unknown } | undefined)?.client;
136+
plan = await buildOrganizationOwnershipPlan({
137+
reader: {
138+
client: client === undefined ? undefined : String(client),
139+
query: async (sql, params) => normalizeRows(await exec(sql, params ? [...params] : [])),
140+
},
141+
posture,
142+
database: stack.dbLabel,
143+
});
144+
} catch (error: unknown) {
145+
if (isExitSignal(error)) throw error;
146+
if (error instanceof OrganizationOwnershipPlanRefusal) {
147+
await refuse(
148+
{ reason: error.reason, ...(error.table ? { table: error.table } : {}), detail: error.message },
149+
`Refused${error.table ? ` (${error.table})` : ''}: ${error.message}`,
150+
);
151+
return;
152+
}
153+
await refuse(
154+
{ reason: 'plan-failed', detail: String((error as Error)?.message ?? error), ...errorCodeFields(error) },
155+
String((error as Error)?.message ?? error),
156+
);
157+
return;
158+
} finally {
159+
await stack.shutdown();
160+
}
161+
162+
const target = resolve(flags.out ?? `organization-ownership-plan-${plan.generatedAt.replace(/[:.]/g, '-')}.json`);
163+
try {
164+
await writeFile(target, `${JSON.stringify(plan, null, 2)}\n`, { flag: 'wx' });
165+
} catch (error) {
166+
await refuse(
167+
{ reason: 'plan-file-unwritable', file: target, detail: String((error as Error).message) },
168+
`The plan could not be written to ${target}: ${(error as Error).message}. An existing plan file is never overwritten.`,
169+
);
170+
return;
171+
}
172+
173+
if (json) {
174+
await emitJson({ file: target, plan });
175+
return;
176+
}
177+
printInfo(`Database: ${chalk.white(plan.database)} · posture ${chalk.white(plan.posture)} · ` +
178+
`Default Organization ${plan.defaultOrganization ? chalk.white(plan.defaultOrganization.id) : chalk.yellow('none')}`);
179+
console.log('');
180+
for (const table of plan.tables) {
181+
if (table.physical === 'absent') continue;
182+
const unattributable = table.unattributable.length;
183+
console.log(
184+
` ${chalk.white(table.object.padEnd(34))} ${table.fate.padEnd(16)} ` +
185+
`rows ${String(table.rows?.total ?? 0).padStart(7)} null ${String(table.rows?.organizationNull ?? 0).padStart(6)} ` +
186+
`${unattributable > 0 ? chalk.yellow(`unattributable ${unattributable}`) : ''}` +
187+
`${table.notNull.willReceive ? chalk.green(' NOT NULL') : ''}`,
188+
);
189+
}
190+
console.log('');
191+
const { summary } = plan;
192+
printInfo(
193+
`${summary.present} table(s) planned, ${summary.absent} inventoried object(s) with no table here; ` +
194+
`${summary.notNull.willReceive.length} will receive NOT NULL, ${summary.notNull.willNotReceive.length} will not.`,
195+
);
196+
if (summary.unattributableRows > 0) {
197+
printWarning(`${summary.unattributableRows} row(s) whose owner cannot be derived are listed by id in the plan file.`);
198+
}
199+
printSuccess(`Plan written to ${target} — nothing in the database was changed.`);
200+
console.log(chalk.dim(` ${timer.display()}`));
201+
}
202+
}
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The ADR-0131 D10 inventory's enumeration pin: every object of both #13564
5+
* censuses, of the platform registry as it stands, and of the cloud supplement
6+
* carries exactly one fate and a citation. A platform object that lands
7+
* without an inventory row turns this red by name — the plan would refuse its
8+
* table on a real database, and this says so before any database does.
9+
*/
10+
11+
import { describe, expect, it } from 'vitest';
12+
import { CLOUD_PROVIDED_OBJECT_NAMES, PLATFORM_OBJECTS_BY_PACKAGE } from '@objectstack/spec/system';
13+
import {
14+
ORGANIZATION_OWNERSHIP_FATES,
15+
ORGANIZATION_OWNERSHIP_INVENTORY,
16+
inventoryEntryFor,
17+
} from './organization-ownership-inventory.js';
18+
19+
/**
20+
* The first census's platform population — 59 carrying the column, 25 not —
21+
* as #13564 comment 5479883784 lists it (measured at `00d8f6541b`). Frozen:
22+
* it is a record of what was counted, not of the tree.
23+
*/
24+
const CENSUS_2026_08_31_PLATFORM_WITH_COLUMN = [
25+
// accidental (7) + global (1)
26+
'sys_file', 'sys_upload_session', 'sys_approval_request', 'sys_approval_action', 'sys_approval_approver',
27+
'sys_automation_run', 'sys_notification_delivery', 'sys_permission_set',
28+
// load-bearing confirmed beyond the ledger
29+
'sys_metadata', 'sys_view_definition',
30+
// split-verdict (7)
31+
'sys_position', 'sys_business_unit', 'sys_business_unit_member', 'sys_user_position',
32+
'sys_position_permission_set', 'sys_user_permission_set', 'sys_capability',
33+
// structural siblings (3)
34+
'sys_metadata_audit', 'sys_metadata_commit', 'sys_metadata_history',
35+
// not individually examined (39)
36+
'sys_activity', 'sys_approval_delegation', 'sys_approval_token', 'sys_attachment',
37+
'sys_audience_binding_suggestion', 'sys_audit_log', 'sys_comment', 'sys_email', 'sys_email_template',
38+
'sys_flow_dispatch', 'sys_http_delivery', 'sys_import_job', 'sys_inbox_message', 'sys_invitation', 'sys_job',
39+
'sys_job_queue', 'sys_job_run', 'sys_member', 'sys_metadata_activation', 'sys_migration', 'sys_migration_journal',
40+
'sys_notification', 'sys_notification_preference', 'sys_notification_receipt', 'sys_notification_subscription',
41+
'sys_notification_template', 'sys_presence', 'sys_record_share', 'sys_report_schedule', 'sys_saved_report',
42+
'sys_scim_connection_credential', 'sys_secret', 'sys_setting', 'sys_setting_audit', 'sys_share_link',
43+
'sys_sharing_rule', 'sys_team', 'sys_user_preference', 'sys_webhook',
44+
] as const;
45+
46+
const CENSUS_2026_08_31_PLATFORM_WITHOUT_COLUMN = [
47+
'sys_account', 'sys_api_key', 'sys_device_code', 'sys_jwks', 'sys_oauth_access_token', 'sys_oauth_application',
48+
'sys_oauth_client_assertion', 'sys_oauth_client_resource', 'sys_oauth_consent', 'sys_oauth_refresh_token',
49+
'sys_oauth_resource', 'sys_organization', 'sys_scim_connection_binding', 'sys_scim_group', 'sys_scim_group_member',
50+
'sys_scim_identity_tombstone', 'sys_scim_projection_grant', 'sys_scim_subject', 'sys_scim_user', 'sys_session',
51+
'sys_team_member', 'sys_two_factor', 'sys_user', 'sys_verification', 'sys_sso_provider',
52+
] as const;
53+
54+
/**
55+
* The census's "28 non-platform objects" — it counted the 28 files; they
56+
* declare these 33 objects, read at the census commit and unchanged on `main`.
57+
*/
58+
const CENSUS_2026_08_31_EXAMPLES = [
59+
'blank_note', 'crm_account', 'crm_activity', 'crm_contact', 'crm_lead', 'crm_opportunity',
60+
'crm_opportunity_line_item', 'dc_account', 'showcase_account', 'showcase_announcement', 'showcase_business_unit',
61+
'showcase_cascade', 'showcase_category', 'showcase_client_brief', 'showcase_contact', 'showcase_expense_line',
62+
'showcase_expense_report', 'showcase_ext_customer', 'showcase_ext_order', 'showcase_field_zoo', 'showcase_inquiry',
63+
'showcase_invoice', 'showcase_invoice_line', 'showcase_preference', 'showcase_private_note', 'showcase_product',
64+
'showcase_project', 'showcase_project_membership', 'showcase_semantic_zoo', 'showcase_semantic_zoo_legacy',
65+
'showcase_task', 'showcase_team', 'todo_task',
66+
] as const;
67+
68+
/**
69+
* #14570 and #15086, read in: each population's table has its own fate, citing
70+
* the record that read it in (a runtime string carries no tracker number).
71+
*/
72+
const READ_IN = { sys_business_unit_member: '6067123924', sys_business_unit: '5536478573' } as const;
73+
74+
const unlisted = (names: readonly string[]): string[] => names.filter((name) => inventoryEntryFor(name) === undefined);
75+
76+
describe('ADR-0131 D10 inventory — every censused object has one fate and a citation', () => {
77+
it('the first census: 59 + 25 platform objects', () => {
78+
expect(CENSUS_2026_08_31_PLATFORM_WITH_COLUMN).toHaveLength(59);
79+
expect(CENSUS_2026_08_31_PLATFORM_WITHOUT_COLUMN).toHaveLength(25);
80+
expect(unlisted([...CENSUS_2026_08_31_PLATFORM_WITH_COLUMN, ...CENSUS_2026_08_31_PLATFORM_WITHOUT_COLUMN])).toEqual([]);
81+
});
82+
83+
it('the first census: the example objects of its 28 files', () => {
84+
expect(unlisted(CENSUS_2026_08_31_EXAMPLES)).toEqual([]);
85+
});
86+
87+
it('the platform registry as it stands — a platform object without a row reds here, by name', () => {
88+
const registered = Object.values(PLATFORM_OBJECTS_BY_PACKAGE).flat();
89+
expect(registered.length).toBeGreaterThan(80);
90+
expect(unlisted(registered)).toEqual([]);
91+
});
92+
93+
it('the cloud supplement: listed as cloud-carried, fate 4, never a guessed fate', () => {
94+
expect(CLOUD_PROVIDED_OBJECT_NAMES.length).toBeGreaterThan(0);
95+
expect(unlisted(CLOUD_PROVIDED_OBJECT_NAMES)).toEqual([]);
96+
for (const name of CLOUD_PROVIDED_OBJECT_NAMES) {
97+
expect(inventoryEntryFor(name), name).toMatchObject({ fate: 'report', cloudCarried: true });
98+
}
99+
});
100+
101+
it('#14570 and #15086 are read in, each with its own fate and citation', () => {
102+
for (const [object, record] of Object.entries(READ_IN)) {
103+
const entry = inventoryEntryFor(object);
104+
expect(entry?.fate, object).toBe('attribution');
105+
expect(entry?.citation, object).toContain(record);
106+
}
107+
});
108+
109+
it('one row per object, a fate from the four, a non-empty citation', () => {
110+
const seen = new Set<string>();
111+
for (const entry of ORGANIZATION_OWNERSHIP_INVENTORY) {
112+
expect(seen.has(entry.object), `${entry.object} listed twice`).toBe(false);
113+
seen.add(entry.object);
114+
expect(ORGANIZATION_OWNERSHIP_FATES, entry.object).toContain(entry.fate);
115+
expect(entry.citation.trim().length, entry.object).toBeGreaterThan(20);
116+
expect(entry.census.length, entry.object).toBeGreaterThan(0);
117+
}
118+
});
119+
120+
it('each fate carries only the fields that fate reads', () => {
121+
for (const entry of ORGANIZATION_OWNERSHIP_INVENTORY) {
122+
if (entry.anchors) expect(entry.fate, `${entry.object}: anchors outside fate 3`).toBe('attribution');
123+
if (entry.mirror) expect(entry.fate, `${entry.object}: a mirror selector outside fate 2`).toBe('mirror-deletion');
124+
if (entry.notNullExempt || entry.cloudCarried) expect(entry.fate, entry.object).toBe('report');
125+
if (entry.fate === 'attribution') expect(entry.anchors, `${entry.object}: fate 3 names its anchors`).toBeDefined();
126+
}
127+
});
128+
129+
it('every parent and holder an anchor names is itself inventoried', () => {
130+
for (const entry of ORGANIZATION_OWNERSHIP_INVENTORY) {
131+
for (const anchor of entry.anchors ?? []) {
132+
const target = anchor.kind === 'parent' ? anchor.parentObject : anchor.kind === 'holder' ? anchor.holderObject : null;
133+
if (target) expect(inventoryEntryFor(target), `${entry.object} → ${target}`).toBeDefined();
134+
}
135+
}
136+
});
137+
});

0 commit comments

Comments
 (0)