Repository navigation
Commit d1d42ec
fix(plugin-audit): sys_activity.actor_name carries the acting user's display name, written with the row (#22526)
Fixes #22510
Clause-②: no
## What this changes
`sys_activity` declares `actor_name` and lists it among its
`highlightFields`, because its entries are "denormalized snapshots
optimized for chronological … reads" (`sys-activity.object.ts`
docblock). The one writer of that table, `plugin-audit`'s `writeAudit`
(`audit-writers.ts`), filled `actor_id` and never `actor_name`. So every
row named the user by id only, and the record History tab, which reads
`actor_name`, showed "Unknown user" for every entry.
The writer now records the acting user's display name when it writes the
activity row:
- **The source** is the title of the user's `sys_user` row, through the
writer's existing `resolveLookupTitles`. That is the ADR-0079 title for
`sys_user` (`nameField: 'name'`, a required column). It is the same
column the approval timeline reads its actor names from, and the same
title this writer already renders for a `user` reference in a
tracked-change summary.
- **The id is the same id.** The name is read for the `userId` that
lands in `actor_id`, so the two columns cannot disagree. This includes
the `attributedUserId` channel, a better-auth write authorized as the
system and credited to a human.
- **An unreadable name stays empty, never the id.** No row, a blank
name, an unregistered `sys_user` or a failing read all leave
`actor_name` unset. An id in a name column reads as a plausible name,
and `actor_id` still says who acted.
- **A write with no user stays empty, as before.** This is ADR-0118 D1.
See "Open decision" below.
- **Cost.** The name is read only when an activity row will be written
(`enable.activities` is not `false`) and a user is present. It is
memoized per user for 30 s, and the promise itself is cached, so a
predicate write over N rows costs one `sys_user` read, not N.
`writeAudit` is dispatched once per row, and this is the hot path that
#6656 / PR #6977 took reads off.
- ⛔ Nothing is resolved on read in the server, `sys-activity.object.ts`
is unchanged, and rows already written are not backfilled. The renderer
half for those rows is objectstack-ai/objectui#12067.
Files: `packages/plugins/plugin-audit/src/audit-writers.ts`,
`packages/plugins/plugin-audit/src/activity-actor-name.test.ts` (new)
and `.changeset/22510-activity-actor-name.md` (`patch`,
`@objectstack/plugin-audit`).
## Reproduction on a real stack (before → after)
Stack: `pnpm dev:crm -- --fresh -p PORT`, built from this worktree.
Before = `origin/main` `4e9fe9ff6`; after = this branch at `d2152c41d`.
The head `82440a8b4` merges `origin/main` `ee8751d41` and carries the
same `audit-writers.ts` blob (`fecc7a3d630c`). A sales rep (`Riley Rep`,
created by the admin through `POST /api/v1/auth/admin/create-user`,
given the `sales_rep` position through `sys_user_position`) creates a
`crm_lead` and then edits it through `/api/v1/data/crm_lead`. The lead's
History source rows are read through `GET /api/v1/data/sys_activity`
(filter `object_name` + `record_id`).
| | before (`4e9fe9ff6`) | after (`d2152c41d`) |
|---|---|---|
| rep's `created` row | `actor_id` = rep id, `actor_name: null` |
`actor_id` = rep id (unchanged), `actor_name: "Riley Rep"` |
| rep's `updated` row | `actor_id` = rep id, `actor_name: null` |
`actor_id` = rep id (unchanged), `actor_name: "Riley Rep"` |
| admin's `updated` row | (not measured before) | `actor_id` = admin id,
`actor_name: "Dev Admin"` |
| census of every `sys_activity` row on the fresh stack | 38 rows: 29
with `actor_id` null and no name, 9 with a user and **no name** | 39
rows: 29 with `actor_id` null and no name (unchanged), 10 with a user
and **a name** (7 Dev Admin, 3 Riley Rep) |
## Pins (`src/activity-actor-name.test.ts`, through the real `ObjectQL`
engine and a copy-returning, read-counting driver)
| # | pin | kind |
|---|---|---|
| 1 | a user's create, update and delete each carry that user's name;
`actor_id` is the same id (control) | positive + control |
| 2 | a system-authorized write credited through `attributedUserId`
names that human; `actor_id` is the same id | positive + control |
| 3 | a user with no `sys_user` row, and a user with a blank name, leave
`actor_name` empty, never the id | negative |
| 4 | a write with no user (`isSystem`), and one by a service principal
on `actor`, leave `actor_name` empty and read no user (ADR-0118 D1 / D5)
| negative |
| 5 | a predicate update over 3 rows names all 3 and costs 1 `sys_user`
read; a later write by the same user costs 0 | cost |
| 6 | an object with `enable.activities: false` writes no activity row
and reads no user | cost |
Pin 1 reads the STORED `sys_activity` row (the driver's store), not a
renderer. That is the server-side proof for every reader of the column.
## Ablation (each leg through `scripts/ablation-replace.mjs` WRAP mode,
the fix committed first, restore proven to the HEAD blob `fecc7a3d630c`,
`git diff HEAD` empty; last run on head `82440a8b4`)
| leg | mutation in `audit-writers.ts` | result |
|---|---|---|
| A | delete the `actor_name` stamp from the row literal | pins 1, 2, 5
red (`expected undefined to be 'Ada Lovelace'`), 3 / 4 / 6 green, 3
failed / 3 passed |
| B | an unreadable name falls back to the id (`?? null` → `?? userId`)
| pin 3 red (`expected 'usr_ghost' to be null`), 1 failed / 5 passed |
| B2 | a write with no user writes `'System'` | pin 4 red (`expected
'System' to be null`), 1 failed / 5 passed |
| C2 | the memo never hits | pin 5 red (`expected 3 to be 1`), 1 failed
/ 5 passed |
| D2 | the `enable.activities` gate is ignored for the read | pin 6 red
(`expected 1 to be +0`), 1 failed / 5 passed |
Two first attempts did nothing and are reported, not hidden. Leg C's
anchor also matched the locale memo (`ANCHOR AMBIGUOUS`, nothing
written). Leg D's replacement was a substring of its own anchor, so the
tool refused its evidence and restored. Both were re-run with unique
anchors as C2 and D2 above. The test imports `./audit-writers.js` from
`src`, so no `dist` rebuild was involved in any leg.
## Open decision: the system-context label (not implemented here, so the
line above is `Part of`)
Triage asked that "a system-context write records the platform's system
label, not null". That conflicts with an accepted ADR, which binds until
a superseding ADR says otherwise (AGENTS.md Prime Directive #13).
ADR-0118 D1, verbatim:
> **显示是渲染规则,不是数据**:UI 对系统写入行的空 actor 渲染「系统」(走 i18n),不落库。
D5 keeps the actor two-valued: a user, or the system as `null`. No named
constant for a system label exists in this repository. The only label is
objectui's `detail.systemActor` i18n key (`'System'`, `'系统'`, …).
objectui's activity feed (`recordActivityFeed.ts`, `row.actor_name ??
systemActorLabel`) already renders it per viewer for a row with no name.
So this PR leaves system rows exactly as they were and pins that (pin 4;
leg B2 shows the pin catches a written label). The question is handed to
the seat. If the maintainer rules for a written label, the change is
that pin, the one line it guards, and an ADR-0118 amendment.
## Gates
All measured on head `82440a8b4` (this branch merged with `origin/main`
`ee8751d41`), after a full workspace rebuild.
- `pnpm --filter @objectstack/plugin-audit test`: 43 files, 678 tests
passed (exit 0, through `os-verify-lock`).
- `pnpm --filter @objectstack/plugin-audit typecheck`: exit 0. The new
test file is in `tsconfig.test.json`'s program (`--listFiles` count 1),
and `check:test-typecheck` is OK.
- `node scripts/pm/dispatch-gates.mjs --commands --repo
objectstack-ai/objectstack`: 67 commands derived from the 3 changed
paths. All 67 were run with their exit codes recorded, and all 67 exited
0. `--ran` reconcile: "67 derived famil(ies) accounted for — 67 run, 0
NOT-MEASURED".
- On the first battery run (pre-merge head `d2152c41d`),
`check:objectql-double-limit` went red: the new test's store double
ignored the caller's `limit` and could not be probed. Fixed in
`0cff02f38`: the double applies the bound after the filter, and reads
are counted by a spy outside it. The gate then graded it conforming (278
conforming doubles, up from 277; none new in the baseline).
- Lint, narrowed: `eslint --no-inline-config --format json` over the 2
touched TypeScript files gave 2 files, 0 errors, 0 warnings. Both are in
the config's population (`--print-config` exit 0). This repo's
`eslint.config.mjs` never enables type-aware linting, so this diff
cannot move the verdict on an untouched file. The repo-wide `pnpm lint`
is CI's.
- NOT MEASURED locally (CI-owned): the path-scheduled CI jobs (Test Core
shards, Dogfood, Temporal Conformance, Build Core) and the workspace
type-check lanes that `dispatch-gates` lists outside its runnable set.
## Acceptance notes
- **The system-actor question is ruled** (seat, `6087899596`): option A,
under accepted ADR-0118 D1. A system write keeps `actor_name` null, and
the label is the renderer's. The open decision is closed, so line 1 is
`Fixes #22510`.
- **One write covers all three renderers.** `record:history`,
`record:activity` and the record Discussion feed all read the same
`sys_activity.actor_name` column. The `repo:hotcrm` seat measured the
second and third on 17.7.0: every entry was authored "System", because
objectui's feed reads `actor_name ?? systemActorLabel`. The same null
showed as "Unknown user" in History. This PR fills the column at write
time, so all three get the name from the row. The proof is the
server-side pin on the stored row (pin 1) and the after-stack rows
above. ⛔ No objectui edit: the renderers, and rows written before this
change, are objectstack-ai/objectui#12067's lane. One case still reaches
the renderers with no name: a user row whose name cannot be read (pin
3). The feed shows that row as "System" until card objectui#12067's
`actor_id` lookup lands.
- **Premise correction (no effect on the direction).** Triage cites
`approval-service.ts:7103` as "another writer already fills it".
Measured, that line is in `listActions`. It enriches
`sys_approval_action` rows with `actor_name` when they are read, for the
approval timeline. It is not a `sys_activity` writer and does not run at
write time. No writer anywhere filled `sys_activity.actor_name`: zero
`actor_name` writes in `git grep` over `packages/**` at `4e9fe9ff6`, and
0 of 9 user rows named on the before stack. The "fill it" direction
still stands on the object's own docblock.
- **Email arm not copied.** The approval reader falls back from `name`
to `email`. This writer reads `name` only. `sys_user.name` is `required:
true`, and a denormalized row every record reader can see is not where
an address should be copied.
- **Staleness is bounded and deliberate.** A rename shows on rows
written after the 30 s window. Rows already written keep the name they
were written with, which is what a snapshot is.
- **Observed, not filed (a deliberate ruling).** On `examples/app-crm`,
a `sales_rep` gets `403 PERMISSION_DENIED` on `GET
/api/v1/data/sys_activity`, even for its own lead's rows, so its History
tab is empty there. `default-permission-sets.ts` excludes `sys_activity`
on purpose ("a separate question if it ever matters"). hotcrm grants it.
- **Found, for the seat to route (same family, not fixed here).**
- `sys_activity.actor_avatar_url` is declared and never written either.
On the after stack, the admin's `sys_user.image` was set through `POST
/api/v1/auth/update-user`, and the admin's next edit wrote
`actor_avatar_url: null`. All 39 rows carry null. objectui reads it
(`record-history.tsx` `user_avatar`, `recordActivityFeed.ts`
`actorAvatarUrl`).
- objectui `record-history.tsx` renders a system row (no `actor_id`, no
name) as "Unknown user". ADR-0118 D1 says such a row renders the
localized "System" label, which its sibling `recordActivityFeed.ts`
already does. The `actor_id` fallback in objectstack-ai/objectui#12067
does not reach a row with no `actor_id`.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01WYYhVJ78u7PhwFViWo1EmQ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent d85615d commit d1d42ec
3 files changed
Lines changed: 376 additions & 6 deletions
File tree
- .changeset
- packages/plugins/plugin-audit/src
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
0 commit comments