@objectstack/plugin-approvals 9.2.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>;
63
+ }
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>;
50
80
  }
51
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
  /**
@@ -755,18 +1602,119 @@ export class ApprovalService implements IApprovalService {
755
1602
  }
756
1603
  }
757
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
+
758
1705
  // ── Read API ─────────────────────────────────────────────────
759
1706
 
760
- async listRequests(
1707
+ /** Filter type accepted by {@link listRequests} / {@link countRequests}. */
1708
+ private buildRequestWhere(
761
1709
  filter: {
762
1710
  object?: string;
763
1711
  recordId?: string;
764
1712
  status?: ApprovalStatus | ApprovalStatus[];
765
- approverId?: string | string[];
766
1713
  submitterId?: string;
1714
+ q?: string;
767
1715
  } | undefined,
768
1716
  context: SharingExecutionContext,
769
- ): Promise<ApprovalRequestRow[]> {
1717
+ ): { where: any; tenantOrg: string | null } {
770
1718
  const f: any = {};
771
1719
  if (filter?.object) f.object_name = filter.object;
772
1720
  if (filter?.recordId) f.record_id = filter.recordId;
@@ -775,40 +1723,145 @@ export class ApprovalService implements IApprovalService {
775
1723
  // (organizationId / tenantId), scope the query to that tenant. SYSTEM
776
1724
  // callers (no tenant) see all rows. This prevents the bespoke endpoint
777
1725
  // from leaking other-tenant rows since we deliberately query with
778
- // SYSTEM_CTX to bypass RLS on the engine (we need CSV substring match
779
- // on pending_approvers which RLS can't model cleanly).
780
- 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;
781
1729
  if (tenantOrg) f.organization_id = tenantOrg;
782
- // Status: when array, post-filter; when single, push into engine filter.
783
- let statusFilter: ApprovalStatus[] | undefined;
784
- if (Array.isArray(filter?.status)) statusFilter = filter!.status as ApprovalStatus[];
785
- 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
+ }
786
1754
 
787
- const rows = await this.engine.find('sys_approval_request', {
788
- 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,
789
1778
  });
790
- let list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
791
- if (statusFilter) list = list.filter(r => statusFilter!.includes(r.status));
792
- if (filter?.approverId) {
793
- // Accept one identity or a list: a request matches when ANY of the
794
- // caller's identities (user id / email / role:<r>) is a pending
795
- // approver. This lets the Console badge fetch "my pending approvals"
796
- // in a single request instead of one-per-identity (previously the
797
- // client looped, firing N near-simultaneous calls per poll).
798
- const targets = (Array.isArray(filter.approverId) ? filter.approverId : [filter.approverId])
799
- .map(t => String(t).trim())
800
- .filter(Boolean);
801
- if (targets.length) {
802
- list = list.filter(r => {
803
- const pending = r.pending_approvers ?? [];
804
- return targets.some(t => pending.includes(t));
805
- });
806
- }
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 };
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;
807
1826
  }
1827
+
1828
+ const rows = await this.engine.find('sys_approval_request', findOpts);
1829
+ const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
808
1830
  await this.enrichRows(list);
809
1831
  return list;
810
1832
  }
811
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
+
812
1865
  async getRequest(requestId: string, context: SharingExecutionContext): Promise<ApprovalRequestRow | null> {
813
1866
  if (!requestId) return null;
814
1867
  const where: any = { id: requestId };
@@ -820,9 +1873,50 @@ export class ApprovalService implements IApprovalService {
820
1873
  if (!Array.isArray(rows) || !rows[0]) return null;
821
1874
  const row = rowFromRequest(rows[0]);
822
1875
  await this.enrichRows([row]);
1876
+ await this.attachFlowSteps(row);
823
1877
  return row;
824
1878
  }
825
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
+
826
1920
  async listActions(requestId: string, context: SharingExecutionContext): Promise<ApprovalActionRow[]> {
827
1921
  if (!requestId) return [];
828
1922
  // Tenant gate: ensure the caller can see the parent request before
@@ -833,7 +1927,7 @@ export class ApprovalService implements IApprovalService {
833
1927
  const rows = await this.engine.find('sys_approval_action', {
834
1928
  where: { request_id: requestId },
835
1929
  limit: 500,
836
- orderBy: [{ field: 'created_at', direction: 'asc' }],
1930
+ orderBy: [{ field: 'created_at', order: 'asc' }],
837
1931
  context: SYSTEM_CTX,
838
1932
  });
839
1933
  const actions = Array.isArray(rows) ? rows.map(rowFromAction) : [];