Skip to content

Commit 538f923

Browse files
fix(plugin-audit,cli): activity is written only when an organization is derivable, never as a NULL row; the ceremony deletes the existing NULL rows no anchor attributes (ADR-0131 D9, C8 stage 8b-0c) (#24429)
Part of #24343 Part of #15212 Clause-②: no C8 stage 8b-0c. It executes ruling record 6257510973 item 3 (Q3 A) on #24377: activity is written only when an organization is derivable, and never as a NULL row. #24343 stays open: stages 8b-0d and 8b-0f remain. ## What changes **`@objectstack/plugin-audit`: the activity mirror** (`audit-writers.ts`, the CRUD mirror's derivation). - The mirror keeps its derivation, in this order: the record's organization, then the session's, then the `tenancy` service's Default Organization under `single`. - When none of them is derivable, the mirror now writes **no** `sys_activity` row. 8b-0b's `?? null` arm is gone. - The ledger row (`sys_audit_log`) is still written, with its D7 `tenant_id` attribution. - An activity table with no `organization_id` column takes the row unstamped, as before. **`@objectstack/cli`: the ownership ceremony.** - `sys_activity`'s inventory row keeps fate 3 (`attribution`, with the subject anchor). It now also declares a platform-only `underivableDeparture` (`underivable-activity`). - On every posture but `single`: - The plan counts the NULL rows no anchor attributes as that departure, instead of reporting them as unattributable. - The plan warns before the first write that `--apply` deletes them. - The apply's `mirror-deletion` step deletes them after attribution. It reads them from a fresh plan, and blocks with nothing deleted if that plan still derives an owner for a NULL row or still reports one. - The post-check reports the count as the table's `deleted`. - Under `single`, the Default Organization takes those rows, as before, and nothing is deleted. - A checkpointed re-run deletes any such row that has appeared since. **Why a new inventory key and not a `mirror-deletion` departure.** A declared departure is classified **before** the anchors (`walkNullRows`), and its `where` runs over every NULL row. Used here, it would delete the rows the subject anchor can attribute, and under `single` every row the Default Organization takes. The new key is classified **after** the anchors and the Default Organization. It is platform-only: the spec's supplement row is unchanged (`OrganizationOwnershipSupplementEntrySchema`), so a host still cannot declare a deletion. `organization-ownership-inventory.test.ts` lists the key with the other platform-only keys. **Tests that model `single`.** 19 plugin-audit suites boot the audit writer without AuthPlugin, with org-less records and sessions. Under this change they would model a wall and write no activity. They now boot the `single` composition they test: `src/__tests__/single-posture-tenancy.ts`, which is the `tenancy` service reduced to `defaultOrgId()`, or `getDefaultOrganizationId` for the direct installs. Their assertions are unchanged. ## Measurements (on `origin/main` `71fa166e`) - **Producers of `sys_activity` rows.** `git grep` over `packages/**` (non-test) finds one insert: `audit-writers.ts` `persistAuditTrailRow`, `sys.object('sys_activity').create`. `installActivityStampHook` stamps inserts and creates none. An app's own server-side action can still insert into the table directly (`sys-activity.object.ts` names hotcrm sites); that path goes through the engine, which is 8b's surface, not this stage's. - **Clause-②: no.** No published schema changes. The rows that disappear are the walled NULL rows. A walled reader never received them: Layer 0's `organization_id = active organization` predicate excludes NULL, and a row is readable only if its parent record is (`activity-read-visibility.ts`). No docs page, skill or spec description promises an activity row for an org-less record (grep over `content/docs`, `skills`, `packages/spec/src`: 0 hits). - **Size.** 471 insertions and 21 deletions in 28 files, under the 3,000-line threshold. ## Verification (head `36af4c05`) - `pnpm --filter @objectstack/plugin-audit test`: 56 files, 898 tests passed. Before the suites booted `single`, the same run had 160 failures in 19 files, all caused by the missing activity rows. - `pnpm --filter @objectstack/plugin-audit typecheck` and `pnpm --filter @objectstack/cli typecheck`: exit 0. - `pnpm --filter @objectstack/cli exec vitest run --project unit`: 309 files passed. 2 files failed only on the precondition "packages/cli is not built". After `pnpm --filter @objectstack/cli build` they passed (2 files, 29 tests). - Ceremony integration tier (`--project integration`), the 15 `organization-ownership*` / `apply-organization-ownership-gate` suites under `commands/migrate/` and `utils/`: 13 files passed, 1 skipped; 68 tests passed, 1 skipped. - New `organization-ownership-apply.underivable-activity.test.ts` (sqlite fixture): - walled (`isolated`): the plan reports the departure (2 rows) and the warning. The apply attributes 1 row, deletes 2 and leaves `remaining 0`. The delete runs after the attribution UPDATE. A second run writes nothing to the table. - `single`: 3 rows attributed (2 to the Default Organization), 0 deleted, no warning. - `packages/qa/dogfood/test/identity-admin-fields-org-peer.dogfood.test.ts` (showcase boot, `single`; the activity reads about `sys_user` rows as an organization member): 12/12 passed. - Ablations, each through `scripts/ablation-replace.mjs` with the restore proven (blob equals HEAD, `git diff HEAD` empty): - mirror `mirrorsActivity && activityOrganizationKnown ?` → `mirrorsActivity ?`: 1 red (`writes no activity row under a wall…`). - plan: the underivable-departure branch in `walkNullRows` disabled (`false &&` prepended): 3 walled cases red. - plan `posture !== 'single'` arm removed: the `single` case red. - Gates. 72 derived families plus the 53-family artifact roster, each exit code captured before any pipe. `dispatch-gates --ran` with coded exits: 72 accounted for, 70 run, 2 NOT MEASURED (exit 3). - NOT MEASURED, exit 3: `check:dual-build-cjs-loads` and `check:published-readme-exports` (no full build); `check-plugin-teardown-shape --self-test` (shallow clone). - NOT MEASURED, no pull request context (exit 2, or 1 for the queue guard): `check-closing-target-claim`, `check-partof-closing-keyword`, `check-single-claim-paths`, `check-governed-queue-guard`. - Every other family exited 0. CI runs all of them. ## Acceptance notes - `derivableActivityOrganization()` still catches a throwing `tenancy.defaultOrgId()` and reads it as "none". Under `single` such a throw now drops the activity row; before, it wrote a NULL row. The ledger row is still written. A throwing tenancy service under `single` is not measured to occur. - Nothing here touches identity mail (8b-0d), `sys_secret` or the settings audit (8b-0e), the self-registration grant (8b-0f), or the engine exemption (8b). 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_018oXkooW8d1rtxYGFYwnAiT --- _Generated by [Claude Code](https://claude.ai/code/session_018oXkooW8d1rtxYGFYwnAiT)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent db1f1fd commit 538f923

28 files changed

Lines changed: 471 additions & 21 deletions
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"@objectstack/plugin-audit": patch
3+
"@objectstack/cli": patch
4+
---
5+
6+
fix: an activity row is written only when an organization is derivable, never with a NULL organization, and the v18 ownership ceremony deletes the existing NULL activity rows it cannot attribute (ADR-0131 D9)
7+
8+
Clause-②: no
9+
10+
- **`@objectstack/plugin-audit`.** The audit writer's activity mirror (`sys_activity`) takes its organization from the record, then from the acting session, then from the `tenancy` service's Default Organization under `single`. When none of them names one, the mirror now writes no activity row at all. Before, it wrote a row with `organization_id` NULL. Under a wall that row was readable by no organization member. The change is still recorded: the compliance ledger row (`sys_audit_log`) is written as before, with its `tenant_id` attribution. Under `single` the Default Organization is always derivable, so nothing changes there. A record that names its organization keeps its activity row, with that organization.
11+
- **`@objectstack/cli`.** `os migrate organization-ownership` still attributes a NULL `sys_activity` row when its record carries an organization. On a walled database (any posture but `single`), the mirror-deletion step now deletes the NULL rows no anchor attributes, such as activity about a sign-up's `sys_user`. Before, those rows were reported as unattributable and held certification. They are 14-day telemetry that the mirror no longer writes, and `sys_audit_log` keeps each change. The plan counts them as the departure `underivable-activity`, and warns before the first write that `--apply` deletes them. The apply report lists the count as the table's `deleted`. Under `single` the Default Organization takes these rows, as before, and nothing is deleted.

‎packages/cli/src/utils/organization-ownership-apply.ts‎

Lines changed: 88 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import {
2828
predicateSql,
2929
PRIMARY_DATABASE,
3030
Sql,
31+
underivableDepartureOf,
3132
type AttributionSink,
3233
type MetadataConflict,
3334
type MetadataKeyRow,
@@ -117,7 +118,11 @@ import type { DeclaredSharingRuleSkip } from '@objectstack/plugin-sharing';
117118
* C3 stage 9): every grant naming a mirror row is renamed to the rule's
118119
* NAME, which the registry must resolve, and only then is the mirror
119120
* deleted ({@link runDeclaredRuleMirrorDeletion}). It runs again on a
120-
* checkpointed run while a mirror stands.
121+
* checkpointed run while a mirror stands. Before them, a fate-3 table's
122+
* `underivableDeparture` (`sys_activity`, ruling record 6257510973 item
123+
* 3): the NULL rows no anchor attributes once attribution is done are
124+
* deleted — never under `single`, where the Default Organization takes
125+
* them ({@link runUnderivableDeletion}).
121126
* 4h. `history-renumber` — seat answer Q2 A: `sys_metadata_history`'s
122127
* `event_seq` and every `(type, name, version)` unique environment-wide
123128
* before the column goes. Every lineage still tagged to an organization
@@ -184,7 +189,7 @@ import type { DeclaredSharingRuleSkip } from '@objectstack/plugin-sharing';
184189
*
185190
* ## What it never does
186191
*
187-
* ⛔ Delete a row outside fate 2. ⛔ Give an unattributable row an owner:
192+
* ⛔ Delete a row outside fate 2 and a declared `underivableDeparture`. ⛔ Give an unattributable row an owner:
188193
* owners come only from the plan's anchors and, under `single`, the Default
189194
* Organization. ⛔ Create any organization but the Default Organization, and
190195
* that one only under `single` on a database that holds none. ⛔ Run at boot:
@@ -2526,6 +2531,11 @@ async function runMirrorDeletion(run: Run): Promise<StepReport> {
25262531
const kept = Math.max(0, table.rows!.total - (run.deleted.get(table.object) ?? 0));
25272532
if (kept > 0) notes.push(`${table.object}: ${kept} row(s) are not mirrors (a named category or their non-seeded provenance claims them) and stay.`);
25282533
}
2534+
const underivable = await runUnderivableDeletion(run);
2535+
total += underivable.deleted;
2536+
if (underivable.blockers.length) {
2537+
return { step: 'mirror-deletion', status: 'blocked', summary: { deleted: total }, blockers: underivable.blockers, notes };
2538+
}
25292539
const declared = await runDeclaredRuleMirrorDeletion(run);
25302540
if (declared.status !== 'done') return { ...declared, summary: { ...declared.summary, deleted: total }, notes: [...notes, ...declared.notes] };
25312541
return {
@@ -2537,6 +2547,65 @@ async function runMirrorDeletion(run: Run): Promise<StepReport> {
25372547
};
25382548
}
25392549

2550+
// ── Step 4, continued: the rows no anchor attributes (ruling record 6257510973 item 3) ─
2551+
2552+
/**
2553+
* The NULL rows of a fate-3 table that declares an `underivableDeparture`
2554+
* (`sys_activity`: activity is never a NULL row, and the existing ones are
2555+
* deleted): once attribution has written every owner it derives, every row
2556+
* still NULL is one of them. Read from a fresh plan, so the delete takes
2557+
* exactly what that plan classifies: a table on which it still derives an
2558+
* owner, or reports a row, blocks with nothing deleted. Never under `single`
2559+
* ({@link underivableDepartureOf}).
2560+
*/
2561+
/** Whether the plan this run started from counts a row an `underivableDeparture` deletes. */
2562+
function underivableRowsDue(run: Run): boolean {
2563+
return run.plan.tables.some((t) => {
2564+
const entry = mirrorEntry(run, t.object);
2565+
const id = entry ? underivableDepartureOf(entry, run.opts.posture)?.id : undefined;
2566+
return id !== undefined && t.departures.some((d) => d.id === id && d.rows > 0);
2567+
});
2568+
}
2569+
2570+
async function runUnderivableDeletion(run: Run): Promise<{ deleted: number; blockers: string[] }> {
2571+
const declares = (object: string): string | undefined => {
2572+
const entry = mirrorEntry(run, object);
2573+
return entry ? underivableDepartureOf(entry, run.opts.posture)?.id : undefined;
2574+
};
2575+
if (!run.plan.tables.some((t) => t.physical === 'present' && declares(t.object))) return { deleted: 0, blockers: [] };
2576+
const plan = await planFor(run);
2577+
let deleted = 0;
2578+
const blockers: string[] = [];
2579+
for (const table of plan.tables) {
2580+
const id = declares(table.object);
2581+
const leaving = id ? table.departures.find((d) => d.id === id) : undefined;
2582+
if (!leaving || leaving.rows === 0 || table.physical !== 'present') continue;
2583+
const deriving = Number(table.fateCounts.rowsToAttribute ?? 0);
2584+
if (deriving > 0 || table.unattributable.length > 0 || leaving.rows !== table.rows!.organizationNull) {
2585+
blockers.push(
2586+
`${table.object}: ${leaving.rows} row(s) no anchor attributes are due for deletion, but the table still holds ` +
2587+
`${table.rows!.organizationNull} NULL row(s) (${deriving} an anchor attributes, ${table.unattributable.length} ` +
2588+
'reported), so nothing was deleted. Re-run --apply: the attribution step runs again first.',
2589+
);
2590+
continue;
2591+
}
2592+
for (const physical of table.tables) {
2593+
const { sql } = databaseFor(run, physical);
2594+
const where = `${sql.q(ORG, physical)} IS NULL`;
2595+
const count = (): Promise<number> => sql.count(physical, `SELECT COUNT(*) AS n FROM ${sql.q(physical, physical)} WHERE ${where}`);
2596+
const before = await count();
2597+
if (before === 0) continue;
2598+
await execute(run, physical, `DELETE FROM ${sql.q(physical, physical)} WHERE ${where}`, []);
2599+
const left = await count();
2600+
if (left !== 0) throw new Error(`The delete on ${physical} left ${left} organization-less row(s) behind; nothing past this table was deleted.`);
2601+
run.deleted.set(table.object, (run.deleted.get(table.object) ?? 0) + before);
2602+
deleted += before;
2603+
run.opts.onProgress?.(`mirror deletion: ${before} organization-less row(s) of ${physical} no anchor attributes`);
2604+
}
2605+
}
2606+
return { deleted, blockers };
2607+
}
2608+
25402609
// ── Step 4, continued: the declared sharing-rule mirrors (ADR-0131 C3 stage 9) ─
25412610

25422611
const SHARING_RULES = 'sys_sharing_rule';
@@ -3097,6 +3166,23 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise<Or
30973166
// checkpointed run while one stands: a database whose ceremony completed on
30983167
// a release whose boot still seeded them carries them (advisory then), and
30993168
// the step that deletes them is this release's.
3169+
// So are the rows no anchor attributes, while one stands: a row written
3170+
// NULL since the run that finished the step is one too.
3171+
if (done && step === 'mirror-deletion' && underivableRowsDue(run)) {
3172+
const again = await runUnderivableDeletion(run);
3173+
const report: StepReport = again.blockers.length
3174+
? { step, status: 'blocked', summary: { deleted: again.deleted }, blockers: again.blockers, notes: [] }
3175+
: { step, status: 'done', summary: { deleted: again.deleted }, blockers: [], notes: [] };
3176+
steps.push(report);
3177+
if (report.status !== 'done') {
3178+
stopped = true;
3179+
continue;
3180+
}
3181+
checkpoint.completed[step] = { at: now().toISOString(), summary: report.summary };
3182+
await opts.ledger.write(checkpoint, { complete: false, blocking: 0, advisory: 0 });
3183+
run.columns.clear();
3184+
if (!(await declaredRuleMirrorsDue(run))) continue;
3185+
}
31003186
if (done && step === 'mirror-deletion' && (await declaredRuleMirrorsDue(run))) {
31013187
const again = await runDeclaredRuleMirrorDeletion(run);
31023188
steps.push(again);
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The ADR-0131 D10 `--apply` on `sys_activity`'s organization-less rows
5+
* (ruling record 6257510973 item 3, Q3 A): activity is written only when an
6+
* organization is derivable, never as a NULL row, and the ceremony deletes the
7+
* existing NULL rows no anchor attributes — 14-day telemetry, whose change the
8+
* deployment ledger (`sys_audit_log`, D7) keeps.
9+
*
10+
* The fixture is a sign-up's measured shape: activity about a `sys_user` (an
11+
* object with no organization column) written NULL, beside a NULL activity
12+
* row about an organization's record and an activity row already stamped.
13+
*
14+
* - Walled (`isolated`): the row about the organization's record takes its
15+
* organization from the subject anchor; the two about the user, which no
16+
* anchor attributes, are deleted by the mirror-deletion step; the stamped
17+
* row is untouched. The plan says so before the first write, and the
18+
* verdict certifies with nothing remaining.
19+
* - `single`: the Default Organization takes the user's rows, as a
20+
* single-posture boot gives it every row it writes; nothing is deleted.
21+
*/
22+
23+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
24+
import Database from 'better-sqlite3';
25+
import { mkdtempSync, rmSync } from 'node:fs';
26+
import { tmpdir } from 'node:os';
27+
import { join } from 'node:path';
28+
import { buildOrganizationOwnershipPlan, inventoryDigest, type OrganizationOwnershipPlan } from './organization-ownership-plan.js';
29+
import {
30+
applyOrganizationOwnership,
31+
reviewedConflictsOf,
32+
type ApplyExecutor,
33+
type CeremonyCheckpoint,
34+
type CeremonyLedgerPort,
35+
type ColumnDropPort,
36+
type OrganizationOwnershipApplyReport,
37+
} from './organization-ownership-apply.js';
38+
39+
let dir: string;
40+
let db: Database.Database;
41+
/** Every write statement the ceremony issued, in order. */
42+
let writes: string[];
43+
44+
function executor(): ApplyExecutor {
45+
return {
46+
dialect: 'sqlite',
47+
client: 'better-sqlite3',
48+
async query(sql, params = []) {
49+
return db.prepare(sql).all(...(params as unknown[])) as Record<string, unknown>[];
50+
},
51+
async execute(sql, params = []) {
52+
writes.push(sql);
53+
db.prepare(sql).run(...(params as unknown[]));
54+
},
55+
};
56+
}
57+
58+
const columnDrops: ColumnDropPort = {
59+
async drop(columns) {
60+
return columns.map(({ table, column }) => {
61+
db.exec(`ALTER TABLE "${table}" DROP COLUMN "${column}"`);
62+
return { table, column, outcome: 'dropped' as const };
63+
});
64+
},
65+
};
66+
67+
function ledger(): CeremonyLedgerPort {
68+
let stored: CeremonyCheckpoint | null = null;
69+
return {
70+
async read() {
71+
return stored ? (JSON.parse(JSON.stringify(stored)) as CeremonyCheckpoint) : null;
72+
},
73+
async write(checkpoint) {
74+
stored = JSON.parse(JSON.stringify(checkpoint)) as CeremonyCheckpoint;
75+
},
76+
};
77+
}
78+
79+
const database = (): string => join(dir, 'fixture.db');
80+
81+
const plan = (posture: string): Promise<OrganizationOwnershipPlan> =>
82+
buildOrganizationOwnershipPlan({ reader: executor(), posture, database: database() });
83+
84+
/** Plan, then apply that reviewed plan, as the command does. */
85+
async function apply(posture: string, l = ledger()): Promise<OrganizationOwnershipApplyReport> {
86+
const reviewed = await plan(posture);
87+
return applyOrganizationOwnership({
88+
executor: executor(),
89+
posture,
90+
database: database(),
91+
reviewedPlan: {
92+
file: 'plan.json',
93+
generatedAt: reviewed.generatedAt,
94+
database: database(),
95+
inventoryDigest: inventoryDigest(),
96+
posture,
97+
conflicts: reviewedConflictsOf(reviewed),
98+
},
99+
catalog: { async resolve() { return undefined; } },
100+
door: {
101+
async save(type, name) {
102+
throw new Error(`no environment save is expected here (${type} ${name})`);
103+
},
104+
},
105+
templates: { async find() { return undefined; } },
106+
views: { async list() { return []; } },
107+
lock: { async verdict() { return { editable: true }; } },
108+
columnDrops,
109+
ledger: l,
110+
now: () => new Date('2026-10-19T00:00:00Z'),
111+
});
112+
}
113+
114+
const activity = (): Record<string, unknown>[] =>
115+
db.prepare(`SELECT id, organization_id FROM sys_activity ORDER BY id`).all() as Record<string, unknown>[];
116+
117+
beforeEach(() => {
118+
dir = mkdtempSync(join(tmpdir(), 'os-d10-underivable-activity-'));
119+
db = new Database(database());
120+
writes = [];
121+
db.exec(`
122+
CREATE TABLE sys_organization (id TEXT PRIMARY KEY, slug TEXT);
123+
INSERT INTO sys_organization VALUES ('org_default', 'default'), ('org_a', 'acme');
124+
CREATE TABLE sys_user (id TEXT PRIMARY KEY, name TEXT, email TEXT);
125+
INSERT INTO sys_user VALUES ('usr_1', 'Ada', 'ada@example.com');
126+
CREATE TABLE crm_deal (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT);
127+
INSERT INTO crm_deal VALUES ('deal_1', 'org_a', 'Acme renewal');
128+
CREATE TABLE sys_activity (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, object_name TEXT, record_id TEXT);
129+
INSERT INTO sys_activity VALUES
130+
('act_deal_null', NULL, 'created', 'crm_deal', 'deal_1'),
131+
('act_deal_stamped', 'org_a', 'updated', 'crm_deal', 'deal_1'),
132+
('act_user_created', NULL, 'created', 'sys_user', 'usr_1'),
133+
('act_user_updated', NULL, 'updated', 'sys_user', 'usr_1');
134+
`);
135+
});
136+
137+
afterEach(() => {
138+
db.close();
139+
rmSync(dir, { recursive: true, force: true });
140+
});
141+
142+
describe('sys_activity rows no organization is derivable for (ruling record 6257510973 item 3)', () => {
143+
it('walled: the plan counts the two underivable rows as departing, says so before the first write, and reports none', async () => {
144+
const reviewed = await plan('isolated');
145+
const table = reviewed.tables.find((t) => t.object === 'sys_activity')!;
146+
expect(table.fateCounts).toMatchObject({ rowsToAttribute: 1, departingRows: 2, unattributableRows: 0 });
147+
expect(table.departures).toEqual([expect.objectContaining({ id: 'underivable-activity', as: 'mirror-deletion', rows: 2 })]);
148+
expect(table.unattributable).toEqual([]);
149+
expect(table.notNull.willReceive).toBe(true);
150+
expect(reviewed.warnings).toEqual([expect.stringMatching(/^sys_activity holds 2 organization-less row\(s\) no anchor attributes: .*--apply deletes them/)]);
151+
});
152+
153+
it('walled: the anchored row takes its organization, the two about the user are deleted, the stamped row stays; nothing remains', async () => {
154+
const report = await apply('isolated');
155+
156+
expect([report.complete, report.blocking]).toEqual([true, 0]);
157+
const table = report.tables.find((t) => t.object === 'sys_activity')!;
158+
expect(table).toMatchObject({ attributed: 1, deleted: 2, remaining: 0, unattributable: [], departures: [] });
159+
expect(table.after).toEqual({ total: 2, organizationNull: 0 });
160+
expect(activity()).toEqual([
161+
{ id: 'act_deal_null', organization_id: 'org_a' },
162+
{ id: 'act_deal_stamped', organization_id: 'org_a' },
163+
]);
164+
// Deleted after attribution, by the mirror-deletion step, NULL rows only.
165+
const deletes = writes.filter((w) => /^DELETE FROM "sys_activity"/i.test(w));
166+
expect(deletes).toEqual(['DELETE FROM "sys_activity" WHERE "organization_id" IS NULL']);
167+
expect(writes.findIndex((w) => /^UPDATE "sys_activity"/i.test(w))).toBeLessThan(writes.indexOf(deletes[0]!));
168+
expect(report.steps.find((s) => s.step === 'mirror-deletion')).toMatchObject({ status: 'done', summary: { deleted: 2 } });
169+
});
170+
171+
it('walled: a second run finds nothing to delete and writes nothing to the table', async () => {
172+
const l = ledger();
173+
await apply('isolated', l);
174+
writes = [];
175+
const again = await apply('isolated', l);
176+
expect([again.complete, again.blocking]).toEqual([true, 0]);
177+
expect(writes.filter((w) => /sys_activity/.test(w))).toEqual([]);
178+
});
179+
180+
it('single: the Default Organization takes the user\'s rows, and nothing is deleted', async () => {
181+
const reviewed = await plan('single');
182+
const table = reviewed.tables.find((t) => t.object === 'sys_activity')!;
183+
expect(table.departures).toEqual([]);
184+
expect(table.attribution).toMatchObject({ 'default-organization': 2 });
185+
expect(reviewed.warnings).toEqual([]);
186+
187+
const report = await apply('single');
188+
expect([report.complete, report.blocking]).toEqual([true, 0]);
189+
expect(report.tables.find((t) => t.object === 'sys_activity')).toMatchObject({ attributed: 3, deleted: 0, remaining: 0 });
190+
expect(activity()).toEqual([
191+
{ id: 'act_deal_null', organization_id: 'org_a' },
192+
{ id: 'act_deal_stamped', organization_id: 'org_a' },
193+
{ id: 'act_user_created', organization_id: 'org_default' },
194+
{ id: 'act_user_updated', organization_id: 'org_default' },
195+
]);
196+
expect(writes.filter((w) => /^DELETE FROM "sys_activity"/i.test(w))).toEqual([]);
197+
});
198+
});

‎packages/cli/src/utils/organization-ownership-inventory.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ describe('ADR-0131 D10 inventory — every censused object has one fate and a ci
204204
* platform states. Compile-time: both directions of `extends` must hold, so a
205205
* key added to either side without the other fails `tsc` on this file.
206206
*/
207-
type HostAuthoredKeys = Exclude<keyof InventoryEntry, 'census' | 'cloudCarried' | 'external'>;
207+
type HostAuthoredKeys = Exclude<keyof InventoryEntry, 'census' | 'cloudCarried' | 'external' | 'underivableDeparture'>;
208208
type SpecRowKeys = OrganizationOwnershipSupplementEntry extends infer Row ? (Row extends unknown ? keyof Row : never) : never;
209209
const KEYS_AGREE: [HostAuthoredKeys] extends [SpecRowKeys] ? ([SpecRowKeys] extends [HostAuthoredKeys] ? true : false) : false = true;
210210

@@ -244,7 +244,7 @@ describe('the inventory row and the spec\'s supplement row are one shape', () =>
244244
// anchor, category and departure form) is declared in the spec: a host can
245245
// author anything the platform's own rows say.
246246
const refused: string[] = [];
247-
for (const { census: _census, cloudCarried: _cloud, external: _external, ...row } of ORGANIZATION_OWNERSHIP_INVENTORY) {
247+
for (const { census: _census, cloudCarried: _cloud, external: _external, underivableDeparture: _underivable, ...row } of ORGANIZATION_OWNERSHIP_INVENTORY) {
248248
const result = OrganizationOwnershipSupplementEntrySchema.safeParse(row);
249249
if (!result.success) refused.push(`${row.object}: ${result.error.issues[0]?.message}`);
250250
}

0 commit comments

Comments
 (0)