Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/24343-8b0c-activity-never-null.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@objectstack/plugin-audit": patch
"@objectstack/cli": patch
---

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)

Clause-②: no

- **`@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.
- **`@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.
90 changes: 88 additions & 2 deletions packages/cli/src/utils/organization-ownership-apply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
predicateSql,
PRIMARY_DATABASE,
Sql,
underivableDepartureOf,
type AttributionSink,
type MetadataConflict,
type MetadataKeyRow,
Expand Down Expand Up @@ -117,7 +118,11 @@ import type { DeclaredSharingRuleSkip } from '@objectstack/plugin-sharing';
* C3 stage 9): every grant naming a mirror row is renamed to the rule's
* NAME, which the registry must resolve, and only then is the mirror
* deleted ({@link runDeclaredRuleMirrorDeletion}). It runs again on a
* checkpointed run while a mirror stands.
* checkpointed run while a mirror stands. Before them, a fate-3 table's
* `underivableDeparture` (`sys_activity`, ruling record 6257510973 item
* 3): the NULL rows no anchor attributes once attribution is done are
* deleted — never under `single`, where the Default Organization takes
* them ({@link runUnderivableDeletion}).
* 4h. `history-renumber` — seat answer Q2 A: `sys_metadata_history`'s
* `event_seq` and every `(type, name, version)` unique environment-wide
* before the column goes. Every lineage still tagged to an organization
Expand Down Expand Up @@ -184,7 +189,7 @@ import type { DeclaredSharingRuleSkip } from '@objectstack/plugin-sharing';
*
* ## What it never does
*
* ⛔ Delete a row outside fate 2. ⛔ Give an unattributable row an owner:
* ⛔ Delete a row outside fate 2 and a declared `underivableDeparture`. ⛔ Give an unattributable row an owner:
* owners come only from the plan's anchors and, under `single`, the Default
* Organization. ⛔ Create any organization but the Default Organization, and
* that one only under `single` on a database that holds none. ⛔ Run at boot:
Expand Down Expand Up @@ -2526,6 +2531,11 @@ async function runMirrorDeletion(run: Run): Promise<StepReport> {
const kept = Math.max(0, table.rows!.total - (run.deleted.get(table.object) ?? 0));
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.`);
}
const underivable = await runUnderivableDeletion(run);
total += underivable.deleted;
if (underivable.blockers.length) {
return { step: 'mirror-deletion', status: 'blocked', summary: { deleted: total }, blockers: underivable.blockers, notes };
}
const declared = await runDeclaredRuleMirrorDeletion(run);
if (declared.status !== 'done') return { ...declared, summary: { ...declared.summary, deleted: total }, notes: [...notes, ...declared.notes] };
return {
Expand All @@ -2537,6 +2547,65 @@ async function runMirrorDeletion(run: Run): Promise<StepReport> {
};
}

// ── Step 4, continued: the rows no anchor attributes (ruling record 6257510973 item 3) ─

/**
* The NULL rows of a fate-3 table that declares an `underivableDeparture`
* (`sys_activity`: activity is never a NULL row, and the existing ones are
* deleted): once attribution has written every owner it derives, every row
* still NULL is one of them. Read from a fresh plan, so the delete takes
* exactly what that plan classifies: a table on which it still derives an
* owner, or reports a row, blocks with nothing deleted. Never under `single`
* ({@link underivableDepartureOf}).
*/
/** Whether the plan this run started from counts a row an `underivableDeparture` deletes. */
function underivableRowsDue(run: Run): boolean {
return run.plan.tables.some((t) => {
const entry = mirrorEntry(run, t.object);
const id = entry ? underivableDepartureOf(entry, run.opts.posture)?.id : undefined;
return id !== undefined && t.departures.some((d) => d.id === id && d.rows > 0);
});
}

async function runUnderivableDeletion(run: Run): Promise<{ deleted: number; blockers: string[] }> {
const declares = (object: string): string | undefined => {
const entry = mirrorEntry(run, object);
return entry ? underivableDepartureOf(entry, run.opts.posture)?.id : undefined;
};
if (!run.plan.tables.some((t) => t.physical === 'present' && declares(t.object))) return { deleted: 0, blockers: [] };
const plan = await planFor(run);
let deleted = 0;
const blockers: string[] = [];
for (const table of plan.tables) {
const id = declares(table.object);
const leaving = id ? table.departures.find((d) => d.id === id) : undefined;
if (!leaving || leaving.rows === 0 || table.physical !== 'present') continue;
const deriving = Number(table.fateCounts.rowsToAttribute ?? 0);
if (deriving > 0 || table.unattributable.length > 0 || leaving.rows !== table.rows!.organizationNull) {
blockers.push(
`${table.object}: ${leaving.rows} row(s) no anchor attributes are due for deletion, but the table still holds ` +
`${table.rows!.organizationNull} NULL row(s) (${deriving} an anchor attributes, ${table.unattributable.length} ` +
'reported), so nothing was deleted. Re-run --apply: the attribution step runs again first.',
);
continue;
}
for (const physical of table.tables) {
const { sql } = databaseFor(run, physical);
const where = `${sql.q(ORG, physical)} IS NULL`;
const count = (): Promise<number> => sql.count(physical, `SELECT COUNT(*) AS n FROM ${sql.q(physical, physical)} WHERE ${where}`);
const before = await count();
if (before === 0) continue;
await execute(run, physical, `DELETE FROM ${sql.q(physical, physical)} WHERE ${where}`, []);
const left = await count();
if (left !== 0) throw new Error(`The delete on ${physical} left ${left} organization-less row(s) behind; nothing past this table was deleted.`);
run.deleted.set(table.object, (run.deleted.get(table.object) ?? 0) + before);
deleted += before;
run.opts.onProgress?.(`mirror deletion: ${before} organization-less row(s) of ${physical} no anchor attributes`);
}
}
return { deleted, blockers };
}

// ── Step 4, continued: the declared sharing-rule mirrors (ADR-0131 C3 stage 9) ─

const SHARING_RULES = 'sys_sharing_rule';
Expand Down Expand Up @@ -3097,6 +3166,23 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise<Or
// checkpointed run while one stands: a database whose ceremony completed on
// a release whose boot still seeded them carries them (advisory then), and
// the step that deletes them is this release's.
// So are the rows no anchor attributes, while one stands: a row written
// NULL since the run that finished the step is one too.
if (done && step === 'mirror-deletion' && underivableRowsDue(run)) {
const again = await runUnderivableDeletion(run);
const report: StepReport = again.blockers.length
? { step, status: 'blocked', summary: { deleted: again.deleted }, blockers: again.blockers, notes: [] }
: { step, status: 'done', summary: { deleted: again.deleted }, blockers: [], notes: [] };
steps.push(report);
if (report.status !== 'done') {
stopped = true;
continue;
}
checkpoint.completed[step] = { at: now().toISOString(), summary: report.summary };
await opts.ledger.write(checkpoint, { complete: false, blocking: 0, advisory: 0 });
run.columns.clear();
if (!(await declaredRuleMirrorsDue(run))) continue;
}
if (done && step === 'mirror-deletion' && (await declaredRuleMirrorsDue(run))) {
const again = await runDeclaredRuleMirrorDeletion(run);
steps.push(again);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The ADR-0131 D10 `--apply` on `sys_activity`'s organization-less rows
* (ruling record 6257510973 item 3, Q3 A): activity is written only when an
* organization is derivable, never as a NULL row, and the ceremony deletes the
* existing NULL rows no anchor attributes — 14-day telemetry, whose change the
* deployment ledger (`sys_audit_log`, D7) keeps.
*
* The fixture is a sign-up's measured shape: activity about a `sys_user` (an
* object with no organization column) written NULL, beside a NULL activity
* row about an organization's record and an activity row already stamped.
*
* - Walled (`isolated`): the row about the organization's record takes its
* organization from the subject anchor; the two about the user, which no
* anchor attributes, are deleted by the mirror-deletion step; the stamped
* row is untouched. The plan says so before the first write, and the
* verdict certifies with nothing remaining.
* - `single`: the Default Organization takes the user's rows, as a
* single-posture boot gives it every row it writes; nothing is deleted.
*/

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import Database from 'better-sqlite3';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { buildOrganizationOwnershipPlan, inventoryDigest, type OrganizationOwnershipPlan } from './organization-ownership-plan.js';
import {
applyOrganizationOwnership,
reviewedConflictsOf,
type ApplyExecutor,
type CeremonyCheckpoint,
type CeremonyLedgerPort,
type ColumnDropPort,
type OrganizationOwnershipApplyReport,
} from './organization-ownership-apply.js';

let dir: string;
let db: Database.Database;
/** Every write statement the ceremony issued, in order. */
let writes: string[];

function executor(): ApplyExecutor {
return {
dialect: 'sqlite',
client: 'better-sqlite3',
async query(sql, params = []) {
return db.prepare(sql).all(...(params as unknown[])) as Record<string, unknown>[];
},
async execute(sql, params = []) {
writes.push(sql);
db.prepare(sql).run(...(params as unknown[]));
},
};
}

const columnDrops: ColumnDropPort = {
async drop(columns) {
return columns.map(({ table, column }) => {
db.exec(`ALTER TABLE "${table}" DROP COLUMN "${column}"`);
return { table, column, outcome: 'dropped' as const };
});
},
};

function ledger(): CeremonyLedgerPort {
let stored: CeremonyCheckpoint | null = null;
return {
async read() {
return stored ? (JSON.parse(JSON.stringify(stored)) as CeremonyCheckpoint) : null;
},
async write(checkpoint) {
stored = JSON.parse(JSON.stringify(checkpoint)) as CeremonyCheckpoint;
},
};
}

const database = (): string => join(dir, 'fixture.db');

const plan = (posture: string): Promise<OrganizationOwnershipPlan> =>
buildOrganizationOwnershipPlan({ reader: executor(), posture, database: database() });

/** Plan, then apply that reviewed plan, as the command does. */
async function apply(posture: string, l = ledger()): Promise<OrganizationOwnershipApplyReport> {
const reviewed = await plan(posture);
return applyOrganizationOwnership({
executor: executor(),
posture,
database: database(),
reviewedPlan: {
file: 'plan.json',
generatedAt: reviewed.generatedAt,
database: database(),
inventoryDigest: inventoryDigest(),
posture,
conflicts: reviewedConflictsOf(reviewed),
},
catalog: { async resolve() { return undefined; } },
door: {
async save(type, name) {
throw new Error(`no environment save is expected here (${type} ${name})`);
},
},
templates: { async find() { return undefined; } },
views: { async list() { return []; } },
lock: { async verdict() { return { editable: true }; } },
columnDrops,
ledger: l,
now: () => new Date('2026-10-19T00:00:00Z'),
});
}

const activity = (): Record<string, unknown>[] =>
db.prepare(`SELECT id, organization_id FROM sys_activity ORDER BY id`).all() as Record<string, unknown>[];

beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'os-d10-underivable-activity-'));
db = new Database(database());
writes = [];
db.exec(`
CREATE TABLE sys_organization (id TEXT PRIMARY KEY, slug TEXT);
INSERT INTO sys_organization VALUES ('org_default', 'default'), ('org_a', 'acme');
CREATE TABLE sys_user (id TEXT PRIMARY KEY, name TEXT, email TEXT);
INSERT INTO sys_user VALUES ('usr_1', 'Ada', 'ada@example.com');
CREATE TABLE crm_deal (id TEXT PRIMARY KEY, organization_id TEXT, name TEXT);
INSERT INTO crm_deal VALUES ('deal_1', 'org_a', 'Acme renewal');
CREATE TABLE sys_activity (id TEXT PRIMARY KEY, organization_id TEXT, type TEXT, object_name TEXT, record_id TEXT);
INSERT INTO sys_activity VALUES
('act_deal_null', NULL, 'created', 'crm_deal', 'deal_1'),
('act_deal_stamped', 'org_a', 'updated', 'crm_deal', 'deal_1'),
('act_user_created', NULL, 'created', 'sys_user', 'usr_1'),
('act_user_updated', NULL, 'updated', 'sys_user', 'usr_1');
`);
});

afterEach(() => {
db.close();
rmSync(dir, { recursive: true, force: true });
});

describe('sys_activity rows no organization is derivable for (ruling record 6257510973 item 3)', () => {
it('walled: the plan counts the two underivable rows as departing, says so before the first write, and reports none', async () => {
const reviewed = await plan('isolated');
const table = reviewed.tables.find((t) => t.object === 'sys_activity')!;
expect(table.fateCounts).toMatchObject({ rowsToAttribute: 1, departingRows: 2, unattributableRows: 0 });
expect(table.departures).toEqual([expect.objectContaining({ id: 'underivable-activity', as: 'mirror-deletion', rows: 2 })]);
expect(table.unattributable).toEqual([]);
expect(table.notNull.willReceive).toBe(true);
expect(reviewed.warnings).toEqual([expect.stringMatching(/^sys_activity holds 2 organization-less row\(s\) no anchor attributes: .*--apply deletes them/)]);
});

it('walled: the anchored row takes its organization, the two about the user are deleted, the stamped row stays; nothing remains', async () => {
const report = await apply('isolated');

expect([report.complete, report.blocking]).toEqual([true, 0]);
const table = report.tables.find((t) => t.object === 'sys_activity')!;
expect(table).toMatchObject({ attributed: 1, deleted: 2, remaining: 0, unattributable: [], departures: [] });
expect(table.after).toEqual({ total: 2, organizationNull: 0 });
expect(activity()).toEqual([
{ id: 'act_deal_null', organization_id: 'org_a' },
{ id: 'act_deal_stamped', organization_id: 'org_a' },
]);
// Deleted after attribution, by the mirror-deletion step, NULL rows only.
const deletes = writes.filter((w) => /^DELETE FROM "sys_activity"/i.test(w));
expect(deletes).toEqual(['DELETE FROM "sys_activity" WHERE "organization_id" IS NULL']);
expect(writes.findIndex((w) => /^UPDATE "sys_activity"/i.test(w))).toBeLessThan(writes.indexOf(deletes[0]!));
expect(report.steps.find((s) => s.step === 'mirror-deletion')).toMatchObject({ status: 'done', summary: { deleted: 2 } });
});

it('walled: a second run finds nothing to delete and writes nothing to the table', async () => {
const l = ledger();
await apply('isolated', l);
writes = [];
const again = await apply('isolated', l);
expect([again.complete, again.blocking]).toEqual([true, 0]);
expect(writes.filter((w) => /sys_activity/.test(w))).toEqual([]);
});

it('single: the Default Organization takes the user\'s rows, and nothing is deleted', async () => {
const reviewed = await plan('single');
const table = reviewed.tables.find((t) => t.object === 'sys_activity')!;
expect(table.departures).toEqual([]);
expect(table.attribution).toMatchObject({ 'default-organization': 2 });
expect(reviewed.warnings).toEqual([]);

const report = await apply('single');
expect([report.complete, report.blocking]).toEqual([true, 0]);
expect(report.tables.find((t) => t.object === 'sys_activity')).toMatchObject({ attributed: 3, deleted: 0, remaining: 0 });
expect(activity()).toEqual([
{ id: 'act_deal_null', organization_id: 'org_a' },
{ id: 'act_deal_stamped', organization_id: 'org_a' },
{ id: 'act_user_created', organization_id: 'org_default' },
{ id: 'act_user_updated', organization_id: 'org_default' },
]);
expect(writes.filter((w) => /^DELETE FROM "sys_activity"/i.test(w))).toEqual([]);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ describe('ADR-0131 D10 inventory — every censused object has one fate and a ci
* 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<keyof InventoryEntry, 'census' | 'cloudCarried' | 'external'>;
type HostAuthoredKeys = Exclude<keyof InventoryEntry, 'census' | 'cloudCarried' | 'external' | 'underivableDeparture'>;
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;

Expand Down Expand Up @@ -244,7 +244,7 @@ describe('the inventory row and the spec\'s supplement row are one shape', () =>
// 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) {
for (const { census: _census, cloudCarried: _cloud, external: _external, underivableDeparture: _underivable, ...row } of ORGANIZATION_OWNERSHIP_INVENTORY) {
const result = OrganizationOwnershipSupplementEntrySchema.safeParse(row);
if (!result.success) refused.push(`${row.object}: ${result.error.issues[0]?.message}`);
}
Expand Down
Loading
Loading