Commit 6f17d1d
fix(approvals): actor_id records the person who decided, acted_as the slot it was taken as; stored slot literals move out at boot (#21493)
Fixes #21411
Clause-②: no
## What changes
`sys_approval_action.actor_id` is a lookup to `sys_user`. Under ADR-0118
D1 it holds a user id or nothing. A slot-gated action recorded the SLOT
it took there instead: the literal `position:P` for a position staffed
after the request opened, or an email for a `user` approver authored as
one.
Measured on a booted showcase at `main` `53fd35e3e`: a position-slot
approve wrote 8 rows across both databases, and none of them names the
person who decided. The 3 audit-ledger rows carry `user_id` / `actor`
null, the 3 activity rows carry `actor_id` null, and the action row
carries `actor_id` `position:finance`. That is the raise to p1.
Triage ruling B (`5954022700`), with its retriage answers
(`5960329631`), puts the person and the slot in two columns.
- **Column.** `sys_approval_action.acted_as` (text, max 255) holds the
pending-approver slot the action was taken as, in its stored spelling.
`actor_id` gains its ADR-0118 D1 describe. Both are in `highlightFields`
and the `recent` / `all_actions` grids, as `via_override` was.
Translations are regenerated, with zh-CN, ja-JP and es-ES written by
hand.
- **Writes.**
- Every insert in `decideNode`, `sendBack` (revise and auto-reject),
`reassign`, `requestInfo`, `comment`, `recall`, `resubmit` and `remind`
records `actor_id: recordedActor(actorId, context)`. That is the person
the context vouches for, never an address the caller named. The
slot-gated ones also record `acted_as: slot ?? null`.
- A system context that vouches for nobody records nobody. One
exception, kept by name: the SLA sweep's reserved `system:sla`, which is
#21455's.
- The action link (`redeemActionToken`) now puts the person behind the
token's slot on the context: a user id as itself, an email as the ONE
account carrying it, otherwise nobody. Before, it put the slot itself
there, so an email-bound link recorded the email as the acting user, on
the action row and in the status mirror alike.
- **Readers.**
- The multi-approver tally and `decision_progress` read `acted_as`.
There is no `??` to `actor_id` anywhere.
- The already-acted probe follows Q3 = A: `actor_id` equals the caller's
user id, OR `acted_as` is in `actingAddresses(caller)`. These are two
facts, each compared only with its own identity kind.
- `listActions` maps `acted_as` onto `ApprovalActionRow.acted_as`, which
#21458 declared and which merged first, beside `actor_id` and
`actor_name`. A row no slot admitted omits the member.
- **Boot-time backfill** (`action-slot-backfill.ts`). It is hooked on
`kernel:ready` beside `backfillApproverIndex`, the plugin's existing
boot-time repair, so it ships as code and no agent runs a bulk write.
- Pass 1 covers the whole history. A row whose `actor_id` holds a slot
address (contains `:` or `@`, and is not one of the reserved
`system:sla` / `system:dead-run`) gets `acted_as := actor_id` and
`actor_id := null`. No stored record names its decider, so null is
ADR-0118's value, not a guess.
- Pass 2 covers the approve votes, at step 0, of still-pending requests
whose `acted_as` is empty: they get `acted_as := actor_id`. This is
exact, not a guess, because an override finalizes the node.
- No other row is stamped. Finished user-id rows are read by their
person.
- It is idempotent (both predicates are empty after a run). A failure
logs at `error` with the consequence and the fix.
- **Docs.** `content/docs/automation/approvals.mdx`: the sentence that
said the decision is recorded in `actor_id` under its slot now names
both columns.
## Pins
- `approval-service.test.ts`:
- The #21379 slot tests now assert `[actor_id, acted_as]`.
- A #21411 block covers:
- position, email and user-id slots through the session door;
- the action link: user id, an email with an account, an email with
none;
- an override recording the admin and no slot, even when the admin named
a position address;
- a system context recording nobody, with the SLA sentinel kept;
- the `per_group` tally and `decision_progress` counting slots;
- both probe halves, including a holder who lost the position, a legacy
row found by its person, and a literal left in `actor_id` never read as
a slot;
- the action log showing person, name and slot, and a backfilled row
showing the slot alone.
- `approver-address-readers.test.ts`, the enumeration pin: `acted_as`
replaces `actor_id` among the slot columns. Every read of `actor_id` in
the package is classified with its count. The only comparison is with
the caller's user id (`actor_id: uid` in `visibleRequestIds`).
- `action-slot-backfill.integration.test.ts` runs on a real `ObjectQL`
with `SqlDriver` (better-sqlite3, in-memory) and the real DDL:
- literals are moved;
- a pending vote is stamped;
- a finished user-id row, the sentinels, a system row and a new-style
row are untouched;
- a second run writes `{0, 0}`;
- end to end, an in-flight unanimous request still finalizes on the
votes the old writer recorded.
- `action-slot-backfill-wiring.test.ts`: the plugin runs the repair once
at `kernel:ready` (never at `start()`), against its own engine, and logs
a failure at `error`.
- `packages/qa/dogfood/test/position-address-readers.dogfood.test.ts`, a
cross-lane `domain:cli` addition declared on #6024: `recorded()` reads
`actor_id`, `acted_as` and `via_override`, and asserts the person plus
the slot, and the admin plus no slot on an override.
## Ablations (committed first; every leg through
`scripts/ablation-replace.mjs`, with the anchor hit once, the blob
changed, the restore proven equal to HEAD and `git diff HEAD` empty)
Legs a to f ran at `b668cf134`; leg g ran at `dc6d72a9c`. Each direction
was predicted before its run.
| leg | mutation | predicted | observed |
| --- | --- | --- | --- |
| a | `decideNode` writes no `acted_as` | red, more than the slot pins
(the tally too) | 24 failed / 304 passed: every slot pin, plus the
unanimous, quorum and per_group tallies and the probe. ⚠ The first
attempt was a no-op: the tool refused it because the replacement text
was a substring of the anchor (count 1 to 1), and it restored. Re-run
with a distinct replacement. |
| b | tally reads `a.actor_id` | red: tally pins, backfill end to end,
enumeration pin | 14 failed / 320 passed across all 3 files |
| b2 | `decision_progress` reads `a.actor_id` | red: per_group progress
pin and enumeration pin | 4 failed / 328 passed |
| c | backfill pass 2 writes nothing | red: backfill pin | 1 failed / 1
passed |
| d | backfill pass 1 writes nothing | red: backfill pin | 1 failed / 1
passed |
| e | the `kernel:ready` hook is removed | red: wiring pin | 2 failed |
| f | the probe's `{ actor_id: uid }` half is removed | red: person pins
and enumeration pin | 3 failed / 329 passed |
| g | `listActions` stops mapping `acted_as` | red: action-log pin only
| 1 failed / 328 passed |
## Gates, at `dc6d72a9c`
- `dispatch-gates --repo objectstack-ai/objectstack --commands` derived
97 commands. All 97 ran with exit 0, plus the 4 in-path roster gates it
flagged (`check-changeset-fixed`, `check:authz-resolver`,
`check:error-code-casing`, `check:filter-alias-parity`), also exit 0.
`--ran` with exit codes answers: 97 derived, 97 run, 0 NOT-MEASURED (a
derived zero).
- `pnpm --filter @objectstack/plugin-approvals typecheck`: exit 0
(`check:test-typecheck` OK). `pnpm --filter
@objectstack/plugin-approvals test`: 58 files, 868 tests passed.
- The dogfood file: 1 of 1 passed. `pnpm --filter @objectstack/dogfood
typecheck`: exit 0.
- Earlier reds, each repaired in this PR:
- `check:engine-double-contract`: the wiring test's double now declares
no write verbs.
- `check-tenant-audit-census`: the backfill adds 2 elevated `update`
sites (229 to 231 write call sites, 110 to 112 elevated). `node
scripts/tenant-audit-census.mjs --write` regenerated both census tables,
and the page's hand-written prose counts were updated to match. This
adds `content/docs/permissions/tenant-audit-census.mdx` and
`docs/audits/2026-08-tenant-audit-write-call-sites.counts.md` to the
diff, declared here.
## Changeset
`@objectstack/plugin-approvals` patch, `Clause-②: no` (the spec widening
is #21458's). It states that it supersedes the "What is recorded"
sentence of the unreleased
`.changeset/21379-position-address-readers.md`, which this PR does not
edit. Both ship in one release.
## Acceptance notes
- **One widening, by ruling (Q3 = A):** a person who decided under a
position they later lost keeps sight of that request. The old probe hid
it.
- **Behaviour narrowed for system contexts:** a machine caller that
names an actor without vouching for a person on the context now records
`actor_id` null, with the slot still taken and recorded. The only
first-party machine callers are the SLA sweep (kept by name) and the
action link (which now vouches for the resolved person). Test fixtures
that decided from a bare system context and asserted `actor_id` moved to
a vouching context.
- **Out of this PR, the ADR-0118 D1 family on #21455:** the SLA and
dead-run sentinels in `actor_id`, `reassign_from` / `reassign_to`
holding slot literals and emails (`reassign_from` still records the slot
handed over; a pin here names it), and `sys_notification.actor_id` fed
by `notify`.
- **Console:** rendering `acted_as` beside the actor's name in the
timeline is objectui work. Until it lands, a backfilled historical row
shows no actor, and the slot is on the wire.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 9ff7428 commit 6f17d1d
17 files changed
Lines changed: 1063 additions & 102 deletions
File tree
- .changeset
- content/docs
- automation
- permissions
- docs/audits
- packages
- plugins/plugin-approvals/src
- translations
- qa/dogfood/test
| 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 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
509 | 509 | | |
510 | 510 | | |
511 | 511 | | |
512 | | - | |
513 | | - | |
514 | | - | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
515 | 516 | | |
516 | 517 | | |
517 | 518 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
122 | 122 | | |
123 | 123 | | |
124 | 124 | | |
125 | | - | |
| 125 | + | |
126 | 126 | | |
127 | 127 | | |
128 | 128 | | |
| |||
187 | 187 | | |
188 | 188 | | |
189 | 189 | | |
190 | | - | |
| 190 | + | |
191 | 191 | | |
192 | | - | |
193 | | - | |
| 192 | + | |
| 193 | + | |
194 | 194 | | |
195 | 195 | | |
196 | 196 | | |
| |||
207 | 207 | | |
208 | 208 | | |
209 | 209 | | |
210 | | - | |
| 210 | + | |
211 | 211 | | |
212 | 212 | | |
213 | 213 | | |
214 | | - | |
| 214 | + | |
215 | 215 | | |
216 | 216 | | |
217 | 217 | | |
| |||
223 | 223 | | |
224 | 224 | | |
225 | 225 | | |
226 | | - | |
227 | | - | |
| 226 | + | |
| 227 | + | |
228 | 228 | | |
229 | | - | |
| 229 | + | |
230 | 230 | | |
231 | | - | |
| 231 | + | |
232 | 232 | | |
233 | 233 | | |
234 | 234 | | |
235 | 235 | | |
236 | | - | |
| 236 | + | |
237 | 237 | | |
238 | 238 | | |
239 | 239 | | |
240 | 240 | | |
241 | 241 | | |
242 | | - | |
| 242 | + | |
243 | 243 | | |
244 | 244 | | |
245 | 245 | | |
246 | 246 | | |
247 | | - | |
| 247 | + | |
248 | 248 | | |
249 | 249 | | |
250 | 250 | | |
| |||
297 | 297 | | |
298 | 298 | | |
299 | 299 | | |
300 | | - | |
| 300 | + | |
301 | 301 | | |
302 | 302 | | |
303 | 303 | | |
304 | | - | |
305 | | - | |
| 304 | + | |
| 305 | + | |
306 | 306 | | |
307 | | - | |
| 307 | + | |
308 | 308 | | |
309 | 309 | | |
Lines changed: 10 additions & 9 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
36 | | - | |
37 | | - | |
| 36 | + | |
| 37 | + | |
38 | 38 | | |
39 | | - | |
| 39 | + | |
40 | 40 | | |
41 | | - | |
| 41 | + | |
42 | 42 | | |
43 | 43 | | |
44 | 44 | | |
45 | 45 | | |
46 | | - | |
| 46 | + | |
47 | 47 | | |
48 | 48 | | |
49 | 49 | | |
| |||
90 | 90 | | |
91 | 91 | | |
92 | 92 | | |
93 | | - | |
| 93 | + | |
94 | 94 | | |
95 | 95 | | |
96 | 96 | | |
97 | | - | |
98 | | - | |
| 97 | + | |
| 98 | + | |
99 | 99 | | |
100 | | - | |
| 100 | + | |
101 | 101 | | |
102 | 102 | | |
103 | 103 | | |
104 | 104 | | |
105 | 105 | | |
106 | 106 | | |
107 | 107 | | |
| 108 | + | |
108 | 109 | | |
109 | 110 | | |
110 | 111 | | |
| |||
Lines changed: 86 additions & 0 deletions
| 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 | + | |
0 commit comments