From de825395661a7018b76b7a843302ec85c0ede844 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 07:50:53 +0000 Subject: [PATCH 1/4] fix(plugin-audit): skip the activity mirror for a user's own per-user UI-state write A user's own write to their own sys_user_preference row (the console's ui.recent / ui.favorites / palette recents) was mirrored into sys_activity, and the bell's Activity tab handed it back to them as "Updated User Preference 'ui.recent'" on every navigation. The writer now keeps the sys_audit_log row and skips only the activity row when the acting user owns the row. The class is a closed, declared map beside SKIP_OBJECTS; other actors' writes keep their activity rows. Claude-Session: https://claude.ai/code/session_01CeRCvvvjxbxu2iYJAc5Cce Co-authored-by: Claude --- ...1-plugin-audit-own-ui-state-no-activity.md | 14 +++ .../plugin-audit/src/audit-writers.test.ts | 100 ++++++++++++++++++ .../plugins/plugin-audit/src/audit-writers.ts | 72 ++++++++++++- 3 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 .changeset/22961-plugin-audit-own-ui-state-no-activity.md diff --git a/.changeset/22961-plugin-audit-own-ui-state-no-activity.md b/.changeset/22961-plugin-audit-own-ui-state-no-activity.md new file mode 100644 index 00000000000..cf34457e72b --- /dev/null +++ b/.changeset/22961-plugin-audit-own-ui-state-no-activity.md @@ -0,0 +1,14 @@ +--- +'@objectstack/plugin-audit': patch +--- + +fix(plugin-audit): a user's own write to their own `sys_user_preference` row no longer adds a `sys_activity` row. The `sys_audit_log` row is written as before + +Clause-②: no + +The console saves the user's "recently visited" list (`ui.recent`), favourites and palette recents as `sys_user_preference` rows as they navigate. The audit writer mirrored each of those writes into `sys_activity`, and the bell's Activity tab lists the newest activity rows the caller may read. So every navigation came back to the user as `Updated User Preference "ui.recent"`, and a fresh user's bell showed nothing else. + +- **What changed.** For a per-user UI-state object, the writer skips the activity row when the acting user is the row's owner (`user_id`). The list of such objects is closed and declared beside the writer's exclusion list. Today it holds one object, `sys_user_preference`. +- **What stays.** The `sys_audit_log` row for the same write is written exactly as before. Another actor's write to a user's preference row keeps its activity row: an organization administrator editing it from Setup, a system write with no user, or a write that gives the row a different owner. Writes to every other object are unchanged. +- **Existing rows.** Activity rows written before this release stay where they are. +- **Nothing to migrate.** No configuration is needed. diff --git a/packages/plugins/plugin-audit/src/audit-writers.test.ts b/packages/plugins/plugin-audit/src/audit-writers.test.ts index 77f162c882e..fa458a7920e 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.test.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.test.ts @@ -686,6 +686,106 @@ describe('audit writers — enable.activities opt-out gate (#2707)', () => { }); }); +/** + * [#22961] A user's own write to their own per-user UI-state row writes the + * ledger row and no activity row. Every other writer keeps today's rows. + * + * The rows below are the console's "recently visited" write as the real engine + * hands it to this hook: the session subject is the owner, and both `result` + * and the bound `previous` carry the full row with its `user_id`. + */ +describe('audit writers — a user\'s own per-user UI-state write is not mirrored (#22961)', () => { + const SCHEMA = { + sys_audit_log: SINGLE_TENANT.sys_audit_log, + sys_activity: SINGLE_TENANT.sys_activity, + sys_user_preference: ['id', 'user_id', 'key', 'value'], + crm_lead: ['id', 'name'], + }; + const OWNER = 'u_alice'; + const PREF = { id: 'pref-1', user_id: OWNER, key: 'ui.recent', value: [] as unknown[] }; + const rowsOn = (created: CapturedRow[], table: string, object: string) => + created.filter((c) => c.object === table && c.row.object_name === object); + + const OWN_WRITES = [ + { event: 'afterInsert', action: 'create', ctx: { input: { id: PREF.id }, result: PREF } }, + { + event: 'afterUpdate', + action: 'update', + ctx: { input: { id: PREF.id }, previous: PREF, result: { ...PREF, value: [{ id: 'acc-1' }] } }, + }, + { event: 'afterDelete', action: 'delete', ctx: { input: { id: PREF.id }, previous: PREF, result: { id: PREF.id } } }, + ] as const; + + for (const w of OWN_WRITES) { + it(`the owner's own ${w.action} writes the sys_audit_log row and no sys_activity row`, async () => { + const { engine, fire, created } = makeEngine(SCHEMA); + installAuditWriters(engine as any, 'test.audit'); + + await fire(w.event, { object: 'sys_user_preference', ...w.ctx, session: { userId: OWNER } }); + + const ledger = rowsOn(created, 'sys_audit_log', 'sys_user_preference'); + expect(ledger).toHaveLength(1); + expect(ledger[0].row.action).toBe(w.action); + expect(ledger[0].row.user_id).toBe(OWNER); + expect(ledger[0].row.record_id).toBe(PREF.id); + expect(created.filter((c) => c.object === 'sys_activity')).toHaveLength(0); + }); + } + + it('CONTROL: the same user\'s write to an ordinary audited object keeps its activity row', async () => { + const { engine, fire, created } = makeEngine(SCHEMA); + installAuditWriters(engine as any, 'test.audit'); + + await fire('afterUpdate', { + object: 'crm_lead', + input: { id: 'lead-1' }, + previous: { id: 'lead-1', name: 'Acme' }, + result: { id: 'lead-1', name: 'Acme Corp' }, + session: { userId: OWNER }, + }); + + expect(rowsOn(created, 'sys_audit_log', 'crm_lead')).toHaveLength(1); + const activity = rowsOn(created, 'sys_activity', 'crm_lead'); + expect(activity).toHaveLength(1); + expect(activity[0].row.actor_id).toBe(OWNER); + }); + + const OTHER_WRITERS = [ + // An organization admin editing the row from Setup's User Preferences grid. + { who: 'another user (an admin)', session: { userId: 'u_admin' }, previous: PREF, result: PREF, actor: 'u_admin' }, + // A system or service write: no acting user at all. + { who: 'a system write with no user', session: { isSystem: true }, previous: PREF, result: PREF, actor: null }, + // The owner moving the row to someone else: not purely their own row. + { + who: 'the owner reassigning the row to another user', + session: { userId: OWNER }, + previous: PREF, + result: { ...PREF, user_id: 'u_bob' }, + actor: OWNER, + }, + ] as const; + + for (const w of OTHER_WRITERS) { + it(`CONTROL: ${w.who} writing a user's preference row keeps its activity row`, async () => { + const { engine, fire, created } = makeEngine(SCHEMA); + installAuditWriters(engine as any, 'test.audit'); + + await fire('afterUpdate', { + object: 'sys_user_preference', + input: { id: PREF.id }, + previous: w.previous, + result: { ...w.result, value: [{ id: 'acc-2' }] }, + session: w.session, + }); + + expect(rowsOn(created, 'sys_audit_log', 'sys_user_preference')).toHaveLength(1); + const activity = rowsOn(created, 'sys_activity', 'sys_user_preference'); + expect(activity).toHaveLength(1); + expect(activity[0].row.actor_id).toBe(w.actor); + }); + } +}); + describe('audit writers — enable.feeds server-side enforcement (#2707)', () => { const SCHEMA = { sys_audit_log: SINGLE_TENANT.sys_audit_log, diff --git a/packages/plugins/plugin-audit/src/audit-writers.ts b/packages/plugins/plugin-audit/src/audit-writers.ts index d8491ce3aeb..790ceaaf910 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.ts @@ -155,6 +155,8 @@ export interface AuditWriterOptions { * Skip rules avoid recursion and noise: * - Never audit the audit/activity tables themselves. * - Never audit session/presence/auth tables (high-frequency, low value). + * - A user's own write to their own per-user UI-state row writes the ledger + * row and no activity row (`PER_USER_UI_STATE_OWNER_FIELD`). * - Read-only operations (`afterFind`) are not audited BY THIS WRITER. Since * #8992 the `read` action has its own writer in `read-audit.ts`, installed * separately and only over the objects a deployment opts in: record-detail @@ -260,6 +262,67 @@ const SKIP_OBJECTS = new Set([ */ export const AUDIT_EXCLUDED_OBJECTS: string[] = [...SKIP_OBJECTS]; +/** + * [#22961] Per-user UI-state objects: the ledger row stays, and the activity + * mirror is skipped for the owning user's OWN write only. + * + * The rule: what reaches a user's bell is another actor's act or something the + * user needs, never the user's own UI state. A `sys_activity` row reaches the + * bell (its Activity tab lists the newest rows the caller may read). A per-user + * UI-state row is re-saved by the client as a side effect of using the + * interface, and RLS keeps it private to its user, so the one reader of its + * activity row is the user who just caused it. Mirrored, every navigation came + * back as `Updated User Preference "ui.recent"`, and a fresh user's bell held + * nothing else. + * + * - CLOSED and DECLARED: an object is a member only by being listed here, + * mapped to the field that names its owning user. ⛔ Never a name heuristic. + * No spec key means "per-user UI state"; `enable.activities: false` mutes + * every writer, which is wider than this rule. + * - OWN WRITES ONLY: the user the activity row would name (`actor_id`) must be + * the owner on every side of the row the write carries. Another actor's + * write keeps its activity row: an organization admin editing the row from + * Setup's User Preferences grid (`modifyAllRecords` passes the self-only + * RLS), a system write with no user, a write that moves the row to another + * owner. + * - ACTIVITY ONLY: the `sys_audit_log` row is written exactly as before. That + * is why this is not `SKIP_OBJECTS`, which drops the ledger row too and is + * the `AUDIT_EXCLUDED_OBJECTS` list the hook registration and `read-audit.ts` + * share. + * + * Membership as measured: the console keeps all of its per-user UI state + * (`ui.recent`, `ui.favorites`, `ui.flow.palette.recents`) on + * `sys_user_preference`, and no other per-user UI-state object exists. A new + * one joins by a line here. + */ +const PER_USER_UI_STATE_OWNER_FIELD: ReadonlyMap = new Map([ + ['sys_user_preference', 'user_id'], +]); + +/** + * Whether this write is a user's own write to their own per-user UI-state row + * ({@link PER_USER_UI_STATE_OWNER_FIELD}): the object is listed, an acting user + * is known, and every side of the row that names an owner names that user. + * A side without the owner field (a delete's result is often just `{ id }`) is + * not evidence either way; no side naming an owner at all answers `false`. + */ +function isOwnUiStateWrite( + object: string, + actorUserId: string | undefined, + before: unknown, + after: unknown, +): boolean { + const ownerField = PER_USER_UI_STATE_OWNER_FIELD.get(object); + if (!ownerField || !actorUserId) return false; + const owners: string[] = []; + for (const side of [before, after]) { + if (!side || typeof side !== 'object') continue; + const owner = (side as Record)[ownerField]; + if (owner !== undefined && owner !== null) owners.push(String(owner)); + } + return owners.length > 0 && owners.every((owner) => owner === actorUserId); +} + /** Fields that are noise in diffs (always change, never user-meaningful). */ const NOISE_FIELDS = new Set([ 'updated_at', @@ -2012,6 +2075,11 @@ export function installAuditWriters( // lever for activity-row growth (ADR-0057). The compliance audit row is // NOT gated — sys_audit_log capture stays unconditional. const activitiesEnabled = getObjectDef(ctx.object)?.enable?.activities !== false; + // [#22961] A user's own write to their own per-user UI-state row is not + // mirrored either (PER_USER_UI_STATE_OWNER_FIELD states the rule). Judged + // on `userId`, the same value `actor_id` would carry. The ledger row is + // untouched. + const mirrorsActivity = activitiesEnabled && !isOwnUiStateWrite(ctx.object, userId, before, after); // [#22510, #22527] The name and avatar of the user `actor_id` names — the // SAME `userId`, so the columns cannot disagree about who acted — from one // memoized `sys_user` read. Read only when an activity row will be @@ -2019,7 +2087,7 @@ export function installAuditWriters( // actor `null`, and rendering it as "System" (with its icon) is the UI's // rule, not data this writer persists. const actor: ActorSnapshot = - activitiesEnabled && userId ? await resolveActorSnapshot(api, userId) : NO_ACTOR_SNAPSHOT; + mirrorsActivity && userId ? await resolveActorSnapshot(api, userId) : NO_ACTOR_SNAPSHOT; const activityRow: Record = { type: activityType, @@ -2055,7 +2123,7 @@ export function installAuditWriters( activityRow.organization_id = tenantId ?? null; } - const activityRowToWrite = activitiesEnabled ? activityRow : undefined; + const activityRowToWrite = mirrorsActivity ? 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' }; From 01008dad0235c4545ad1eae4080801b6622f80b2 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 07:52:24 +0000 Subject: [PATCH 2/4] test(plugin-audit): reword the per-user UI-state control titles Claude-Session: https://claude.ai/code/session_01CeRCvvvjxbxu2iYJAc5Cce Co-authored-by: Claude --- packages/plugins/plugin-audit/src/audit-writers.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/plugins/plugin-audit/src/audit-writers.test.ts b/packages/plugins/plugin-audit/src/audit-writers.test.ts index fa458a7920e..cb6bf00c8f5 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.test.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.test.ts @@ -752,12 +752,12 @@ describe('audit writers — a user\'s own per-user UI-state write is not mirrore const OTHER_WRITERS = [ // An organization admin editing the row from Setup's User Preferences grid. - { who: 'another user (an admin)', session: { userId: 'u_admin' }, previous: PREF, result: PREF, actor: 'u_admin' }, + { who: "another user's write (an admin)", session: { userId: 'u_admin' }, previous: PREF, result: PREF, actor: 'u_admin' }, // A system or service write: no acting user at all. { who: 'a system write with no user', session: { isSystem: true }, previous: PREF, result: PREF, actor: null }, // The owner moving the row to someone else: not purely their own row. { - who: 'the owner reassigning the row to another user', + who: 'the owner moving the row to another user', session: { userId: OWNER }, previous: PREF, result: { ...PREF, user_id: 'u_bob' }, @@ -766,7 +766,7 @@ describe('audit writers — a user\'s own per-user UI-state write is not mirrore ] as const; for (const w of OTHER_WRITERS) { - it(`CONTROL: ${w.who} writing a user's preference row keeps its activity row`, async () => { + it(`CONTROL: on a user's preference row, ${w.who} keeps its activity row`, async () => { const { engine, fire, created } = makeEngine(SCHEMA); installAuditWriters(engine as any, 'test.audit'); From 2632f1ee51513dbc4777a8f63b62d966e9968544 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 07:52:40 +0000 Subject: [PATCH 3/4] test(plugin-audit): single-quote the admin control title Claude-Session: https://claude.ai/code/session_01CeRCvvvjxbxu2iYJAc5Cce Co-authored-by: Claude --- packages/plugins/plugin-audit/src/audit-writers.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/plugin-audit/src/audit-writers.test.ts b/packages/plugins/plugin-audit/src/audit-writers.test.ts index cb6bf00c8f5..488c9fa906d 100644 --- a/packages/plugins/plugin-audit/src/audit-writers.test.ts +++ b/packages/plugins/plugin-audit/src/audit-writers.test.ts @@ -752,7 +752,7 @@ describe('audit writers — a user\'s own per-user UI-state write is not mirrore const OTHER_WRITERS = [ // An organization admin editing the row from Setup's User Preferences grid. - { who: "another user's write (an admin)", session: { userId: 'u_admin' }, previous: PREF, result: PREF, actor: 'u_admin' }, + { who: 'a write by another user (an admin)', session: { userId: 'u_admin' }, previous: PREF, result: PREF, actor: 'u_admin' }, // A system or service write: no acting user at all. { who: 'a system write with no user', session: { isSystem: true }, previous: PREF, result: PREF, actor: null }, // The owner moving the row to someone else: not purely their own row. From 03373429f16960344d6b012cef06d50c807d6191 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 08:15:16 +0000 Subject: [PATCH 4/4] docs(changeset): say when pre-existing activity rows leave the feed Claude-Session: https://claude.ai/code/session_01CeRCvvvjxbxu2iYJAc5Cce Co-authored-by: Claude --- .changeset/22961-plugin-audit-own-ui-state-no-activity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/22961-plugin-audit-own-ui-state-no-activity.md b/.changeset/22961-plugin-audit-own-ui-state-no-activity.md index cf34457e72b..6a2db9dcb11 100644 --- a/.changeset/22961-plugin-audit-own-ui-state-no-activity.md +++ b/.changeset/22961-plugin-audit-own-ui-state-no-activity.md @@ -10,5 +10,5 @@ The console saves the user's "recently visited" list (`ui.recent`), favourites a - **What changed.** For a per-user UI-state object, the writer skips the activity row when the acting user is the row's owner (`user_id`). The list of such objects is closed and declared beside the writer's exclusion list. Today it holds one object, `sys_user_preference`. - **What stays.** The `sys_audit_log` row for the same write is written exactly as before. Another actor's write to a user's preference row keeps its activity row: an organization administrator editing it from Setup, a system write with no user, or a write that gives the row a different owner. Writes to every other object are unchanged. -- **Existing rows.** Activity rows written before this release stay where they are. +- **Existing rows.** Activity rows written before this release are not deleted. They leave the feed when the `sys_activity` retention window (14 days) removes them. - **Nothing to migrate.** No configuration is needed.