Skip to content

Commit 6709a20

Browse files
committed
Merge remote-tracking branch 'origin/main' into claude/issue-22634-analytics-exposure-gate
2 parents 8ae8b87 + cc305a3 commit 6709a20

10 files changed

Lines changed: 2387 additions & 6 deletions
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/plugin-audit': patch
3+
---
4+
5+
`sys_activity.actor_name` is written: a user's create, update or delete records that user's display name on its activity row, so the record History tab names who acted instead of "Unknown user"
6+
7+
Clause-②: no
8+
9+
`sys_activity` declares `actor_name` and lists it among its highlight fields, because activity
10+
entries are denormalized snapshots read in time order. The audit writer filled `actor_id` and never
11+
`actor_name`, so every row carried the user's id and no name. A renderer that reads the name, such
12+
as the record History tab, showed "Unknown user" for every entry, for every user.
13+
14+
The writer now records the name when it writes the row. The name is the title of the user's
15+
`sys_user` row (its `name` column), read once per user and kept for 30 seconds. A predicate write
16+
over many rows costs one `sys_user` read for its actor, not one per row.
17+
18+
- **A user write.** `actor_name` is the user's display name. `actor_id` is unchanged, and the name
19+
is read for that same id. This includes a write the system authorized on behalf of a credited
20+
user (`attributedUserId`).
21+
- **A user whose name cannot be read** (no row, or a blank name). `actor_name` stays empty. The
22+
writer never puts the id in it.
23+
- **A write with no user** (boot, migration, a scheduled job, a flow running as the system).
24+
`actor_name` stays empty, as before. Per ADR-0118 D1 the system actor is `null`, and the UI
25+
renders the "System" label for it.
26+
- **An object with `enable.activities: false`.** No activity row is written and no name is read,
27+
as before.
28+
29+
Rows written before this change keep `actor_id` and no name. This change does not backfill them.
30+
Nothing you author changes: no key, option, export or type is added or removed.
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)