@objectstack/plugin-approvals 16.1.0 → 17.0.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +1023 -0
  3. package/dist/index.d.mts +650 -535
  4. package/dist/index.d.ts +650 -535
  5. package/dist/index.js +1713 -207
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +1711 -197
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +9 -7
  10. package/scripts/i18n-extract.config.ts +6 -1
  11. package/src/approval-actor-impersonation.test.ts +330 -0
  12. package/src/approval-node.test.ts +160 -0
  13. package/src/approval-node.ts +57 -0
  14. package/src/approval-revise.test.ts +41 -34
  15. package/src/approval-service.test.ts +1408 -40
  16. package/src/approval-service.ts +1364 -107
  17. package/src/approvals-plugin.ts +36 -5
  18. package/src/approver-cross-org.integration.test.ts +206 -0
  19. package/src/approver-org-scope.test.ts +201 -0
  20. package/src/approver-org-scope.ts +261 -0
  21. package/src/index.ts +3 -0
  22. package/src/lifecycle-hooks.ts +22 -0
  23. package/src/record-lock-schedule-run.integration.test.ts +206 -0
  24. package/src/status-mirror-cascade.integration.test.ts +224 -0
  25. package/src/sys-approval-action.object.ts +9 -0
  26. package/src/sys-approval-delegation.object.test.ts +42 -0
  27. package/src/sys-approval-delegation.object.ts +3 -3
  28. package/src/sys-approval-request.object.test.ts +13 -0
  29. package/src/sys-approval-request.object.ts +17 -5
  30. package/src/translations/bundle-ownership.test.ts +48 -0
  31. package/src/translations/en.objects.generated.ts +111 -5
  32. package/src/translations/es-ES.objects.generated.ts +111 -5
  33. package/src/translations/ja-JP.objects.generated.ts +111 -5
  34. package/src/translations/zh-CN.objects.generated.ts +110 -4
@@ -9,7 +9,7 @@
9
9
  * the read API, and the global record-lock hook.
10
10
  */
11
11
 
12
- import { describe, it, expect, beforeEach } from 'vitest';
12
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
13
13
  import { ApprovalService, REMIND_COOLDOWN_MS } from './approval-service.js';
14
14
  import { bindApprovalLockHook, bindDelegationWriteGuard, unbindAllHooks } from './lifecycle-hooks.js';
15
15
 
@@ -45,9 +45,13 @@ function makeFakeEngine() {
45
45
  return true;
46
46
  }
47
47
 
48
+ /** Every `update` the service made, with the context it presented (#3783). */
49
+ const writes: Array<{ object: string; data: any; context: any }> = [];
50
+
48
51
  return {
49
52
  _tables: tables,
50
53
  _hooks: hooks,
54
+ _writes: writes,
51
55
  async find(object: string, options?: any) {
52
56
  const rows = ensure(object).filter(r => matches(r, options?.filter ?? options?.where));
53
57
  if (options?.orderBy?.[0]) {
@@ -73,6 +77,7 @@ function makeFakeEngine() {
73
77
  async update(object: string, idOrData: any, _opts?: any) {
74
78
  const data = typeof idOrData === 'object' ? idOrData : _opts;
75
79
  const id = typeof idOrData === 'object' ? idOrData.id : idOrData;
80
+ writes.push({ object, data, context: _opts?.context });
76
81
  const table = ensure(object);
77
82
  const i = table.findIndex(r => r.id === id);
78
83
  if (i >= 0) table[i] = { ...table[i], ...data };
@@ -113,6 +118,15 @@ function makeFakeEngine() {
113
118
  const CTX = { userId: 'u1', tenantId: 't1', positions: [], permissions: [] } as any;
114
119
  const SYS = { isSystem: true, positions: [], permissions: [] } as any;
115
120
 
121
+ /**
122
+ * The signed-in caller, when it is someone other than {@link CTX}'s `u1`.
123
+ * An approval action is recorded against the AUTHENTICATED caller (#3800), so a
124
+ * test that acts as `u9` has to present `u9`'s context — naming them in
125
+ * `actorId` while calling as `u1` is the impersonation the service now refuses.
126
+ */
127
+ const asUser = (userId: string) =>
128
+ ({ userId, tenantId: 't1', positions: [], permissions: [] }) as any;
129
+
116
130
  function nodeConfig(approvers: string[], extra: Record<string, any> = {}) {
117
131
  return {
118
132
  approvers: approvers.map(v => ({ type: 'user' as const, value: v })),
@@ -169,6 +183,40 @@ describe('ApprovalService (node era)', () => {
169
183
  expect(JSON.parse(raw.node_config_json)).toMatchObject({ behavior: 'first_response', lockRecord: true });
170
184
  });
171
185
 
186
+ // ── record-lock policy on the read row (objectui#2902) ──────────
187
+ //
188
+ // The lock is enforced in `lifecycle-hooks.ts` off `node_config_json`, but
189
+ // the row projection used to drop the flag entirely — so a client could see
190
+ // "a pending request exists" and nothing more, and had to assume every
191
+ // pending node locked the record. Chaining nodes with different policies
192
+ // made that visibly wrong. These pin the flag onto every read path.
193
+
194
+ it('lock_record: true when the node locks (the schema default)', async () => {
195
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
196
+ expect(req.lock_record).toBe(true);
197
+ const [listed] = await svc.listRequests({ object: 'opportunity', recordId: 'opp1' }, SYS);
198
+ expect(listed.lock_record).toBe(true);
199
+ expect((await svc.getRequest(req.id, SYS))!.lock_record).toBe(true);
200
+ });
201
+
202
+ it('lock_record: false when the node opts out — the same read the hook honors', async () => {
203
+ const req = await svc.openNodeRequest(openInput(['u9'], {}, { lockRecord: false }), CTX);
204
+ expect(req.lock_record).toBe(false);
205
+ const [listed] = await svc.listRequests({ object: 'opportunity', recordId: 'opp1' }, SYS);
206
+ expect(listed.lock_record).toBe(false);
207
+ expect((await svc.getRequest(req.id, SYS))!.lock_record).toBe(false);
208
+ });
209
+
210
+ it('lock_record: an unset lockRecord reads as locked, matching the hook default', async () => {
211
+ // The hook allows the write only on an explicit `=== false`; the flag must
212
+ // default the same way or the UI would offer an edit the server rejects.
213
+ const req = await svc.openNodeRequest(
214
+ { ...openInput(['u9']), config: { approvers: [{ type: 'user' as const, value: 'u9' }], behavior: 'first_response' as const } } as any,
215
+ CTX,
216
+ );
217
+ expect(req.lock_record).toBe(true);
218
+ });
219
+
172
220
  it('openNodeRequest: deduplicates a pending request per (object, record)', async () => {
173
221
  await svc.openNodeRequest(openInput(['u9']), CTX);
174
222
  await expect(svc.openNodeRequest(openInput(['u9'], { runId: 'run_2' }), CTX))
@@ -187,6 +235,325 @@ describe('ApprovalService (node era)', () => {
187
235
  expect(engine._tables['opportunity'][0].approval_status).toBe('pending');
188
236
  });
189
237
 
238
+ // ── approver expansion: field (live re-read + fan-out, #3447) ────
239
+ //
240
+ // A `field` approver names WHO decides from a record field. It must bind to
241
+ // the record's LIVE value at node entry — an earlier step (or the approver of
242
+ // an earlier step) may have written it after submit — not the trigger snapshot
243
+ // the run froze into `$record`. A multi-select user field fans out into one
244
+ // slot per user.
245
+
246
+ const fieldInput = (extra: Record<string, any> = {}) => ({
247
+ ...openInput([]),
248
+ config: {
249
+ approvers: [{ type: 'field' as const, value: 'approvers_dynamic' }],
250
+ behavior: 'unanimous' as const,
251
+ lockRecord: true,
252
+ },
253
+ ...extra,
254
+ });
255
+
256
+ it('field approver: resolves against the LIVE record, not the trigger snapshot (#3447)', async () => {
257
+ // The minimal repro: submitted with the routing field empty; a prior step
258
+ // wrote the co-reviewers mid-flow. Resolution must see them.
259
+ engine._tables['opportunity'] = [{ id: 'opp1', amount: 100, approvers_dynamic: ['u2', 'u3'] }];
260
+ const req = await svc.openNodeRequest(
261
+ fieldInput({ record: { id: 'opp1', amount: 100, approvers_dynamic: [] } }), CTX,
262
+ );
263
+ expect(req.pending_approvers.sort()).toEqual(['u2', 'u3']);
264
+ });
265
+
266
+ it('field approver: the live value WINS over a stale snapshot value (#3447)', async () => {
267
+ // Snapshot named u1; the record now names u2. u2 decides.
268
+ engine._tables['opportunity'] = [{ id: 'opp1', approvers_dynamic: ['u2'] }];
269
+ const req = await svc.openNodeRequest(
270
+ fieldInput({ record: { id: 'opp1', approvers_dynamic: ['u1'] } }), CTX,
271
+ );
272
+ expect(req.pending_approvers).toEqual(['u2']);
273
+ });
274
+
275
+ it('field approver: fans a multi-select user field into one slot per user (#3447)', async () => {
276
+ engine._tables['opportunity'] = [{ id: 'opp1', approvers_dynamic: ['u2', 'u3', 'u4'] }];
277
+ const req = await svc.openNodeRequest(fieldInput({ record: { id: 'opp1' } }), CTX);
278
+ expect(req.pending_approvers.sort()).toEqual(['u2', 'u3', 'u4']);
279
+ });
280
+
281
+ it('field approver: fans a legacy CSV string field into multiple slots (#3447)', async () => {
282
+ engine._tables['opportunity'] = [{ id: 'opp1', approvers_dynamic: 'u2,u3' }];
283
+ const req = await svc.openNodeRequest(fieldInput({ record: { id: 'opp1' } }), CTX);
284
+ expect(req.pending_approvers.sort()).toEqual(['u2', 'u3']);
285
+ });
286
+
287
+ it('field approver: falls back to the trigger snapshot when the record is gone (#3447)', async () => {
288
+ // No `opportunity` row — hard-deleted between submit and node entry. The
289
+ // snapshot still carries a value, so the request opens against it (warn but
290
+ // proceed) rather than wedging the flow.
291
+ const req = await svc.openNodeRequest(
292
+ fieldInput({ record: { id: 'opp1', approvers_dynamic: ['u7'] } }), CTX,
293
+ );
294
+ expect(req.pending_approvers).toEqual(['u7']);
295
+ });
296
+
297
+ // ── approver expansion: expression (#3447 P2) ───────────────────
298
+ //
299
+ // A CEL expression resolved at node entry over three EXPLICIT roots:
300
+ // `current.*` (live record), `trigger.*` (submit snapshot), `vars.*` (flow
301
+ // variables). `record` / bare fields are rejected before evaluation — the
302
+ // runtime env would resolve them as dyn → null → a silently-empty slate.
303
+
304
+ const exprInput = (
305
+ value: string,
306
+ extra: Record<string, any> = {},
307
+ approverExtra: Record<string, any> = {},
308
+ configExtra: Record<string, any> = {},
309
+ ) => ({
310
+ ...openInput([]),
311
+ config: {
312
+ approvers: [{ type: 'expression' as const, value, ...approverExtra }],
313
+ behavior: 'unanimous' as const,
314
+ lockRecord: true,
315
+ ...configExtra,
316
+ },
317
+ ...extra,
318
+ });
319
+
320
+ it('expression: current.* reads the LIVE record at node entry (#3447 P2)', async () => {
321
+ engine._tables['opportunity'] = [{ id: 'opp1', approvers_dynamic: ['u2', 'u3'] }];
322
+ const req = await svc.openNodeRequest(
323
+ exprInput('current.approvers_dynamic', { record: { id: 'opp1', approvers_dynamic: [] } }), CTX,
324
+ ) as any;
325
+ expect(req.pending_approvers.sort()).toEqual(['u2', 'u3']);
326
+ });
327
+
328
+ it('expression: trigger.* reads the submit-time snapshot, not the live row (#3447 P2)', async () => {
329
+ engine._tables['opportunity'] = [{ id: 'opp1', reviewer: 'new_reviewer' }];
330
+ const req = await svc.openNodeRequest(
331
+ exprInput('trigger.reviewer', { record: { id: 'opp1', reviewer: 'old_reviewer' } }), CTX,
332
+ ) as any;
333
+ expect(req.pending_approvers).toEqual(['old_reviewer']);
334
+ });
335
+
336
+ it('expression: vars.* reads flow variables; a CSV string fans out (#3447 P2)', async () => {
337
+ const req = await svc.openNodeRequest(
338
+ exprInput('vars.approval_lead.next_reviewers', {
339
+ variables: { approval_lead: { next_reviewers: 'u5, u6' } },
340
+ }), CTX,
341
+ ) as any;
342
+ expect(req.pending_approvers.sort()).toEqual(['u5', 'u6']);
343
+ });
344
+
345
+ it('expression: an array result fans out into one slot per id (#3447 P2)', async () => {
346
+ const req = await svc.openNodeRequest(
347
+ exprInput('vars.picked', { variables: { picked: ['u2', 'u3', 'u4'] } }), CTX,
348
+ ) as any;
349
+ expect(req.pending_approvers.sort()).toEqual(['u2', 'u3', 'u4']);
350
+ });
351
+
352
+ it('expression: OOO delegation applies per resolved user (#3447 P2 / #1322)', async () => {
353
+ engine._tables['sys_approval_delegation'] = [{
354
+ id: 'del1', delegator_id: 'u2', delegate_id: 'u9',
355
+ valid_from: '2026-01-01T00:00:00Z', valid_until: '2026-12-31T00:00:00Z',
356
+ reason: 'leave', organization_id: 't1',
357
+ }];
358
+ const req = await svc.openNodeRequest(
359
+ exprInput('vars.picked', { variables: { picked: ['u2', 'u3'] } }), CTX,
360
+ ) as any;
361
+ expect(req.pending_approvers.sort()).toEqual(['u3', 'u9']);
362
+ });
363
+
364
+ it('expression: rejects a `record` root BEFORE evaluation, prescribing current/trigger (#3447 P2)', async () => {
365
+ await expect(svc.openNodeRequest(exprInput('record.approvers_dynamic'), CTX))
366
+ .rejects.toThrow(/VALIDATION_FAILED[\s\S]*`record`[\s\S]*current\.<field>/);
367
+ });
368
+
369
+ it('expression: rejects an unknown bare root with the closed-root hint (#3447 P2)', async () => {
370
+ await expect(svc.openNodeRequest(exprInput('approvers_dynamic'), CTX))
371
+ .rejects.toThrow(/VALIDATION_FAILED.*approvers_dynamic.*current\.\*/s);
372
+ });
373
+
374
+ it('expression: a non-parsing source fails loudly, not as an empty slate (#3447 P2)', async () => {
375
+ await expect(svc.openNodeRequest(exprInput('current..'), CTX))
376
+ .rejects.toThrow(/VALIDATION_FAILED.*does not parse/s);
377
+ });
378
+
379
+ it('expression: a non-id result type (bool) fails loudly (#3447 P2)', async () => {
380
+ const req = svc.openNodeRequest(
381
+ exprInput('current.amount > 100', { record: { id: 'opp1', amount: 500 } }), CTX,
382
+ );
383
+ await expect(req).rejects.toThrow(/EXPRESSION_FAILED.*must yield ids/s);
384
+ });
385
+
386
+ it('expression + resolveAs department: expands each returned id through the graph, one per_group group per department (#3447 P2)', async () => {
387
+ engine._tables['sys_business_unit'] = [
388
+ { id: 'd1', active: true, organization_id: 't1' },
389
+ { id: 'd2', active: true, organization_id: 't1' },
390
+ ];
391
+ engine._tables['sys_business_unit_member'] = [
392
+ { id: 'm1', business_unit_id: 'd1', user_id: 'u2' },
393
+ { id: 'm2', business_unit_id: 'd1', user_id: 'u3' },
394
+ { id: 'm3', business_unit_id: 'd2', user_id: 'u4' },
395
+ ];
396
+ const req = await svc.openNodeRequest(
397
+ exprInput('vars.picked_departments', {
398
+ variables: { picked_departments: ['d1', 'd2'] },
399
+ }, { resolveAs: 'department' }, { behavior: 'per_group' }), CTX,
400
+ ) as any;
401
+ expect(req.pending_approvers.sort()).toEqual(['u2', 'u3', 'u4']);
402
+ // Each department forms its own sub-group: one sign-off per department.
403
+ const raw = engine._tables['sys_approval_request'][0];
404
+ const snapshot = JSON.parse(raw.node_config_json);
405
+ expect(snapshot.__approverGroups).toEqual({
406
+ u2: ['#0:d1'], u3: ['#0:d1'], u4: ['#0:d2'],
407
+ });
408
+ });
409
+
410
+ it('expression + resolveAs: an unstaffed department keeps a literal slot (#3447 P2)', async () => {
411
+ engine._tables['sys_business_unit'] = [{ id: 'd9', active: true, organization_id: 't1' }];
412
+ const req = await svc.openNodeRequest(
413
+ exprInput('vars.picked', { variables: { picked: ['d9'] } }, { resolveAs: 'department' }), CTX,
414
+ ) as any;
415
+ expect(req.pending_approvers).toEqual(['department:d9']);
416
+ });
417
+
418
+ it('expression: __resolvedFrom snapshots the resolution INPUT for audit (#3447 P2)', async () => {
419
+ engine._tables['opportunity'] = [{ id: 'opp1', approvers_dynamic: ['u2'] }];
420
+ await svc.openNodeRequest(exprInput('current.approvers_dynamic', { record: { id: 'opp1' } }), CTX);
421
+ const raw = engine._tables['sys_approval_request'][0];
422
+ const snapshot = JSON.parse(raw.node_config_json);
423
+ expect(snapshot.__resolvedFrom).toEqual({ 'expression#0': ['u2'] });
424
+ });
425
+
426
+ it('expression: a MISSING key is a loud error, never a silent empty slate (#3447 P2)', async () => {
427
+ // CEL map access on an absent key throws ("No such key") — deliberate:
428
+ // referencing a variable nobody wrote is a wiring bug, not "no approvers".
429
+ // An EMPTY slate is expressed by a present-but-empty value (next test
430
+ // group); authors guard optional inputs with `has(...)` / `.?` explicitly.
431
+ await expect(svc.openNodeRequest(
432
+ exprInput('vars.never_written', { variables: {} }), CTX,
433
+ )).rejects.toThrow(/EXPRESSION_FAILED.*No such key/s);
434
+ });
435
+
436
+ // ── onEmptyApprovers policy (#3447 P2) ──────────────────────────
437
+ //
438
+ // "Empty" = the expression/field RESOLVED (key present) but yielded nobody.
439
+ // A missing key is a loud error instead (test above).
440
+
441
+ it("onEmptyApprovers 'fail': an empty slate fails the open loudly", async () => {
442
+ await expect(svc.openNodeRequest(
443
+ exprInput('vars.picked', { variables: { picked: [] } }, {}, { onEmptyApprovers: 'fail' }), CTX,
444
+ )).rejects.toThrow(/NO_APPROVERS/);
445
+ expect(engine._tables['sys_approval_request'] ?? []).toHaveLength(0);
446
+ });
447
+
448
+ it("onEmptyApprovers 'auto_approve': no request opens, outcome says autoApproved", async () => {
449
+ const outcome = await svc.openNodeRequest(
450
+ exprInput('vars.picked', { variables: { picked: [] } }, {}, { onEmptyApprovers: 'auto_approve' }), CTX,
451
+ );
452
+ expect(outcome).toEqual({ autoApproved: true, reason: 'empty_approvers' });
453
+ expect(engine._tables['sys_approval_request'] ?? []).toHaveLength(0);
454
+ });
455
+
456
+ it("onEmptyApprovers default ('admin_rescue'): the request still opens for admin takeover (#3424)", async () => {
457
+ const req = await svc.openNodeRequest(
458
+ exprInput('vars.picked', { variables: { picked: [] } }), CTX,
459
+ ) as any;
460
+ expect(req.status).toBe('pending');
461
+ expect(req.pending_approvers).toEqual([]);
462
+ expect(engine._tables['sys_approval_request']).toHaveLength(1);
463
+ });
464
+
465
+ // ── decision outputs (#3447 P2) ─────────────────────────────────
466
+
467
+ it('decision outputs: rejected when the node declares none', async () => {
468
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
469
+ await expect(svc.decideNode(req.id, {
470
+ decision: 'approve', actorId: 'u9', outputs: { next: 'u2' },
471
+ }, SYS)).rejects.toThrow(/VALIDATION_FAILED.*declares no decisionOutputs/s);
472
+ });
473
+
474
+ it('decision outputs: rejected when a key is undeclared', async () => {
475
+ const req = await svc.openNodeRequest(
476
+ openInput(['u9'], {}, { decisionOutputs: ['next_reviewers'] }), CTX,
477
+ );
478
+ await expect(svc.decideNode(req.id, {
479
+ decision: 'approve', actorId: 'u9', outputs: { other_key: 1 },
480
+ }, SYS)).rejects.toThrow(/VALIDATION_FAILED.*other_key.*not declared/s);
481
+ });
482
+
483
+ it('decision outputs: reserved keys are rejected even when declared', async () => {
484
+ const req = await svc.openNodeRequest(
485
+ openInput(['u9'], {}, { decisionOutputs: ['decision'] }), CTX,
486
+ );
487
+ await expect(svc.decideNode(req.id, {
488
+ decision: 'approve', actorId: 'u9', outputs: { decision: 'spoofed' },
489
+ }, SYS)).rejects.toThrow(/VALIDATION_FAILED.*reserved/s);
490
+ });
491
+
492
+ it('decision outputs: typed declarations normalize — whitelist by key, defs surfaced for the UI', async () => {
493
+ const req = await svc.openNodeRequest(
494
+ openInput(['u9'], {}, {
495
+ decisionOutputs: [
496
+ { key: 'next_reviewers', label: 'Next Reviewers', type: 'user', multiple: true },
497
+ 'note',
498
+ ],
499
+ }), CTX,
500
+ ) as any;
501
+ // Key list stays the version-skew-safe shape; defs carry the typed form.
502
+ expect(req.decision_outputs).toEqual(['next_reviewers', 'note']);
503
+ expect(req.decision_output_defs).toEqual([
504
+ { key: 'next_reviewers', label: 'Next Reviewers', type: 'user', multiple: true },
505
+ { key: 'note' },
506
+ ]);
507
+ // A typed declaration whitelists exactly like a bare key.
508
+ const out = await svc.decideNode(req.id, {
509
+ decision: 'approve', actorId: 'u9', outputs: { next_reviewers: ['u2', 'u3'] },
510
+ }, SYS);
511
+ expect(out.outputs).toEqual({ next_reviewers: ['u2', 'u3'] });
512
+ });
513
+
514
+ it('decision outputs: declared keys surface on the request row as decision_outputs (#3447 P2 UI)', async () => {
515
+ // The decision UI renders one input per declared key — the keys ride the
516
+ // request read (per-request; the static action params can't carry them).
517
+ const req = await svc.openNodeRequest(
518
+ openInput(['u9'], {}, { decisionOutputs: ['next_reviewers', 'note'] }), CTX,
519
+ ) as any;
520
+ expect(req.decision_outputs).toEqual(['next_reviewers', 'note']);
521
+ const listed = await svc.listRequests({ status: 'pending' }, SYS);
522
+ expect((listed[0] as any).decision_outputs).toEqual(['next_reviewers', 'note']);
523
+ // A node declaring none omits the field entirely.
524
+ engine._tables['sys_approval_request'] = [];
525
+ const plain = await svc.openNodeRequest(openInput(['u9']), CTX) as any;
526
+ expect(plain.decision_outputs).toBeUndefined();
527
+ });
528
+
529
+ it('decision outputs: accepted keys return from decideNode and snapshot as __decisionOutputs', async () => {
530
+ const req = await svc.openNodeRequest(
531
+ openInput(['u9'], {}, { decisionOutputs: ['next_reviewers', 'note'] }), CTX,
532
+ );
533
+ const out = await svc.decideNode(req.id, {
534
+ decision: 'approve', actorId: 'u9', outputs: { next_reviewers: ['u2', 'u3'] },
535
+ }, SYS);
536
+ expect(out.finalized).toBe(true);
537
+ expect(out.outputs).toEqual({ next_reviewers: ['u2', 'u3'] });
538
+ const raw = engine._tables['sys_approval_request'][0];
539
+ expect(JSON.parse(raw.node_config_json).__decisionOutputs).toEqual({ next_reviewers: ['u2', 'u3'] });
540
+ });
541
+
542
+ it('decision outputs: co-sign votes accumulate, the finalizing decision hands the merged set over', async () => {
543
+ const req = await svc.openNodeRequest(
544
+ openInput(['u1', 'u2'], {}, { behavior: 'unanimous', decisionOutputs: ['legal_note', 'finance_note'] }), CTX,
545
+ );
546
+ const first = await svc.decideNode(req.id, {
547
+ decision: 'approve', actorId: 'u1', outputs: { legal_note: 'ok' },
548
+ }, SYS);
549
+ expect(first.finalized).toBe(false);
550
+ const second = await svc.decideNode(req.id, {
551
+ decision: 'approve', actorId: 'u2', outputs: { finance_note: 'ok too' },
552
+ }, SYS);
553
+ expect(second.finalized).toBe(true);
554
+ expect(second.outputs).toEqual({ legal_note: 'ok', finance_note: 'ok too' });
555
+ });
556
+
190
557
  // ── approver expansion: position (ADR-0090 D3) ──────────────────
191
558
 
192
559
  const positionInput = (extra: Record<string, any> = {}) => ({
@@ -297,6 +664,69 @@ describe('ApprovalService (node era)', () => {
297
664
  expect(req.pending_approvers.sort()).toEqual(['u5', 'u6']);
298
665
  });
299
666
 
667
+ // #3807 — an app's org tree is normally SEEDED, and a seed cannot know the
668
+ // organization id the runtime mints at boot, so those rows carry
669
+ // `organization_id = null` while every approval request carries an org. The
670
+ // old strict equality made each of them invisible and every `department`
671
+ // approver resolved to the dead `department:<id>` literal.
672
+ const departmentInput = (value: string) => positionInput({
673
+ config: {
674
+ approvers: [{ type: 'department' as const, value }],
675
+ behavior: 'first_response' as const,
676
+ lockRecord: true,
677
+ },
678
+ });
679
+
680
+ it('department approver: an env-wide (null-org) business unit still resolves (#3807)', async () => {
681
+ engine._tables['sys_business_unit'] = [
682
+ { id: 'bu_seeded', organization_id: null, active: true },
683
+ { id: 'bu_seeded_child', parent_business_unit_id: 'bu_seeded', organization_id: null, active: true },
684
+ ];
685
+ engine._tables['sys_business_unit_member'] = [
686
+ { id: 'bm1', business_unit_id: 'bu_seeded', user_id: 'u5' },
687
+ { id: 'bm2', business_unit_id: 'bu_seeded_child', user_id: 'u6' },
688
+ ];
689
+ const req = await svc.openNodeRequest(departmentInput('bu_seeded'), CTX);
690
+ // Both the seed check AND the subtree descent must see the null-org rows.
691
+ expect(req.pending_approvers.sort()).toEqual(['u5', 'u6']);
692
+ });
693
+
694
+ it('department approver: another organization’s unit stays invisible (#3807 keeps the wall)', async () => {
695
+ engine._tables['sys_business_unit'] = [
696
+ { id: 'bu_other', organization_id: 't2', active: true },
697
+ { id: 'bu_other_child', parent_business_unit_id: 'bu_other', organization_id: 't2', active: true },
698
+ ];
699
+ engine._tables['sys_business_unit_member'] = [
700
+ { id: 'bm1', business_unit_id: 'bu_other', user_id: 'intruder' },
701
+ { id: 'bm2', business_unit_id: 'bu_other_child', user_id: 'intruder2' },
702
+ ];
703
+ const req = await svc.openNodeRequest(departmentInput('bu_other'), CTX);
704
+ expect(req.pending_approvers).toEqual(['department:bu_other']);
705
+ });
706
+
707
+ it('department approver: a null-org subtree does not drag in another org’s child unit (#3807)', async () => {
708
+ engine._tables['sys_business_unit'] = [
709
+ { id: 'bu_seeded', organization_id: null, active: true },
710
+ { id: 'bu_mine', parent_business_unit_id: 'bu_seeded', organization_id: 't1', active: true },
711
+ { id: 'bu_theirs', parent_business_unit_id: 'bu_seeded', organization_id: 't2', active: true },
712
+ ];
713
+ engine._tables['sys_business_unit_member'] = [
714
+ { id: 'bm1', business_unit_id: 'bu_mine', user_id: 'u5' },
715
+ { id: 'bm2', business_unit_id: 'bu_theirs', user_id: 'intruder' },
716
+ ];
717
+ const req = await svc.openNodeRequest(departmentInput('bu_seeded'), CTX);
718
+ expect(req.pending_approvers).toEqual(['u5']);
719
+ });
720
+
721
+ it('department approver: an inactive env-wide unit still contributes nobody (#3807)', async () => {
722
+ engine._tables['sys_business_unit'] = [{ id: 'bu_seeded', organization_id: null, active: false }];
723
+ engine._tables['sys_business_unit_member'] = [
724
+ { id: 'bm1', business_unit_id: 'bu_seeded', user_id: 'u5' },
725
+ ];
726
+ const req = await svc.openNodeRequest(departmentInput('bu_seeded'), CTX);
727
+ expect(req.pending_approvers).toEqual(['department:bu_seeded']);
728
+ });
729
+
300
730
  // ── decideNode ──────────────────────────────────────────────────
301
731
 
302
732
  it('decideNode: first_response approve finalizes immediately', async () => {
@@ -418,11 +848,13 @@ describe('ApprovalService (node era)', () => {
418
848
  it('getRequest: viewer.can_act is true for a pending approver, false for the submitter', async () => {
419
849
  const req = await svc.openNodeRequest(openInput(['u9']), CTX); // submitter u1, approver u9
420
850
  const asApprover = await svc.getRequest(req.id, { userId: 'u9', tenantId: 't1' } as any);
421
- expect(asApprover!.viewer).toEqual({ can_act: true, is_submitter: false });
851
+ expect(asApprover!.viewer).toEqual({ can_act: true, is_submitter: false, can_override: false });
422
852
  const asSubmitter = await svc.getRequest(req.id, { userId: 'u1', tenantId: 't1' } as any);
423
- expect(asSubmitter!.viewer).toEqual({ can_act: false, is_submitter: true });
424
- const asOther = await svc.getRequest(req.id, { userId: 'u_stranger', tenantId: 't1' } as any);
425
- expect(asOther!.viewer).toEqual({ can_act: false, is_submitter: false });
853
+ expect(asSubmitter!.viewer).toEqual({ can_act: false, is_submitter: true, can_override: false });
854
+ // #3590: a same-tenant stranger participates in nothing, so the request is
855
+ // not readable at all — previously it came back with an all-false viewer
856
+ // block, which meant every authenticated user could read every request.
857
+ expect(await svc.getRequest(req.id, { userId: 'u_stranger', tenantId: 't1' } as any)).toBeNull();
426
858
  });
427
859
 
428
860
  it('getRequest: viewer.can_act drops to false once the request is finalized', async () => {
@@ -539,6 +971,27 @@ describe('ApprovalService (node era)', () => {
539
971
  expect(rows[0].payload_display).toEqual({ account: 'Acme Corp' });
540
972
  });
541
973
 
974
+ it('enrichment maps snapshot field keys to the object field labels', async () => {
975
+ (engine as any).getSchema = (name: string) =>
976
+ name === 'opportunity'
977
+ ? {
978
+ label: 'Opportunity',
979
+ fields: {
980
+ id: {}, // no label → excluded from payload_labels
981
+ name: { label: 'Deal Name' },
982
+ amount: { label: 'Deal Amount' },
983
+ },
984
+ }
985
+ : undefined;
986
+ await svc.openNodeRequest(
987
+ openInput(['u9'], { record: { id: 'opp1', name: 'Acme Renewal', amount: 100 } }), CTX,
988
+ );
989
+ const rows = await svc.listRequests({ status: 'pending' }, SYS);
990
+ // Only keys present in the snapshot AND carrying a schema label are mapped;
991
+ // `id` (unlabeled) is dropped.
992
+ expect(rows[0].payload_labels).toEqual({ name: 'Deal Name', amount: 'Deal Amount' });
993
+ });
994
+
542
995
  it('enrichment maps user-id approvers to display names', async () => {
543
996
  engine._tables['sys_user'] = [{ id: 'u9', name: 'Grace Hopper', email: 'grace@example.com' }];
544
997
  await svc.openNodeRequest(openInput(['u9']), CTX);
@@ -561,7 +1014,7 @@ describe('ApprovalService (node era)', () => {
561
1014
 
562
1015
  it('reassign: hands the slot to a new approver and audits the move', async () => {
563
1016
  const req = await svc.openNodeRequest(openInput(['u9', 'u2']), CTX);
564
- const out = await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, CTX);
1017
+ const out = await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, asUser('u9'));
565
1018
  expect(out.request.pending_approvers).toEqual(['u7', 'u2']);
566
1019
  const actions = await svc.listActions(req.id, SYS);
567
1020
  expect(actions.at(-1)).toMatchObject({ action: 'reassign', actor_id: 'u9', comment: 'u9 → u7' });
@@ -571,15 +1024,15 @@ describe('ApprovalService (node era)', () => {
571
1024
  const emitted: any[] = [];
572
1025
  svc.attachMessaging({ async emit(input) { emitted.push(input); } });
573
1026
  const req = await svc.openNodeRequest(openInput(['u9']), CTX);
574
- await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, CTX);
1027
+ await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, asUser('u9'));
575
1028
  expect(emitted).toHaveLength(1);
576
1029
  expect(emitted[0]).toMatchObject({ topic: 'approval.reassigned', audience: ['u7'] });
577
1030
  });
578
1031
 
579
1032
  it('reassign: blocks a non-holder and duplicate targets', async () => {
580
1033
  const req = await svc.openNodeRequest(openInput(['u9', 'u2']), CTX);
581
- await expect(svc.reassign(req.id, { actorId: 'intruder', to: 'u7' }, CTX)).rejects.toThrow(/FORBIDDEN/);
582
- await expect(svc.reassign(req.id, { actorId: 'u9', to: 'u2' }, CTX)).rejects.toThrow(/VALIDATION_FAILED/);
1034
+ await expect(svc.reassign(req.id, { actorId: 'intruder', to: 'u7' }, asUser('intruder'))).rejects.toThrow(/FORBIDDEN/);
1035
+ await expect(svc.reassign(req.id, { actorId: 'u9', to: 'u2' }, asUser('u9'))).rejects.toThrow(/VALIDATION_FAILED/);
583
1036
  });
584
1037
 
585
1038
  it('remind: notifies pending approvers, audits, and throttles repeats', async () => {
@@ -628,7 +1081,7 @@ describe('ApprovalService (node era)', () => {
628
1081
  const emitted: any[] = [];
629
1082
  svc.attachMessaging({ async emit(input) { emitted.push(input); } });
630
1083
  const req = await svc.openNodeRequest(openInput(['u9']), CTX);
631
- const out = await svc.requestInfo(req.id, { actorId: 'u9', comment: 'Need the Q3 numbers' }, CTX);
1084
+ const out = await svc.requestInfo(req.id, { actorId: 'u9', comment: 'Need the Q3 numbers' }, asUser('u9'));
632
1085
  expect(out.request.status).toBe('pending');
633
1086
  expect(out.request.pending_approvers).toEqual(['u9']);
634
1087
  expect(emitted[0]).toMatchObject({ topic: 'approval.request_info', audience: ['u1'] });
@@ -639,8 +1092,8 @@ describe('ApprovalService (node era)', () => {
639
1092
  it('comment: submitter and approver may reply; outsiders may not', async () => {
640
1093
  const req = await svc.openNodeRequest(openInput(['u9']), CTX);
641
1094
  await svc.comment(req.id, { actorId: 'u1', comment: 'Numbers attached.' }, CTX);
642
- await svc.comment(req.id, { actorId: 'u9', comment: 'Thanks, reviewing.' }, CTX);
643
- await expect(svc.comment(req.id, { actorId: 'outsider', comment: 'hi' }, { positions: [], permissions: [] } as any))
1095
+ await svc.comment(req.id, { actorId: 'u9', comment: 'Thanks, reviewing.' }, asUser('u9'));
1096
+ await expect(svc.comment(req.id, { actorId: 'outsider', comment: 'hi' }, asUser('outsider')))
644
1097
  .rejects.toThrow(/FORBIDDEN/);
645
1098
  const actions = await svc.listActions(req.id, SYS);
646
1099
  expect(actions.filter(a => a.action === 'comment')).toHaveLength(2);
@@ -692,7 +1145,7 @@ describe('ApprovalService (node era)', () => {
692
1145
  expect(await svc.redeemActionToken(short.approve)).toMatchObject({ ok: false, reason: 'expired' });
693
1146
 
694
1147
  const live = await svc.issueActionTokens(req.id, 'u9');
695
- await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, CTX);
1148
+ await svc.reassign(req.id, { actorId: 'u9', to: 'u7' }, asUser('u9'));
696
1149
  expect(await svc.redeemActionToken(live.approve)).toMatchObject({ ok: false, reason: 'not_approver' });
697
1150
 
698
1151
  const forU7 = await svc.issueActionTokens(req.id, 'u7');
@@ -1034,6 +1487,127 @@ describe('ApprovalService (node era)', () => {
1034
1487
  });
1035
1488
  });
1036
1489
 
1490
+ // ── Admin / privileged override (#3424) ──────────────────────────────
1491
+ //
1492
+ // An approval routed to a position/team with NO holders resolves to only the
1493
+ // unresolvable `position:<name>` literal — no concrete user is in the slate, so
1494
+ // every normal decision is FORBIDDEN and (with lockRecord) the record stays
1495
+ // locked forever with no in-product recovery. A platform or tenant admin may
1496
+ // act on the pending request to release it: approve, reject, reassign it to a
1497
+ // real approver, or recall it. Privilege is org-scoped for tenant admins.
1498
+ describe('ApprovalService — admin override (#3424)', () => {
1499
+ let engine: ReturnType<typeof makeFakeEngine>;
1500
+ let svc: ApprovalService;
1501
+ let n = 0;
1502
+ const baseTime = new Date('2026-01-15T10:00:00Z').getTime();
1503
+
1504
+ // Admin exec contexts, shaped like the resolved authz envelope (permissions
1505
+ // carry the permission-set names the shared resolver aggregates).
1506
+ const PLATFORM_ADMIN = { userId: 'root', tenantId: 't1', positions: [], permissions: ['admin_full_access'] } as any;
1507
+ const TENANT_ADMIN = { userId: 'owner', tenantId: 't1', positions: [], permissions: ['organization_admin'] } as any;
1508
+ const OTHER_TENANT_ADMIN = { userId: 'owner2', tenantId: 't2', positions: [], permissions: ['organization_admin'] } as any;
1509
+ const MEMBER = { userId: 'nobody', tenantId: 't1', positions: [], permissions: [] } as any;
1510
+
1511
+ // A request routed to an UNSTAFFED position → `pending_approvers` falls back to
1512
+ // the `position:sales_manager` literal, undecidable by any normal user.
1513
+ const stuckInput = (extra: Record<string, any> = {}) => ({
1514
+ object: 'opportunity', recordId: 'opp1', runId: 'run_1', nodeId: 'approve_step',
1515
+ flowName: 'deal_approval',
1516
+ config: { approvers: [{ type: 'position' as const, value: 'sales_manager' }], behavior: 'first_response' as const, lockRecord: true },
1517
+ record: { id: 'opp1', amount: 100 },
1518
+ ...extra,
1519
+ });
1520
+
1521
+ beforeEach(() => {
1522
+ engine = makeFakeEngine();
1523
+ n = 0;
1524
+ svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(baseTime + (n++) * 1000) } });
1525
+ });
1526
+
1527
+ it('the stuck request is undecidable by any normal user (repro)', async () => {
1528
+ const req = await svc.openNodeRequest(stuckInput(), CTX);
1529
+ expect(req.pending_approvers).toEqual(['position:sales_manager']);
1530
+ // Even the org owner-by-id is not in the resolved (empty) slate.
1531
+ await expect(svc.decideNode(req.id, { decision: 'approve', actorId: 'nobody' }, MEMBER))
1532
+ .rejects.toThrow(/FORBIDDEN/);
1533
+ });
1534
+
1535
+ it('a tenant admin can approve a stuck request, finalizing it (which releases the lock)', async () => {
1536
+ const req = await svc.openNodeRequest(stuckInput(), CTX);
1537
+ const out = await svc.decide(req.id, { decision: 'approve', actorId: 'owner' }, TENANT_ADMIN);
1538
+ expect(out.finalized).toBe(true);
1539
+ expect(out.request.status).toBe('approved');
1540
+ // No pending request remains → the record-lock hook no longer blocks edits.
1541
+ const fresh = await svc.getRequest(req.id, SYS);
1542
+ expect(fresh?.status).toBe('approved');
1543
+ expect(fresh?.pending_approvers).toEqual([]);
1544
+ // Audited under the admin's own id — never spoofed as an approver.
1545
+ const acts = await svc.listActions(req.id, SYS);
1546
+ expect(acts.at(-1)).toMatchObject({ action: 'approve', actor_id: 'owner' });
1547
+ });
1548
+
1549
+ it('a platform admin can reject a stuck request', async () => {
1550
+ const req = await svc.openNodeRequest(stuckInput(), CTX);
1551
+ const out = await svc.decide(req.id, { decision: 'reject', actorId: 'root' }, PLATFORM_ADMIN);
1552
+ expect(out.finalized).toBe(true);
1553
+ expect(out.request.status).toBe('rejected');
1554
+ });
1555
+
1556
+ it('an admin override finalizes even a unanimous request immediately (not one vote among the slate)', async () => {
1557
+ const req = await svc.openNodeRequest(stuckInput({
1558
+ config: { approvers: [{ type: 'position' as const, value: 'sales_manager' }], behavior: 'unanimous' as const, lockRecord: true },
1559
+ }), CTX);
1560
+ const out = await svc.decide(req.id, { decision: 'approve', actorId: 'owner' }, TENANT_ADMIN);
1561
+ expect(out.finalized).toBe(true);
1562
+ expect(out.request.status).toBe('approved');
1563
+ });
1564
+
1565
+ it('an admin can reassign a stuck request to a real approver, who then decides normally', async () => {
1566
+ const req = await svc.openNodeRequest(stuckInput(), CTX);
1567
+ const out = await svc.reassign(req.id, { actorId: 'owner', to: 'u7' }, TENANT_ADMIN);
1568
+ expect(out.request.pending_approvers).toEqual(['u7']);
1569
+ const decided = await svc.decideNode(
1570
+ req.id, { decision: 'approve', actorId: 'u7' },
1571
+ { userId: 'u7', tenantId: 't1', positions: [], permissions: [] } as any,
1572
+ );
1573
+ expect(decided.finalized).toBe(true);
1574
+ });
1575
+
1576
+ it('an admin can recall (withdraw) a stuck request', async () => {
1577
+ const req = await svc.openNodeRequest(stuckInput(), CTX);
1578
+ const out = await svc.recall(req.id, { actorId: 'owner', comment: 'unstaffed role' }, TENANT_ADMIN);
1579
+ expect(out.request.status).toBe('recalled');
1580
+ expect(out.request.pending_approvers).toEqual([]);
1581
+ });
1582
+
1583
+ it('a tenant admin of a DIFFERENT org cannot override (privilege is org-scoped)', async () => {
1584
+ const req = await svc.openNodeRequest(stuckInput(), CTX); // organization_id = t1
1585
+ await expect(svc.decideNode(req.id, { decision: 'approve', actorId: 'owner2' }, OTHER_TENANT_ADMIN))
1586
+ .rejects.toThrow(/FORBIDDEN/);
1587
+ });
1588
+
1589
+ it('viewer.can_override reflects the privilege, and drops once finalized', async () => {
1590
+ const req = await svc.openNodeRequest(stuckInput(), CTX);
1591
+ const asAdmin = await svc.getRequest(req.id, TENANT_ADMIN);
1592
+ expect(asAdmin!.viewer).toMatchObject({ can_act: false, can_override: true });
1593
+ // Someone who CAN see the request but holds no override privilege — the
1594
+ // submitter. `can_override` is about the privilege, not about access, so
1595
+ // the check needs a participant rather than a stranger.
1596
+ const asSubmitter = await svc.getRequest(req.id, CTX);
1597
+ expect(asSubmitter!.viewer!.can_override).toBe(false);
1598
+ await svc.decide(req.id, { decision: 'approve', actorId: 'owner' }, TENANT_ADMIN);
1599
+ const after = await svc.getRequest(req.id, TENANT_ADMIN);
1600
+ expect(after!.viewer!.can_override).toBe(false);
1601
+ });
1602
+
1603
+ // #3590: a plain member who participates in nothing now sees nothing — a
1604
+ // request is no longer readable merely because you are in the same tenant.
1605
+ it('a non-participant member cannot read the request at all', async () => {
1606
+ const req = await svc.openNodeRequest(stuckInput(), CTX);
1607
+ expect(await svc.getRequest(req.id, MEMBER)).toBeNull();
1608
+ });
1609
+ });
1610
+
1037
1611
  describe('record-lock hook (node era)', () => {
1038
1612
  let engine: ReturnType<typeof makeFakeEngine>;
1039
1613
  let svc: ApprovalService;
@@ -1098,34 +1672,227 @@ describe('record-lock hook (node era)', () => {
1098
1672
  ).resolves.toBeUndefined();
1099
1673
  });
1100
1674
 
1101
- it('unbindAllHooks removes the lock hook', () => {
1102
- expect(unbindAllHooks(engine as any)).toBe(1);
1103
- expect(engine._hooks['beforeUpdate']).toHaveLength(0);
1675
+ // ── #3456 prevention half: the lock must not kill the run that owns it ──
1676
+
1677
+ it('allows the OWNING run to write its own target record', async () => {
1678
+ await expect(
1679
+ engine.fire('beforeUpdate', {
1680
+ object: 'opportunity',
1681
+ input: { id: 'opp1', data: { amount: 200 } },
1682
+ // Neither elevated nor admin — the exemption rides on run identity
1683
+ // alone, so a `runAs:'user'` run stays RLS-scoped while it writes.
1684
+ session: { isSystem: false, positions: [], userId: 'u1' },
1685
+ provenance: { flowRunId: 'run_1' },
1686
+ }),
1687
+ ).resolves.toBeUndefined();
1104
1688
  });
1105
- });
1106
1689
 
1107
- // ── Out-of-office auto-skip (#1322 M1/M4) ─────────────────────────────
1108
- //
1109
- // When a resolved individual approver has declared an active OOO delegation,
1110
- // the slot is rerouted to the delegate at resolution time (never a background
1111
- // job), audited as `ooo_substitute`, and both parties are notified. Group /
1112
- // graph approvers (position/team/department/tier) are left untouched.
1113
- describe('ApprovalService — out-of-office delegation (#1322)', () => {
1114
- // Mid-window instant for the issue's own example (leave 5/26–5/30).
1115
- const OOO_NOW = new Date('2026-05-27T10:00:00Z').getTime();
1116
- let engine: ReturnType<typeof makeFakeEngine>;
1117
- let svc: ApprovalService;
1118
- let emitted: any[];
1690
+ // #3712 the residual #3703 left open. A schedule-triggered run resolves NO
1691
+ // principal, so it arrives with no session at all; provenance is the only
1692
+ // thing it carries, and the exemption must key on that rather than on an
1693
+ // identity the run does not have.
1694
+ it('allows an identity-less (schedule-triggered) owning run no session at all', async () => {
1695
+ await expect(
1696
+ engine.fire('beforeUpdate', {
1697
+ object: 'opportunity',
1698
+ input: { id: 'opp1', data: { amount: 200 } },
1699
+ provenance: { flowRunId: 'run_1' },
1700
+ }),
1701
+ ).resolves.toBeUndefined();
1702
+ });
1119
1703
 
1120
- function seedDelegation(rows: Array<Record<string, any>>) {
1121
- engine._tables['sys_approval_delegation'] = rows.map((r, i) => ({
1122
- id: `del${i}`,
1123
- organization_id: 't1',
1124
- valid_from: '2026-05-26T00:00:00Z',
1125
- valid_until: '2026-05-30T00:00:00Z',
1126
- reason: 'Annual leave',
1127
- ...r,
1128
- }));
1704
+ it('still blocks a DIFFERENT run writing the locked record', async () => {
1705
+ await expect(
1706
+ engine.fire('beforeUpdate', {
1707
+ object: 'opportunity',
1708
+ input: { id: 'opp1', data: { amount: 200 } },
1709
+ session: { isSystem: false, positions: [], userId: 'u1' },
1710
+ provenance: { flowRunId: 'run_other' },
1711
+ }),
1712
+ ).rejects.toThrow(/RECORD_LOCKED/);
1713
+ });
1714
+
1715
+ it('still blocks an identity-less caller with no provenance at all', async () => {
1716
+ // The bare-kernel / no-context write. Nothing to match, nothing exempted.
1717
+ await expect(
1718
+ engine.fire('beforeUpdate', {
1719
+ object: 'opportunity',
1720
+ input: { id: 'opp1', data: { amount: 200 } },
1721
+ }),
1722
+ ).rejects.toThrow(/RECORD_LOCKED/);
1723
+ });
1724
+
1725
+ it('does not exempt anyone when the pending request carries no run id', async () => {
1726
+ // A request with no owning run has nothing to match against — a stray
1727
+ // `flowRunId` must not become a skeleton key.
1728
+ engine._tables['sys_approval_request'][0].flow_run_id = null;
1729
+ await expect(
1730
+ engine.fire('beforeUpdate', {
1731
+ object: 'opportunity',
1732
+ input: { id: 'opp1', data: { amount: 200 } },
1733
+ session: { isSystem: false, positions: [], userId: 'u1' },
1734
+ provenance: { flowRunId: 'run_1' },
1735
+ }),
1736
+ ).rejects.toThrow(/RECORD_LOCKED/);
1737
+ });
1738
+
1739
+ it('unbindAllHooks removes the lock hook', () => {
1740
+ expect(unbindAllHooks(engine as any)).toBe(1);
1741
+ expect(engine._hooks['beforeUpdate']).toHaveLength(0);
1742
+ });
1743
+ });
1744
+
1745
+ // ── #3456 recovery half: release records held by a dead approval run ──
1746
+ //
1747
+ // The prevention half above stops a run from dying on its own lock. This sweep
1748
+ // covers the runs that die anyway — including a process crash, which no in-band
1749
+ // handler can clean up because the process that would run it is gone.
1750
+ //
1751
+ // The load-bearing property is what it must NOT do: a run merely *paused* on its
1752
+ // approval is the normal state of every live request, so anything short of an
1753
+ // explicit terminal status has to be read as "alive".
1754
+ describe('ApprovalService — dead-run release (#3456)', () => {
1755
+ let engine: ReturnType<typeof makeFakeEngine>;
1756
+ let svc: ApprovalService;
1757
+ let n = 0;
1758
+ const baseTime = new Date('2026-01-15T10:00:00Z').getTime();
1759
+
1760
+ /** Attach an automation surface whose `getRun` answers with `status`. */
1761
+ const withRunStatus = (status: string | null) =>
1762
+ svc.attachAutomation({ getRun: async () => (status == null ? null : { status }) } as any);
1763
+
1764
+ const requestRow = () => engine._tables['sys_approval_request'][0];
1765
+
1766
+ beforeEach(async () => {
1767
+ engine = makeFakeEngine();
1768
+ n = 0;
1769
+ svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(baseTime + (n++) * 1000) } });
1770
+ bindApprovalLockHook(engine as any);
1771
+ await svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status' }), CTX);
1772
+ engine._tables['opportunity'] = [{ id: 'opp1', amount: 100 }];
1773
+ });
1774
+
1775
+ it('releases a pending request whose owning run failed', async () => {
1776
+ withRunStatus('failed');
1777
+ expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 1, released: 1 });
1778
+ expect(requestRow().status).toBe('recalled');
1779
+ expect(requestRow().pending_approvers).toBeNull();
1780
+ expect(requestRow().completed_at).toBeTruthy();
1781
+ });
1782
+
1783
+ it('audits the release as a dead-run abandonment, not a submitter recall', async () => {
1784
+ withRunStatus('failed');
1785
+ await svc.releaseDeadRunRequests();
1786
+ const action = engine._tables['sys_approval_action'].find((a: any) => a.actor_id === 'system:dead-run');
1787
+ expect(action).toBeTruthy();
1788
+ expect(action.action).toBe('recall');
1789
+ expect(action.comment).toMatch(/run_1/);
1790
+ expect(action.comment).toMatch(/failed/);
1791
+ });
1792
+
1793
+ it('actually unlocks the record — a plain user edit succeeds afterwards', async () => {
1794
+ // The end-to-end point of the whole sweep.
1795
+ const edit = () => engine.fire('beforeUpdate', {
1796
+ object: 'opportunity',
1797
+ input: { id: 'opp1', data: { amount: 200 } },
1798
+ session: { isSystem: false, positions: [], userId: 'u1' },
1799
+ });
1800
+ await expect(edit()).rejects.toThrow(/RECORD_LOCKED/); // held by the dead run
1801
+ withRunStatus('failed');
1802
+ await svc.releaseDeadRunRequests();
1803
+ await expect(edit()).resolves.toBeUndefined(); // released
1804
+ });
1805
+
1806
+ it('mirrors the configured status field on release', async () => {
1807
+ withRunStatus('failed');
1808
+ await svc.releaseDeadRunRequests();
1809
+ expect(engine._tables['opportunity'][0].approval_status).toBe('recalled');
1810
+ });
1811
+
1812
+ it('leaves a PAUSED run alone — that is a live approval', async () => {
1813
+ withRunStatus('paused');
1814
+ expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 1, released: 0 });
1815
+ expect(requestRow().status).toBe('pending');
1816
+ });
1817
+
1818
+ it.each([
1819
+ ['an unknown run (null)', null],
1820
+ ['an unrecognised status', 'reticulating_splines'],
1821
+ ['a still-running run', 'running'],
1822
+ ])('leaves the request pending for %s', async (_label, status) => {
1823
+ withRunStatus(status as any);
1824
+ expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 1, released: 0 });
1825
+ expect(requestRow().status).toBe('pending');
1826
+ });
1827
+
1828
+ it('leaves the request pending when getRun throws', async () => {
1829
+ svc.attachAutomation({ getRun: async () => { throw new Error('engine unreachable'); } } as any);
1830
+ expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 1, released: 0 });
1831
+ expect(requestRow().status).toBe('pending');
1832
+ });
1833
+
1834
+ it('is a no-op with no automation engine attached', async () => {
1835
+ expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 0, released: 0 });
1836
+ expect(requestRow().status).toBe('pending');
1837
+ });
1838
+
1839
+ it('is a no-op when the surface has no getRun (older engine)', async () => {
1840
+ svc.attachAutomation({ resume: async () => undefined } as any);
1841
+ expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 0, released: 0 });
1842
+ expect(requestRow().status).toBe('pending');
1843
+ });
1844
+
1845
+ it('skips a request with no owning run', async () => {
1846
+ requestRow().flow_run_id = null;
1847
+ withRunStatus('failed');
1848
+ expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 1, released: 0 });
1849
+ expect(requestRow().status).toBe('pending');
1850
+ });
1851
+
1852
+ it.each(['completed', 'cancelled', 'timed_out'])(
1853
+ 'releases on the other terminal status %s', async (status) => {
1854
+ // A terminal run can never decide its request, whatever ended it — a
1855
+ // `completed` one means someone resumed the run out of band.
1856
+ withRunStatus(status);
1857
+ expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 1, released: 1 });
1858
+ expect(requestRow().status).toBe('recalled');
1859
+ },
1860
+ );
1861
+
1862
+ it('one unreadable request does not stop the sweep', async () => {
1863
+ await svc.openNodeRequest(
1864
+ { ...openInput(['u9']), recordId: 'opp2', runId: 'run_2' } as any, CTX,
1865
+ );
1866
+ let call = 0;
1867
+ svc.attachAutomation({
1868
+ getRun: async () => { call++; if (call === 1) throw new Error('boom'); return { status: 'failed' }; },
1869
+ } as any);
1870
+ expect(await svc.releaseDeadRunRequests()).toEqual({ scanned: 2, released: 1 });
1871
+ });
1872
+ });
1873
+
1874
+ // ── Out-of-office auto-skip (#1322 M1/M4) ─────────────────────────────
1875
+ //
1876
+ // When a resolved individual approver has declared an active OOO delegation,
1877
+ // the slot is rerouted to the delegate at resolution time (never a background
1878
+ // job), audited as `ooo_substitute`, and both parties are notified. Group /
1879
+ // graph approvers (position/team/department/tier) are left untouched.
1880
+ describe('ApprovalService — out-of-office delegation (#1322)', () => {
1881
+ // Mid-window instant for the issue's own example (leave 5/26–5/30).
1882
+ const OOO_NOW = new Date('2026-05-27T10:00:00Z').getTime();
1883
+ let engine: ReturnType<typeof makeFakeEngine>;
1884
+ let svc: ApprovalService;
1885
+ let emitted: any[];
1886
+
1887
+ function seedDelegation(rows: Array<Record<string, any>>) {
1888
+ engine._tables['sys_approval_delegation'] = rows.map((r, i) => ({
1889
+ id: `del${i}`,
1890
+ organization_id: 't1',
1891
+ valid_from: '2026-05-26T00:00:00Z',
1892
+ valid_until: '2026-05-30T00:00:00Z',
1893
+ reason: 'Annual leave',
1894
+ ...r,
1895
+ }));
1129
1896
  }
1130
1897
 
1131
1898
  beforeEach(() => {
@@ -1183,6 +1950,20 @@ describe('ApprovalService — out-of-office delegation (#1322)', () => {
1183
1950
  expect(req.pending_approvers).toEqual(['bob']);
1184
1951
  });
1185
1952
 
1953
+ it('type:field (multi-select) — reroutes each out-of-office user independently (#3447)', async () => {
1954
+ // Pre-#3447 the array was stringified to one bogus id ('alice,dave'), which
1955
+ // matched no delegation — OOO silently no-op'd. Fanned out, alice → bob
1956
+ // applies and dave is left untouched.
1957
+ seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
1958
+ const input = {
1959
+ ...openInput([]),
1960
+ record: { id: 'opp1', reviewers: ['alice', 'dave'] },
1961
+ config: { approvers: [{ type: 'field', value: 'reviewers' }], behavior: 'unanimous', lockRecord: true },
1962
+ };
1963
+ const req = await svc.openNodeRequest(input as any, CTX);
1964
+ expect(req.pending_approvers.sort()).toEqual(['bob', 'dave']);
1965
+ });
1966
+
1186
1967
  it('type:manager — reroutes when the resolved manager is out of office', async () => {
1187
1968
  engine._tables['sys_user'] = [{ id: 'carol', manager_id: 'alice' }];
1188
1969
  seedDelegation([{ delegator_id: 'alice', delegate_id: 'bob' }]);
@@ -1451,6 +2232,33 @@ describe('ApprovalService — decision_progress & deep links (#2678 P1.5)', () =
1451
2232
  expect(row.decision_progress.groups.find((g: any) => g.group === 'finance')).toMatchObject({ got: 0, satisfied: false });
1452
2233
  });
1453
2234
 
2235
+ it('per_group: pending_approver_groups maps each pending approver to its group (objectui#2807)', async () => {
2236
+ const req = await svc.openNodeRequest(cfg([U('l1', 'legal'), U('f1', 'finance')], 'per_group'), CTX);
2237
+ let row: any = await svc.getRequest(req.id, SYS);
2238
+ // Every pending slot is labeled with the group it fills.
2239
+ expect(row.pending_approver_groups).toEqual({ l1: ['legal'], f1: ['finance'] });
2240
+ // Once legal signs off, l1 drops out of pending — and out of the map.
2241
+ await svc.decideNode(req.id, { decision: 'approve', actorId: 'l1' }, SYS);
2242
+ row = await svc.getRequest(req.id, SYS);
2243
+ expect(row.pending_approver_groups).toEqual({ f1: ['finance'] });
2244
+ });
2245
+
2246
+ it('per_group with unnamed groups omits synthetic keys; non-per_group omits the map (objectui#2807)', async () => {
2247
+ // Distinct (record, run) so the two opens aren't a duplicate-pending clash.
2248
+ const mk = (approvers: any[], behavior: string, recordId: string, runId: string, extra: Record<string, any> = {}) => ({
2249
+ ...openInput([], { recordId, runId }),
2250
+ config: { approvers, behavior, lockRecord: true, ...extra },
2251
+ });
2252
+ // Unnamed approvers → synthetic `#N` group keys, which are not surfaced.
2253
+ const unnamed = await svc.openNodeRequest(mk([U('u1'), U('u2')], 'per_group', 'opp_u', 'run_u'), CTX);
2254
+ const uRow: any = await svc.getRequest(unnamed.id, SYS);
2255
+ expect(uRow.pending_approver_groups).toBeUndefined();
2256
+ // Quorum aggregates approvals, not groups — no approver→group map.
2257
+ const q = await svc.openNodeRequest(mk([U('a1'), U('a2')], 'quorum', 'opp_q', 'run_q', { minApprovals: 2 }), CTX);
2258
+ const qRow: any = await svc.getRequest(q.id, SYS);
2259
+ expect(qRow.pending_approver_groups).toBeUndefined();
2260
+ });
2261
+
1454
2262
  it('quorum: progress reports approvals against the clamped threshold', async () => {
1455
2263
  const req = await svc.openNodeRequest(cfg([U('u1'), U('u2'), U('u3')], 'quorum', { minApprovals: 2 }), CTX);
1456
2264
  await svc.decideNode(req.id, { decision: 'approve', actorId: 'u1' }, SYS);
@@ -1477,7 +2285,7 @@ describe('ApprovalService — decision_progress & deep links (#2678 P1.5)', () =
1477
2285
  // listActions must surface decision attachments through the contract mapping
1478
2286
  // (#3266 — the column existed but rowFromAction dropped it; caught in browser).
1479
2287
  describe('ApprovalService — listActions attachments mapping (#3266)', () => {
1480
- it('returns the attachments recorded on a decision', async () => {
2288
+ it('normalizes a bare fileId string into an attachment descriptor', async () => {
1481
2289
  const engine = makeFakeEngine();
1482
2290
  let n = 0;
1483
2291
  const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(1757000000000 + (n++) * 1000) } });
@@ -1485,6 +2293,566 @@ describe('ApprovalService — listActions attachments mapping (#3266)', () => {
1485
2293
  await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9', attachments: ['file_a'] }, SYS);
1486
2294
  const acts = await svc.listActions(req.id, SYS);
1487
2295
  const approve = acts.find(a => a.action === 'approve');
1488
- expect(approve?.attachments).toEqual(['file_a']);
2296
+ expect(approve?.attachments).toEqual([{ id: 'file_a' }]);
2297
+ });
2298
+
2299
+ // The normal case. The column STORES an opaque sys_file id (ADR-0104 D3);
2300
+ // the ObjectQL read path resolves it into the expanded
2301
+ // `{ id, name, size, mimeType, url }` form on the way out. The old
2302
+ // `.map(String)` turned that object into "[object Object]", so the inbox chip
2303
+ // had no name and 404'd on open. The mapping must pass it through unmangled.
2304
+ it('passes the engine-expanded file value through with its name and url', async () => {
2305
+ const engine = makeFakeEngine();
2306
+ let n = 0;
2307
+ const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(1757000000000 + (n++) * 1000) } });
2308
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
2309
+ await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
2310
+ // Simulate what the read path hands back after resolving the stored id.
2311
+ const row = engine._tables['sys_approval_action'].find((a: any) => a.action === 'approve');
2312
+ row.attachments = [
2313
+ { id: 'file_a', name: 'signed-contract.pdf', mimeType: 'application/pdf', size: 24, url: '/api/v1/storage/files/file_a' },
2314
+ ];
2315
+ const acts = await svc.listActions(req.id, SYS);
2316
+ const approve = acts.find(a => a.action === 'approve');
2317
+ expect(approve?.attachments).toEqual([
2318
+ { id: 'file_a', name: 'signed-contract.pdf', mimeType: 'application/pdf', size: 24, url: '/api/v1/storage/files/file_a' },
2319
+ ]);
2320
+ });
2321
+
2322
+ // Rows written before file-as-reference hold an inline blob whose keys are
2323
+ // snake_case (`file_id`, `mime_type`). They stay readable until the backfill
2324
+ // converts them, so both casings must map — the same drift that made objectui
2325
+ // stop recognising images when the expanded form arrived.
2326
+ it('maps a legacy inline blob (file_id / mime_type) written before the cutover', async () => {
2327
+ const engine = makeFakeEngine();
2328
+ let n = 0;
2329
+ const svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(1757000000000 + (n++) * 1000) } });
2330
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
2331
+ await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
2332
+ const row = engine._tables['sys_approval_action'].find((a: any) => a.action === 'approve');
2333
+ row.attachments = [
2334
+ { file_id: 'file_b', name: 'old.pdf', mime_type: 'application/pdf', size: 12, url: 'https://cdn/old.pdf' },
2335
+ ];
2336
+ const acts = await svc.listActions(req.id, SYS);
2337
+ expect(acts.find(a => a.action === 'approve')?.attachments).toEqual([
2338
+ { id: 'file_b', name: 'old.pdf', mimeType: 'application/pdf', size: 12, url: 'https://cdn/old.pdf' },
2339
+ ]);
2340
+ });
2341
+ });
2342
+
2343
+ // #3508: `queue` is declared-but-unenforced — resolveApproverSpec has no queue
2344
+ // branch, so the spec value falls through to the dead `queue:<id>` literal.
2345
+ // The engine must at least WARN so operators can see the silent dead slot;
2346
+ // the spec marks the type non-authorable so designers stop offering it.
2347
+ describe('ApprovalService — queue approver is unresolved (#3508)', () => {
2348
+ it('falls back to the dead literal and warns', async () => {
2349
+ const engine = makeFakeEngine();
2350
+ const warnings: any[] = [];
2351
+ let n = 0;
2352
+ const svc = new ApprovalService({
2353
+ engine: engine as any,
2354
+ clock: { now: () => new Date(1757000000000 + (n++) * 1000) },
2355
+ logger: { warn: (msg: any, meta: any) => warnings.push([msg, meta]) },
2356
+ });
2357
+ const req = await svc.openNodeRequest(
2358
+ { ...openInput([]), config: { approvers: [{ type: 'queue', value: 'q_west' }], behavior: 'first_response', lockRecord: false } },
2359
+ CTX,
2360
+ );
2361
+ // No queue expansion exists: the slot is the raw `type:value` literal,
2362
+ // which matches no real user id — the request routes to nobody.
2363
+ expect(req.pending_approvers).toEqual(['queue:q_west']);
2364
+ expect(warnings.some(([msg]) => String(msg).includes("'queue'") && String(msg).includes('#3508'))).toBe(true);
2365
+ });
2366
+ });
2367
+
2368
+ // #3807 follow-up: `queue` was not the only way to end up with a slot nobody
2369
+ // can act on — every GRAPH approver type falls back to the same literal when
2370
+ // its lookup finds no one, and that fallback used to happen in total silence.
2371
+ // A stuck approval was the first symptom; the log said nothing. The literal
2372
+ // stays (15.x slots and substring fixtures depend on it) — it just announces
2373
+ // itself now.
2374
+ describe('ApprovalService — a graph approver that expands to nobody warns (#3807)', () => {
2375
+ const svcWithWarnings = (engine: any) => {
2376
+ const warnings: any[] = [];
2377
+ let n = 0;
2378
+ const svc = new ApprovalService({
2379
+ engine,
2380
+ clock: { now: () => new Date(1757000000000 + (n++) * 1000) },
2381
+ logger: { warn: (msg: any, meta: any) => warnings.push([msg, meta]) },
2382
+ });
2383
+ return { svc, warnings };
2384
+ };
2385
+
2386
+ const approverInput = (type: string, value: string) => ({
2387
+ ...openInput([]),
2388
+ config: { approvers: [{ type, value }], behavior: 'first_response' as const, lockRecord: false },
2389
+ });
2390
+
2391
+ it.each([
2392
+ ['team', 'team_gone'],
2393
+ ['department', 'bu_gone'],
2394
+ ['position', 'nobody_holds_this'],
2395
+ ['org_membership_level', 'member'],
2396
+ ])('%s: the dead literal is logged with its type, value and org', async (type, value) => {
2397
+ const engine = makeFakeEngine();
2398
+ const { svc, warnings } = svcWithWarnings(engine);
2399
+ const req = await svc.openNodeRequest(approverInput(type, value), CTX);
2400
+
2401
+ expect(req.pending_approvers).toEqual([`${type}:${value}`]);
2402
+ const hit = warnings.find(([msg]) => String(msg).includes('expanded to nobody'));
2403
+ expect(hit, `no warning for ${type}`).toBeTruthy();
2404
+ expect(String(hit[0])).toContain('#3807');
2405
+ expect(hit[1]).toMatchObject({ type, value, organizationId: 't1' });
2406
+ });
2407
+
2408
+ it('stays quiet when the graph DOES resolve someone', async () => {
2409
+ const engine = makeFakeEngine();
2410
+ engine._tables['sys_team_member'] = [{ id: 'tm1', team_id: 'team_ok', user_id: 'u5' }];
2411
+ const { svc, warnings } = svcWithWarnings(engine);
2412
+ const req = await svc.openNodeRequest(approverInput('team', 'team_ok'), CTX);
2413
+
2414
+ expect(req.pending_approvers).toEqual(['u5']);
2415
+ expect(warnings.filter(([msg]) => String(msg).includes('expanded to nobody'))).toEqual([]);
2416
+ });
2417
+
2418
+ it('stays quiet for `user` — a literal id was never a lookup that could come back empty', async () => {
2419
+ const engine = makeFakeEngine();
2420
+ const { svc, warnings } = svcWithWarnings(engine);
2421
+ const req = await svc.openNodeRequest(approverInput('user', 'u_unknown'), CTX);
2422
+
2423
+ expect(req.pending_approvers).toEqual(['u_unknown']);
2424
+ expect(warnings.filter(([msg]) => String(msg).includes('expanded to nobody'))).toEqual([]);
2425
+ });
2426
+ });
2427
+
2428
+ // ── File-access delegate (ADR-0104 D3 wave 2) ────────────────────────
2429
+ //
2430
+ // A decision attachment is OWNED by its `sys_approval_action` row, so the
2431
+ // storage service would otherwise authorize the download by testing whether
2432
+ // the caller can READ that row. It cannot — the table is closed to ordinary
2433
+ // approver positions — which denied the very approver the attachment was filed
2434
+ // for (reproduced in the browser against app-showcase). `sys_approval_action`
2435
+ // therefore declares `fileAccessDelegate: 'approvals'` and the service answers,
2436
+ // reusing the rule that already governs seeing a decision: visibility of the
2437
+ // PARENT REQUEST, exactly as listActions applies it.
2438
+ describe('ApprovalService — authorizeFileRead delegate (ADR-0104 D3 wave 2)', () => {
2439
+ const svcFor = (engine: any) => {
2440
+ let n = 0;
2441
+ return new ApprovalService({
2442
+ engine,
2443
+ clock: { now: () => new Date(1757000000000 + (n++) * 1000) },
2444
+ });
2445
+ };
2446
+
2447
+ const seedDecision = async (engine: any) => {
2448
+ const svc = svcFor(engine);
2449
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
2450
+ await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9', attachments: ['file_a'] }, SYS);
2451
+ const action = engine._tables['sys_approval_action'].find((a: any) => a.action === 'approve');
2452
+ return { svc, req, actionId: String(action.id) };
2453
+ };
2454
+
2455
+ it('allows a caller who can see the parent request', async () => {
2456
+ const engine = makeFakeEngine();
2457
+ const { svc, actionId } = await seedDecision(engine);
2458
+
2459
+ expect(await svc.authorizeFileRead(actionId, SYS)).toBe(true);
2460
+ });
2461
+
2462
+ it('denies a caller who cannot see the parent request', async () => {
2463
+ const engine = makeFakeEngine();
2464
+ const { svc, actionId } = await seedDecision(engine);
2465
+ // getRequest is the single gate this delegates to — when it yields nothing
2466
+ // for this caller, the bytes must be refused too.
2467
+ vi.spyOn(svc as any, 'getRequest').mockResolvedValue(null);
2468
+
2469
+ expect(await svc.authorizeFileRead(actionId, CTX)).toBe(false);
2470
+ });
2471
+
2472
+ it('denies an unknown action id', async () => {
2473
+ const engine = makeFakeEngine();
2474
+ const { svc } = await seedDecision(engine);
2475
+
2476
+ expect(await svc.authorizeFileRead('aact_does_not_exist', SYS)).toBe(false);
2477
+ expect(await svc.authorizeFileRead('', SYS)).toBe(false);
2478
+ });
2479
+
2480
+ it('fails CLOSED when the lookup throws', async () => {
2481
+ const engine = makeFakeEngine();
2482
+ const { svc, actionId } = await seedDecision(engine);
2483
+ vi.spyOn(engine as any, 'find').mockRejectedValue(new Error('driver down'));
2484
+
2485
+ expect(await svc.authorizeFileRead(actionId, SYS)).toBe(false);
2486
+ });
2487
+
2488
+ it('sys_approval_action declares the delegate, so the storage gate asks the service', async () => {
2489
+ const { SysApprovalAction } = await import('./sys-approval-action.object.js');
2490
+ expect((SysApprovalAction as any).fileAccessDelegate).toBe('approvals');
2491
+ });
2492
+ });
2493
+
2494
+ // ── Participant visibility (#3590) ───────────────────────────────────
2495
+ //
2496
+ // getRequest/listRequests deliberately query with SYSTEM_CTX (the
2497
+ // approver-visibility rule spans identity forms RLS cannot model), but only
2498
+ // the TENANT half of that rule was ever applied — so any authenticated user
2499
+ // could read any request in their tenant, and after #3580 its decision
2500
+ // attachments too. These lock in the participant half.
2501
+ describe('ApprovalService — participant visibility (#3590)', () => {
2502
+ const svcFor = (engine: any) => {
2503
+ let n = 0;
2504
+ return new ApprovalService({ engine, clock: { now: () => new Date(1757000000000 + (n++) * 1000) } });
2505
+ };
2506
+ const asUser = (userId: string) => ({ userId, tenantId: 't1', positions: [], permissions: [] } as any);
2507
+ const ADMIN = { userId: 'root', tenantId: 't1', positions: [], permissions: ['admin_full_access'] } as any;
2508
+
2509
+ it('the submitter and a pending approver can read it; a same-tenant stranger cannot', async () => {
2510
+ const engine = makeFakeEngine();
2511
+ const svc = svcFor(engine);
2512
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX); // submitter u1, approver u9
2513
+
2514
+ expect(await svc.getRequest(req.id, asUser('u1'))).not.toBeNull();
2515
+ expect(await svc.getRequest(req.id, asUser('u9'))).not.toBeNull();
2516
+ expect(await svc.getRequest(req.id, asUser('u_stranger'))).toBeNull();
2517
+ });
2518
+
2519
+ it('someone who already acted keeps access after their slot moves on', async () => {
2520
+ const engine = makeFakeEngine();
2521
+ const svc = svcFor(engine);
2522
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
2523
+ await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
2524
+
2525
+ // u9 is no longer a pending approver, but the decision is theirs — the
2526
+ // audit trail (and its attachments) must not vanish from under them.
2527
+ expect(await svc.getRequest(req.id, asUser('u9'))).not.toBeNull();
2528
+ });
2529
+
2530
+ it('an override admin keeps the unrestricted view the console depends on', async () => {
2531
+ const engine = makeFakeEngine();
2532
+ const svc = svcFor(engine);
2533
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
2534
+
2535
+ expect(await svc.getRequest(req.id, ADMIN)).not.toBeNull();
2536
+ });
2537
+
2538
+ it('a tokenless context sees nothing — the gate fails closed', async () => {
2539
+ const engine = makeFakeEngine();
2540
+ const svc = svcFor(engine);
2541
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
2542
+
2543
+ expect(await svc.getRequest(req.id, { tenantId: 't1', positions: [], permissions: [] } as any)).toBeNull();
2544
+ });
2545
+
2546
+ it('listRequests no longer returns the whole tenant when no approverId filter is passed', async () => {
2547
+ const engine = makeFakeEngine();
2548
+ const svc = svcFor(engine);
2549
+ const mine = await svc.openNodeRequest(openInput(['u9']), CTX); // submitter u1
2550
+ await svc.openNodeRequest(openInput(['u7'], { recordId: 'opp2', record: { id: 'opp2' } }), asUser('u_other')); // unrelated to u1
2551
+
2552
+ // The old behaviour: omit approverId and receive every request in the
2553
+ // tenant. `approverId` is a filter, never authorization.
2554
+ const seen = await svc.listRequests(undefined, asUser('u1'));
2555
+ expect(seen.map(r => r.id)).toEqual([mine.id]);
2556
+
2557
+ const strangerSees = await svc.listRequests(undefined, asUser('u_stranger'));
2558
+ expect(strangerSees).toEqual([]);
2559
+
2560
+ // The admin console still sees everything.
2561
+ expect((await svc.listRequests(undefined, ADMIN)).length).toBeGreaterThanOrEqual(2);
2562
+ });
2563
+
2564
+ it('countRequests agrees with the list it paginates', async () => {
2565
+ const engine = makeFakeEngine();
2566
+ const svc = svcFor(engine);
2567
+ await svc.openNodeRequest(openInput(['u9']), CTX);
2568
+ await svc.openNodeRequest(openInput(['u7'], { recordId: 'opp2', record: { id: 'opp2' } }), asUser('u_other'));
2569
+
2570
+ expect(await svc.countRequests(undefined, asUser('u_stranger'))).toBe(0);
2571
+ expect(await svc.countRequests(undefined, asUser('u1'))).toBe(1);
2572
+ });
2573
+
2574
+ it('a write path still echoes back its own result to the user who made it', async () => {
2575
+ const engine = makeFakeEngine();
2576
+ const svc = svcFor(engine);
2577
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
2578
+
2579
+ // Approving CLEARS `pending_approvers`, so the approver stops being a
2580
+ // participant the instant their own write lands. The operation authorized
2581
+ // itself; re-gating the echo would turn a successful write into a null
2582
+ // result for the very person who made it.
2583
+ const res = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, asUser('u9'));
2584
+ expect(res.request).not.toBeNull();
2585
+ expect(res.request.status).toBe('approved');
2586
+ });
2587
+
2588
+ it('a service-to-service write echoes back too (no session at all)', async () => {
2589
+ const engine = makeFakeEngine();
2590
+ const svc = svcFor(engine);
2591
+ const req = await svc.openNodeRequest(openInput(['u9']), CTX);
2592
+
2593
+ // Flow-driven resumes and the SLA sweep carry no user. Since #3800 that is
2594
+ // expressible only as a SYSTEM context — a user-less non-system caller can
2595
+ // no longer act by naming an approver.
2596
+ const res = await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
2597
+ expect(res.request).not.toBeNull();
2598
+ expect(res.request.status).toBe('approved');
2599
+ });
2600
+ });
2601
+
2602
+ // ── The ordering invariant the dead-run sweep rests on (#3456) ─────────
2603
+ //
2604
+ // `releaseDeadRunRequests` recalls a PENDING request whose owning run has
2605
+ // reached a TERMINAL state, on the premise that such a pair can only be an
2606
+ // orphan. That premise is not self-evident — it holds only because every
2607
+ // in-band transition moves the request OUT of `pending` before it hands the run
2608
+ // back. Resume first and a run that finishes promptly afterwards would be
2609
+ // indistinguishable from an orphan, so the sweep would cancel a LIVE approval —
2610
+ // precisely the one failure mode it is built never to have.
2611
+ //
2612
+ // Nothing enforces that ordering: it is a convention spread across four public
2613
+ // methods and seven resume/cancelRun call sites, any of which a refactor could
2614
+ // reorder without a single existing test going red. So pin the invariant
2615
+ // itself rather than the call order of any one method — at the instant the run
2616
+ // is handed back, no request owned by that run may still be `pending`.
2617
+ describe('in-band transitions finalise before they resume (#3456 invariant)', () => {
2618
+ let engine: ReturnType<typeof makeFakeEngine>;
2619
+ let svc: ApprovalService;
2620
+ let n = 0;
2621
+ const baseTime = new Date('2026-01-15T10:00:00Z').getTime();
2622
+ /** One entry per hand-back, with any still-pending requests owned by the run. */
2623
+ let handoffs: Array<{ hook: string; stillPending: string[] }>;
2624
+
2625
+ /** A flow whose approval node declares the `revise` out-edge send-back needs. */
2626
+ const REVISE_FLOW = {
2627
+ name: 'deal_approval',
2628
+ edges: [{ id: 'e_rev', source: 'approve_step', target: 'wait_revision', label: 'revise' }],
2629
+ };
2630
+
2631
+ function recordHandoff(hook: string) {
2632
+ const rows = (engine._tables['sys_approval_request'] ?? []) as any[];
2633
+ handoffs.push({
2634
+ hook,
2635
+ stillPending: rows
2636
+ .filter(r => String(r.flow_run_id ?? '') === 'run_1' && r.status === 'pending')
2637
+ .map(r => String(r.id)),
2638
+ });
2639
+ }
2640
+
2641
+ /** Assert every hand-back this scenario made was clean. */
2642
+ function expectCleanHandoffs() {
2643
+ expect(
2644
+ handoffs.length,
2645
+ 'the run was never handed back — this scenario did not exercise the invariant',
2646
+ ).toBeGreaterThan(0);
2647
+ for (const h of handoffs) {
2648
+ expect(
2649
+ h.stillPending,
2650
+ `${h.hook}() handed run_1 back while it still owned a pending request — `
2651
+ + 'the dead-run sweep would treat that as an orphan and cancel a live approval',
2652
+ ).toEqual([]);
2653
+ }
2654
+ }
2655
+
2656
+ beforeEach(async () => {
2657
+ engine = makeFakeEngine();
2658
+ n = 0;
2659
+ handoffs = [];
2660
+ svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(baseTime + (n++) * 1000) } });
2661
+ svc.attachAutomation({
2662
+ async resume() { recordHandoff('resume'); },
2663
+ async cancelRun() { recordHandoff('cancelRun'); },
2664
+ async getFlow() { return REVISE_FLOW; },
2665
+ } as any);
2666
+ engine._tables['opportunity'] = [{ id: 'opp1', amount: 100 }];
2667
+ });
2668
+
2669
+ const open = (configExtra: Record<string, any> = {}) =>
2670
+ svc.openNodeRequest(openInput(['u9'], {}, configExtra), CTX);
2671
+
2672
+ it('decide(approve) finalises before resuming', async () => {
2673
+ const req = await open();
2674
+ await svc.decide(req.id, { decision: 'approve', actorId: 'u9' }, SYS);
2675
+ expectCleanHandoffs();
2676
+ });
2677
+
2678
+ it('decide(reject) finalises before resuming', async () => {
2679
+ const req = await open();
2680
+ await svc.decide(req.id, { decision: 'reject', actorId: 'u9' }, SYS);
2681
+ expectCleanHandoffs();
2682
+ });
2683
+
2684
+ it('recall finalises before resuming', async () => {
2685
+ const req = await open();
2686
+ await svc.recall(req.id, { actorId: 'u1' }, CTX);
2687
+ expectCleanHandoffs();
2688
+ });
2689
+
2690
+ it('sendBack finalises before resuming', async () => {
2691
+ const req = await open();
2692
+ await svc.sendBack(req.id, { actorId: 'u9', comment: 'fix the totals' }, asUser('u9'));
2693
+ expectCleanHandoffs();
2694
+ });
2695
+
2696
+ it('sendBack past the revision budget auto-rejects before resuming', async () => {
2697
+ // `maxRevisions: 0` takes the ADR-0044 loop-guard branch on the first
2698
+ // send-back — a separate resume site from the normal path above.
2699
+ const req = await open({ maxRevisions: 0 });
2700
+ const out = await svc.sendBack(req.id, { actorId: 'u9' }, asUser('u9'));
2701
+ expect(out.autoRejected, 'expected the auto-reject branch').toBe(true);
2702
+ expectCleanHandoffs();
2703
+ });
2704
+
2705
+ it('recall inside the revise window cancels the run without a pending request', async () => {
2706
+ const req = await open();
2707
+ await svc.sendBack(req.id, { actorId: 'u9' }, asUser('u9'));
2708
+ handoffs = []; // isolate the recall's own hand-back
2709
+ await svc.recall(req.id, { actorId: 'u1' }, CTX);
2710
+ expect(handoffs.map(h => h.hook)).toContain('cancelRun');
2711
+ expectCleanHandoffs();
2712
+ });
2713
+
2714
+ it('resubmit re-enters the node without leaving the old request pending', async () => {
2715
+ const req = await open();
2716
+ await svc.sendBack(req.id, { actorId: 'u9' }, asUser('u9'));
2717
+ handoffs = []; // isolate the resubmit's own hand-back
2718
+ await svc.resubmit(req.id, { actorId: 'u1' }, CTX);
2719
+ expectCleanHandoffs();
2720
+ });
2721
+ });
2722
+
2723
+ /**
2724
+ * #3783 — the status mirror names the human who caused the transition.
2725
+ *
2726
+ * The mirror write lands on the CUSTOMER's object, so it is what fires that
2727
+ * object's record-change flows. It has to stay `isSystem` (the record is locked
2728
+ * while its approval is live), but dropping the actor left every one of those
2729
+ * cascades with no trigger user — which #3760 now refuses outright, forcing
2730
+ * "when the invoice is approved, do X" to declare `runAs:'system'`.
2731
+ *
2732
+ * Each case therefore asserts BOTH halves: the elevation survives (or the lock
2733
+ * hook stops mirroring at all) and the identity is present.
2734
+ */
2735
+ describe('status mirror identity (#3783)', () => {
2736
+ let engine: ReturnType<typeof makeFakeEngine>;
2737
+ let svc: ApprovalService;
2738
+ let n = 0;
2739
+ const baseTime = new Date('2026-01-15T10:00:00Z').getTime();
2740
+
2741
+ const REVISE_FLOW = {
2742
+ name: 'deal_approval',
2743
+ edges: [{ id: 'e_rev', source: 'approve_step', target: 'wait_revision', label: 'revise' }],
2744
+ };
2745
+
2746
+ /** The context the service presented on the mirror write, or undefined. */
2747
+ const mirrorContext = () =>
2748
+ engine._writes.filter(w => w.object === 'opportunity').at(-1)?.context as any;
2749
+
2750
+ const open = (configExtra: Record<string, any> = {}, ctx: any = CTX) =>
2751
+ svc.openNodeRequest(openInput(['u9'], {}, { approvalStatusField: 'approval_status', ...configExtra }), ctx);
2752
+
2753
+ beforeEach(() => {
2754
+ engine = makeFakeEngine();
2755
+ n = 0;
2756
+ svc = new ApprovalService({ engine: engine as any, clock: { now: () => new Date(baseTime + (n++) * 1000) } });
2757
+ svc.attachAutomation({
2758
+ async resume() {},
2759
+ async cancelRun() {},
2760
+ async getFlow() { return REVISE_FLOW; },
2761
+ } as any);
2762
+ engine._tables['opportunity'] = [{ id: 'opp1', amount: 100 }];
2763
+ });
2764
+
2765
+ it('submit: mirrors as the submitter, still elevated', async () => {
2766
+ await open();
2767
+ expect(mirrorContext()).toMatchObject({ isSystem: true, userId: 'u1' });
2768
+ });
2769
+
2770
+ it('decide: mirrors as the deciding user', async () => {
2771
+ const req = await open();
2772
+ const approver = { ...CTX, userId: 'u9' };
2773
+ await svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, approver as any);
2774
+ expect(engine._tables['opportunity'][0].approval_status).toBe('approved');
2775
+ expect(mirrorContext()).toMatchObject({ isSystem: true, userId: 'u9' });
2776
+ });
2777
+
2778
+ it('recall: mirrors as the recalling user', async () => {
2779
+ const req = await open();
2780
+ await svc.recall(req.id, { actorId: 'u1' }, CTX);
2781
+ expect(mirrorContext()).toMatchObject({ isSystem: true, userId: 'u1' });
2782
+ });
2783
+
2784
+ it('sendBack: mirrors as the approver who returned it', async () => {
2785
+ const req = await open();
2786
+ const approver = { ...CTX, userId: 'u9' };
2787
+ await svc.sendBack(req.id, { actorId: 'u9', comment: 'redo the totals' }, approver as any);
2788
+ expect(engine._tables['opportunity'][0].approval_status).toBe('returned');
2789
+ expect(mirrorContext()).toMatchObject({ isSystem: true, userId: 'u9' });
2790
+ });
2791
+
2792
+ it('sendBack past the revision budget: the auto-reject mirror names the approver too', async () => {
2793
+ const req = await open({ maxRevisions: 0 });
2794
+ const approver = { ...CTX, userId: 'u9' };
2795
+ const out = await svc.sendBack(req.id, { actorId: 'u9' }, approver as any);
2796
+ expect(out.autoRejected, 'expected the auto-reject branch').toBe(true);
2797
+ expect(engine._tables['opportunity'][0].approval_status).toBe('rejected');
2798
+ expect(mirrorContext()).toMatchObject({ isSystem: true, userId: 'u9' });
2799
+ });
2800
+
2801
+ it('action link: mirrors as the approver the token is bound to', async () => {
2802
+ // ADR-0043 email approval — no session at all, but the single-use hashed
2803
+ // token names exactly one approver, and `resolveActionToken` has just
2804
+ // re-checked they still hold a pending slot. That IS an authenticated act.
2805
+ const req = await open();
2806
+ const { approve } = await svc.issueActionTokens(req.id, 'u9');
2807
+ expect(await svc.redeemActionToken(approve)).toMatchObject({ ok: true });
2808
+ expect(mirrorContext()).toMatchObject({ isSystem: true, userId: 'u9' });
2809
+ });
2810
+
2811
+ it('never takes the identity from the caller-supplied actorId', async () => {
2812
+ // `actorId` arrives in the REST body (`body.actorId ?? context.userId`).
2813
+ // #3783 kept it out of the mirror identity; #3800 then stopped it from
2814
+ // reaching the slate check too, so borrowing a slot holder's identity now
2815
+ // fails outright rather than merely mislabelling the write. The mirror is
2816
+ // the second line here, not the first — assert both, so neither can regress
2817
+ // silently behind the other.
2818
+ const req = await open();
2819
+ const someoneElse = { ...CTX, userId: 'intruder' };
2820
+ await expect(
2821
+ svc.decideNode(req.id, { decision: 'approve', actorId: 'u9' }, someoneElse as any),
2822
+ ).rejects.toThrow(/FORBIDDEN/);
2823
+ expect(engine._tables['opportunity'][0].approval_status).toBe('pending');
2824
+ expect(mirrorContext()?.userId).not.toBe('u9');
2825
+ });
2826
+
2827
+ it('SLA auto-decision: stays user-less — no human did it', async () => {
2828
+ const req = await open({ escalation: { timeoutHours: 1, action: 'auto_approve', notifySubmitter: false } });
2829
+ const raw = engine._tables['sys_approval_request'].find((r: any) => r.id === req.id)!;
2830
+ raw.created_at = new Date(baseTime - 3 * 60 * 60 * 1000).toISOString();
2831
+ await svc.runEscalations();
2832
+ expect(engine._tables['opportunity'][0].approval_status).toBe('approved');
2833
+ // `system:sla` is a reserved audit actor, not a user — it must never be
2834
+ // presented as one. The cascade stays user-less on purpose; a flow that
2835
+ // wants to react to an SLA auto-decision declares runAs:'system'.
2836
+ expect(mirrorContext()?.userId).toBeUndefined();
2837
+ expect(mirrorContext()).toMatchObject({ isSystem: true });
2838
+ });
2839
+
2840
+ it('dead-run sweep: stays user-less — no human did it', async () => {
2841
+ await open();
2842
+ svc.attachAutomation({ getRun: async () => ({ status: 'failed' }) } as any);
2843
+ expect(await svc.releaseDeadRunRequests()).toMatchObject({ released: 1 });
2844
+ expect(engine._tables['opportunity'][0].approval_status).toBe('recalled');
2845
+ expect(mirrorContext()?.userId).toBeUndefined();
2846
+ expect(mirrorContext()).toMatchObject({ isSystem: true });
2847
+ });
2848
+
2849
+ it('carries the actor WITHOUT org-scoping the write', async () => {
2850
+ // `tenantId` on an ExecutionContext is a driver-scoping knob, not
2851
+ // attribution: ObjectQL turns it into a tenant predicate on the update. The
2852
+ // submitter's org (`t1` on CTX) must therefore not ride along, or the mirror
2853
+ // would silently no-op on a record whose org differs from the request's.
2854
+ await open();
2855
+ expect(mirrorContext()).not.toHaveProperty('tenantId');
2856
+ expect(mirrorContext()).not.toHaveProperty('organizationId');
1489
2857
  });
1490
2858
  });