Skip to content

Commit d2152c4

Browse files
committed
fix(plugin-audit): write sys_activity.actor_name — the acting user's display name, captured with the row
The audit writer filled sys_activity.actor_id and never actor_name, a column the object declares and lists among its highlight fields, so every record History entry read "Unknown user". The name is now the acting user's sys_user title, read once per user (30 s memo) and only when an activity row is written. An unreadable name stays null, never the id; a write with no user stays null (ADR-0118 D1). Claude-Session: https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4e9fe9f commit d2152c4

3 files changed

Lines changed: 371 additions & 6 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/plugin-audit': patch
3+
---
4+
5+
`sys_activity.actor_name` is written: a user's create, update or delete records that user's display name on its activity row, so the record History tab names who acted instead of "Unknown user"
6+
7+
Clause-②: no
8+
9+
`sys_activity` declares `actor_name` and lists it among its highlight fields, because activity
10+
entries are denormalized snapshots read in time order. The audit writer filled `actor_id` and never
11+
`actor_name`, so every row carried the user's id and no name. A renderer that reads the name, such
12+
as the record History tab, showed "Unknown user" for every entry, for every user.
13+
14+
The writer now records the name when it writes the row. The name is the title of the user's
15+
`sys_user` row (its `name` column), read once per user and kept for 30 seconds. A predicate write
16+
over many rows costs one `sys_user` read for its actor, not one per row.
17+
18+
- **A user write.** `actor_name` is the user's display name. `actor_id` is unchanged, and the name
19+
is read for that same id. This includes a write the system authorized on behalf of a credited
20+
user (`attributedUserId`).
21+
- **A user whose name cannot be read** (no row, or a blank name). `actor_name` stays empty. The
22+
writer never puts the id in it.
23+
- **A write with no user** (boot, migration, a scheduled job, a flow running as the system).
24+
`actor_name` stays empty, as before. Per ADR-0118 D1 the system actor is `null`, and the UI
25+
renders the "System" label for it.
26+
- **An object with `enable.activities: false`.** No activity row is written and no name is read,
27+
as before.
28+
29+
Rows written before this change keep `actor_id` and no name. This change does not backfill them.
30+
Nothing you author changes: no key, option, export or type is added or removed.
Lines changed: 280 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,280 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22510] `sys_activity.actor_name` is WRITTEN — the acting user's display
5+
* name, captured when the activity row is written.
6+
*
7+
* `sys_activity` declares the column (and lists it among its highlight
8+
* fields) because its entries are "denormalized snapshots" read
9+
* chronologically. No writer filled it: every row carried `actor_id` and a
10+
* null `actor_name`, and the record History tab — which reads `actor_name` —
11+
* showed "Unknown user" for every entry, for every user.
12+
*
13+
* Pinned through the REAL engine (`ObjectQL`), so the user travels the way it
14+
* does in production — `ExecutionContext.userId` → the hook session — rather
15+
* than as a hand-built hook context:
16+
*
17+
* 1. a user's write names that user, on create, update and delete alike;
18+
* 2. `actor_id` is unchanged, and is the id the name was read for (control);
19+
* 3. a user whose name cannot be read leaves `actor_name` empty — never the
20+
* id, which would read as a plausible name;
21+
* 4. a write with no user leaves it empty too: ADR-0118 D1 keeps the system
22+
* actor `null`, and its "System" label is a rendering rule, not data;
23+
* 5. the read is one per user, not one per row — `writeAudit` runs once per
24+
* row of a predicate write, and this is its hot path.
25+
*/
26+
27+
import { describe, it, expect } from 'vitest';
28+
import { ObjectQL } from '@objectstack/objectql';
29+
import { installAuditWriters } from './audit-writers.js';
30+
31+
const f = (name: string, type: string, extra: Record<string, unknown> = {}) =>
32+
({ name, label: name, type, ...extra }) as any;
33+
34+
const sysAuditLog = {
35+
name: 'sys_audit_log', label: 'Audit Log',
36+
fields: {
37+
id: f('id', 'text', { primaryKey: true }), action: f('action', 'text'),
38+
user_id: f('user_id', 'text'), object_name: f('object_name', 'text'),
39+
record_id: f('record_id', 'text'), old_value: f('old_value', 'textarea'),
40+
new_value: f('new_value', 'textarea'), tenant_id: f('tenant_id', 'text'),
41+
},
42+
};
43+
44+
/** The two actor columns the shipped `sys_activity` declares, both present. */
45+
const sysActivity = {
46+
name: 'sys_activity', label: 'Activity',
47+
fields: {
48+
id: f('id', 'text', { primaryKey: true }), type: f('type', 'text'),
49+
timestamp: f('timestamp', 'datetime'), summary: f('summary', 'text'),
50+
actor_id: f('actor_id', 'text'), actor_name: f('actor_name', 'text'),
51+
object_name: f('object_name', 'text'), record_id: f('record_id', 'text'),
52+
record_label: f('record_label', 'text'), metadata: f('metadata', 'textarea'),
53+
},
54+
};
55+
56+
/** `nameField: 'name'`, as the shipped `sys_user` declares it. */
57+
const sysUser = {
58+
name: 'sys_user', label: 'User', nameField: 'name',
59+
fields: {
60+
id: f('id', 'text', { primaryKey: true }), name: f('name', 'text'),
61+
email: f('email', 'email'),
62+
},
63+
};
64+
65+
const bizTask = {
66+
name: 'biz_task', label: 'Task',
67+
fields: { id: f('id', 'text', { primaryKey: true }), title: f('title', 'text'), status: f('status', 'text') },
68+
};
69+
70+
/** `enable.activities: false` — this object's writes produce no activity row. */
71+
const bizQuiet = {
72+
name: 'biz_quiet', label: 'Quiet', enable: { activities: false },
73+
fields: { id: f('id', 'text', { primaryKey: true }), title: f('title', 'text') },
74+
};
75+
76+
/** Copy-returning, read-counting store — the shape `audit-lookup-summary.test.ts` measures with. */
77+
function makeCountingDriver() {
78+
const stores = new Map<string, Map<string, Record<string, unknown>>>();
79+
const reads: Record<string, number> = {};
80+
const storeFor = (o: string) => {
81+
let s = stores.get(o);
82+
if (!s) { s = new Map(); stores.set(o, s); }
83+
return s;
84+
};
85+
let nextId = 0;
86+
const copy = <T,>(r: T): T => (r == null ? r : JSON.parse(JSON.stringify(r)));
87+
const matches = (row: Record<string, unknown>, where: any): boolean => {
88+
if (!where || typeof where !== 'object') return true;
89+
for (const [k, v] of Object.entries<any>(where)) {
90+
if (k === '$and' && Array.isArray(v)) {
91+
if (!v.every((sub) => matches(row, sub))) return false;
92+
continue;
93+
}
94+
if (k.startsWith('$')) continue;
95+
if (v && typeof v === 'object' && '$in' in v) {
96+
if (!(v.$in as unknown[]).includes(row[k])) return false;
97+
continue;
98+
}
99+
const expected = (v && typeof v === 'object' && '$eq' in v) ? v.$eq : v;
100+
if ((row[k] ?? null) !== (expected ?? null)) return false;
101+
}
102+
return true;
103+
};
104+
const driver: any = {
105+
name: 'memory', version: '0.0.0', supports: {} as any,
106+
async connect() {}, async disconnect() {}, async checkHealth() { return true; },
107+
async execute() { return null; },
108+
async find(object: string, ast: any) {
109+
reads[object] = (reads[object] ?? 0) + 1;
110+
return Array.from(storeFor(object).values()).filter((r) => matches(r, ast?.where)).map(copy);
111+
},
112+
async findOne(object: string, ast: any) {
113+
for (const r of storeFor(object).values()) if (matches(r, ast?.where)) return copy(r);
114+
return null;
115+
},
116+
async create(object: string, data: Record<string, unknown>) {
117+
nextId += 1;
118+
const id = (data.id as string) ?? `r_${nextId}`;
119+
const row = { ...data, id };
120+
storeFor(object).set(id, row);
121+
return copy(row);
122+
},
123+
async update(object: string, id: string, data: Record<string, unknown>) {
124+
const s = storeFor(object);
125+
const cur = s.get(id);
126+
if (!cur) return null;
127+
const updated = { ...cur, ...data, id };
128+
s.set(id, updated);
129+
return copy(updated);
130+
},
131+
async upsert(object: string, data: Record<string, unknown>) {
132+
const id = data.id as string | undefined;
133+
return id && storeFor(object).has(id) ? this.update(object, id, data) : this.create(object, data);
134+
},
135+
async delete(object: string, id: string) { return storeFor(object).delete(id); },
136+
async count(object: string, ast: any) { return (await this.find(object, ast)).length; },
137+
async bulkCreate(object: string, rows: Record<string, unknown>[]) {
138+
return Promise.all(rows.map((r) => this.create(object, r)));
139+
},
140+
async bulkUpdate() { return []; },
141+
async bulkDelete() {},
142+
async updateMany(object: string, ast: any, data: Record<string, unknown>) {
143+
const rows = await this.find(object, ast);
144+
const s = storeFor(object);
145+
for (const r of rows) s.set(r.id as string, { ...s.get(r.id as string), ...data, id: r.id });
146+
return rows.length;
147+
},
148+
async deleteMany(object: string, ast: any) {
149+
const rows = await this.find(object, ast);
150+
for (const r of rows) storeFor(object).delete(r.id as string);
151+
return rows.length;
152+
},
153+
async beginTransaction() { return { commit: async () => {}, rollback: async () => {} }; },
154+
async commit() {}, async rollback() {},
155+
};
156+
return { driver, reads, storeFor };
157+
}
158+
159+
const OWNER_PACKAGE = 'com.objectstack.test.activity-actor-name';
160+
const ADA = 'usr_ada';
161+
162+
async function boot() {
163+
const engine = new ObjectQL();
164+
const stub = makeCountingDriver();
165+
engine.registerDriver(stub.driver, true);
166+
await engine.init();
167+
for (const o of [sysAuditLog, sysActivity, sysUser, bizTask, bizQuiet]) {
168+
engine.registry.registerObject(o as any, OWNER_PACKAGE);
169+
}
170+
installAuditWriters(engine as any, 'test.audit');
171+
// Seeded with no context: a system write, which names nobody.
172+
await engine.insert('sys_user', { id: ADA, name: 'Ada Lovelace', email: 'ada@example.com' });
173+
return { engine, ...stub };
174+
}
175+
176+
const as = (userId: string) => ({ context: { userId } });
177+
178+
/** Every activity row written about `recordId`, in write order. */
179+
const activityFor = (storeFor: (o: string) => Map<string, Record<string, unknown>>, recordId: string) =>
180+
Array.from(storeFor('sys_activity').values()).filter((r) => r.record_id === recordId);
181+
182+
describe('[#22510] sys_activity.actor_name — the acting user\'s name, written with the row', () => {
183+
it('a user\'s create, update and delete each carry that user\'s name', async () => {
184+
const { engine, storeFor } = await boot();
185+
186+
await engine.insert('biz_task', { id: 't1', title: 'Call Acme', status: 'open' }, as(ADA) as any);
187+
await engine.update('biz_task', { status: 'done' }, { where: { id: 't1' }, ...as(ADA) } as any);
188+
await engine.delete('biz_task', { where: { id: 't1' }, ...as(ADA) } as any);
189+
190+
const rows = activityFor(storeFor, 't1');
191+
expect(rows.map((r) => r.type)).toEqual(['created', 'updated', 'deleted']);
192+
for (const row of rows) {
193+
expect(row.actor_name).toBe('Ada Lovelace');
194+
// Control: `actor_id` is exactly what it was — the same id the name was read for.
195+
expect(row.actor_id).toBe(ADA);
196+
}
197+
});
198+
199+
it('names the human a system-authorized write is ATTRIBUTED to — the same id `actor_id` records', async () => {
200+
const { engine, storeFor } = await boot();
201+
202+
// The better-auth envelope: authorized as the system, crediting a human
203+
// through provenance (`attributedUserId`) rather than the session.
204+
await engine.insert(
205+
'biz_task',
206+
{ id: 't2', title: 'Grade change', status: 'open' },
207+
{ context: { isSystem: true, attributedUserId: ADA } } as any,
208+
);
209+
210+
const [row] = activityFor(storeFor, 't2');
211+
expect(row.actor_id).toBe(ADA);
212+
expect(row.actor_name).toBe('Ada Lovelace');
213+
});
214+
215+
it('a user whose name cannot be read leaves actor_name empty — never the id', async () => {
216+
const { engine, storeFor } = await boot();
217+
await engine.insert('sys_user', { id: 'usr_blank', name: ' ', email: 'blank@example.com' });
218+
219+
// No `sys_user` row at all, and a row whose name is blank.
220+
await engine.insert('biz_task', { id: 't3', title: 'Ghost', status: 'open' }, as('usr_ghost') as any);
221+
await engine.insert('biz_task', { id: 't4', title: 'Blank', status: 'open' }, as('usr_blank') as any);
222+
223+
const [ghost] = activityFor(storeFor, 't3');
224+
const [blank] = activityFor(storeFor, 't4');
225+
expect(ghost.actor_id).toBe('usr_ghost');
226+
expect(ghost.actor_name ?? null).toBeNull();
227+
expect(blank.actor_id).toBe('usr_blank');
228+
expect(blank.actor_name ?? null).toBeNull();
229+
});
230+
231+
it('a write with no user leaves actor_name empty (ADR-0118 D1: the system actor is null) and reads no user', async () => {
232+
const { engine, storeFor, reads } = await boot();
233+
const before = reads.sys_user ?? 0;
234+
235+
await engine.insert('biz_task', { id: 't5', title: 'Nightly', status: 'open' }, { context: { isSystem: true } } as any);
236+
// A service principal on `actor` is not a user either (ADR-0118 D5: user or system).
237+
await engine.insert(
238+
'biz_task',
239+
{ id: 't6', title: 'Flow', status: 'open' },
240+
{ context: { isSystem: true, actor: 'svc:flow:nightly' } } as any,
241+
);
242+
243+
for (const id of ['t5', 't6']) {
244+
const [row] = activityFor(storeFor, id);
245+
expect(row.actor_id).toBeNull();
246+
expect(row.actor_name ?? null).toBeNull();
247+
}
248+
expect((reads.sys_user ?? 0) - before).toBe(0);
249+
});
250+
});
251+
252+
describe('[#22510] the name costs one read per user, not one per row', () => {
253+
it('a predicate update over three rows reads sys_user ONCE, and a later write by the same user not at all', async () => {
254+
const { engine, storeFor, reads } = await boot();
255+
for (const id of ['p1', 'p2', 'p3']) {
256+
await engine.insert('biz_task', { id, title: id, status: 'open' });
257+
}
258+
const before = reads.sys_user ?? 0;
259+
260+
await engine.update('biz_task', { status: 'done' }, { where: { status: 'open' }, multi: true, ...as(ADA) } as any);
261+
262+
const updated = Array.from(storeFor('sys_activity').values()).filter((r) => r.type === 'updated');
263+
expect(updated).toHaveLength(3);
264+
for (const row of updated) expect(row.actor_name).toBe('Ada Lovelace');
265+
expect((reads.sys_user ?? 0) - before).toBe(1);
266+
267+
await engine.update('biz_task', { title: 'renamed' }, { where: { id: 'p1' }, ...as(ADA) } as any);
268+
expect((reads.sys_user ?? 0) - before).toBe(1);
269+
});
270+
271+
it('an object with `enable.activities: false` writes no activity row and reads no user', async () => {
272+
const { engine, storeFor, reads } = await boot();
273+
const before = reads.sys_user ?? 0;
274+
275+
await engine.insert('biz_quiet', { id: 'q1', title: 'Quiet' }, as(ADA) as any);
276+
277+
expect(activityFor(storeFor, 'q1')).toHaveLength(0);
278+
expect((reads.sys_user ?? 0) - before).toBe(0);
279+
});
280+
});

0 commit comments

Comments
 (0)