Skip to content

Commit dc6d72a

Browse files
committed
feat(approvals): the action log returns acted_as beside the person
listActions maps sys_approval_action.acted_as onto ApprovalActionRow.acted_as (declared by @objectstack/spec in #21458), so a timeline shows who acted and as which slot; a row no slot admitted omits it. Pinned with a backfilled row that shows its slot and no person. Claude-Session: https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ Co-authored-by: Claude <noreply@anthropic.com>
1 parent d373752 commit dc6d72a

3 files changed

Lines changed: 36 additions & 3 deletions

File tree

‎.changeset/21411-approval-actor-person.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ Clause-②: no
1919
- The multi-approver tally and `decision_progress` count `acted_as`.
2020
- A participant who already acted keeps sight of a request by either of two facts: `actor_id` is their user id, or `acted_as` is a slot they act under (so a decision taken as `position:<name>` stays visible to that position's holders).
2121
- Nothing compares a slot with `actor_id` any more.
22+
- The action log (`GET /api/v1/approvals/requests/:id/actions`, `listActions`) returns `acted_as` beside `actor_id` and `actor_name`, filling the `ApprovalActionRow.acted_as` member `@objectstack/spec` declares. It is omitted when the action took no slot, or when no stored record kept the slot.
2223
- **Stored rows.** A repair runs on every boot and is idempotent.
2324
- Pass 1: a row whose `actor_id` still holds a slot address gets `acted_as` set to it and `actor_id` cleared. No stored record names who decided it, so it shows the slot and no person.
2425
- Pass 2: the approve votes a still-pending request's tally counts get their `acted_as`, so in-flight `unanimous`, `quorum` and `per_group` requests keep the approvals they already collected.

‎packages/plugins/plugin-approvals/src/approval-service.test.ts‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4089,6 +4089,30 @@ describe('ApprovalService — actor_id records the person, acted_as the slot (#2
40894089
expect(await svc.getRequest(req.id, holding('u_holder', [])), 'the person, position lost').not.toBeNull();
40904090
});
40914091

4092+
it('the action log shows the person by name and the slot beside it — and a backfilled row the slot alone', async () => {
4093+
const engine = makeFakeEngine();
4094+
await engine.insert('sys_user', { id: 'u_holder', name: 'Hana Holder', email: 'hana@example.com' });
4095+
const svc = svcFor(engine);
4096+
const req = await open(svc, [...toPosition, { type: 'user', value: 'u9' }], 'unanimous');
4097+
await svc.decideNode(req.id, { decision: 'approve' } as any, HOLDER);
4098+
// A row as the boot-time backfill leaves a pre-acted_as slot literal: the
4099+
// slot kept, the person unknown.
4100+
await engine.insert('sys_approval_action', {
4101+
id: 'aact_backfilled', request_id: req.id, step_index: 0, action: 'comment',
4102+
actor_id: null, acted_as: SLOT, comment: 'older note', created_at: '2099-01-01T00:00:00.000Z',
4103+
});
4104+
4105+
const log = await svc.listActions(req.id, SYS);
4106+
const approve = log.find((a) => a.action === 'approve')!;
4107+
expect([approve.actor_id, approve.actor_name, approve.acted_as]).toEqual(['u_holder', 'Hana Holder', SLOT]);
4108+
// The submitter's own action took no slot: no acted_as member at all.
4109+
const submit = log.find((a) => a.action === 'submit')!;
4110+
expect(submit.actor_id).toBe('u1');
4111+
expect('acted_as' in submit && submit.acted_as !== undefined).toBe(false);
4112+
const backfilled = log.find((a) => a.id === 'aact_backfilled')!;
4113+
expect([backfilled.actor_id, backfilled.actor_name, backfilled.acted_as]).toEqual([undefined, undefined, SLOT]);
4114+
});
4115+
40924116
it('already acted: a row written before acted_as existed is found by its person, and a slot is never matched against actor_id', async () => {
40934117
const engine = makeFakeEngine();
40944118
const svc = svcFor(engine);

‎packages/plugins/plugin-approvals/src/approval-service.ts‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1145,6 +1145,12 @@ function rowFromAction(row: any): ApprovalActionRow {
11451145
// `null` (a row written before the column existed) stays `undefined`:
11461146
// "not recorded" is not the same claim as "not an override".
11471147
via_override: row.via_override == null ? undefined : row.via_override === true,
1148+
// #21411 / #21458 — the slot the action was taken as, beside the person in
1149+
// `actor_id`, so the action log shows both: "who" and "as which slot". A
1150+
// row no slot admitted (the submitter's own actions, a system action, an
1151+
// override) — or one recorded before the column, whose slot nothing kept —
1152+
// omits it: the contract's "not recorded" case, never an empty string.
1153+
acted_as: typeof row.acted_as === 'string' && row.acted_as !== '' ? row.acted_as : undefined,
11481154
// Decision attachments (#3266): rich descriptors carrying the display name +
11491155
// download URL, so consumers label/open them without reading `sys_file`.
11501156
attachments: attachments.length ? attachments : undefined,
@@ -6987,9 +6993,11 @@ export class ApprovalService implements IApprovalService {
69876993
context: SYSTEM_CTX,
69886994
});
69896995
const actions = Array.isArray(rows) ? rows.map(rowFromAction) : [];
6990-
// Timeline display: resolve actor ids to names so the audit trail never
6991-
// shows a raw identifier. Role/team literals are already readable. The
6992-
// reassign hand-off parties (#4365) resolve through the same batch.
6996+
// Timeline display: resolve the PERSON in `actor_id` to a name so the
6997+
// audit trail never shows a raw identifier. The slot the action was taken
6998+
// as travels beside it in `acted_as`, as stored — the "acting as" half of
6999+
// the line (#21411). The reassign hand-off parties (#4365) resolve through
7000+
// the same batch; a `type:value` literal or a machine sentinel is skipped.
69937001
const names = await this.resolveUserNames(
69947002
actions
69957003
.flatMap(a => [a.actor_id, a.reassign_from, a.reassign_to])

0 commit comments

Comments
 (0)