From 36af4c053bb716f68ae5687811d0240e3c85b2b5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 02:03:50 +0000 Subject: [PATCH] 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) Ruling record 6257510973 item 3 (Q3 A). - plugin-audit: the activity mirror takes its organization from the record, the session, then the Default Organization under single. When none is derivable it writes no sys_activity row; the sys_audit_log row is still written. Suites that boot the writer without AuthPlugin now boot the single composition they model (src/__tests__/single-posture-tenancy.ts). - cli: sys_activity's inventory row declares an underivableDeparture. On any posture but single, the plan counts the NULL rows no anchor attributes as departing (and warns), and the apply's mirror-deletion step deletes them after attribution, read from a fresh plan. Under single the Default Organization takes them and nothing is deleted. Claude-Session: https://claude.ai/code/session_018oXkooW8d1rtxYGFYwnAiT Co-authored-by: Claude --- .changeset/24343-8b0c-activity-never-null.md | 11 + .../src/utils/organization-ownership-apply.ts | 90 +++++++- ...nership-apply.underivable-activity.test.ts | 198 ++++++++++++++++++ .../organization-ownership-inventory.test.ts | 4 +- .../utils/organization-ownership-inventory.ts | 27 ++- .../src/utils/organization-ownership-plan.ts | 45 +++- .../src/__tests__/single-posture-tenancy.ts | 32 +++ .../src/activity-actor-name.test.ts | 3 +- ...hanged-fields-backfill.integration.test.ts | 2 + .../src/activity-field-redaction.test.ts | 2 + ...ctivity-milestone-kind.integration.test.ts | 2 + .../src/activity-predicate-guard.test.ts | 2 + ...tivity-read-visibility.integration.test.ts | 2 + ...tivity-type-vocabulary-enforcement.test.ts | 3 +- ...tivity-withheld-update.integration.test.ts | 2 + .../src/audit-internal-field-omission.test.ts | 3 +- ...it-log-read-visibility.integration.test.ts | 2 + .../src/audit-lookup-summary.test.ts | 2 + .../src/audit-milestone-summary.test.ts | 2 + .../src/audit-option-label-summary.test.ts | 2 + .../audit-record-label-display-field.test.ts | 2 + ...e-locale-settings-seam.integration.test.ts | 2 + ...t-writers.own-ui-state.integration.test.ts | 2 + .../plugin-audit/src/audit-writers.test.ts | 27 ++- .../plugins/plugin-audit/src/audit-writers.ts | 17 +- .../implicit-reference-target-summary.test.ts | 2 + ...-record-read-admission.integration.test.ts | 2 + ...arent-record-read-leaf.integration.test.ts | 2 + 28 files changed, 471 insertions(+), 21 deletions(-) create mode 100644 .changeset/24343-8b0c-activity-never-null.md create mode 100644 packages/cli/src/utils/organization-ownership-apply.underivable-activity.test.ts create mode 100644 packages/plugins/plugin-audit/src/__tests__/single-posture-tenancy.ts diff --git a/.changeset/24343-8b0c-activity-never-null.md b/.changeset/24343-8b0c-activity-never-null.md new file mode 100644 index 00000000000..f887d9178ad --- /dev/null +++ b/.changeset/24343-8b0c-activity-never-null.md @@ -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. diff --git a/packages/cli/src/utils/organization-ownership-apply.ts b/packages/cli/src/utils/organization-ownership-apply.ts index dcb8dd2b6a0..2fdb0c395bd 100644 --- a/packages/cli/src/utils/organization-ownership-apply.ts +++ b/packages/cli/src/utils/organization-ownership-apply.ts @@ -28,6 +28,7 @@ import { predicateSql, PRIMARY_DATABASE, Sql, + underivableDepartureOf, type AttributionSink, type MetadataConflict, type MetadataKeyRow, @@ -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 @@ -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: @@ -2526,6 +2531,11 @@ async function runMirrorDeletion(run: Run): Promise { 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 { @@ -2537,6 +2547,65 @@ async function runMirrorDeletion(run: Run): Promise { }; } +// ── 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 => 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'; @@ -3097,6 +3166,23 @@ export async function applyOrganizationOwnership(opts: ApplyOptions): Promise[]; + }, + 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 => + buildOrganizationOwnershipPlan({ reader: executor(), posture, database: database() }); + +/** Plan, then apply that reviewed plan, as the command does. */ +async function apply(posture: string, l = ledger()): Promise { + 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[] => + db.prepare(`SELECT id, organization_id FROM sys_activity ORDER BY id`).all() as Record[]; + +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([]); + }); +}); diff --git a/packages/cli/src/utils/organization-ownership-inventory.test.ts b/packages/cli/src/utils/organization-ownership-inventory.test.ts index 42dbb40d7d1..9706824063e 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.test.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.test.ts @@ -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; +type HostAuthoredKeys = Exclude; type SpecRowKeys = OrganizationOwnershipSupplementEntry extends infer Row ? (Row extends unknown ? keyof Row : never) : never; const KEYS_AGREE: [HostAuthoredKeys] extends [SpecRowKeys] ? ([SpecRowKeys] extends [HostAuthoredKeys] ? true : false) : false = true; @@ -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}`); } diff --git a/packages/cli/src/utils/organization-ownership-inventory.ts b/packages/cli/src/utils/organization-ownership-inventory.ts index b7dcf2616fa..a3abc052ee7 100644 --- a/packages/cli/src/utils/organization-ownership-inventory.ts +++ b/packages/cli/src/utils/organization-ownership-inventory.ts @@ -158,6 +158,15 @@ export interface InventoryEntry { mirror?: RowPredicate; /** Fate 3 / 4: rows that leave the table before the NOT NULL gate. */ departures?: readonly InventoryDeparture[]; + /** + * Fate 3 only, platform-only: the NULL rows no anchor names leave the table + * by the mirror-deletion step instead of being reported — on a table whose + * runtime writer no longer writes a row it cannot attribute, so such a row + * is one it would not write today. Classified after the anchors; under + * `single` the Default Organization takes them first, and none leave. A + * host declares no deletion, so the spec's supplement row has no such key. + */ + underivableDeparture?: Omit; categories?: readonly InventoryCategory[]; /** * Fate 4 only: the column is not the tenancy anchor, so it never receives @@ -515,7 +524,23 @@ export const ORGANIZATION_OWNERSHIP_INVENTORY: readonly InventoryEntry[] = [ }, { object: 'sys_share_link', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; the linked record carries the organization`, anchors: [subject('object_name', 'record_id')] }, { object: 'sys_attachment', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; the attachment's parent record carries the organization`, anchors: [subject('parent_object', 'parent_id')] }, - { object: 'sys_activity', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; the activity's record carries the organization (rotation-sharded storage)`, anchors: [subject('object_name', 'record_id')] }, + { + object: 'sys_activity', + census: P1, + fate: 'attribution', + citation: `${D3_ORG_ROWS}; the activity's record carries the organization (rotation-sharded storage)`, + anchors: [subject('object_name', 'record_id')], + // The mirror writes activity only when an organization is derivable, never + // a NULL row (audit-writers.ts), so a NULL row no anchor names is one it + // would not write today: 14-day telemetry, deleted rather than reported. + underivableDeparture: { + id: 'underivable-activity', + label: + 'activity rows about a record no organization owns (a sign-up\'s user, a global setting) — telemetry the ' + + 'mirror no longer writes; deleted by the mirror-deletion step, the change itself stays in sys_audit_log', + citation: 'ruling record 6257510973 item 3 (Q3 A: activity is never a NULL row; existing NULL rows are deleted); ADR-0131 D7, D9', + }, + }, { object: 'sys_comment', census: P1, fate: 'attribution', citation: `${D3_ORG_ROWS}; a reply's parent comment carries the organization`, anchors: [parent('parent_id', 'sys_comment')] }, { object: 'sys_comment_reaction', census: PMAIN, fate: 'attribution', citation: `${CENSUS_MAIN} (in reach); the reaction's comment carries the organization`, anchors: [parent('comment_id', 'sys_comment')] }, { object: 'sys_email', census: P1, fate: 'attribution', citation: `${D7}: sys_email is stamped at its producers; the related record carries the organization`, anchors: [subject('related_object', 'related_id')] }, diff --git a/packages/cli/src/utils/organization-ownership-plan.ts b/packages/cli/src/utils/organization-ownership-plan.ts index f264051ac1c..f8f8801f998 100644 --- a/packages/cli/src/utils/organization-ownership-plan.ts +++ b/packages/cli/src/utils/organization-ownership-plan.ts @@ -1010,10 +1010,16 @@ async function planPresentTable(ctx: PlanContext, entry: InventoryEntry, source: organizationColumn: hasOrg ? 'present' : 'absent', rows: { total: 0, organizationNull: 0, stamped: 0 }, fateCounts: {}, - departures: (entry.departures ?? []).map((d) => { - const advisory = advisoryDepartureReason(entry.object, d.id); - return { id: d.id, label: d.label, citation: d.citation, as: d.as, rows: 0, ...(advisory ? { advisory } : {}) }; - }), + departures: [ + ...(entry.departures ?? []).map((d) => { + const advisory = advisoryDepartureReason(entry.object, d.id); + return { id: d.id, label: d.label, citation: d.citation, as: d.as, rows: 0, ...(advisory ? { advisory } : {}) }; + }), + // Last, so the declared departures keep their index (`walkNullRows`). + ...(underivableDepartureOf(entry, ctx.posture) + ? [{ ...underivableDepartureOf(entry, ctx.posture)!, as: 'mirror-deletion' as const, rows: 0 }] + : []), + ], categories: [], unattributable: [], notNull: { willReceive: false, reason: '' }, @@ -1141,6 +1147,20 @@ function heldNullReason(plan: TablePlan): string | null { ); } +/** + * The inventory's `underivableDeparture` of a fate-3 table, when it applies: + * on every posture but `single`, where the Default Organization takes every + * row no anchor names (or, with none to take them, they are reported) and so + * nothing leaves (ruling record 6257510973 item 3: under `single` nothing + * changes). + */ +export function underivableDepartureOf( + entry: InventoryEntry, + posture: string, +): NonNullable | undefined { + return entry.fate === 'attribution' && posture !== 'single' ? entry.underivableDeparture : undefined; +} + /** Keyset-walk the NULL-organization rows of one physical table and classify each. */ async function walkNullRows( ctx: PlanContext, @@ -1211,6 +1231,13 @@ async function walkNullRows( } pending = []; } + // What no anchor names, on a table that declares it a departure, leaves + // instead of being reported ({@link underivableDepartureOf}). + const underivable = underivableDepartureOf(entry, ctx.posture); + if (underivable && pending.length > 0) { + plan.departures.find((d) => d.id === underivable.id)!.rows += pending.length; + pending = []; + } } if (ctx.onAttribution && assigned.length > 0) await ctx.onAttribution({ object: entry.object, assignments: assigned }); for (const row of pending) { @@ -1444,7 +1471,17 @@ export async function buildOrganizationOwnershipPlan(opts: { } // Certifying with NULL rows standing is decided on the operator's behalf, so // it is said before the first write, once per departure that holds rows. + // So is a deletion no fate-2 selector names: the rows no anchor attributes. for (const table of tables) { + const entry = inventory.find((e) => e.object === table.object); + const underivable = entry ? underivableDepartureOf(entry, opts.posture) : undefined; + const leaving = underivable ? table.departures.find((d) => d.id === underivable.id) : undefined; + if (leaving && leaving.rows > 0) { + warnings.push( + `${table.object} holds ${leaving.rows} organization-less row(s) no anchor attributes: ${leaving.label}. ` + + '--apply deletes them, and keeps every row an anchor attributes.', + ); + } for (const departure of table.departures) { if (!departure.advisory || departure.rows === 0) continue; warnings.push( diff --git a/packages/plugins/plugin-audit/src/__tests__/single-posture-tenancy.ts b/packages/plugins/plugin-audit/src/__tests__/single-posture-tenancy.ts new file mode 100644 index 00000000000..965aa5e593e --- /dev/null +++ b/packages/plugins/plugin-audit/src/__tests__/single-posture-tenancy.ts @@ -0,0 +1,32 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The `single` posture's Default Organization, for suites that boot the audit + * writer without AuthPlugin. + * + * [ADR-0131 D9; ruling 6257510973 item 3, Q3 A] The activity mirror writes a + * row only when an organization is derivable — the record's, the session's, + * or the `tenancy` service's Default Organization under `single` — and never a + * NULL one. A deployment's AuthPlugin registers that service; a suite that + * boots ObjectQL and the audit writer alone has none, so with org-less records + * and sessions it would model a wall and write no activity at all. These two + * helpers give such a suite the composition it means: a `single` deployment. + */ + +import type { Plugin } from '@objectstack/core'; + +/** The Default Organization these suites' activity rows carry. */ +export const DEFAULT_ORGANIZATION_ID = 'org_default'; + +/** `AuditWriterOptions.getDefaultOrganizationId`, as AuditPlugin wires it under `single`. */ +export const singlePostureDefaultOrganization = async (): Promise => DEFAULT_ORGANIZATION_ID; + +/** The `tenancy` service AuthPlugin registers under `single`, reduced to the one method the audit writer asks. */ +export function singlePostureTenancy(): Plugin { + return { + name: 'test.single-posture-tenancy', + async init(ctx) { + ctx.registerService('tenancy', { defaultOrgId: singlePostureDefaultOrganization }); + }, + } as Plugin; +} diff --git a/packages/plugins/plugin-audit/src/activity-actor-name.test.ts b/packages/plugins/plugin-audit/src/activity-actor-name.test.ts index 427ea9728a4..0482993fd8b 100644 --- a/packages/plugins/plugin-audit/src/activity-actor-name.test.ts +++ b/packages/plugins/plugin-audit/src/activity-actor-name.test.ts @@ -37,6 +37,7 @@ import { describe, it, expect, vi } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { installAuditWriters } from './audit-writers.js'; +import { singlePostureDefaultOrganization } from './__tests__/single-posture-tenancy.js'; const f = (name: string, type: string, extra: Record = {}) => ({ name, label: name, type, ...extra }) as any; @@ -192,7 +193,7 @@ async function boot() { for (const o of [sysAuditLog, sysActivity, sysUser, bizTask, bizQuiet]) { engine.registry.registerObject(o as any, OWNER_PACKAGE); } - installAuditWriters(engine as any, 'test.audit'); + installAuditWriters(engine as any, 'test.audit', { getDefaultOrganizationId: singlePostureDefaultOrganization }); // Seeded with no context: a system write, which names nobody. Ada has no image. await engine.insert('sys_user', { id: ADA, name: 'Ada Lovelace', email: 'ada@example.com' }); await engine.insert('sys_user', { id: GRACE, name: 'Grace Hopper', email: 'grace@example.com', image: GRACE_IMAGE }); diff --git a/packages/plugins/plugin-audit/src/activity-changed-fields-backfill.integration.test.ts b/packages/plugins/plugin-audit/src/activity-changed-fields-backfill.integration.test.ts index a5a1c9665f1..7c3e33023ab 100644 --- a/packages/plugins/plugin-audit/src/activity-changed-fields-backfill.integration.test.ts +++ b/packages/plugins/plugin-audit/src/activity-changed-fields-backfill.integration.test.ts @@ -23,6 +23,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AuditPlugin } from './audit-plugin.js'; import { backfillActivityChangedFields } from './activity-changed-fields-backfill.js'; +import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; const ACTIVITY = 'sys_activity'; const ITEM = 'acb_item'; @@ -61,6 +62,7 @@ describe('[ADR-0139 E6] backfillActivityChangedFields', () => { beforeAll(async () => { kernel = new ObjectKernel({ logger: { level: 'silent' } }); await kernel.use(new ObjectQLPlugin()); + await kernel.use(singlePostureTenancy()); await kernel.use(new AuditPlugin()); await kernel.bootstrap(); engine = kernel.getService('objectql'); diff --git a/packages/plugins/plugin-audit/src/activity-field-redaction.test.ts b/packages/plugins/plugin-audit/src/activity-field-redaction.test.ts index e3c0c9b1ca1..6540d261fd5 100644 --- a/packages/plugins/plugin-audit/src/activity-field-redaction.test.ts +++ b/packages/plugins/plugin-audit/src/activity-field-redaction.test.ts @@ -40,6 +40,7 @@ import type { EngineQueryOptions } from '@objectstack/spec/data'; import { AuditPlugin } from './audit-plugin.js'; import { resolveServedFields } from './activity-field-redaction.js'; +import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; const ACTIVITY = 'sys_activity'; const ITEM = 'afr_item'; @@ -147,6 +148,7 @@ describe('[#21081] sys_activity value-bearing columns are served through the sec init: async (ctx: any) => ctx.registerService('security', security), start: async () => {}, } as any); + await kernel.use(singlePostureTenancy()); await kernel.use(new AuditPlugin()); await kernel.bootstrap(); diff --git a/packages/plugins/plugin-audit/src/activity-milestone-kind.integration.test.ts b/packages/plugins/plugin-audit/src/activity-milestone-kind.integration.test.ts index 0ce82c230aa..91844faf349 100644 --- a/packages/plugins/plugin-audit/src/activity-milestone-kind.integration.test.ts +++ b/packages/plugins/plugin-audit/src/activity-milestone-kind.integration.test.ts @@ -61,6 +61,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AuditPlugin } from './audit-plugin.js'; import { keptRowsOfParent } from './activity-field-redaction.js'; import { declaredMilestoneWatchedFields, milestoneKeyOf, planActivityStamp } from './activity-changed-fields.js'; +import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; const ACTIVITY = 'sys_activity'; const ITEM = 'amk_item'; @@ -155,6 +156,7 @@ describe('[#22771] a fired milestone is served with metadata.kind, judged like t init: async (ctx: any) => ctx.registerService('security', security), start: async () => {}, } as any); + await kernel.use(singlePostureTenancy()); await kernel.use(new AuditPlugin()); await kernel.bootstrap(); diff --git a/packages/plugins/plugin-audit/src/activity-predicate-guard.test.ts b/packages/plugins/plugin-audit/src/activity-predicate-guard.test.ts index 73aad5945fc..493a46cd97a 100644 --- a/packages/plugins/plugin-audit/src/activity-predicate-guard.test.ts +++ b/packages/plugins/plugin-audit/src/activity-predicate-guard.test.ts @@ -38,6 +38,7 @@ import type { EngineAggregateOptions, EngineQueryOptions } from '@objectstack/sp import { AuditPlugin } from './audit-plugin.js'; import { redactActivityRows } from './activity-field-redaction.js'; import { ACTIVITY_QUERY_GUARD, AUDIT_LOG_QUERY_GUARD, namedValueBearingColumns, pinnedParentObject } from './parent-field-query-guard.js'; +import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; const ACTIVITY = 'sys_activity'; const ITEM = 'apg_item'; @@ -203,6 +204,7 @@ describe('[#21154] a query over the activity text by a reader withheld a parent init: async (ctx: any) => ctx.registerService('security', security), start: async () => {}, } as any); + await kernel.use(singlePostureTenancy()); await kernel.use(new AuditPlugin()); await kernel.bootstrap(); diff --git a/packages/plugins/plugin-audit/src/activity-read-visibility.integration.test.ts b/packages/plugins/plugin-audit/src/activity-read-visibility.integration.test.ts index d5f86525884..fb84a1ce94f 100644 --- a/packages/plugins/plugin-audit/src/activity-read-visibility.integration.test.ts +++ b/packages/plugins/plugin-audit/src/activity-read-visibility.integration.test.ts @@ -37,6 +37,7 @@ import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AuditPlugin } from './audit-plugin.js'; +import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; const ACTIVITY = 'sys_activity'; /** A parent object whose rows each caller reads only when it owns them. */ @@ -82,6 +83,7 @@ describe('sys_activity read visibility — the plugin read path', () => { beforeAll(async () => { kernel = new ObjectKernel({ logger: { level: 'silent' } }); await kernel.use(new ObjectQLPlugin()); + await kernel.use(singlePostureTenancy()); await kernel.use(new AuditPlugin()); await kernel.bootstrap(); diff --git a/packages/plugins/plugin-audit/src/activity-type-vocabulary-enforcement.test.ts b/packages/plugins/plugin-audit/src/activity-type-vocabulary-enforcement.test.ts index d4b2b91b5fc..4ceb031df6f 100644 --- a/packages/plugins/plugin-audit/src/activity-type-vocabulary-enforcement.test.ts +++ b/packages/plugins/plugin-audit/src/activity-type-vocabulary-enforcement.test.ts @@ -56,6 +56,7 @@ import { describe, it, expect } from 'vitest'; import { ObjectQL, ValidationError } from '@objectstack/objectql'; import { installAuditWriters } from './audit-writers.js'; import { SysActivity, SysAuditLog } from './objects/index.js'; +import { singlePostureDefaultOrganization } from './__tests__/single-posture-tenancy.js'; const OWNER_PACKAGE = 'com.objectstack.test.activity-type-vocabulary'; @@ -208,7 +209,7 @@ async function boot() { for (const o of [SysAuditLog, SysActivity, bizTicket, writableClone()]) { engine.registry.registerObject(o as any, OWNER_PACKAGE); } - installAuditWriters(engine as any, 'test.audit'); + installAuditWriters(engine as any, 'test.audit', { getDefaultOrganizationId: singlePostureDefaultOrganization }); return { engine, ...stub }; } diff --git a/packages/plugins/plugin-audit/src/activity-withheld-update.integration.test.ts b/packages/plugins/plugin-audit/src/activity-withheld-update.integration.test.ts index 3baba8e82d0..fc14952edcb 100644 --- a/packages/plugins/plugin-audit/src/activity-withheld-update.integration.test.ts +++ b/packages/plugins/plugin-audit/src/activity-withheld-update.integration.test.ts @@ -48,6 +48,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AuditPlugin } from './audit-plugin.js'; import { computeWithheldUpdateFilter, keptRowsOfParent } from './activity-field-redaction.js'; import { planActivityStamp } from './activity-changed-fields.js'; +import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; const ACTIVITY = 'sys_activity'; /** A record the member reads with two fields withheld. */ @@ -158,6 +159,7 @@ describe('[#21388] an update whose every recorded change is withheld from the re init: async (ctx: any) => ctx.registerService('security', security), start: async () => {}, } as any); + await kernel.use(singlePostureTenancy()); await kernel.use(new AuditPlugin()); await kernel.bootstrap(); diff --git a/packages/plugins/plugin-audit/src/audit-internal-field-omission.test.ts b/packages/plugins/plugin-audit/src/audit-internal-field-omission.test.ts index 0a36569ab41..6027ad45058 100644 --- a/packages/plugins/plugin-audit/src/audit-internal-field-omission.test.ts +++ b/packages/plugins/plugin-audit/src/audit-internal-field-omission.test.ts @@ -41,6 +41,7 @@ import { } from '@objectstack/platform-objects/identity'; import { SysEmail } from '@objectstack/platform-objects/audit'; import { installAuditWriters } from './audit-writers.js'; +import { singlePostureDefaultOrganization } from './__tests__/single-posture-tenancy.js'; type Row = Record; type ObjectDef = { name: string; fields: Record }; @@ -168,7 +169,7 @@ async function boot(def: ObjectDef) { for (const o of [sysAuditLog, sysActivity, def]) { engine.registry.registerObject(o as any, 'com.objectstack.test.audit-internal-field-omission'); } - installAuditWriters(engine as any); + installAuditWriters(engine as any, undefined, { getDefaultOrganizationId: singlePostureDefaultOrganization }); return { engine: engine as any, storeFor }; } diff --git a/packages/plugins/plugin-audit/src/audit-log-read-visibility.integration.test.ts b/packages/plugins/plugin-audit/src/audit-log-read-visibility.integration.test.ts index 85a01675044..b914a29712e 100644 --- a/packages/plugins/plugin-audit/src/audit-log-read-visibility.integration.test.ts +++ b/packages/plugins/plugin-audit/src/audit-log-read-visibility.integration.test.ts @@ -36,6 +36,7 @@ import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AuditPlugin } from './audit-plugin.js'; +import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; const LEDGER = 'sys_audit_log'; /** A parent object whose records each caller reads only when it owns them. */ @@ -105,6 +106,7 @@ describe('[#21175] sys_audit_log read visibility — the plugin read path', () = beforeAll(async () => { kernel = new ObjectKernel({ logger: { level: 'silent' } }); await kernel.use(new ObjectQLPlugin()); + await kernel.use(singlePostureTenancy()); await kernel.use(new AuditPlugin()); await kernel.bootstrap(); diff --git a/packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts b/packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts index aa96fa2bce1..24372d24926 100644 --- a/packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts +++ b/packages/plugins/plugin-audit/src/audit-lookup-summary.test.ts @@ -51,6 +51,7 @@ import { describe, it, expect, vi } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { createMemoryI18n } from '@objectstack/core'; import { installAuditWriters } from './audit-writers.js'; +import { singlePostureDefaultOrganization } from './__tests__/single-posture-tenancy.js'; // --------------------------------------------------------------------------- // Fixtures @@ -257,6 +258,7 @@ async function boot(opts: { locale?: string; i18n?: unknown } = {}) { engine.registry.registerObject(o as any, OWNER_PACKAGE); } installAuditWriters(engine as any, 'test.audit', { + getDefaultOrganizationId: singlePostureDefaultOrganization, getI18n: () => opts.i18n as any, getLocale: async () => opts.locale, }); diff --git a/packages/plugins/plugin-audit/src/audit-milestone-summary.test.ts b/packages/plugins/plugin-audit/src/audit-milestone-summary.test.ts index 79e3feda308..ce0f87da23a 100644 --- a/packages/plugins/plugin-audit/src/audit-milestone-summary.test.ts +++ b/packages/plugins/plugin-audit/src/audit-milestone-summary.test.ts @@ -56,6 +56,7 @@ import { describe, it, expect } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { createMemoryI18n } from '@objectstack/core'; import { installAuditWriters } from './audit-writers.js'; +import { singlePostureDefaultOrganization } from './__tests__/single-posture-tenancy.js'; // --------------------------------------------------------------------------- // Fixtures @@ -330,6 +331,7 @@ async function boot(opts: { locale?: string; i18n?: unknown } = {}) { engine.registry.registerObject(o as any, OWNER_PACKAGE); } installAuditWriters(engine as any, 'test.audit', { + getDefaultOrganizationId: singlePostureDefaultOrganization, getI18n: () => opts.i18n as any, getLocale: async () => opts.locale, }); diff --git a/packages/plugins/plugin-audit/src/audit-option-label-summary.test.ts b/packages/plugins/plugin-audit/src/audit-option-label-summary.test.ts index a1592291f1c..95aa480f43f 100644 --- a/packages/plugins/plugin-audit/src/audit-option-label-summary.test.ts +++ b/packages/plugins/plugin-audit/src/audit-option-label-summary.test.ts @@ -102,6 +102,7 @@ import { ObjectQL } from '@objectstack/objectql'; import { createMemoryI18n } from '@objectstack/core'; import { installAuditWriters } from './audit-writers.js'; import { zhCNObjects } from './translations/zh-CN.objects.generated.js'; +import { singlePostureDefaultOrganization } from './__tests__/single-posture-tenancy.js'; // --------------------------------------------------------------------------- // Fixtures @@ -342,6 +343,7 @@ async function boot(opts: { locale?: string; i18n?: unknown } = {}) { engine.registry.registerObject(o as any, OWNER_PACKAGE); } installAuditWriters(engine as any, 'test.audit', { + getDefaultOrganizationId: singlePostureDefaultOrganization, getI18n: () => opts.i18n as any, getLocale: async () => opts.locale, }); diff --git a/packages/plugins/plugin-audit/src/audit-record-label-display-field.test.ts b/packages/plugins/plugin-audit/src/audit-record-label-display-field.test.ts index 2cb69833e94..88871c68cfc 100644 --- a/packages/plugins/plugin-audit/src/audit-record-label-display-field.test.ts +++ b/packages/plugins/plugin-audit/src/audit-record-label-display-field.test.ts @@ -38,6 +38,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import type { EngineQueryOptions } from '@objectstack/spec/data'; import { AuditPlugin } from './audit-plugin.js'; +import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; const ACTIVITY = 'sys_activity'; const HARNESS_PACKAGE = 'com.objectstack.audit.test.record-label-display-field'; @@ -152,6 +153,7 @@ describe('[#21878] the activity record label is the ADR-0079 title, with the id init: async (ctx: any) => ctx.registerService('security', security), start: async () => {}, } as any); + await kernel.use(singlePostureTenancy()); await kernel.use(new AuditPlugin()); await kernel.bootstrap(); diff --git a/packages/plugins/plugin-audit/src/audit-write-locale-settings-seam.integration.test.ts b/packages/plugins/plugin-audit/src/audit-write-locale-settings-seam.integration.test.ts index 631a632f989..10baf3d402d 100644 --- a/packages/plugins/plugin-audit/src/audit-write-locale-settings-seam.integration.test.ts +++ b/packages/plugins/plugin-audit/src/audit-write-locale-settings-seam.integration.test.ts @@ -9,6 +9,7 @@ import { ObjectKernel } from '@objectstack/core'; import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AuditPlugin } from './audit-plugin.js'; +import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; /** * [#24235] A summary line is stored ONCE, at write time, so the writer's held @@ -68,6 +69,7 @@ describe('audit writers — a localization change reaches the very next audited beforeAll(async () => { kernel = new ObjectKernel({ logger: { level: 'silent' } } as any); await kernel.use(new ObjectQLPlugin()); + await kernel.use(singlePostureTenancy()); await kernel.use(new AuditPlugin()); await kernel.bootstrap(); diff --git a/packages/plugins/plugin-audit/src/audit-writers.own-ui-state.integration.test.ts b/packages/plugins/plugin-audit/src/audit-writers.own-ui-state.integration.test.ts index 05bebe8ded8..db1206ce1fd 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.own-ui-state.integration.test.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.own-ui-state.integration.test.ts @@ -11,6 +11,7 @@ import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { SysUserPreference } from '@objectstack/platform-objects/identity'; import { AuditPlugin } from './audit-plugin.js'; +import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; /** * [#23804, ruling record 6212163130] A user's own write to their own per-user @@ -82,6 +83,7 @@ describe('audit writers — a user\'s own UI-state write leaves no ledger row, o beforeAll(async () => { kernel = new ObjectKernel({ logger: { level: 'silent' } } as any); await kernel.use(new ObjectQLPlugin()); + await kernel.use(singlePostureTenancy()); await kernel.use(new AuditPlugin()); await kernel.bootstrap(); diff --git a/packages/plugins/plugin-audit/src/audit-writers.test.ts b/packages/plugins/plugin-audit/src/audit-writers.test.ts index 34765644bff..6f6a423cf5d 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.test.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.test.ts @@ -2569,9 +2569,11 @@ describe('[#21207] stored metadata copies carry no content hash', () => { * [ADR-0131 D9] An activity row about a record with no organization of its own * (a `sys_user`, a platform setting), written with no session organization (a * sign-up), takes the one the host derives — the `tenancy` service's Default - * Organization under `single`, `null` under a wall. The ledger row's - * `tenant_id` is an attribution of what the row is ABOUT (D7) and is not - * touched; a known organization is never overridden. + * Organization under `single`. Under a wall none is derivable, and then no + * activity row is written at all — never a NULL one (ruling 6257510973 item 3, + * Q3 A); the ledger row still is. The ledger row's `tenant_id` is an + * attribution of what the row is ABOUT (D7) and is not touched; a known + * organization is never overridden. */ describe('audit writers — the activity row of an org-less record names the derivable organization (ADR-0131 D9)', () => { const ORG_LESS = { ...MULTI_TENANT, sys_user: ['id', 'name', 'email'] }; @@ -2591,15 +2593,30 @@ describe('audit writers — the activity row of an org-less record names the der expect(created.find((c) => c.object === 'sys_audit_log')?.row.tenant_id).toBeNull(); }); - it('names none under a wall, and none when the host derives nothing or cannot answer', async () => { + it('writes no activity row under a wall, nor when the host derives nothing or cannot answer — the ledger row still lands', async () => { for (const getDefaultOrganizationId of [async () => null, undefined, async () => { throw new Error('no tenancy'); }]) { const { engine, fire, created } = makeEngine(ORG_LESS); installAuditWriters(engine as any, 'test.audit', getDefaultOrganizationId ? { getDefaultOrganizationId } : {}); await fire('afterInsert', signUp); - expect(created.find((c) => c.object === 'sys_activity')?.row.organization_id).toBeNull(); + expect(created.filter((c) => c.object === 'sys_activity')).toEqual([]); + const ledger = created.filter((c) => c.object === 'sys_audit_log'); + expect(ledger).toHaveLength(1); + expect(ledger[0]!.row).toMatchObject({ object_name: 'sys_user', record_id: 'usr-1', tenant_id: null }); } }); + it('a record that names its organization still has its activity row under a wall, with that organization', async () => { + const { engine, fire, created } = makeEngine({ ...ORG_LESS, crm_lead: ['id', 'name', 'organization_id'] }); + installAuditWriters(engine as any, 'test.audit', { getDefaultOrganizationId: async () => null }); + await fire('afterInsert', { + object: 'crm_lead', + input: { id: 'lead-1' }, + result: { id: 'lead-1', name: 'Acme', organization_id: 'org-A' }, + session: {}, + }); + expect(created.filter((c) => c.object === 'sys_activity').map((c) => c.row.organization_id)).toEqual(['org-A']); + }); + it('never overrides a known organization — the record\'s, then the session\'s — nor asks for one', async () => { let asked = 0; const getDefaultOrganizationId = async () => { asked += 1; return 'org-default'; }; diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts index b517196b358..70e3235b024 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.ts @@ -154,8 +154,9 @@ export interface AuditWriterOptions { * record nor the acting session names one: the Default Organization under * `single` (the only organization there is), `null` under a wall — the * `tenancy` service's `defaultOrgId()`. Asked only for such a row, never - * for one whose organization is already known. Absent, or an answer that - * throws → the row names none, as before. + * for one whose organization is already known. Absent, `null`, or an answer + * that throws → no activity row is written; the ledger row still is + * (ruling 6257510973 item 3: activity is never a NULL row). */ getDefaultOrganizationId?(): Promise; } @@ -2227,11 +2228,19 @@ export function installAuditWriters( // takes the derivable one: the Default Organization under `single`. Only // the activity row: the ledger's `tenant_id` above is an attribution of // what the row is ABOUT (D7) and stays the record's. + // + // [ADR-0131 D9; ruling 6257510973 item 3, Q3 A] When none is derivable (a + // wall, with neither the record nor the session naming one), NO activity + // row is written — never a NULL one. The ledger row still is: + // `sys_audit_log` is the deployment-level record of the change (D7). + let activityOrganizationKnown = true; if (objectHasField('sys_activity', 'organization_id')) { - activityRow.organization_id = tenantId ?? (await derivableActivityOrganization()) ?? null; + const organizationId = tenantId ?? (await derivableActivityOrganization()); + activityOrganizationKnown = organizationId != null; + if (activityOrganizationKnown) activityRow.organization_id = organizationId; } - const activityRowToWrite = mirrorsActivity ? activityRow : undefined; + const activityRowToWrite = mirrorsActivity && activityOrganizationKnown ? activityRow : undefined; // [#21262] The ledger row is written first; the writer advances this // before the activity row, so after a throw it names the refused table. const progress: AuditTrailWriteProgress = { writing: 'sys_audit_log' }; diff --git a/packages/plugins/plugin-audit/src/implicit-reference-target-summary.test.ts b/packages/plugins/plugin-audit/src/implicit-reference-target-summary.test.ts index bea83a18f47..56ce425dcac 100644 --- a/packages/plugins/plugin-audit/src/implicit-reference-target-summary.test.ts +++ b/packages/plugins/plugin-audit/src/implicit-reference-target-summary.test.ts @@ -46,6 +46,7 @@ import { describe, it, expect } from 'vitest'; import { ObjectQL } from '@objectstack/objectql'; import { referenceTargetOf } from '@objectstack/spec/data'; import { installAuditWriters } from './audit-writers.js'; +import { singlePostureDefaultOrganization } from './__tests__/single-posture-tenancy.js'; /** * The target a `user` field names when the author does not restate it — read @@ -264,6 +265,7 @@ async function boot() { engine.registry.registerObject(o as any, OWNER_PACKAGE); } installAuditWriters(engine as any, 'test.audit', { + getDefaultOrganizationId: singlePostureDefaultOrganization, getI18n: () => undefined as any, getLocale: async () => undefined, }); diff --git a/packages/plugins/plugin-audit/src/parent-record-read-admission.integration.test.ts b/packages/plugins/plugin-audit/src/parent-record-read-admission.integration.test.ts index be695280d81..cb0d7f7fde1 100644 --- a/packages/plugins/plugin-audit/src/parent-record-read-admission.integration.test.ts +++ b/packages/plugins/plugin-audit/src/parent-record-read-admission.integration.test.ts @@ -46,6 +46,7 @@ import { ObjectQL, ObjectQLPlugin } from '@objectstack/objectql'; import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AuditPlugin } from './audit-plugin.js'; +import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; const ACTIVITY = 'sys_activity'; const LEDGER = 'sys_audit_log'; @@ -130,6 +131,7 @@ describe('parent-record read gates: the object-level read admission', () => { security.canReadObject = admit; kernel = new ObjectKernel({ logger: { level: 'silent' } }); await kernel.use(new ObjectQLPlugin()); + await kernel.use(singlePostureTenancy()); await kernel.use(new AuditPlugin()); // Registered AFTER the audit plugin: the gate resolves it per read. await kernel.use(securityStandIn()); diff --git a/packages/plugins/plugin-audit/src/parent-record-read-leaf.integration.test.ts b/packages/plugins/plugin-audit/src/parent-record-read-leaf.integration.test.ts index d78f3014c64..ec4e6d88237 100644 --- a/packages/plugins/plugin-audit/src/parent-record-read-leaf.integration.test.ts +++ b/packages/plugins/plugin-audit/src/parent-record-read-leaf.integration.test.ts @@ -50,6 +50,7 @@ import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; import { AuditPlugin } from './audit-plugin.js'; import { LEDGER_AUDIT_CAPABILITY } from './audit-log-read-visibility.js'; import { PARENT_GATE_SCAN_LIMIT } from './parent-record-read-gate.js'; +import { singlePostureTenancy } from './__tests__/single-posture-tenancy.js'; const ACTIVITY = 'sys_activity'; const LEDGER = 'sys_audit_log'; @@ -121,6 +122,7 @@ const objectOf = (name: string, extra: Record = {}) => ({ async function bootKernel(driver: SqliteWasmDriver) { const kernel = new ObjectKernel({ logger: { level: 'silent' } }); await kernel.use(new ObjectQLPlugin()); + await kernel.use(singlePostureTenancy()); await kernel.use(new AuditPlugin()); await kernel.use({ name: 'test.security-stand-in',