@objectstack/plugin-approvals 9.1.0 → 9.3.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.
@@ -1,5 +1,6 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
+ import { createHash, randomBytes } from 'node:crypto';
3
4
  import {
4
5
  APPROVAL_BRANCH_LABELS,
5
6
  type ApprovalNodeConfig,
@@ -12,6 +13,10 @@ import type {
12
13
  ApprovalDecisionResult,
13
14
  ApprovalRecallInput,
14
15
  ApprovalRecallResult,
16
+ ApprovalSendBackInput,
17
+ ApprovalSendBackResult,
18
+ ApprovalResubmitInput,
19
+ ApprovalResubmitResult,
15
20
  ApprovalStatus,
16
21
  SharingExecutionContext,
17
22
  } from '@objectstack/spec/contracts';
@@ -47,8 +52,51 @@ export interface ApprovalClock { now(): Date }
47
52
  */
48
53
  export interface ApprovalResumeSurface {
49
54
  resume?(runId: string, signal?: { output?: Record<string, unknown>; branchLabel?: string }): Promise<unknown>;
55
+ /** Flow definition lookup, used to derive step-progress display data. */
56
+ getFlow?(name: string): Promise<any | null>;
57
+ /**
58
+ * Terminally cancel a suspended run (ADR-0044). Used when a recall lands
59
+ * during a revision window — the run is paused at the revise wait node,
60
+ * which has no reject edge to resume down.
61
+ */
62
+ cancelRun?(runId: string, reason?: string): Promise<unknown>;
50
63
  }
51
64
 
65
+ /**
66
+ * Optional messaging surface (ADR-0012 `messaging` service). When attached,
67
+ * thread interactions (reassign / remind / request-info / comment) notify the
68
+ * affected users; without it they degrade to audit-only.
69
+ */
70
+ export interface ApprovalMessagingSurface {
71
+ emit(input: {
72
+ topic: string;
73
+ audience: string[];
74
+ payload?: Record<string, unknown>;
75
+ severity?: string;
76
+ dedupKey?: string;
77
+ source?: { object: string; id: string };
78
+ actorId?: string;
79
+ }): Promise<unknown>;
80
+ }
81
+
82
+ /** Minimum time between submitter reminders on one request. */
83
+ export const REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1000;
84
+
85
+ /** Named job under which the SLA escalation scan is registered (ADR-0042). */
86
+ export const ESCALATION_JOB_NAME = 'approvals-sla-escalation';
87
+ /** Default interval between SLA escalation scans. */
88
+ export const ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1000;
89
+ /** Reserved actor id for machine decisions made by the SLA scanner. */
90
+ export const SLA_ACTOR_ID = 'system:sla';
91
+
92
+ /** Default lifetime of an actionable-link token (ADR-0043). */
93
+ export const ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1000;
94
+
95
+ /** Outcome of redeeming (or peeking) an actionable-link token. */
96
+ export type ActionTokenOutcome =
97
+ | { ok: true; action: 'approve' | 'reject'; request: ApprovalRequestRow; approverId: string }
98
+ | { ok: false; reason: 'invalid' | 'expired' | 'consumed' | 'not_pending' | 'not_approver'; request?: ApprovalRequestRow };
99
+
52
100
  const SYSTEM_CTX = { isSystem: true, roles: [], permissions: [] } as const;
53
101
 
54
102
  function uid(prefix: string): string {
@@ -114,9 +162,21 @@ function rowFromRequest(row: any): ApprovalRequestRow {
114
162
  submitted_at: row.created_at ?? undefined,
115
163
  process_label: cfg?.__flowLabel ?? prettifyMachineName(row.process_name),
116
164
  step_label: cfg?.__nodeLabel ?? prettifyMachineName(row.current_step),
165
+ sla_due_at: slaDueAt(row.created_at, cfg),
166
+ // ADR-0044 revision round (rides the config snapshot; absent ⇒ round 1).
167
+ round: typeof cfg?.__round === 'number' ? cfg.__round : undefined,
117
168
  } as any;
118
169
  }
119
170
 
171
+ /** `created_at + escalation.timeoutHours`, when the node declares an SLA. */
172
+ function slaDueAt(createdAt: unknown, cfg: any): string | undefined {
173
+ const hours = cfg?.escalation?.timeoutHours;
174
+ if (typeof hours !== 'number' || hours <= 0 || !createdAt) return undefined;
175
+ const t = Date.parse(String(createdAt));
176
+ if (Number.isNaN(t)) return undefined;
177
+ return new Date(t + hours * 3600_000).toISOString();
178
+ }
179
+
120
180
  function rowFromAction(row: any): ApprovalActionRow {
121
181
  return {
122
182
  id: String(row.id),
@@ -141,6 +201,14 @@ export interface ApprovalServiceOptions {
141
201
  * available.
142
202
  */
143
203
  automation?: ApprovalResumeSurface;
204
+ /** Optional messaging service for thread notifications. */
205
+ messaging?: ApprovalMessagingSurface;
206
+ /**
207
+ * Absolute origin prefixed onto actionable links (ADR-0043), e.g.
208
+ * `https://app.example.com`. Defaults to relative URLs, which work inside
209
+ * the Console and IM webviews; outbound email needs the absolute form.
210
+ */
211
+ publicBaseUrl?: string;
144
212
  }
145
213
 
146
214
  export class ApprovalService implements IApprovalService {
@@ -148,12 +216,16 @@ export class ApprovalService implements IApprovalService {
148
216
  private readonly clock: ApprovalClock;
149
217
  private readonly logger?: ApprovalServiceOptions['logger'];
150
218
  private automation?: ApprovalResumeSurface;
219
+ private messaging?: ApprovalMessagingSurface;
220
+ private publicBaseUrl: string;
151
221
 
152
222
  constructor(opts: ApprovalServiceOptions) {
153
223
  this.engine = opts.engine;
154
224
  this.clock = opts.clock ?? { now: () => new Date() };
155
225
  this.logger = opts.logger;
156
226
  this.automation = opts.automation;
227
+ this.messaging = opts.messaging;
228
+ this.publicBaseUrl = (opts.publicBaseUrl ?? '').replace(/\/$/, '');
157
229
  }
158
230
 
159
231
  /** Attach (or replace) the automation surface used to resume flow runs. */
@@ -161,6 +233,45 @@ export class ApprovalService implements IApprovalService {
161
233
  this.automation = automation;
162
234
  }
163
235
 
236
+ /** Attach (or replace) the messaging surface used for thread notifications. */
237
+ attachMessaging(messaging: ApprovalMessagingSurface): void {
238
+ this.messaging = messaging;
239
+ }
240
+
241
+ /** Best-effort notification fan-out — failures only log. */
242
+ private async notify(input: {
243
+ topic: string;
244
+ audience: string[];
245
+ payload?: Record<string, unknown>;
246
+ dedupKey?: string;
247
+ source?: { object: string; id: string };
248
+ actorId?: string;
249
+ }): Promise<number> {
250
+ const audience = input.audience.filter(a => a && !a.includes(':'));
251
+ if (!this.messaging || !audience.length) return 0;
252
+ try {
253
+ await this.messaging.emit({ severity: 'info', ...input, audience });
254
+ return audience.length;
255
+ } catch (err: any) {
256
+ this.logger?.warn?.('[approvals] notification failed', {
257
+ topic: input.topic, error: err?.message ?? String(err),
258
+ });
259
+ return 0;
260
+ }
261
+ }
262
+
263
+ /** Load a request row and assert it is still pending. */
264
+ private async loadPendingRow(requestId: string): Promise<any> {
265
+ if (!requestId) throw new Error('VALIDATION_FAILED: requestId is required');
266
+ const rows = await this.engine.find('sys_approval_request', {
267
+ where: { id: requestId }, limit: 1, context: SYSTEM_CTX,
268
+ });
269
+ const raw: any = Array.isArray(rows) ? rows[0] : null;
270
+ if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
271
+ if (raw.status !== 'pending') throw new Error(`INVALID_STATE: request is ${raw.status}`);
272
+ return raw;
273
+ }
274
+
164
275
  /**
165
276
  * Expand the approvers on an Approval node into user IDs by querying the
166
277
  * graph tables for `team:` / `department:` / `role:` / `manager:` approver
@@ -351,6 +462,16 @@ export class ApprovalService implements IApprovalService {
351
462
  const configSnapshot: any = { ...input.config };
352
463
  if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
353
464
  if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
465
+ // ADR-0044 round numbering: rounds of a revise loop share the run — count
466
+ // this (run, node)'s prior requests; the new one is round N+1. Stamped on
467
+ // the snapshot (precedent: __flowLabel), so no schema migration.
468
+ try {
469
+ const prior = await this.engine.find('sys_approval_request', {
470
+ where: { flow_run_id: input.runId, flow_node_id: input.nodeId }, limit: 500, context: SYSTEM_CTX,
471
+ });
472
+ const n = Array.isArray(prior) ? prior.length : 0;
473
+ if (n > 0) configSnapshot.__round = n + 1;
474
+ } catch { /* round display is best-effort */ }
354
475
  const row: any = {
355
476
  id,
356
477
  process_name: processName,
@@ -370,6 +491,7 @@ export class ApprovalService implements IApprovalService {
370
491
  updated_at: now,
371
492
  };
372
493
  await this.engine.insert('sys_approval_request', row, { context: SYSTEM_CTX });
494
+ await this.syncApproverIndex(id, approvers, ctxOrg, now);
373
495
  await this.engine.insert('sys_approval_action', {
374
496
  id: uid('aact'), request_id: id, organization_id: ctxOrg,
375
497
  step_name: input.nodeId, step_index: 0, action: 'submit',
@@ -443,6 +565,7 @@ export class ApprovalService implements IApprovalService {
443
565
  await this.engine.update('sys_approval_request', {
444
566
  id: requestId, pending_approvers: stillPending.join(','), updated_at: now,
445
567
  }, { context: SYSTEM_CTX });
568
+ await this.syncApproverIndex(requestId, stillPending, org, now);
446
569
  const fresh = await this.getRequest(requestId, context);
447
570
  return { request: fresh!, runId, nodeId, finalized: false, decision: input.decision };
448
571
  }
@@ -452,6 +575,7 @@ export class ApprovalService implements IApprovalService {
452
575
  await this.engine.update('sys_approval_request', {
453
576
  id: requestId, status: finalStatus, pending_approvers: null, completed_at: now, updated_at: now,
454
577
  }, { context: SYSTEM_CTX });
578
+ await this.syncApproverIndex(requestId, [], org, now);
455
579
  if (config.approvalStatusField) {
456
580
  await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, finalStatus);
457
581
  }
@@ -502,8 +626,14 @@ export class ApprovalService implements IApprovalService {
502
626
  * Withdraw a pending request (submitter only). Finalises the row as
503
627
  * `recalled`, releases the record lock (keyed on pending status), mirrors
504
628
  * the status field when configured, and resumes the owning flow run down
505
- * the `reject` branch with `output.decision = 'recall'` — the engine has no
506
- * run-cancel primitive, and leaving the run suspended forever would leak it.
629
+ * the `reject` branch with `output.decision = 'recall'` — leaving the run
630
+ * suspended forever would leak it.
631
+ *
632
+ * ADR-0044: also valid on the LATEST `returned` request of its run — the
633
+ * submitter abandons the revision window instead of resubmitting. The run
634
+ * is then paused at the revise wait node (no reject edge), so it is
635
+ * terminally cancelled via {@link ApprovalResumeSurface.cancelRun} rather
636
+ * than resumed.
507
637
  */
508
638
  async recall(
509
639
  requestId: string,
@@ -518,10 +648,16 @@ export class ApprovalService implements IApprovalService {
518
648
  });
519
649
  const raw: any = Array.isArray(rawRows) ? rawRows[0] : null;
520
650
  if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
521
- if (raw.status !== 'pending') throw new Error(`INVALID_STATE: request is ${raw.status}`);
651
+ const inReviseWindow = raw.status === 'returned';
652
+ if (raw.status !== 'pending' && !inReviseWindow) {
653
+ throw new Error(`INVALID_STATE: request is ${raw.status}`);
654
+ }
522
655
  if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
523
656
  throw new Error(`FORBIDDEN: only the submitter may recall this request`);
524
657
  }
658
+ // A returned request is only recallable while it is still the run's live
659
+ // frontier — a resubmitted (or later-node) request supersedes it.
660
+ if (inReviseWindow) await this.assertLatestForRun(raw);
525
661
 
526
662
  const config = parseJson<ApprovalNodeConfig>(raw.node_config_json, { approvers: [], behavior: 'first_response' } as any);
527
663
  const org = raw.organization_id ?? null;
@@ -538,12 +674,25 @@ export class ApprovalService implements IApprovalService {
538
674
  await this.engine.update('sys_approval_request', {
539
675
  id: requestId, status: 'recalled', pending_approvers: null, completed_at: now, updated_at: now,
540
676
  }, { context: SYSTEM_CTX });
677
+ await this.syncApproverIndex(requestId, [], org, now);
541
678
  if (config.approvalStatusField) {
542
679
  await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, 'recalled');
543
680
  }
544
681
 
545
682
  let resumed = false;
546
- if (runId && typeof this.automation?.resume === 'function') {
683
+ if (inReviseWindow) {
684
+ // ADR-0044: the run is paused at the revise wait node, which has no
685
+ // reject out-edge to resume down — terminally cancel it instead.
686
+ if (runId && typeof this.automation?.cancelRun === 'function') {
687
+ try {
688
+ await this.automation.cancelRun(runId, `approval request ${requestId} recalled during revision`);
689
+ } catch (err: any) {
690
+ this.logger?.warn?.('[approvals] cancelRun after revise-window recall failed', {
691
+ request: requestId, run: runId, error: err?.message ?? String(err),
692
+ });
693
+ }
694
+ }
695
+ } else if (runId && typeof this.automation?.resume === 'function') {
547
696
  try {
548
697
  await this.automation.resume(runId, {
549
698
  branchLabel: APPROVAL_BRANCH_LABELS.reject,
@@ -561,6 +710,704 @@ export class ApprovalService implements IApprovalService {
561
710
  return { request: fresh!, runId, resumed };
562
711
  }
563
712
 
713
+ // ── Send back for revision / resubmit (ADR-0044) ─────────────
714
+
715
+ /**
716
+ * ADR-0044 send back for revision. Finalises the pending request as
717
+ * `returned` (a third terminal state — approver-initiated rework, distinct
718
+ * from submitter-initiated `recalled`) and resumes the owning flow run down
719
+ * its `revise` edge to a wait point: the record lock (keyed on `pending`)
720
+ * releases, the submitter reworks the data, then {@link resubmit}s.
721
+ *
722
+ * Requires the approval node to declare a `revise` out-edge — validated
723
+ * BEFORE any mutation, because resuming with an unmatched `branchLabel`
724
+ * falls back to *all* out-edges. Past the node's `maxRevisions` budget the
725
+ * request auto-rejects instead (resumes down `reject` with
726
+ * `output.autoRejected = true`) so instances cannot orbit forever.
727
+ */
728
+ async sendBack(
729
+ requestId: string,
730
+ input: ApprovalSendBackInput,
731
+ context: SharingExecutionContext,
732
+ ): Promise<ApprovalSendBackResult> {
733
+ if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
734
+ const raw = await this.loadPendingRow(requestId);
735
+ const pending = csvSplit(raw.pending_approvers);
736
+ if (!context.isSystem && !pending.includes(input.actorId)) {
737
+ throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
738
+ }
739
+
740
+ const config = parseJson<ApprovalNodeConfig>(raw.node_config_json, { approvers: [], behavior: 'first_response' } as any);
741
+ const org = raw.organization_id ?? null;
742
+ const nodeId: string | null = raw.flow_node_id ?? raw.current_step ?? null;
743
+ const runId: string | null = raw.flow_run_id ?? null;
744
+
745
+ await this.assertReviseEdge(raw, nodeId);
746
+
747
+ const now = this.clock.now().toISOString();
748
+ const maxRevisions = typeof (config as any).maxRevisions === 'number' ? (config as any).maxRevisions : 3;
749
+ let priorSendBacks = 0;
750
+ if (runId && nodeId) {
751
+ const siblings = await this.engine.find('sys_approval_request', {
752
+ where: { flow_run_id: runId, flow_node_id: nodeId, status: 'returned' }, limit: 500, context: SYSTEM_CTX,
753
+ });
754
+ priorSendBacks = Array.isArray(siblings) ? siblings.length : 0;
755
+ }
756
+
757
+ // Audit the revise intent first (audit-first, like decideNode) — on the
758
+ // auto-reject path the trail then reads `revise → reject`, preserving
759
+ // what the approver actually asked for.
760
+ await this.engine.insert('sys_approval_action', {
761
+ id: uid('aact'), request_id: requestId, organization_id: org,
762
+ step_name: nodeId, step_index: 0, action: 'revise',
763
+ actor_id: input.actorId, comment: input.comment ?? null, created_at: now,
764
+ }, { context: SYSTEM_CTX });
765
+
766
+ if (priorSendBacks >= maxRevisions) {
767
+ // Revision budget exhausted — auto-reject (ADR-0044 loop guard).
768
+ await this.engine.insert('sys_approval_action', {
769
+ id: uid('aact'), request_id: requestId, organization_id: org,
770
+ step_name: nodeId, step_index: 0, action: 'reject',
771
+ actor_id: input.actorId,
772
+ comment: `Auto-rejected: revision limit (${maxRevisions}) exceeded`, created_at: now,
773
+ }, { context: SYSTEM_CTX });
774
+ await this.engine.update('sys_approval_request', {
775
+ id: requestId, status: 'rejected', pending_approvers: null, completed_at: now, updated_at: now,
776
+ }, { context: SYSTEM_CTX });
777
+ await this.syncApproverIndex(requestId, [], org, now);
778
+ if (config.approvalStatusField) {
779
+ await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, 'rejected');
780
+ }
781
+ let resumed = false;
782
+ if (runId && typeof this.automation?.resume === 'function') {
783
+ try {
784
+ await this.automation.resume(runId, {
785
+ branchLabel: APPROVAL_BRANCH_LABELS.reject,
786
+ output: { decision: 'reject', autoRejected: true, requestId },
787
+ });
788
+ resumed = true;
789
+ } catch (err: any) {
790
+ this.logger?.warn?.('[approvals] resume after auto-reject failed', {
791
+ request: requestId, run: runId, error: err?.message ?? String(err),
792
+ });
793
+ }
794
+ }
795
+ if (raw.submitter_id) {
796
+ await this.notify({
797
+ topic: 'approval.returned',
798
+ audience: [String(raw.submitter_id)],
799
+ actorId: input.actorId,
800
+ source: { object: 'sys_approval_request', id: requestId },
801
+ payload: {
802
+ title: 'Approval auto-rejected',
803
+ message: `Your ${raw.object_name}/${raw.record_id} exceeded the revision limit (${maxRevisions}) and was rejected.`,
804
+ actionUrl: '/system/approvals',
805
+ },
806
+ });
807
+ }
808
+ const fresh = await this.getRequest(requestId, context);
809
+ return { request: fresh!, runId, resumed, autoRejected: true };
810
+ }
811
+
812
+ await this.engine.update('sys_approval_request', {
813
+ id: requestId, status: 'returned', pending_approvers: null, completed_at: now, updated_at: now,
814
+ }, { context: SYSTEM_CTX });
815
+ await this.syncApproverIndex(requestId, [], org, now);
816
+ if (config.approvalStatusField) {
817
+ await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, 'returned');
818
+ }
819
+
820
+ let resumed = false;
821
+ if (runId && typeof this.automation?.resume === 'function') {
822
+ try {
823
+ await this.automation.resume(runId, {
824
+ branchLabel: APPROVAL_BRANCH_LABELS.revise,
825
+ output: { decision: 'revise', requestId },
826
+ });
827
+ resumed = true;
828
+ } catch (err: any) {
829
+ this.logger?.warn?.('[approvals] resume after send-back failed', {
830
+ request: requestId, run: runId, error: err?.message ?? String(err),
831
+ });
832
+ }
833
+ }
834
+
835
+ if (raw.submitter_id) {
836
+ await this.notify({
837
+ topic: 'approval.returned',
838
+ audience: [String(raw.submitter_id)],
839
+ actorId: input.actorId,
840
+ source: { object: 'sys_approval_request', id: requestId },
841
+ payload: {
842
+ title: 'Sent back for revision',
843
+ message: input.comment?.trim() || `Your ${raw.object_name}/${raw.record_id} needs rework before it can be approved.`,
844
+ actionUrl: '/system/approvals',
845
+ },
846
+ });
847
+ }
848
+
849
+ const fresh = await this.getRequest(requestId, context);
850
+ return { request: fresh!, runId, resumed };
851
+ }
852
+
853
+ /**
854
+ * ADR-0044 resubmit after rework. Valid on the LATEST `returned` request of
855
+ * its run, submitter-only. Audits `resubmit` on the returned (round-N)
856
+ * request and resumes the run from the revise wait node; traversal walks
857
+ * the declared back-edge into the approval node, whose executor opens the
858
+ * round-N+1 request — fresh approver slate, record re-locks.
859
+ */
860
+ async resubmit(
861
+ requestId: string,
862
+ input: ApprovalResubmitInput,
863
+ context: SharingExecutionContext,
864
+ ): Promise<ApprovalResubmitResult> {
865
+ if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
866
+ const rawRows = await this.engine.find('sys_approval_request', {
867
+ where: { id: requestId }, limit: 1, context: SYSTEM_CTX,
868
+ });
869
+ const raw: any = Array.isArray(rawRows) ? rawRows[0] : null;
870
+ if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
871
+ if (raw.status !== 'returned') {
872
+ throw new Error(`INVALID_STATE: request is ${raw.status} (resubmit applies to returned requests)`);
873
+ }
874
+ if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
875
+ throw new Error('FORBIDDEN: only the submitter may resubmit');
876
+ }
877
+ await this.assertLatestForRun(raw);
878
+
879
+ // A colliding pending request on the same record (e.g. a record-change
880
+ // trigger re-fired off an edit made inside the revise window) would make
881
+ // the approval node's re-entry fail AFTER the engine consumed the
882
+ // suspension — permanently killing the run. Refuse up front instead; the
883
+ // submitter resolves the collision (recall the other request) first.
884
+ const colliding = await this.engine.find('sys_approval_request', {
885
+ where: { object_name: raw.object_name, record_id: raw.record_id, status: 'pending' },
886
+ limit: 1, context: SYSTEM_CTX,
887
+ });
888
+ if (Array.isArray(colliding) && colliding[0]) {
889
+ throw new Error(
890
+ `DUPLICATE_REQUEST: another approval request is already pending on ${raw.object_name}/${raw.record_id} — resolve it before resubmitting`,
891
+ );
892
+ }
893
+
894
+ const org = raw.organization_id ?? null;
895
+ const nodeId: string | null = raw.flow_node_id ?? raw.current_step ?? null;
896
+ const runId: string | null = raw.flow_run_id ?? null;
897
+ const now = this.clock.now().toISOString();
898
+
899
+ await this.engine.insert('sys_approval_action', {
900
+ id: uid('aact'), request_id: requestId, organization_id: org,
901
+ step_name: nodeId, step_index: 0, action: 'resubmit',
902
+ actor_id: input.actorId, comment: input.comment ?? null, created_at: now,
903
+ }, { context: SYSTEM_CTX });
904
+
905
+ // The next round only exists if this resume lands — surface `resumed`
906
+ // honestly so a stuck run is visible instead of silently swallowed.
907
+ let resumed = false;
908
+ if (runId && typeof this.automation?.resume === 'function') {
909
+ try {
910
+ await this.automation.resume(runId, {
911
+ branchLabel: APPROVAL_BRANCH_LABELS.resubmit,
912
+ output: { resubmitted: true, requestId },
913
+ });
914
+ resumed = true;
915
+ } catch (err: any) {
916
+ this.logger?.warn?.('[approvals] resume after resubmit failed', {
917
+ request: requestId, run: runId, error: err?.message ?? String(err),
918
+ });
919
+ }
920
+ }
921
+
922
+ const fresh = await this.getRequest(requestId, context);
923
+ return { request: fresh!, runId, resumed };
924
+ }
925
+
926
+ /**
927
+ * ADR-0044 guard: the flow's approval node must declare a `revise`
928
+ * out-edge before send-back is allowed — the engine's branch-label fallback
929
+ * (no matching label ⇒ ALL out-edges) must never be reachable from a user
930
+ * action.
931
+ */
932
+ private async assertReviseEdge(raw: any, nodeId: string | null): Promise<void> {
933
+ const processName = String(raw.process_name ?? '');
934
+ const flowName = processName.startsWith('flow:') ? processName.slice('flow:'.length) : undefined;
935
+ if (!flowName || !nodeId || typeof this.automation?.getFlow !== 'function') {
936
+ throw new Error('VALIDATION_FAILED: send-back requires the owning flow definition (automation engine unavailable)');
937
+ }
938
+ const flow: any = await this.automation.getFlow(flowName);
939
+ const hasRevise = Array.isArray(flow?.edges)
940
+ && flow.edges.some((e: any) => e?.source === nodeId && e?.label === APPROVAL_BRANCH_LABELS.revise);
941
+ if (!hasRevise) {
942
+ throw new Error(
943
+ `VALIDATION_FAILED: approval node '${nodeId}' has no '${APPROVAL_BRANCH_LABELS.revise}' out-edge — ` +
944
+ 'the flow does not support send-back for revision',
945
+ );
946
+ }
947
+ }
948
+
949
+ /**
950
+ * ADR-0044 guard: a `returned` request is only actionable (resubmit /
951
+ * recall) while it is still the newest request on its run — a later round
952
+ * or a later node's request supersedes it.
953
+ */
954
+ private async assertLatestForRun(raw: any): Promise<void> {
955
+ const runId = raw.flow_run_id;
956
+ if (!runId) return;
957
+ // SortNode's key is `order` (spec/data/query.zod.ts) — `direction` would
958
+ // silently default to ascending and return the OLDEST row.
959
+ const rows = await this.engine.find('sys_approval_request', {
960
+ where: { flow_run_id: runId },
961
+ orderBy: [{ field: 'created_at', order: 'desc' }], limit: 1, context: SYSTEM_CTX,
962
+ });
963
+ const latest: any = Array.isArray(rows) ? rows[0] : null;
964
+ if (latest && String(latest.id) !== String(raw.id)) {
965
+ throw new Error('INVALID_STATE: a newer approval request supersedes this one');
966
+ }
967
+ }
968
+
969
+ // ── Thread interactions (no flow movement) ───────────────────
970
+
971
+ /**
972
+ * Hand a pending-approver slot to someone else. `from` defaults to the
973
+ * actor itself; the actor must hold the slot being handed over (or be a
974
+ * system caller). Audits `reassign` and notifies the new approver.
975
+ */
976
+ async reassign(
977
+ requestId: string,
978
+ input: { actorId: string; to: string; from?: string; comment?: string },
979
+ context: SharingExecutionContext,
980
+ ): Promise<{ request: ApprovalRequestRow }> {
981
+ if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
982
+ const to = String(input?.to ?? '').trim();
983
+ if (!to) throw new Error('VALIDATION_FAILED: `to` (new approver) is required');
984
+ const raw = await this.loadPendingRow(requestId);
985
+
986
+ const pending = csvSplit(raw.pending_approvers);
987
+ const from = String(input.from ?? input.actorId).trim();
988
+ if (!pending.includes(from)) {
989
+ throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`);
990
+ }
991
+ if (!context.isSystem && input.actorId !== from && !pending.includes(input.actorId)) {
992
+ throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
993
+ }
994
+ if (pending.includes(to)) {
995
+ throw new Error(`VALIDATION_FAILED: '${to}' is already a pending approver`);
996
+ }
997
+
998
+ const next = pending.map(a => (a === from ? to : a));
999
+ const now = this.clock.now().toISOString();
1000
+ // Audit first, then mutate — mirrors decideNode(), so a failed audit
1001
+ // write can never leave a moved slot without a trail.
1002
+ await this.engine.insert('sys_approval_action', {
1003
+ id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null,
1004
+ step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'reassign',
1005
+ actor_id: input.actorId, comment: input.comment ?? `${from} → ${to}`, created_at: now,
1006
+ }, { context: SYSTEM_CTX });
1007
+ await this.engine.update('sys_approval_request', {
1008
+ id: requestId, pending_approvers: next.join(','), updated_at: now,
1009
+ }, { context: SYSTEM_CTX });
1010
+ await this.syncApproverIndex(requestId, next, raw.organization_id ?? null, now);
1011
+
1012
+ await this.notify({
1013
+ topic: 'approval.reassigned',
1014
+ audience: [to],
1015
+ actorId: input.actorId,
1016
+ source: { object: 'sys_approval_request', id: requestId },
1017
+ dedupKey: `approval-reassign-${requestId}-${to}`,
1018
+ payload: {
1019
+ title: 'Approval handed to you',
1020
+ message: `You are now an approver on ${raw.object_name}/${raw.record_id}.`,
1021
+ actionUrl: '/system/approvals',
1022
+ },
1023
+ });
1024
+
1025
+ const fresh = await this.getRequest(requestId, context);
1026
+ return { request: fresh! };
1027
+ }
1028
+
1029
+ /**
1030
+ * Submitter nudge — notify every pending approver. Throttled to one
1031
+ * reminder per {@link REMIND_COOLDOWN_MS} per request.
1032
+ */
1033
+ async remind(
1034
+ requestId: string,
1035
+ input: { actorId: string; comment?: string },
1036
+ context: SharingExecutionContext,
1037
+ ): Promise<{ request: ApprovalRequestRow; notified: number }> {
1038
+ if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
1039
+ const raw = await this.loadPendingRow(requestId);
1040
+ if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
1041
+ throw new Error('FORBIDDEN: only the submitter may send reminders');
1042
+ }
1043
+
1044
+ const acts = await this.engine.find('sys_approval_action', {
1045
+ where: { request_id: requestId, action: 'remind' },
1046
+ orderBy: [{ field: 'created_at', order: 'desc' }], limit: 1, context: SYSTEM_CTX,
1047
+ });
1048
+ const last: any = Array.isArray(acts) ? acts[0] : null;
1049
+ const now = this.clock.now();
1050
+ if (last?.created_at && now.getTime() - Date.parse(last.created_at) < REMIND_COOLDOWN_MS) {
1051
+ throw new Error('THROTTLED: a reminder was already sent recently');
1052
+ }
1053
+
1054
+ const pending = csvSplit(raw.pending_approvers);
1055
+ const nowIso = now.toISOString();
1056
+ await this.engine.insert('sys_approval_action', {
1057
+ id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null,
1058
+ step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'remind',
1059
+ actor_id: input.actorId, comment: input.comment ?? null, created_at: nowIso,
1060
+ }, { context: SYSTEM_CTX });
1061
+
1062
+ // Per-approver fan-out: concrete identities (user ids / emails) each get
1063
+ // their OWN one-tap approve/reject links (ADR-0043); `role:*`-style
1064
+ // literals can't carry a personal token and fall back to a plain nudge.
1065
+ let notified = 0;
1066
+ const concrete = pending.filter(a => a && !a.includes(':'));
1067
+ const literals = pending.filter(a => a && a.includes(':'));
1068
+ for (const approver of concrete) {
1069
+ try {
1070
+ const tokens = await this.issueActionTokens(requestId, approver);
1071
+ notified += await this.notify({
1072
+ topic: 'approval.reminder',
1073
+ audience: [approver],
1074
+ actorId: input.actorId,
1075
+ source: { object: 'sys_approval_request', id: requestId },
1076
+ dedupKey: `approval-remind-${requestId}-${nowIso}-${approver}`,
1077
+ payload: {
1078
+ title: 'Approval reminder',
1079
+ message: `A decision on ${raw.object_name}/${raw.record_id} is still waiting on you.`,
1080
+ actionUrl: '/system/approvals',
1081
+ actions: [
1082
+ { label: 'Approve', url: this.actionLinkUrl(tokens.approve) },
1083
+ { label: 'Reject', url: this.actionLinkUrl(tokens.reject) },
1084
+ ],
1085
+ },
1086
+ });
1087
+ } catch (err: any) {
1088
+ this.logger?.warn?.('[approvals] reminder with action links failed', {
1089
+ request: requestId, approver, error: err?.message ?? String(err),
1090
+ });
1091
+ }
1092
+ }
1093
+ if (literals.length) {
1094
+ notified += await this.notify({
1095
+ topic: 'approval.reminder',
1096
+ audience: literals,
1097
+ actorId: input.actorId,
1098
+ source: { object: 'sys_approval_request', id: requestId },
1099
+ dedupKey: `approval-remind-${requestId}-${nowIso}`,
1100
+ payload: {
1101
+ title: 'Approval reminder',
1102
+ message: `A decision on ${raw.object_name}/${raw.record_id} is still waiting on you.`,
1103
+ actionUrl: '/system/approvals',
1104
+ },
1105
+ });
1106
+ }
1107
+
1108
+ const fresh = await this.getRequest(requestId, context);
1109
+ return { request: fresh!, notified };
1110
+ }
1111
+
1112
+ // ── Actionable links (ADR-0043) ──────────────────────────────
1113
+
1114
+ /** Build the session-less confirm-page URL for a raw token. */
1115
+ actionLinkUrl(rawToken: string): string {
1116
+ return `${this.publicBaseUrl}/api/v1/approvals/act?token=${encodeURIComponent(rawToken)}`;
1117
+ }
1118
+
1119
+ /**
1120
+ * Issue one-tap approve/reject tokens for one approver on one pending
1121
+ * request. Raw tokens are returned ONCE; only SHA-256 hashes are stored
1122
+ * (`sys_approval_token`), so a DB leak yields no usable links.
1123
+ */
1124
+ async issueActionTokens(
1125
+ requestId: string,
1126
+ approverId: string,
1127
+ opts?: { ttlMs?: number },
1128
+ ): Promise<{ approve: string; reject: string }> {
1129
+ if (!approverId?.trim()) throw new Error('VALIDATION_FAILED: approverId is required');
1130
+ const raw = await this.loadPendingRow(requestId);
1131
+ const pending = csvSplit(raw.pending_approvers);
1132
+ if (!pending.includes(approverId)) {
1133
+ throw new Error(`FORBIDDEN: '${approverId}' is not a pending approver on this request`);
1134
+ }
1135
+ const now = this.clock.now();
1136
+ const expires = new Date(now.getTime() + (opts?.ttlMs ?? ACTION_TOKEN_TTL_MS)).toISOString();
1137
+ const out = { approve: '', reject: '' };
1138
+ for (const action of ['approve', 'reject'] as const) {
1139
+ const rawToken = randomBytes(32).toString('base64url');
1140
+ await this.engine.insert('sys_approval_token', {
1141
+ id: uid('atok'),
1142
+ organization_id: raw.organization_id ?? null,
1143
+ token_hash: createHash('sha256').update(rawToken).digest('hex'),
1144
+ request_id: requestId,
1145
+ action,
1146
+ approver_id: approverId,
1147
+ expires_at: expires,
1148
+ consumed_at: null,
1149
+ created_at: now.toISOString(),
1150
+ }, { context: SYSTEM_CTX });
1151
+ out[action] = rawToken;
1152
+ }
1153
+ return out;
1154
+ }
1155
+
1156
+ /** Shared validation chain for peek/redeem. Returns the token row when live. */
1157
+ private async resolveActionToken(rawToken: string): Promise<
1158
+ { ok: true; token: any; request: ApprovalRequestRow } | Extract<ActionTokenOutcome, { ok: false }>
1159
+ > {
1160
+ const trimmed = rawToken?.trim();
1161
+ if (!trimmed) return { ok: false, reason: 'invalid' };
1162
+ const hash = createHash('sha256').update(trimmed).digest('hex');
1163
+ const rows = await this.engine.find('sys_approval_token', {
1164
+ where: { token_hash: hash }, limit: 1, context: SYSTEM_CTX,
1165
+ });
1166
+ const token: any = Array.isArray(rows) ? rows[0] : null;
1167
+ if (!token) return { ok: false, reason: 'invalid' };
1168
+ if (token.consumed_at) return { ok: false, reason: 'consumed' };
1169
+ if (Date.parse(token.expires_at) < this.clock.now().getTime()) {
1170
+ return { ok: false, reason: 'expired' };
1171
+ }
1172
+ const request = await this.getRequest(token.request_id, SYSTEM_CTX as unknown as SharingExecutionContext);
1173
+ if (!request || request.status !== 'pending') {
1174
+ return { ok: false, reason: 'not_pending', request: request ?? undefined };
1175
+ }
1176
+ if (!(request.pending_approvers ?? []).includes(token.approver_id)) {
1177
+ // Reassigned away / slot consumed by a unanimous round — the link died
1178
+ // with the slot (ADR-0043 invalidation row).
1179
+ return { ok: false, reason: 'not_approver', request };
1180
+ }
1181
+ return { ok: true, token, request };
1182
+ }
1183
+
1184
+ /** GET confirm page: validate WITHOUT consuming — never mutates. */
1185
+ async peekActionToken(rawToken: string): Promise<ActionTokenOutcome> {
1186
+ const res = await this.resolveActionToken(rawToken);
1187
+ if (!res.ok) return res;
1188
+ return { ok: true, action: res.token.action, request: res.request, approverId: res.token.approver_id };
1189
+ }
1190
+
1191
+ /**
1192
+ * POST redemption: consume the token FIRST (a failed decide still burns
1193
+ * it — replay-safe), then decide as the bound approver.
1194
+ */
1195
+ async redeemActionToken(rawToken: string): Promise<ActionTokenOutcome> {
1196
+ const res = await this.resolveActionToken(rawToken);
1197
+ if (!res.ok) return res;
1198
+ await this.engine.update('sys_approval_token', {
1199
+ id: res.token.id, consumed_at: this.clock.now().toISOString(),
1200
+ }, { context: SYSTEM_CTX });
1201
+ const out = await this.decide(res.token.request_id, {
1202
+ decision: res.token.action,
1203
+ actorId: res.token.approver_id,
1204
+ comment: 'Via action link',
1205
+ }, SYSTEM_CTX as unknown as SharingExecutionContext);
1206
+ return { ok: true, action: res.token.action, request: out.request, approverId: res.token.approver_id };
1207
+ }
1208
+
1209
+ /**
1210
+ * Approver asks the submitter for more information. The request stays
1211
+ * pending — a thread interaction, not a flow decision.
1212
+ */
1213
+ async requestInfo(
1214
+ requestId: string,
1215
+ input: { actorId: string; comment: string },
1216
+ context: SharingExecutionContext,
1217
+ ): Promise<{ request: ApprovalRequestRow }> {
1218
+ if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
1219
+ if (!input?.comment?.trim()) throw new Error('VALIDATION_FAILED: comment is required');
1220
+ const raw = await this.loadPendingRow(requestId);
1221
+ const pending = csvSplit(raw.pending_approvers);
1222
+ if (!context.isSystem && !pending.includes(input.actorId)) {
1223
+ throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
1224
+ }
1225
+
1226
+ const now = this.clock.now().toISOString();
1227
+ await this.engine.insert('sys_approval_action', {
1228
+ id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null,
1229
+ step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'request_info',
1230
+ actor_id: input.actorId, comment: input.comment.trim(), created_at: now,
1231
+ }, { context: SYSTEM_CTX });
1232
+
1233
+ if (raw.submitter_id) {
1234
+ await this.notify({
1235
+ topic: 'approval.request_info',
1236
+ audience: [String(raw.submitter_id)],
1237
+ actorId: input.actorId,
1238
+ source: { object: 'sys_approval_request', id: requestId },
1239
+ payload: {
1240
+ title: 'More information requested',
1241
+ message: input.comment.trim(),
1242
+ actionUrl: '/system/approvals',
1243
+ },
1244
+ });
1245
+ }
1246
+
1247
+ const fresh = await this.getRequest(requestId, context);
1248
+ return { request: fresh! };
1249
+ }
1250
+
1251
+ /** Free-form reply on the thread (submitter or any pending approver). */
1252
+ async comment(
1253
+ requestId: string,
1254
+ input: { actorId: string; comment: string },
1255
+ context: SharingExecutionContext,
1256
+ ): Promise<{ request: ApprovalRequestRow }> {
1257
+ if (!input?.actorId) throw new Error('VALIDATION_FAILED: actorId is required');
1258
+ if (!input?.comment?.trim()) throw new Error('VALIDATION_FAILED: comment is required');
1259
+ const raw = await this.loadPendingRow(requestId);
1260
+ const pending = csvSplit(raw.pending_approvers);
1261
+ const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(input.actorId);
1262
+ if (!context.isSystem && !isSubmitter && !pending.includes(input.actorId)) {
1263
+ throw new Error(`FORBIDDEN: actor '${input.actorId}' is not on this request`);
1264
+ }
1265
+
1266
+ const now = this.clock.now().toISOString();
1267
+ await this.engine.insert('sys_approval_action', {
1268
+ id: uid('aact'), request_id: requestId, organization_id: raw.organization_id ?? null,
1269
+ step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'comment',
1270
+ actor_id: input.actorId, comment: input.comment.trim(), created_at: now,
1271
+ }, { context: SYSTEM_CTX });
1272
+
1273
+ // Notify the other side of the thread.
1274
+ const audience = isSubmitter ? pending : [String(raw.submitter_id ?? '')].filter(Boolean);
1275
+ await this.notify({
1276
+ topic: 'approval.comment',
1277
+ audience,
1278
+ actorId: input.actorId,
1279
+ source: { object: 'sys_approval_request', id: requestId },
1280
+ payload: {
1281
+ title: 'New comment on an approval',
1282
+ message: input.comment.trim(),
1283
+ actionUrl: '/system/approvals',
1284
+ },
1285
+ });
1286
+
1287
+ const fresh = await this.getRequest(requestId, context);
1288
+ return { request: fresh! };
1289
+ }
1290
+
1291
+ // ── SLA escalation (ADR-0042) ─────────────────────────────────
1292
+
1293
+ /**
1294
+ * One escalation sweep: every *pending* request whose node config declares
1295
+ * `escalation.timeoutHours` and whose deadline has passed is escalated
1296
+ * **at most once, ever** — the `escalate` audit row is the idempotency
1297
+ * marker, written before any mutation (audit-first, like reassign). One
1298
+ * bad row never stops the sweep.
1299
+ */
1300
+ async runEscalations(): Promise<{ scanned: number; escalated: number }> {
1301
+ let rows: any[] = [];
1302
+ try {
1303
+ rows = await this.engine.find('sys_approval_request', {
1304
+ where: { status: 'pending' }, limit: 500, context: SYSTEM_CTX,
1305
+ }) ?? [];
1306
+ } catch (err: any) {
1307
+ this.logger?.warn?.('[approvals] escalation scan failed to list requests', {
1308
+ error: err?.message ?? String(err),
1309
+ });
1310
+ return { scanned: 0, escalated: 0 };
1311
+ }
1312
+
1313
+ let escalated = 0;
1314
+ for (const raw of rows) {
1315
+ try {
1316
+ const cfg = parseJson<any>(raw.node_config_json, undefined);
1317
+ const esc = cfg?.escalation;
1318
+ if (!esc || typeof esc.timeoutHours !== 'number' || esc.timeoutHours <= 0) continue;
1319
+ const due = slaDueAt(raw.created_at, cfg);
1320
+ if (!due || Date.parse(due) > this.clock.now().getTime()) continue;
1321
+
1322
+ // Single-shot: a prior 'escalate' action means this request is done.
1323
+ const prior = await this.engine.find('sys_approval_action', {
1324
+ where: { request_id: raw.id, action: 'escalate' }, limit: 1, context: SYSTEM_CTX,
1325
+ });
1326
+ if (Array.isArray(prior) && prior[0]) continue;
1327
+
1328
+ await this.escalateRequest(raw, esc);
1329
+ escalated++;
1330
+ } catch (err: any) {
1331
+ this.logger?.warn?.('[approvals] escalation failed for request', {
1332
+ request: raw?.id, error: err?.message ?? String(err),
1333
+ });
1334
+ }
1335
+ }
1336
+ if (escalated > 0) {
1337
+ this.logger?.info?.('[approvals] SLA escalation sweep', { scanned: rows.length, escalated });
1338
+ }
1339
+ return { scanned: rows.length, escalated };
1340
+ }
1341
+
1342
+ /** Execute the configured escalation action for one overdue request. */
1343
+ private async escalateRequest(raw: any, esc: any): Promise<void> {
1344
+ const action: string = esc.action ?? 'notify';
1345
+ const escalateTo: string | undefined =
1346
+ typeof esc.escalateTo === 'string' && esc.escalateTo.trim() ? esc.escalateTo.trim() : undefined;
1347
+ const now = this.clock.now().toISOString();
1348
+ const pending = csvSplit(raw.pending_approvers);
1349
+
1350
+ // Audit first — this row IS the idempotency marker (ADR-0042 §1).
1351
+ await this.engine.insert('sys_approval_action', {
1352
+ id: uid('aact'), request_id: raw.id, organization_id: raw.organization_id ?? null,
1353
+ step_name: raw.flow_node_id ?? raw.current_step ?? null, step_index: 0, action: 'escalate',
1354
+ actor_id: SLA_ACTOR_ID,
1355
+ comment: `${action}${escalateTo ? ` → ${escalateTo}` : ''}`,
1356
+ created_at: now,
1357
+ }, { context: SYSTEM_CTX });
1358
+
1359
+ if (action === 'reassign' && escalateTo) {
1360
+ await this.engine.update('sys_approval_request', {
1361
+ id: raw.id, pending_approvers: escalateTo, updated_at: now,
1362
+ }, { context: SYSTEM_CTX });
1363
+ await this.syncApproverIndex(raw.id, [escalateTo], raw.organization_id ?? null, now);
1364
+ await this.notify({
1365
+ topic: 'approval.escalated',
1366
+ audience: [escalateTo],
1367
+ actorId: SLA_ACTOR_ID,
1368
+ source: { object: 'sys_approval_request', id: raw.id },
1369
+ payload: {
1370
+ title: 'Approval escalated to you',
1371
+ message: `An overdue approval on ${raw.object_name}/${raw.record_id} was escalated to you.`,
1372
+ actionUrl: '/system/approvals',
1373
+ },
1374
+ });
1375
+ } else if (action === 'auto_approve' || action === 'auto_reject') {
1376
+ await this.decide(raw.id, {
1377
+ decision: action === 'auto_approve' ? 'approve' : 'reject',
1378
+ actorId: SLA_ACTOR_ID,
1379
+ comment: 'SLA escalation',
1380
+ }, SYSTEM_CTX as unknown as SharingExecutionContext);
1381
+ } else {
1382
+ // 'notify' (and the reassign-without-target fallback)
1383
+ await this.notify({
1384
+ topic: 'approval.sla_breached',
1385
+ audience: [...pending, ...(escalateTo ? [escalateTo] : [])],
1386
+ actorId: SLA_ACTOR_ID,
1387
+ source: { object: 'sys_approval_request', id: raw.id },
1388
+ payload: {
1389
+ title: 'Approval SLA breached',
1390
+ message: `A decision on ${raw.object_name}/${raw.record_id} is overdue.`,
1391
+ actionUrl: '/system/approvals',
1392
+ },
1393
+ });
1394
+ }
1395
+
1396
+ if (esc.notifySubmitter !== false && raw.submitter_id) {
1397
+ await this.notify({
1398
+ topic: 'approval.sla_breached',
1399
+ audience: [String(raw.submitter_id)],
1400
+ actorId: SLA_ACTOR_ID,
1401
+ source: { object: 'sys_approval_request', id: raw.id },
1402
+ payload: {
1403
+ title: 'Your approval request breached its SLA',
1404
+ message: `${raw.object_name}/${raw.record_id}: escalation action '${action}' was taken.`,
1405
+ actionUrl: '/system/approvals',
1406
+ },
1407
+ });
1408
+ }
1409
+ }
1410
+
564
1411
  // ── Display enrichment ───────────────────────────────────────
565
1412
 
566
1413
  /**
@@ -593,15 +1440,66 @@ export class ApprovalService implements IApprovalService {
593
1440
  }
594
1441
 
595
1442
  /**
596
- * Attach inbox display fields (`record_title`, `submitter_name`) to rows.
597
- * Batched: one query per distinct target object plus one `sys_user` lookup.
598
- * Best-effort — a deleted record falls back to the payload snapshot, and a
599
- * lookup failure leaves the field unset rather than failing the list.
1443
+ * Batch-resolve `sys_user` display names for identifiers that may be user
1444
+ * ids or emails. Best-effort failures leave entries unresolved.
1445
+ */
1446
+ private async resolveUserNames(identifiers: Array<string | null | undefined>): Promise<Map<string, string>> {
1447
+ const names = new Map<string, string>();
1448
+ const targets = Array.from(new Set(identifiers.filter(Boolean))) as string[];
1449
+ if (!targets.length) return names;
1450
+ try {
1451
+ const users = await this.engine.find('sys_user', {
1452
+ where: { id: { $in: targets } }, fields: ['id', 'name', 'email'],
1453
+ limit: targets.length, context: SYSTEM_CTX,
1454
+ });
1455
+ for (const u of (users ?? []) as any[]) {
1456
+ if (u?.id && (u.name || u.email)) names.set(String(u.id), String(u.name ?? u.email));
1457
+ }
1458
+ } catch { /* best-effort */ }
1459
+ const unresolvedEmails = targets.filter(t => !names.has(t) && t.includes('@'));
1460
+ if (unresolvedEmails.length) {
1461
+ try {
1462
+ const users = await this.engine.find('sys_user', {
1463
+ where: { email: { $in: unresolvedEmails } }, fields: ['email', 'name'],
1464
+ limit: unresolvedEmails.length, context: SYSTEM_CTX,
1465
+ });
1466
+ for (const u of (users ?? []) as any[]) {
1467
+ if (u?.email && u.name) names.set(String(u.email), String(u.name));
1468
+ }
1469
+ } catch { /* best-effort */ }
1470
+ }
1471
+ return names;
1472
+ }
1473
+
1474
+ /** Lookup-typed fields (key + referenced object) of an object's schema. */
1475
+ private resolveLookupFields(object: string): Array<{ key: string; reference: string }> {
1476
+ try {
1477
+ const schema: any = (this.engine as any).getSchema?.(object);
1478
+ const fields = schema?.fields ?? {};
1479
+ const out: Array<{ key: string; reference: string }> = [];
1480
+ for (const [key, f] of Object.entries<any>(fields)) {
1481
+ if ((f?.type === 'lookup' || f?.type === 'master_detail') && f?.reference) {
1482
+ out.push({ key, reference: String(f.reference) });
1483
+ }
1484
+ }
1485
+ return out;
1486
+ } catch { return []; }
1487
+ }
1488
+
1489
+ /**
1490
+ * Attach inbox display fields to rows so clients never render a raw
1491
+ * identifier: `record_title`, `submitter_name`, `object_label`,
1492
+ * `pending_approver_names` (user-id approvers), and `payload_display`
1493
+ * (lookup foreign keys in the snapshot → referenced record titles).
1494
+ * Batched: one query per distinct object (target + referenced) plus one
1495
+ * `sys_user` lookup. Best-effort — a deleted record falls back to the
1496
+ * payload snapshot, and any failure leaves the field unset rather than
1497
+ * failing the list.
600
1498
  */
601
1499
  private async enrichRows(rows: ApprovalRequestRow[]): Promise<void> {
602
1500
  if (!rows.length) return;
603
1501
 
604
- // Record titles, batched per object.
1502
+ // Record titles + object labels, batched per object.
605
1503
  const byObject = new Map<string, Set<string>>();
606
1504
  for (const r of rows) {
607
1505
  if (!r.object_name || !r.record_id) continue;
@@ -610,7 +1508,12 @@ export class ApprovalService implements IApprovalService {
610
1508
  set.add(r.record_id);
611
1509
  }
612
1510
  const titles = new Map<string, string>();
1511
+ const objectLabels = new Map<string, string>();
613
1512
  for (const [object, idSet] of byObject) {
1513
+ try {
1514
+ const schema: any = (this.engine as any).getSchema?.(object);
1515
+ if (schema?.label) objectLabels.set(object, String(schema.label));
1516
+ } catch { /* label optional */ }
614
1517
  const ids = Array.from(idSet);
615
1518
  const displayField = this.resolveDisplayField(object);
616
1519
  try {
@@ -619,59 +1522,199 @@ export class ApprovalService implements IApprovalService {
619
1522
  });
620
1523
  for (const rec of (recs ?? []) as any[]) {
621
1524
  const title = ApprovalService.pickTitle(rec, displayField);
622
- if (rec?.id && title) titles.set(`${object}${rec.id}`, title);
1525
+ if (rec?.id && title) titles.set(`${object} ${rec.id}`, title);
623
1526
  }
624
1527
  } catch { /* object may be unregistered — payload fallback below */ }
625
1528
  }
626
1529
 
627
- // Submitter display names submitter_id may be a user id or an email.
628
- const submitters = Array.from(new Set(rows.map(r => r.submitter_id).filter(Boolean))) as string[];
629
- const names = new Map<string, string>();
630
- if (submitters.length) {
1530
+ // Lookup foreign keys inside payload snapshots referenced record titles.
1531
+ const lookupFieldsByObject = new Map<string, Array<{ key: string; reference: string }>>();
1532
+ for (const object of byObject.keys()) {
1533
+ const lookups = this.resolveLookupFields(object);
1534
+ if (lookups.length) lookupFieldsByObject.set(object, lookups);
1535
+ }
1536
+ const refIds = new Map<string, Set<string>>();
1537
+ for (const r of rows) {
1538
+ const lookups = lookupFieldsByObject.get(r.object_name);
1539
+ const payload: any = r.payload;
1540
+ if (!lookups || !payload || typeof payload !== 'object') continue;
1541
+ for (const { key, reference } of lookups) {
1542
+ const v = payload[key];
1543
+ if (v == null || typeof v === 'object' || !String(v).trim()) continue;
1544
+ let set = refIds.get(reference);
1545
+ if (!set) { set = new Set(); refIds.set(reference, set); }
1546
+ set.add(String(v));
1547
+ }
1548
+ }
1549
+ const refTitles = new Map<string, string>();
1550
+ for (const [object, idSet] of refIds) {
1551
+ const ids = Array.from(idSet);
1552
+ const displayField = this.resolveDisplayField(object);
631
1553
  try {
632
- const users = await this.engine.find('sys_user', {
633
- where: { id: { $in: submitters } }, fields: ['id', 'name', 'email'],
634
- limit: submitters.length, context: SYSTEM_CTX,
1554
+ const recs = await this.engine.find(object, {
1555
+ where: { id: { $in: ids } }, limit: ids.length, context: SYSTEM_CTX,
635
1556
  });
636
- for (const u of (users ?? []) as any[]) {
637
- if (u?.id && (u.name || u.email)) names.set(String(u.id), String(u.name ?? u.email));
1557
+ for (const rec of (recs ?? []) as any[]) {
1558
+ const title = ApprovalService.pickTitle(rec, displayField);
1559
+ if (rec?.id && title) refTitles.set(`${object} ${rec.id}`, title);
638
1560
  }
639
- } catch { /* best-effort */ }
640
- const unresolvedEmails = submitters.filter(s => !names.has(s) && s.includes('@'));
641
- if (unresolvedEmails.length) {
642
- try {
643
- const users = await this.engine.find('sys_user', {
644
- where: { email: { $in: unresolvedEmails } }, fields: ['email', 'name'],
645
- limit: unresolvedEmails.length, context: SYSTEM_CTX,
646
- });
647
- for (const u of (users ?? []) as any[]) {
648
- if (u?.email && u.name) names.set(String(u.email), String(u.name));
649
- }
650
- } catch { /* best-effort */ }
1561
+ } catch { /* referenced object unreadable — leave unresolved */ }
1562
+ }
1563
+
1564
+ // Display names for submitters AND user-id approvers in one lookup.
1565
+ // `role:<r>` (and other `type:value` literals) are already readable.
1566
+ const userIdentifiers: Array<string | null | undefined> = [];
1567
+ for (const r of rows) {
1568
+ userIdentifiers.push(r.submitter_id);
1569
+ for (const a of r.pending_approvers ?? []) {
1570
+ if (a && !a.includes(':')) userIdentifiers.push(a);
651
1571
  }
652
1572
  }
1573
+ const names = await this.resolveUserNames(userIdentifiers);
653
1574
 
654
1575
  for (const r of rows as any[]) {
655
- const title = titles.get(`${r.object_name}${r.record_id}`)
1576
+ const title = titles.get(`${r.object_name} ${r.record_id}`)
656
1577
  ?? ApprovalService.pickTitle(r.payload, undefined);
657
1578
  if (title) r.record_title = title;
658
1579
  const name = r.submitter_id ? names.get(String(r.submitter_id)) : undefined;
659
1580
  if (name) r.submitter_name = name;
1581
+ const label = objectLabels.get(r.object_name);
1582
+ if (label) r.object_label = label;
1583
+
1584
+ const approverNames: Record<string, string> = {};
1585
+ for (const a of r.pending_approvers ?? []) {
1586
+ const n = names.get(String(a));
1587
+ if (n) approverNames[a] = n;
1588
+ }
1589
+ if (Object.keys(approverNames).length) r.pending_approver_names = approverNames;
1590
+
1591
+ const lookups = lookupFieldsByObject.get(r.object_name);
1592
+ if (lookups && r.payload && typeof r.payload === 'object') {
1593
+ const display: Record<string, string> = {};
1594
+ for (const { key, reference } of lookups) {
1595
+ const v = (r.payload as any)[key];
1596
+ if (v == null) continue;
1597
+ const t = refTitles.get(`${reference} ${String(v)}`);
1598
+ if (t) display[key] = t;
1599
+ }
1600
+ if (Object.keys(display).length) r.payload_display = display;
1601
+ }
660
1602
  }
661
1603
  }
662
1604
 
1605
+ // ── Pending-approver index (issue #1745) ─────────────────────
1606
+
1607
+ /**
1608
+ * Mirror one request's `pending_approvers` CSV into the normalized
1609
+ * `sys_approval_approver` index. Called by every write path that changes
1610
+ * the approver set; an empty `approvers` clears the request's rows (the
1611
+ * request left `pending`). Diff-based so reassign/unanimous churn doesn't
1612
+ * rewrite untouched rows.
1613
+ */
1614
+ private async syncApproverIndex(
1615
+ requestId: string,
1616
+ approvers: string[],
1617
+ org: string | null,
1618
+ now: string,
1619
+ ): Promise<void> {
1620
+ const desired = new Set(approvers.map(a => String(a).trim()).filter(Boolean));
1621
+ const existing = await this.engine.find('sys_approval_approver', {
1622
+ where: { request_id: requestId }, limit: 500, context: SYSTEM_CTX,
1623
+ });
1624
+ const rows: any[] = Array.isArray(existing) ? existing : [];
1625
+ for (const row of rows) {
1626
+ if (desired.has(String(row.approver))) desired.delete(String(row.approver));
1627
+ else await this.engine.delete('sys_approval_approver', { where: { id: row.id }, context: SYSTEM_CTX });
1628
+ }
1629
+ for (const approver of desired) {
1630
+ await this.engine.insert('sys_approval_approver', {
1631
+ id: uid('aapr'), request_id: requestId, approver,
1632
+ organization_id: org, created_at: now,
1633
+ }, { context: SYSTEM_CTX });
1634
+ }
1635
+ }
1636
+
1637
+ /**
1638
+ * Rebuild the whole `sys_approval_approver` index from the CSV source of
1639
+ * truth. Idempotent; run at plugin start so rows written before the index
1640
+ * existed (or drifted past a crashed sync) become queryable. Cost tracks
1641
+ * the number of *pending* requests, not the request history.
1642
+ */
1643
+ async rebuildApproverIndex(): Promise<{ requests: number; inserted: number; deleted: number }> {
1644
+ // Desired state: every pending request's CSV entries.
1645
+ const desired = new Map<string, { approvers: Set<string>; org: string | null }>();
1646
+ const PAGE = 500;
1647
+ for (let offset = 0; ; offset += PAGE) {
1648
+ const batch = await this.engine.find('sys_approval_request', {
1649
+ where: { status: 'pending' },
1650
+ fields: ['id', 'pending_approvers', 'organization_id'],
1651
+ limit: PAGE, offset, context: SYSTEM_CTX,
1652
+ });
1653
+ const rows: any[] = Array.isArray(batch) ? batch : [];
1654
+ for (const r of rows) {
1655
+ desired.set(String(r.id), {
1656
+ approvers: new Set(csvSplit(r.pending_approvers)),
1657
+ org: r.organization_id ?? null,
1658
+ });
1659
+ }
1660
+ if (rows.length < PAGE) break;
1661
+ }
1662
+
1663
+ // Current state: read the whole index first (bounded by the live work
1664
+ // queue), THEN mutate — deleting while paginating would shift the cursor.
1665
+ const indexRows: any[] = [];
1666
+ for (let offset = 0; ; offset += PAGE) {
1667
+ const batch = await this.engine.find('sys_approval_approver', {
1668
+ orderBy: [{ field: 'created_at', order: 'asc' }],
1669
+ limit: PAGE, offset, context: SYSTEM_CTX,
1670
+ });
1671
+ const rows: any[] = Array.isArray(batch) ? batch : [];
1672
+ indexRows.push(...rows);
1673
+ if (rows.length < PAGE) break;
1674
+ }
1675
+ let inserted = 0; let deleted = 0;
1676
+ const seen = new Map<string, Set<string>>();
1677
+ for (const row of indexRows) {
1678
+ const reqId = String(row.request_id);
1679
+ const want = desired.get(reqId);
1680
+ const have = seen.get(reqId) ?? seen.set(reqId, new Set()).get(reqId)!;
1681
+ // Orphan (request no longer pending), stale entry, or duplicate → drop.
1682
+ if (!want || !want.approvers.has(String(row.approver)) || have.has(String(row.approver))) {
1683
+ await this.engine.delete('sys_approval_approver', { where: { id: row.id }, context: SYSTEM_CTX });
1684
+ deleted++;
1685
+ continue;
1686
+ }
1687
+ have.add(String(row.approver));
1688
+ }
1689
+
1690
+ const now = this.clock.now().toISOString();
1691
+ for (const [reqId, want] of desired) {
1692
+ const have = seen.get(reqId);
1693
+ for (const approver of want.approvers) {
1694
+ if (have?.has(approver)) continue;
1695
+ await this.engine.insert('sys_approval_approver', {
1696
+ id: uid('aapr'), request_id: reqId, approver,
1697
+ organization_id: want.org, created_at: now,
1698
+ }, { context: SYSTEM_CTX });
1699
+ inserted++;
1700
+ }
1701
+ }
1702
+ return { requests: desired.size, inserted, deleted };
1703
+ }
1704
+
663
1705
  // ── Read API ─────────────────────────────────────────────────
664
1706
 
665
- async listRequests(
1707
+ /** Filter type accepted by {@link listRequests} / {@link countRequests}. */
1708
+ private buildRequestWhere(
666
1709
  filter: {
667
1710
  object?: string;
668
1711
  recordId?: string;
669
1712
  status?: ApprovalStatus | ApprovalStatus[];
670
- approverId?: string | string[];
671
1713
  submitterId?: string;
1714
+ q?: string;
672
1715
  } | undefined,
673
1716
  context: SharingExecutionContext,
674
- ): Promise<ApprovalRequestRow[]> {
1717
+ ): { where: any; tenantOrg: string | null } {
675
1718
  const f: any = {};
676
1719
  if (filter?.object) f.object_name = filter.object;
677
1720
  if (filter?.recordId) f.record_id = filter.recordId;
@@ -680,40 +1723,145 @@ export class ApprovalService implements IApprovalService {
680
1723
  // (organizationId / tenantId), scope the query to that tenant. SYSTEM
681
1724
  // callers (no tenant) see all rows. This prevents the bespoke endpoint
682
1725
  // from leaking other-tenant rows since we deliberately query with
683
- // SYSTEM_CTX to bypass RLS on the engine (we need CSV substring match
684
- // on pending_approvers which RLS can't model cleanly).
685
- const tenantOrg = (context as any)?.organizationId ?? (context as any)?.tenantId;
1726
+ // SYSTEM_CTX to bypass RLS on the engine (the approver-visibility rule
1727
+ // spans three identity forms, which RLS can't model cleanly).
1728
+ const tenantOrg = (context as any)?.organizationId ?? (context as any)?.tenantId ?? null;
686
1729
  if (tenantOrg) f.organization_id = tenantOrg;
687
- // Status: when array, post-filter; when single, push into engine filter.
688
- let statusFilter: ApprovalStatus[] | undefined;
689
- if (Array.isArray(filter?.status)) statusFilter = filter!.status as ApprovalStatus[];
690
- else if (filter?.status) f.status = filter.status;
1730
+ // Free-text search, pushed down: `payload_json` carries the record
1731
+ // snapshot, so record titles match without any join. `$contains` is the
1732
+ // driver's escaped-LIKE operator.
1733
+ const q = filter?.q?.trim();
1734
+ if (q) {
1735
+ f.$or = [
1736
+ { process_name: { $contains: q } },
1737
+ { object_name: { $contains: q } },
1738
+ { record_id: { $contains: q } },
1739
+ { submitter_id: { $contains: q } },
1740
+ { payload_json: { $contains: q } },
1741
+ ];
1742
+ }
1743
+ // Status pushes down whole: `$in` for arrays (all bundled drivers
1744
+ // support it), equality for a single value.
1745
+ if (Array.isArray(filter?.status)) {
1746
+ const statuses = (filter!.status as ApprovalStatus[]).filter(Boolean);
1747
+ if (statuses.length === 1) f.status = statuses[0];
1748
+ else if (statuses.length > 1) f.status = { $in: statuses };
1749
+ } else if (filter?.status) {
1750
+ f.status = filter.status;
1751
+ }
1752
+ return { where: f, tenantOrg };
1753
+ }
691
1754
 
692
- const rows = await this.engine.find('sys_approval_request', {
693
- where: f, limit: 500, orderBy: [{ field: 'updated_at', direction: 'desc' }], context: SYSTEM_CTX,
1755
+ /** Window the approver-index probe — pending queues live far below this. */
1756
+ private static readonly APPROVER_INDEX_CAP = 10_000;
1757
+
1758
+ /**
1759
+ * Resolve an approver filter to matching request ids via the normalized
1760
+ * `sys_approval_approver` index — the indexed replacement for the old
1761
+ * in-memory CSV scan, and what makes approver-filtered pagination correct
1762
+ * past any scan window (issue #1745). A request matches when ANY of the
1763
+ * caller's identities (user id / email / role:<r>) holds a pending slot.
1764
+ * Returns null when the filter is absent (callers skip the id constraint).
1765
+ */
1766
+ private async approverRequestIds(
1767
+ targets: string[],
1768
+ tenantOrg: string | null,
1769
+ ): Promise<string[] | null> {
1770
+ if (!targets.length) return null;
1771
+ const where: any = targets.length === 1
1772
+ ? { approver: targets[0] }
1773
+ : { approver: { $in: targets } };
1774
+ if (tenantOrg) where.organization_id = tenantOrg;
1775
+ const rows = await this.engine.find('sys_approval_approver', {
1776
+ where, fields: ['request_id'],
1777
+ limit: ApprovalService.APPROVER_INDEX_CAP, context: SYSTEM_CTX,
694
1778
  });
695
- let list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
696
- if (statusFilter) list = list.filter(r => statusFilter!.includes(r.status));
697
- if (filter?.approverId) {
698
- // Accept one identity or a list: a request matches when ANY of the
699
- // caller's identities (user id / email / role:<r>) is a pending
700
- // approver. This lets the Console badge fetch "my pending approvals"
701
- // in a single request instead of one-per-identity (previously the
702
- // client looped, firing N near-simultaneous calls per poll).
703
- const targets = (Array.isArray(filter.approverId) ? filter.approverId : [filter.approverId])
704
- .map(t => String(t).trim())
705
- .filter(Boolean);
706
- if (targets.length) {
707
- list = list.filter(r => {
708
- const pending = r.pending_approvers ?? [];
709
- return targets.some(t => pending.includes(t));
710
- });
711
- }
1779
+ const list: any[] = Array.isArray(rows) ? rows : [];
1780
+ if (list.length >= ApprovalService.APPROVER_INDEX_CAP) {
1781
+ this.logger?.warn?.('[approvals] approver index probe hit its window — results may be truncated', {
1782
+ cap: ApprovalService.APPROVER_INDEX_CAP, targets: targets.length,
1783
+ });
1784
+ }
1785
+ return [...new Set<string>(list.map(r => String(r.request_id)))];
1786
+ }
1787
+
1788
+ async listRequests(
1789
+ filter: {
1790
+ object?: string;
1791
+ recordId?: string;
1792
+ status?: ApprovalStatus | ApprovalStatus[];
1793
+ approverId?: string | string[];
1794
+ submitterId?: string;
1795
+ q?: string;
1796
+ limit?: number;
1797
+ offset?: number;
1798
+ } | undefined,
1799
+ context: SharingExecutionContext,
1800
+ ): Promise<ApprovalRequestRow[]> {
1801
+ const { where, tenantOrg } = this.buildRequestWhere(filter, context);
1802
+ const approverTargets = (Array.isArray(filter?.approverId) ? filter!.approverId : filter?.approverId ? [filter.approverId] : [])
1803
+ .map(t => String(t).trim())
1804
+ .filter(Boolean);
1805
+
1806
+ // Every filter now pushes into the engine (issue #1745): approver via
1807
+ // the normalized index, status arrays via $in — so the page window is
1808
+ // always engine-side and correct at any table size.
1809
+ const ids = await this.approverRequestIds(approverTargets, tenantOrg);
1810
+ if (ids) {
1811
+ if (ids.length === 0) return [];
1812
+ where.id = ids.length === 1 ? ids[0] : { $in: ids };
712
1813
  }
1814
+
1815
+ const findOpts: any = {
1816
+ where,
1817
+ orderBy: [{ field: 'created_at', order: 'desc' }],
1818
+ context: SYSTEM_CTX,
1819
+ };
1820
+ if (filter?.limit != null || filter?.offset != null) {
1821
+ findOpts.limit = Math.min(Math.max(filter?.limit ?? 50, 1), 200);
1822
+ if (filter?.offset) findOpts.offset = Math.max(filter.offset, 0);
1823
+ } else {
1824
+ // Unpaginated callers keep the legacy bounded window.
1825
+ findOpts.limit = 500;
1826
+ }
1827
+
1828
+ const rows = await this.engine.find('sys_approval_request', findOpts);
1829
+ const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
713
1830
  await this.enrichRows(list);
714
1831
  return list;
715
1832
  }
716
1833
 
1834
+ async countRequests(
1835
+ filter: Parameters<IApprovalService['listRequests']>[0],
1836
+ context: SharingExecutionContext,
1837
+ ): Promise<number> {
1838
+ const { where, tenantOrg } = this.buildRequestWhere(filter, context);
1839
+ const approverTargets = (Array.isArray(filter?.approverId) ? filter!.approverId : filter?.approverId ? [filter.approverId] : [])
1840
+ .map(t => String(t).trim())
1841
+ .filter(Boolean);
1842
+
1843
+ const ids = await this.approverRequestIds(approverTargets, tenantOrg);
1844
+ if (ids) {
1845
+ if (ids.length === 0) return 0;
1846
+ where.id = ids.length === 1 ? ids[0] : { $in: ids };
1847
+ }
1848
+
1849
+ const countFn = (this.engine as any).count;
1850
+ if (typeof countFn === 'function') {
1851
+ try {
1852
+ const n = await countFn.call(this.engine, 'sys_approval_request', { where, context: SYSTEM_CTX });
1853
+ if (typeof n === 'number') return n;
1854
+ } catch { /* fall through to scan */ }
1855
+ }
1856
+ // Engine without count(): bounded scan. The approver-filtered case is
1857
+ // exact (the id set bounds it); the unfiltered case keeps the legacy
1858
+ // 500 window.
1859
+ const rows = await this.engine.find('sys_approval_request', {
1860
+ where, fields: ['id'], limit: ids ? Math.max(500, ids.length) : 500, context: SYSTEM_CTX,
1861
+ });
1862
+ return Array.isArray(rows) ? rows.length : 0;
1863
+ }
1864
+
717
1865
  async getRequest(requestId: string, context: SharingExecutionContext): Promise<ApprovalRequestRow | null> {
718
1866
  if (!requestId) return null;
719
1867
  const where: any = { id: requestId };
@@ -725,9 +1873,50 @@ export class ApprovalService implements IApprovalService {
725
1873
  if (!Array.isArray(rows) || !rows[0]) return null;
726
1874
  const row = rowFromRequest(rows[0]);
727
1875
  await this.enrichRows([row]);
1876
+ await this.attachFlowSteps(row);
728
1877
  return row;
729
1878
  }
730
1879
 
1880
+ /**
1881
+ * Derive approval-step progress from the owning flow's graph (single-read
1882
+ * enrichment only — list reads skip it). Walks from the start node
1883
+ * preferring `approve`/`true` edges, so the result is the flow's main
1884
+ * approval trunk; conditional side-steps show as part of the potential
1885
+ * path. Display-only and best-effort.
1886
+ */
1887
+ private async attachFlowSteps(row: ApprovalRequestRow): Promise<void> {
1888
+ try {
1889
+ const flowName = row.process_name?.startsWith('flow:') ? row.process_name.slice(5) : undefined;
1890
+ if (!flowName || typeof this.automation?.getFlow !== 'function') return;
1891
+ const flow: any = await this.automation.getFlow(flowName);
1892
+ if (!flow?.nodes?.length) return;
1893
+ const nodesById = new Map<string, any>(flow.nodes.map((n: any) => [n.id, n]));
1894
+ const steps: Array<{ id: string; label: string }> = [];
1895
+ const seen = new Set<string>();
1896
+ let cur: any = flow.nodes.find((n: any) => n.type === 'start');
1897
+ while (cur && !seen.has(cur.id)) {
1898
+ seen.add(cur.id);
1899
+ if (cur.type === 'approval') steps.push({ id: cur.id, label: cur.label || cur.id });
1900
+ const out = (flow.edges ?? []).filter((e: any) => e.source === cur.id);
1901
+ if (!out.length) break;
1902
+ const pick = out.find((e: any) => e.label === 'approve')
1903
+ ?? out.find((e: any) => e.label === 'true')
1904
+ ?? out[0];
1905
+ cur = nodesById.get(pick.target);
1906
+ }
1907
+ if (steps.length === 0) return;
1908
+ const currentId = row.flow_node_id ?? row.current_step;
1909
+ const currentIdx = steps.findIndex(s => s.id === currentId);
1910
+ (row as any).flow_steps = steps.map((s, i) => ({
1911
+ ...s,
1912
+ state: currentIdx < 0 ? 'upcoming'
1913
+ : i < currentIdx ? 'done'
1914
+ : i === currentIdx ? (row.status === 'approved' ? 'done' : 'current')
1915
+ : 'upcoming',
1916
+ }));
1917
+ } catch { /* display-only — never fail the read */ }
1918
+ }
1919
+
731
1920
  async listActions(requestId: string, context: SharingExecutionContext): Promise<ApprovalActionRow[]> {
732
1921
  if (!requestId) return [];
733
1922
  // Tenant gate: ensure the caller can see the parent request before
@@ -738,9 +1927,19 @@ export class ApprovalService implements IApprovalService {
738
1927
  const rows = await this.engine.find('sys_approval_action', {
739
1928
  where: { request_id: requestId },
740
1929
  limit: 500,
741
- orderBy: [{ field: 'created_at', direction: 'asc' }],
1930
+ orderBy: [{ field: 'created_at', order: 'asc' }],
742
1931
  context: SYSTEM_CTX,
743
1932
  });
744
- return Array.isArray(rows) ? rows.map(rowFromAction) : [];
1933
+ const actions = Array.isArray(rows) ? rows.map(rowFromAction) : [];
1934
+ // Timeline display: resolve actor ids to names so the audit trail never
1935
+ // shows a raw identifier. Role/team literals are already readable.
1936
+ const names = await this.resolveUserNames(
1937
+ actions.map(a => a.actor_id).filter(id => id && !id.includes(':')),
1938
+ );
1939
+ for (const a of actions as any[]) {
1940
+ const n = a.actor_id ? names.get(String(a.actor_id)) : undefined;
1941
+ if (n) a.actor_name = n;
1942
+ }
1943
+ return actions;
745
1944
  }
746
1945
  }