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

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.
package/dist/index.mjs CHANGED
@@ -54,7 +54,8 @@ var init_en_objects_generated = __esm({
54
54
  pending: "pending",
55
55
  approved: "approved",
56
56
  rejected: "rejected",
57
- recalled: "recalled"
57
+ recalled: "recalled",
58
+ returned: "returned"
58
59
  }
59
60
  },
60
61
  current_step: {
@@ -96,7 +97,11 @@ var init_en_objects_generated = __esm({
96
97
  },
97
98
  _views: {
98
99
  my_pending: {
99
- label: "My Pending"
100
+ label: "My Pending",
101
+ emptyState: {
102
+ title: "No pending approvals",
103
+ message: "You're all caught up."
104
+ }
100
105
  },
101
106
  submitted_by_me: {
102
107
  label: "I Submitted"
@@ -107,6 +112,92 @@ var init_en_objects_generated = __esm({
107
112
  all_requests: {
108
113
  label: "All"
109
114
  }
115
+ },
116
+ _actions: {
117
+ approval_approve: {
118
+ label: "Approve",
119
+ successMessage: "Approved.",
120
+ params: {
121
+ comment: {
122
+ label: "Comment"
123
+ },
124
+ attachments: {
125
+ label: "Attachments"
126
+ }
127
+ }
128
+ },
129
+ approval_reject: {
130
+ label: "Reject",
131
+ confirmText: "Reject this request? A rejection is final for every approver.",
132
+ successMessage: "Rejected.",
133
+ params: {
134
+ comment: {
135
+ label: "Comment"
136
+ },
137
+ attachments: {
138
+ label: "Attachments"
139
+ }
140
+ }
141
+ },
142
+ approval_reassign: {
143
+ label: "Reassign",
144
+ successMessage: "Reassigned.",
145
+ params: {
146
+ to: {
147
+ label: "New approver",
148
+ helpText: "User to hand this step to"
149
+ },
150
+ comment: {
151
+ label: "Comment"
152
+ }
153
+ }
154
+ },
155
+ approval_send_back: {
156
+ label: "Send back",
157
+ successMessage: "Sent back for revision.",
158
+ params: {
159
+ comment: {
160
+ label: "Reason"
161
+ }
162
+ }
163
+ },
164
+ approval_request_info: {
165
+ label: "Request info",
166
+ successMessage: "Information requested.",
167
+ params: {
168
+ comment: {
169
+ label: "What do you need?"
170
+ }
171
+ }
172
+ },
173
+ approval_remind: {
174
+ label: "Send reminder",
175
+ successMessage: "Reminder sent.",
176
+ params: {
177
+ comment: {
178
+ label: "Note"
179
+ }
180
+ }
181
+ },
182
+ approval_recall: {
183
+ label: "Recall",
184
+ confirmText: "Recall this request? Approvers can no longer act on it and the record is unlocked.",
185
+ successMessage: "Recalled.",
186
+ params: {
187
+ comment: {
188
+ label: "Comment"
189
+ }
190
+ }
191
+ },
192
+ approval_resubmit: {
193
+ label: "Resubmit",
194
+ successMessage: "Resubmitted.",
195
+ params: {
196
+ comment: {
197
+ label: "What changed?"
198
+ }
199
+ }
200
+ }
110
201
  }
111
202
  },
112
203
  sys_approval_action: {
@@ -138,7 +229,14 @@ var init_en_objects_generated = __esm({
138
229
  approve: "approve",
139
230
  reject: "reject",
140
231
  recall: "recall",
141
- escalate: "escalate"
232
+ escalate: "escalate",
233
+ reassign: "reassign",
234
+ remind: "remind",
235
+ request_info: "request_info",
236
+ comment: "comment",
237
+ revise: "revise",
238
+ resubmit: "resubmit",
239
+ ooo_substitute: "ooo_substitute"
142
240
  }
143
241
  },
144
242
  actor_id: {
@@ -147,13 +245,29 @@ var init_en_objects_generated = __esm({
147
245
  comment: {
148
246
  label: "Comment"
149
247
  },
248
+ reassign_from: {
249
+ label: "Reassigned From",
250
+ help: "User whose pending-approver slot was handed over (reassign actions only)"
251
+ },
252
+ reassign_to: {
253
+ label: "Reassigned To",
254
+ help: "User who received the pending-approver slot (reassign actions only)"
255
+ },
256
+ attachments: {
257
+ label: "Attachments",
258
+ help: "Files supporting this action \u2014 e.g. a signed contract or evidence (#3266)."
259
+ },
150
260
  created_at: {
151
261
  label: "Created At"
152
262
  }
153
263
  },
154
264
  _views: {
155
265
  recent: {
156
- label: "Recent"
266
+ label: "Recent",
267
+ emptyState: {
268
+ title: "No approval actions yet",
269
+ message: "Actions are logged automatically when approvals progress."
270
+ }
157
271
  },
158
272
  by_actor: {
159
273
  label: "By Actor"
@@ -257,7 +371,8 @@ var init_zh_CN_objects_generated = __esm({
257
371
  pending: "\u5F85\u5904\u7406",
258
372
  approved: "\u5DF2\u6279\u51C6",
259
373
  rejected: "\u5DF2\u62D2\u7EDD",
260
- recalled: "\u5DF2\u64A4\u56DE"
374
+ recalled: "\u5DF2\u64A4\u56DE",
375
+ returned: "\u5DF2\u9000\u56DE\u4FEE\u6539"
261
376
  }
262
377
  },
263
378
  current_step: {
@@ -299,7 +414,11 @@ var init_zh_CN_objects_generated = __esm({
299
414
  },
300
415
  _views: {
301
416
  my_pending: {
302
- label: "\u6211\u7684\u5F85\u529E"
417
+ label: "\u6211\u7684\u5F85\u529E",
418
+ emptyState: {
419
+ title: "\u6682\u65E0\u5F85\u529E\u5BA1\u6279",
420
+ message: "\u5168\u90E8\u5904\u7406\u5B8C\u6BD5\uFF0C\u6CA1\u6709\u7B49\u5F85\u4F60\u5BA1\u6279\u7684\u8BF7\u6C42\u3002"
421
+ }
303
422
  },
304
423
  submitted_by_me: {
305
424
  label: "\u6211\u63D0\u4EA4\u7684"
@@ -310,6 +429,92 @@ var init_zh_CN_objects_generated = __esm({
310
429
  all_requests: {
311
430
  label: "\u5168\u90E8"
312
431
  }
432
+ },
433
+ _actions: {
434
+ approval_approve: {
435
+ label: "\u901A\u8FC7",
436
+ successMessage: "\u5DF2\u901A\u8FC7\u3002",
437
+ params: {
438
+ comment: {
439
+ label: "\u5BA1\u6279\u610F\u89C1"
440
+ },
441
+ attachments: {
442
+ label: "\u9644\u4EF6"
443
+ }
444
+ }
445
+ },
446
+ approval_reject: {
447
+ label: "\u62D2\u7EDD",
448
+ confirmText: "\u62D2\u7EDD\u8BE5\u8BF7\u6C42\uFF1F\u62D2\u7EDD\u5BF9\u6240\u6709\u5BA1\u6279\u4EBA\u7ACB\u5373\u751F\u6548\u3002",
449
+ successMessage: "\u5DF2\u62D2\u7EDD\u3002",
450
+ params: {
451
+ comment: {
452
+ label: "\u5BA1\u6279\u610F\u89C1"
453
+ },
454
+ attachments: {
455
+ label: "\u9644\u4EF6"
456
+ }
457
+ }
458
+ },
459
+ approval_reassign: {
460
+ label: "\u8F6C\u7B7E",
461
+ successMessage: "\u5DF2\u8F6C\u7B7E\u3002",
462
+ params: {
463
+ to: {
464
+ label: "\u65B0\u5BA1\u6279\u4EBA",
465
+ helpText: "\u5C06\u6B64\u5BA1\u6279\u6B65\u9AA4\u8F6C\u4EA4\u7ED9\u7684\u7528\u6237"
466
+ },
467
+ comment: {
468
+ label: "\u5907\u6CE8"
469
+ }
470
+ }
471
+ },
472
+ approval_send_back: {
473
+ label: "\u9000\u56DE\u4FEE\u6539",
474
+ successMessage: "\u5DF2\u9000\u56DE\u4FEE\u6539\u3002",
475
+ params: {
476
+ comment: {
477
+ label: "\u539F\u56E0"
478
+ }
479
+ }
480
+ },
481
+ approval_request_info: {
482
+ label: "\u9000\u56DE\u8865\u5145",
483
+ successMessage: "\u5DF2\u9000\u56DE\u7ED9\u7533\u8BF7\u4EBA\u8865\u5145\u6750\u6599\u3002",
484
+ params: {
485
+ comment: {
486
+ label: "\u9700\u8981\u7533\u8BF7\u4EBA\u8865\u5145\u4EC0\u4E48\uFF1F"
487
+ }
488
+ }
489
+ },
490
+ approval_remind: {
491
+ label: "\u50AC\u529E",
492
+ successMessage: "\u5DF2\u53D1\u9001\u50AC\u529E\u3002",
493
+ params: {
494
+ comment: {
495
+ label: "\u5907\u6CE8"
496
+ }
497
+ }
498
+ },
499
+ approval_recall: {
500
+ label: "\u64A4\u56DE",
501
+ confirmText: "\u64A4\u56DE\u8BE5\u8BF7\u6C42\uFF1F\u64A4\u56DE\u540E\u5BA1\u6279\u4EBA\u5C06\u65E0\u6CD5\u7EE7\u7EED\u5904\u7406\uFF0C\u8BB0\u5F55\u968F\u5373\u89E3\u9501\u3002",
502
+ successMessage: "\u5DF2\u64A4\u56DE\u3002",
503
+ params: {
504
+ comment: {
505
+ label: "\u5907\u6CE8"
506
+ }
507
+ }
508
+ },
509
+ approval_resubmit: {
510
+ label: "\u91CD\u65B0\u63D0\u4EA4",
511
+ successMessage: "\u5DF2\u91CD\u65B0\u63D0\u4EA4\u3002",
512
+ params: {
513
+ comment: {
514
+ label: "\u4F60\u4FEE\u6539\u4E86\u4EC0\u4E48\uFF1F"
515
+ }
516
+ }
517
+ }
313
518
  }
314
519
  },
315
520
  sys_approval_action: {
@@ -341,7 +546,14 @@ var init_zh_CN_objects_generated = __esm({
341
546
  approve: "\u6279\u51C6",
342
547
  reject: "\u62D2\u7EDD",
343
548
  recall: "\u64A4\u56DE",
344
- escalate: "\u5347\u7EA7"
549
+ escalate: "\u5347\u7EA7",
550
+ reassign: "\u8F6C\u7B7E",
551
+ remind: "\u50AC\u529E",
552
+ request_info: "\u8981\u6C42\u8865\u5145\u6750\u6599",
553
+ comment: "\u8BC4\u8BBA",
554
+ revise: "\u9000\u56DE\u4FEE\u6539",
555
+ resubmit: "\u91CD\u65B0\u63D0\u4EA4",
556
+ ooo_substitute: "\u4E0D\u5728\u5C97\u6539\u6D3E"
345
557
  }
346
558
  },
347
559
  actor_id: {
@@ -350,13 +562,29 @@ var init_zh_CN_objects_generated = __esm({
350
562
  comment: {
351
563
  label: "\u8BC4\u8BBA"
352
564
  },
565
+ reassign_from: {
566
+ label: "\u8F6C\u51FA\u4EBA",
567
+ help: "\u88AB\u79FB\u4EA4\u5F85\u5BA1\u6279\u69FD\u4F4D\u7684\u7528\u6237\uFF08\u4EC5\u8F6C\u7B7E\u64CD\u4F5C\uFF09"
568
+ },
569
+ reassign_to: {
570
+ label: "\u8F6C\u5165\u4EBA",
571
+ help: "\u63A5\u6536\u5F85\u5BA1\u6279\u69FD\u4F4D\u7684\u7528\u6237\uFF08\u4EC5\u8F6C\u7B7E\u64CD\u4F5C\uFF09"
572
+ },
573
+ attachments: {
574
+ label: "\u9644\u4EF6",
575
+ help: "\u652F\u6301\u8BE5\u64CD\u4F5C\u7684\u6587\u4EF6\u2014\u2014\u4F8B\u5982\u5DF2\u7B7E\u7F72\u7684\u5408\u540C\u6216\u8BC1\u660E\u6750\u6599\uFF08#3266\uFF09\u3002"
576
+ },
353
577
  created_at: {
354
578
  label: "\u521B\u5EFA\u65F6\u95F4"
355
579
  }
356
580
  },
357
581
  _views: {
358
582
  recent: {
359
- label: "\u6700\u8FD1"
583
+ label: "\u6700\u8FD1",
584
+ emptyState: {
585
+ title: "\u6682\u65E0\u5BA1\u6279\u64CD\u4F5C\u8BB0\u5F55",
586
+ message: "\u5BA1\u6279\u63A8\u8FDB\u65F6\u4F1A\u81EA\u52A8\u8BB0\u5F55\u64CD\u4F5C\u3002"
587
+ }
360
588
  },
361
589
  by_actor: {
362
590
  label: "\u6309\u6267\u884C\u4EBA"
@@ -460,7 +688,8 @@ var init_ja_JP_objects_generated = __esm({
460
688
  pending: "\u4FDD\u7559\u4E2D",
461
689
  approved: "\u627F\u8A8D\u6E08\u307F",
462
690
  rejected: "\u5374\u4E0B\u6E08\u307F",
463
- recalled: "\u53D6\u308A\u6D88\u3057\u6E08\u307F"
691
+ recalled: "\u53D6\u308A\u6D88\u3057\u6E08\u307F",
692
+ returned: "\u5DEE\u3057\u623B\u3057\u6E08\u307F"
464
693
  }
465
694
  },
466
695
  current_step: {
@@ -502,7 +731,11 @@ var init_ja_JP_objects_generated = __esm({
502
731
  },
503
732
  _views: {
504
733
  my_pending: {
505
- label: "\u81EA\u5206\u306E\u4FDD\u7559\u4E2D"
734
+ label: "\u81EA\u5206\u306E\u4FDD\u7559\u4E2D",
735
+ emptyState: {
736
+ title: "\u627F\u8A8D\u5F85\u3061\u306F\u3042\u308A\u307E\u305B\u3093",
737
+ message: "\u3059\u3079\u3066\u51E6\u7406\u6E08\u307F\u3067\u3059\u3002\u3042\u306A\u305F\u306E\u627F\u8A8D\u3092\u5F85\u3064\u30EA\u30AF\u30A8\u30B9\u30C8\u306F\u3042\u308A\u307E\u305B\u3093\u3002"
738
+ }
506
739
  },
507
740
  submitted_by_me: {
508
741
  label: "\u81EA\u5206\u304C\u9001\u4FE1"
@@ -513,6 +746,92 @@ var init_ja_JP_objects_generated = __esm({
513
746
  all_requests: {
514
747
  label: "\u3059\u3079\u3066"
515
748
  }
749
+ },
750
+ _actions: {
751
+ approval_approve: {
752
+ label: "\u627F\u8A8D",
753
+ successMessage: "\u627F\u8A8D\u3057\u307E\u3057\u305F\u3002",
754
+ params: {
755
+ comment: {
756
+ label: "\u30B3\u30E1\u30F3\u30C8"
757
+ },
758
+ attachments: {
759
+ label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB"
760
+ }
761
+ }
762
+ },
763
+ approval_reject: {
764
+ label: "\u5374\u4E0B",
765
+ confirmText: "\u3053\u306E\u30EA\u30AF\u30A8\u30B9\u30C8\u3092\u5374\u4E0B\u3057\u307E\u3059\u304B\uFF1F\u5374\u4E0B\u306F\u3059\u3079\u3066\u306E\u627F\u8A8D\u8005\u306B\u5BFE\u3057\u3066\u6700\u7D42\u6C7A\u5B9A\u306B\u306A\u308A\u307E\u3059\u3002",
766
+ successMessage: "\u5374\u4E0B\u3057\u307E\u3057\u305F\u3002",
767
+ params: {
768
+ comment: {
769
+ label: "\u30B3\u30E1\u30F3\u30C8"
770
+ },
771
+ attachments: {
772
+ label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB"
773
+ }
774
+ }
775
+ },
776
+ approval_reassign: {
777
+ label: "\u518D\u5272\u308A\u5F53\u3066",
778
+ successMessage: "\u518D\u5272\u308A\u5F53\u3066\u3057\u307E\u3057\u305F\u3002",
779
+ params: {
780
+ to: {
781
+ label: "\u65B0\u3057\u3044\u627F\u8A8D\u8005",
782
+ helpText: "\u3053\u306E\u30B9\u30C6\u30C3\u30D7\u3092\u5F15\u304D\u7D99\u3050\u30E6\u30FC\u30B6\u30FC"
783
+ },
784
+ comment: {
785
+ label: "\u30B3\u30E1\u30F3\u30C8"
786
+ }
787
+ }
788
+ },
789
+ approval_send_back: {
790
+ label: "\u5DEE\u3057\u623B\u3057",
791
+ successMessage: "\u4FEE\u6B63\u306E\u305F\u3081\u5DEE\u3057\u623B\u3057\u307E\u3057\u305F\u3002",
792
+ params: {
793
+ comment: {
794
+ label: "\u7406\u7531"
795
+ }
796
+ }
797
+ },
798
+ approval_request_info: {
799
+ label: "\u8FFD\u52A0\u60C5\u5831\u3092\u4F9D\u983C",
800
+ successMessage: "\u8FFD\u52A0\u60C5\u5831\u3092\u4F9D\u983C\u3057\u307E\u3057\u305F\u3002",
801
+ params: {
802
+ comment: {
803
+ label: "\u4F55\u304C\u5FC5\u8981\u3067\u3059\u304B\uFF1F"
804
+ }
805
+ }
806
+ },
807
+ approval_remind: {
808
+ label: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC\u9001\u4FE1",
809
+ successMessage: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC\u3092\u9001\u4FE1\u3057\u307E\u3057\u305F\u3002",
810
+ params: {
811
+ comment: {
812
+ label: "\u30E1\u30E2"
813
+ }
814
+ }
815
+ },
816
+ approval_recall: {
817
+ label: "\u53D6\u308A\u4E0B\u3052",
818
+ confirmText: "\u3053\u306E\u30EA\u30AF\u30A8\u30B9\u30C8\u3092\u53D6\u308A\u4E0B\u3052\u307E\u3059\u304B\uFF1F\u627F\u8A8D\u8005\u306F\u64CD\u4F5C\u3067\u304D\u306A\u304F\u306A\u308A\u3001\u30EC\u30B3\u30FC\u30C9\u306E\u30ED\u30C3\u30AF\u304C\u89E3\u9664\u3055\u308C\u307E\u3059\u3002",
819
+ successMessage: "\u53D6\u308A\u4E0B\u3052\u307E\u3057\u305F\u3002",
820
+ params: {
821
+ comment: {
822
+ label: "\u30B3\u30E1\u30F3\u30C8"
823
+ }
824
+ }
825
+ },
826
+ approval_resubmit: {
827
+ label: "\u518D\u63D0\u51FA",
828
+ successMessage: "\u518D\u63D0\u51FA\u3057\u307E\u3057\u305F\u3002",
829
+ params: {
830
+ comment: {
831
+ label: "\u4F55\u3092\u5909\u66F4\u3057\u307E\u3057\u305F\u304B\uFF1F"
832
+ }
833
+ }
834
+ }
516
835
  }
517
836
  },
518
837
  sys_approval_action: {
@@ -544,7 +863,14 @@ var init_ja_JP_objects_generated = __esm({
544
863
  approve: "\u627F\u8A8D",
545
864
  reject: "\u5374\u4E0B",
546
865
  recall: "\u53D6\u6D88",
547
- escalate: "\u30A8\u30B9\u30AB\u30EC\u30FC\u30B7\u30E7\u30F3"
866
+ escalate: "\u30A8\u30B9\u30AB\u30EC\u30FC\u30B7\u30E7\u30F3",
867
+ reassign: "\u518D\u5272\u308A\u5F53\u3066",
868
+ remind: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC",
869
+ request_info: "\u8FFD\u52A0\u60C5\u5831\u4F9D\u983C",
870
+ comment: "\u30B3\u30E1\u30F3\u30C8",
871
+ revise: "\u5DEE\u3057\u623B\u3057",
872
+ resubmit: "\u518D\u63D0\u51FA",
873
+ ooo_substitute: "\u4E0D\u5728\u6642\u4EE3\u7406"
548
874
  }
549
875
  },
550
876
  actor_id: {
@@ -553,13 +879,29 @@ var init_ja_JP_objects_generated = __esm({
553
879
  comment: {
554
880
  label: "\u30B3\u30E1\u30F3\u30C8"
555
881
  },
882
+ reassign_from: {
883
+ label: "\u5F15\u304D\u7D99\u304E\u5143",
884
+ help: "\u627F\u8A8D\u5F85\u3061\u30B9\u30ED\u30C3\u30C8\u3092\u5F15\u304D\u6E21\u3057\u305F\u30E6\u30FC\u30B6\u30FC\uFF08\u5F15\u304D\u7D99\u304E\u64CD\u4F5C\u306E\u307F\uFF09"
885
+ },
886
+ reassign_to: {
887
+ label: "\u5F15\u304D\u7D99\u304E\u5148",
888
+ help: "\u627F\u8A8D\u5F85\u3061\u30B9\u30ED\u30C3\u30C8\u3092\u53D7\u3051\u53D6\u3063\u305F\u30E6\u30FC\u30B6\u30FC\uFF08\u5F15\u304D\u7D99\u304E\u64CD\u4F5C\u306E\u307F\uFF09"
889
+ },
890
+ attachments: {
891
+ label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB",
892
+ help: "\u3053\u306E\u64CD\u4F5C\u3092\u88CF\u4ED8\u3051\u308B\u30D5\u30A1\u30A4\u30EB\u2014\u2014\u7F72\u540D\u6E08\u307F\u5951\u7D04\u66F8\u3084\u8A3C\u6191\u306A\u3069\uFF08#3266\uFF09\u3002"
893
+ },
556
894
  created_at: {
557
895
  label: "\u4F5C\u6210\u65E5\u6642"
558
896
  }
559
897
  },
560
898
  _views: {
561
899
  recent: {
562
- label: "\u6700\u8FD1"
900
+ label: "\u6700\u8FD1",
901
+ emptyState: {
902
+ title: "\u627F\u8A8D\u64CD\u4F5C\u306F\u307E\u3060\u3042\u308A\u307E\u305B\u3093",
903
+ message: "\u627F\u8A8D\u304C\u9032\u3080\u3068\u64CD\u4F5C\u306F\u81EA\u52D5\u7684\u306B\u8A18\u9332\u3055\u308C\u307E\u3059\u3002"
904
+ }
563
905
  },
564
906
  by_actor: {
565
907
  label: "\u64CD\u4F5C\u8005\u5225"
@@ -663,7 +1005,8 @@ var init_es_ES_objects_generated = __esm({
663
1005
  pending: "Pendiente",
664
1006
  approved: "Aprobada",
665
1007
  rejected: "Rechazada",
666
- recalled: "Retirada"
1008
+ recalled: "Retirada",
1009
+ returned: "Devuelta para revisi\xF3n"
667
1010
  }
668
1011
  },
669
1012
  current_step: {
@@ -705,7 +1048,11 @@ var init_es_ES_objects_generated = __esm({
705
1048
  },
706
1049
  _views: {
707
1050
  my_pending: {
708
- label: "Mis pendientes"
1051
+ label: "Mis pendientes",
1052
+ emptyState: {
1053
+ title: "Sin aprobaciones pendientes",
1054
+ message: "Est\xE1s al d\xEDa: nada espera tu aprobaci\xF3n."
1055
+ }
709
1056
  },
710
1057
  submitted_by_me: {
711
1058
  label: "Enviadas por m\xED"
@@ -716,6 +1063,92 @@ var init_es_ES_objects_generated = __esm({
716
1063
  all_requests: {
717
1064
  label: "Todas"
718
1065
  }
1066
+ },
1067
+ _actions: {
1068
+ approval_approve: {
1069
+ label: "Aprobar",
1070
+ successMessage: "Aprobada.",
1071
+ params: {
1072
+ comment: {
1073
+ label: "Comentario"
1074
+ },
1075
+ attachments: {
1076
+ label: "Adjuntos"
1077
+ }
1078
+ }
1079
+ },
1080
+ approval_reject: {
1081
+ label: "Rechazar",
1082
+ confirmText: "\xBFRechazar esta solicitud? Un rechazo es definitivo para todos los aprobadores.",
1083
+ successMessage: "Rechazada.",
1084
+ params: {
1085
+ comment: {
1086
+ label: "Comentario"
1087
+ },
1088
+ attachments: {
1089
+ label: "Adjuntos"
1090
+ }
1091
+ }
1092
+ },
1093
+ approval_reassign: {
1094
+ label: "Reasignar",
1095
+ successMessage: "Reasignada.",
1096
+ params: {
1097
+ to: {
1098
+ label: "Nuevo aprobador",
1099
+ helpText: "Usuario al que se entrega este paso"
1100
+ },
1101
+ comment: {
1102
+ label: "Comentario"
1103
+ }
1104
+ }
1105
+ },
1106
+ approval_send_back: {
1107
+ label: "Devolver",
1108
+ successMessage: "Devuelta para revisi\xF3n.",
1109
+ params: {
1110
+ comment: {
1111
+ label: "Motivo"
1112
+ }
1113
+ }
1114
+ },
1115
+ approval_request_info: {
1116
+ label: "Pedir informaci\xF3n",
1117
+ successMessage: "Informaci\xF3n solicitada.",
1118
+ params: {
1119
+ comment: {
1120
+ label: "\xBFQu\xE9 necesitas?"
1121
+ }
1122
+ }
1123
+ },
1124
+ approval_remind: {
1125
+ label: "Enviar recordatorio",
1126
+ successMessage: "Recordatorio enviado.",
1127
+ params: {
1128
+ comment: {
1129
+ label: "Nota"
1130
+ }
1131
+ }
1132
+ },
1133
+ approval_recall: {
1134
+ label: "Retirar",
1135
+ confirmText: "\xBFRetirar esta solicitud? Los aprobadores ya no podr\xE1n actuar sobre ella y el registro se desbloquear\xE1.",
1136
+ successMessage: "Retirada.",
1137
+ params: {
1138
+ comment: {
1139
+ label: "Comentario"
1140
+ }
1141
+ }
1142
+ },
1143
+ approval_resubmit: {
1144
+ label: "Reenviar",
1145
+ successMessage: "Reenviada.",
1146
+ params: {
1147
+ comment: {
1148
+ label: "\xBFQu\xE9 ha cambiado?"
1149
+ }
1150
+ }
1151
+ }
719
1152
  }
720
1153
  },
721
1154
  sys_approval_action: {
@@ -747,7 +1180,14 @@ var init_es_ES_objects_generated = __esm({
747
1180
  approve: "Aprobar",
748
1181
  reject: "Rechazar",
749
1182
  recall: "Retirar",
750
- escalate: "Escalar"
1183
+ escalate: "Escalar",
1184
+ reassign: "Reasignaci\xF3n",
1185
+ remind: "Recordatorio",
1186
+ request_info: "Petici\xF3n de informaci\xF3n",
1187
+ comment: "Comentario",
1188
+ revise: "Devoluci\xF3n",
1189
+ resubmit: "Reenv\xEDo",
1190
+ ooo_substitute: "Sustituci\xF3n por ausencia"
751
1191
  }
752
1192
  },
753
1193
  actor_id: {
@@ -756,13 +1196,29 @@ var init_es_ES_objects_generated = __esm({
756
1196
  comment: {
757
1197
  label: "Comentario"
758
1198
  },
1199
+ reassign_from: {
1200
+ label: "Reasignado de",
1201
+ help: "Usuario cuyo turno de aprobaci\xF3n pendiente fue traspasado (solo acciones de reasignaci\xF3n)"
1202
+ },
1203
+ reassign_to: {
1204
+ label: "Reasignado a",
1205
+ help: "Usuario que recibi\xF3 el turno de aprobaci\xF3n pendiente (solo acciones de reasignaci\xF3n)"
1206
+ },
1207
+ attachments: {
1208
+ label: "Adjuntos",
1209
+ help: "Archivos que respaldan esta acci\xF3n, p. ej. un contrato firmado o pruebas (#3266)."
1210
+ },
759
1211
  created_at: {
760
1212
  label: "Creado el"
761
1213
  }
762
1214
  },
763
1215
  _views: {
764
1216
  recent: {
765
- label: "Recientes"
1217
+ label: "Recientes",
1218
+ emptyState: {
1219
+ title: "A\xFAn no hay acciones de aprobaci\xF3n",
1220
+ message: "Las acciones se registran autom\xE1ticamente a medida que avanza la aprobaci\xF3n."
1221
+ }
766
1222
  },
767
1223
  by_actor: {
768
1224
  label: "Por actor"
@@ -849,6 +1305,7 @@ var init_translations = __esm({
849
1305
 
850
1306
  // src/sys-approval-request.object.ts
851
1307
  import { ObjectSchema, Field } from "@objectstack/spec/data";
1308
+ import { APPROVAL_STATUSES } from "@objectstack/spec/contracts";
852
1309
  var SysApprovalRequest = ObjectSchema.create({
853
1310
  name: "sys_approval_request",
854
1311
  label: "Approval Request",
@@ -947,9 +1404,10 @@ var SysApprovalRequest = ObjectSchema.create({
947
1404
  group: "Target"
948
1405
  }),
949
1406
  status: Field.select(
950
- // Keep in sync with `ApprovalStatus` (spec/contracts). `returned` =
951
- // sent back for revision (ADR-0044) terminal for this round.
952
- ["pending", "approved", "rejected", "recalled", "returned"],
1407
+ // Spread from the contract, not re-typed (#3786). `APPROVAL_STATUSES` is
1408
+ // where the list and the reason for each entry live; `ApprovalStatus` is
1409
+ // derived from it, so this column and the contract cannot disagree.
1410
+ [...APPROVAL_STATUSES],
953
1411
  {
954
1412
  label: "Status",
955
1413
  required: true,
@@ -1047,13 +1505,22 @@ var SysApprovalRequest = ObjectSchema.create({
1047
1505
  // per-viewer block (#3310): approver actions on `record.viewer.can_act`
1048
1506
  // (the caller is a current pending approver — same check the service
1049
1507
  // authorizes a decision with, so position/team approvers resolve correctly),
1050
- // submitter actions on `record.viewer.is_submitter`. `viewer` is attached by
1051
- // getRequest/listRequests; where it is absent the predicate fails closed.
1508
+ // submitter actions on `record.viewer.is_submitter`. The core decision levers
1509
+ // (approve/reject/reassign) additionally OR in `record.viewer.can_override`
1510
+ // (#3424) so a platform/tenant admin can rescue a request routed to an
1511
+ // unstaffed position — otherwise undecidable, locking the record forever — by
1512
+ // approving, rejecting, or reassigning it to a real approver. `viewer` is
1513
+ // attached by getRequest/listRequests; where it is absent the predicate fails
1514
+ // closed.
1052
1515
  actions: [
1053
1516
  {
1054
1517
  name: "approval_approve",
1055
1518
  label: "Approve",
1056
1519
  icon: "check-circle",
1520
+ // Primary decision — the console renders this filled/highlighted so it
1521
+ // stands out from the secondary levers in the drawer's action bar,
1522
+ // matching the mobile card hierarchy (objectui#2762 P1-5).
1523
+ variant: "primary",
1057
1524
  type: "api",
1058
1525
  method: "POST",
1059
1526
  target: "/api/v1/approvals/requests/{id}/approve",
@@ -1064,7 +1531,7 @@ var SysApprovalRequest = ObjectSchema.create({
1064
1531
  // string[]`; the decision route persists them on `sys_approval_action`.
1065
1532
  { name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
1066
1533
  ],
1067
- visible: "record.viewer.can_act",
1534
+ visible: "record.viewer.can_act || record.viewer.can_override",
1068
1535
  locations: ["record_section", "list_item"],
1069
1536
  successMessage: "Approved.",
1070
1537
  refreshAfter: true
@@ -1073,6 +1540,9 @@ var SysApprovalRequest = ObjectSchema.create({
1073
1540
  name: "approval_reject",
1074
1541
  label: "Reject",
1075
1542
  icon: "x-circle",
1543
+ // Destructive decision — rendered in the console's danger styling so it
1544
+ // reads as the irreversible action it is (objectui#2762 P1-5).
1545
+ variant: "danger",
1076
1546
  type: "api",
1077
1547
  method: "POST",
1078
1548
  target: "/api/v1/approvals/requests/{id}/reject",
@@ -1080,7 +1550,7 @@ var SysApprovalRequest = ObjectSchema.create({
1080
1550
  { name: "comment", label: "Comment", type: "textarea", required: false },
1081
1551
  { name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
1082
1552
  ],
1083
- visible: "record.viewer.can_act",
1553
+ visible: "record.viewer.can_act || record.viewer.can_override",
1084
1554
  confirmText: "Reject this request? A rejection is final for every approver.",
1085
1555
  locations: ["record_section", "list_item"],
1086
1556
  successMessage: "Rejected.",
@@ -1102,7 +1572,7 @@ var SysApprovalRequest = ObjectSchema.create({
1102
1572
  { field: "submitter_id", name: "to", label: "New approver", required: true, helpText: "User to hand this step to" },
1103
1573
  { name: "comment", label: "Comment", type: "textarea", required: false }
1104
1574
  ],
1105
- visible: "record.viewer.can_act",
1575
+ visible: "record.viewer.can_act || record.viewer.can_override",
1106
1576
  locations: ["record_section"],
1107
1577
  successMessage: "Reassigned.",
1108
1578
  refreshAfter: true
@@ -1206,6 +1676,7 @@ var SysApprovalRequest = ObjectSchema.create({
1206
1676
 
1207
1677
  // src/sys-approval-action.object.ts
1208
1678
  import { ObjectSchema as ObjectSchema2, Field as Field2 } from "@objectstack/spec/data";
1679
+ import { APPROVAL_ACTION_KINDS } from "@objectstack/spec/contracts";
1209
1680
  var SysApprovalAction = ObjectSchema2.create({
1210
1681
  name: "sys_approval_action",
1211
1682
  label: "Approval Action",
@@ -1219,6 +1690,14 @@ var SysApprovalAction = ObjectSchema2.create({
1219
1690
  // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
1220
1691
  titleFormat: "{action} \xB7 {step_name}",
1221
1692
  highlightFields: ["request_id", "step_name", "action", "actor_id", "created_at"],
1693
+ // ADR-0104 D3 wave 2. `attachments` is a media field, so the files it holds
1694
+ // are OWNED by this row — and the storage service would otherwise authorize
1695
+ // their download by testing whether the caller can READ this row. It cannot:
1696
+ // this table is deliberately closed to ordinary approver positions, so that
1697
+ // test denies the very approver the attachment was filed for. The approvals
1698
+ // service already owns the rule for seeing a decision (visibility of the
1699
+ // parent request, exactly as `listActions` applies it), so it answers.
1700
+ fileAccessDelegate: "approvals",
1222
1701
  listViews: {
1223
1702
  recent: {
1224
1703
  type: "grid",
@@ -1276,12 +1755,11 @@ var SysApprovalAction = ObjectSchema2.create({
1276
1755
  group: "Target"
1277
1756
  }),
1278
1757
  action: Field2.select(
1279
- // Keep in sync with `ApprovalActionKind` (spec/contracts). reassign /
1280
- // remind / request_info / comment are thread interactions they never
1281
- // move the flow. revise / resubmit (ADR-0044) DO move it: send back for
1282
- // revision and the later resubmission. ooo_substitute (#1322 M1) is a
1283
- // system-recorded reroute of an out-of-office approver — no flow movement.
1284
- ["submit", "approve", "reject", "recall", "escalate", "reassign", "remind", "request_info", "comment", "revise", "resubmit", "ooo_substitute"],
1758
+ // Spread from the contract, not re-typed (#3786). `APPROVAL_ACTION_KINDS`
1759
+ // is where the list and the per-kind notes live (which kinds move the flow
1760
+ // and which are thread-only); `ApprovalActionKind` is derived from it, so
1761
+ // this column and the contract cannot disagree.
1762
+ [...APPROVAL_ACTION_KINDS],
1285
1763
  {
1286
1764
  label: "Action",
1287
1765
  required: true,
@@ -1294,6 +1772,23 @@ var SysApprovalAction = ObjectSchema2.create({
1294
1772
  group: "Action"
1295
1773
  }),
1296
1774
  comment: Field2.textarea({ label: "Comment", required: false, group: "Action" }),
1775
+ // Structured hand-off parties for `action: 'reassign'` (#4365). Before
1776
+ // these existed the pair lived only inside a default free-text comment
1777
+ // ("<from_id> → <to_id>"), which no client could parse or render readably.
1778
+ // `comment` is pure user input again; timelines render "from A to B" from
1779
+ // these fields.
1780
+ reassign_from: Field2.lookup("sys_user", {
1781
+ label: "Reassigned From",
1782
+ required: false,
1783
+ group: "Action",
1784
+ description: "User whose pending-approver slot was handed over (reassign actions only)"
1785
+ }),
1786
+ reassign_to: Field2.lookup("sys_user", {
1787
+ label: "Reassigned To",
1788
+ required: false,
1789
+ group: "Action",
1790
+ description: "User who received the pending-approver slot (reassign actions only)"
1791
+ }),
1297
1792
  attachments: Field2.file({
1298
1793
  label: "Attachments",
1299
1794
  required: false,
@@ -1470,9 +1965,9 @@ var SysApprovalDelegation = ObjectSchema4.create({
1470
1965
  trackHistory: true,
1471
1966
  searchable: true,
1472
1967
  apiEnabled: true,
1473
- apiMethods: ["get", "list", "create", "update", "delete"],
1474
- trash: true,
1475
- mru: false
1968
+ // `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child`
1969
+ // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026).
1970
+ apiMethods: ["get", "list", "create", "update", "delete", "bulk"]
1476
1971
  }
1477
1972
  });
1478
1973
 
@@ -1480,16 +1975,168 @@ var SysApprovalDelegation = ObjectSchema4.create({
1480
1975
  import { createHash, randomBytes } from "crypto";
1481
1976
  import {
1482
1977
  APPROVAL_BRANCH_LABELS,
1483
- canonicalApproverType
1978
+ approverTypeIsOrgScoped,
1979
+ canonicalApproverType,
1980
+ normalizeDecisionOutputs
1484
1981
  } from "@objectstack/spec/automation";
1982
+ import { ExpressionEngine, collectCelRootIdentifiers } from "@objectstack/formula";
1983
+ import { keysetWalk } from "@objectstack/types";
1984
+ import {
1985
+ ADMIN_FULL_ACCESS,
1986
+ ORGANIZATION_ADMIN_GRANTS,
1987
+ BUILTIN_IDENTITY_PLATFORM_ADMIN,
1988
+ BUILTIN_IDENTITY_ORG_OWNER,
1989
+ BUILTIN_IDENTITY_ORG_ADMIN
1990
+ } from "@objectstack/spec/identity";
1991
+ import { RESUME_AUTHORITY_SERVICE } from "@objectstack/spec/contracts";
1992
+ import { isFileIdToken } from "@objectstack/spec/data";
1485
1993
  import { isGrantActive } from "@objectstack/core";
1994
+
1995
+ // src/approver-org-scope.ts
1996
+ var MAX_ORG_DEPTH = 32;
1997
+ var SYSTEM_CTX = { isSystem: true };
1998
+ function fail(message) {
1999
+ throw new Error(`VALIDATION_FAILED: ${message}`);
2000
+ }
2001
+ async function findOrg(engine, where) {
2002
+ try {
2003
+ const rows = await engine.find("sys_organization", {
2004
+ where,
2005
+ fields: ["id", "slug", "parent_organization_id"],
2006
+ limit: 1,
2007
+ context: SYSTEM_CTX
2008
+ });
2009
+ return Array.isArray(rows) && rows[0] ? rows[0] : null;
2010
+ } catch {
2011
+ return null;
2012
+ }
2013
+ }
2014
+ async function ancestorChain(engine, orgId) {
2015
+ const chain = [];
2016
+ const seen = /* @__PURE__ */ new Set();
2017
+ let cursor = orgId;
2018
+ for (let depth = 0; cursor && depth < MAX_ORG_DEPTH; depth++) {
2019
+ if (seen.has(cursor)) break;
2020
+ seen.add(cursor);
2021
+ chain.push(cursor);
2022
+ const row = await findOrg(engine, { id: cursor });
2023
+ const parent = row?.parent_organization_id;
2024
+ cursor = parent ? String(parent) : null;
2025
+ }
2026
+ return chain;
2027
+ }
2028
+ async function resolveApproverDirectoryOrg(deps, declaration, requestOrgId, approverType, isOrgScopedType) {
2029
+ const declared = typeof declaration === "string" ? declaration.trim() : "";
2030
+ if (!declared) return requestOrgId;
2031
+ if (!isOrgScopedType) {
2032
+ fail(
2033
+ `approver type '${approverType}' resolves people without an organization directory, so 'organization: ${declared}' would have no effect \u2014 remove it (ADR-0105 D9 applies to position / org_membership_level / department / expression)`
2034
+ );
2035
+ }
2036
+ const posture = deps.posture?.();
2037
+ if (posture && posture !== "group") {
2038
+ fail(
2039
+ `cross-organization approver targeting ('organization: ${declared}') requires the 'group' tenancy posture; this deployment resolves '${posture}' (ADR-0105 D9)`
2040
+ );
2041
+ }
2042
+ const requestOrg = requestOrgId ? String(requestOrgId) : "";
2043
+ if (!requestOrg) {
2044
+ fail(
2045
+ `'organization: ${declared}' cannot be resolved for a request that carries no organization \u2014 cross-organization targeting needs an organization to resolve from`
2046
+ );
2047
+ }
2048
+ const chain = await ancestorChain(deps.engine, requestOrg);
2049
+ if (declared === "$root") {
2050
+ const root = chain[chain.length - 1];
2051
+ if (root === requestOrg && chain.length === 1) {
2052
+ fail(
2053
+ `'organization: $root' resolved to the request's own organization \u2014 this organization has no 'parent_organization_id' lineage. Declare the group hierarchy (ADR-0105 D6) or drop the targeting`
2054
+ );
2055
+ }
2056
+ return root;
2057
+ }
2058
+ if (declared === "$parent") {
2059
+ const parent = chain[1];
2060
+ if (!parent) {
2061
+ fail(
2062
+ `'organization: $parent' has no parent to resolve to \u2014 the request's organization is already the root of its group (ADR-0105 D6 'parent_organization_id')`
2063
+ );
2064
+ }
2065
+ return parent;
2066
+ }
2067
+ const target = await findOrg(deps.engine, { slug: declared });
2068
+ if (!target?.id) {
2069
+ fail(
2070
+ `no organization with slug '${declared}' \u2014 'organization' takes an organization SLUG or a symbol ($root / $parent), never an id (ids are per-deployment, flows are portable)`
2071
+ );
2072
+ }
2073
+ const targetId = String(target.id);
2074
+ if (targetId === requestOrg) return targetId;
2075
+ const targetChain = await ancestorChain(deps.engine, targetId);
2076
+ const targetRoot = targetChain[targetChain.length - 1];
2077
+ const requestRoot = chain[chain.length - 1];
2078
+ if (!targetRoot || !requestRoot || targetRoot !== requestRoot) {
2079
+ fail(
2080
+ `organization '${declared}' is not in the same group as the request's organization \u2014 cross-organization approval routes WITHIN one group (they must share a 'parent_organization_id' root, ADR-0105 D6/D9)`
2081
+ );
2082
+ }
2083
+ return targetId;
2084
+ }
2085
+ async function filterApproversWhoCanRead(deps, userIds, requestOrgId, context) {
2086
+ const requestOrg = requestOrgId ? String(requestOrgId) : "";
2087
+ if (!requestOrg || userIds.length === 0) return userIds;
2088
+ let members = [];
2089
+ try {
2090
+ members = await deps.engine.find("sys_member", {
2091
+ where: { organization_id: requestOrg, user_id: { $in: userIds } },
2092
+ fields: ["user_id"],
2093
+ limit: 1e4,
2094
+ context: SYSTEM_CTX
2095
+ });
2096
+ } catch {
2097
+ return userIds;
2098
+ }
2099
+ const canRead = new Set(
2100
+ (members ?? []).map((m) => String(m?.user_id ?? "")).filter(Boolean)
2101
+ );
2102
+ const dropped = userIds.filter((u) => !canRead.has(u));
2103
+ if (dropped.length === 0) return userIds;
2104
+ deps.logger?.warn?.(
2105
+ `[approvals] ADR-0105 D9: ${dropped.length} cross-organization approver(s) dropped \u2014 they hold '${context.value ?? context.approverType}' in organization '${context.directoryOrgId}' but no membership in the request's organization '${requestOrg}', so the D2 union wall would hide the request from them. Grant those users a membership in the request's organization, or retarget the approver.`,
2106
+ { dropped, requestOrganizationId: requestOrg, directoryOrganizationId: context.directoryOrgId }
2107
+ );
2108
+ return userIds.filter((u) => canRead.has(u));
2109
+ }
2110
+
2111
+ // src/approval-service.ts
1486
2112
  var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
1487
2113
  var ESCALATION_JOB_NAME = "approvals-sla-escalation";
1488
2114
  var ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1e3;
1489
2115
  var SLA_ACTOR_ID = "system:sla";
2116
+ var DEAD_RUN_ACTOR_ID = "system:dead-run";
2117
+ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
2118
+ "completed",
2119
+ "failed",
2120
+ "cancelled",
2121
+ "timed_out"
2122
+ ]);
1490
2123
  var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
1491
- var SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
2124
+ var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
2125
+ function actingUserId(context) {
2126
+ const userId = context?.userId;
2127
+ return typeof userId === "string" && userId ? userId : null;
2128
+ }
1492
2129
  var OOO_MAX_CHAIN = 8;
2130
+ var GRAPH_APPROVER_TYPES = /* @__PURE__ */ new Set([
2131
+ "team",
2132
+ "department",
2133
+ "business_unit",
2134
+ "bu",
2135
+ "position",
2136
+ "org_membership_level",
2137
+ "manager"
2138
+ ]);
2139
+ var APPROVER_EXPRESSION_ROOTS = /* @__PURE__ */ new Set(["current", "trigger", "vars"]);
1493
2140
  function uid(prefix) {
1494
2141
  const g = globalThis;
1495
2142
  if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
@@ -1511,6 +2158,12 @@ function csvSplit(raw) {
1511
2158
  if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
1512
2159
  return String(raw).split(",").map((s) => s.trim()).filter(Boolean);
1513
2160
  }
2161
+ function isBlankDecisionOutput(value) {
2162
+ if (value === void 0 || value === null) return true;
2163
+ if (typeof value === "string") return value.trim() === "";
2164
+ if (Array.isArray(value)) return value.filter((v) => v !== null && v !== void 0 && String(v).trim() !== "").length === 0;
2165
+ return false;
2166
+ }
1514
2167
  function prettifyMachineName(raw) {
1515
2168
  if (!raw) return void 0;
1516
2169
  const base = String(raw).replace(/^flow:/, "").trim();
@@ -1543,7 +2196,26 @@ function rowFromRequest(row) {
1543
2196
  step_label: cfg?.__nodeLabel ?? prettifyMachineName(row.current_step),
1544
2197
  sla_due_at: slaDueAt(row.created_at, cfg),
1545
2198
  // ADR-0044 revision round (rides the config snapshot; absent ⇒ round 1).
1546
- round: typeof cfg?.__round === "number" ? cfg.__round : void 0
2199
+ round: typeof cfg?.__round === "number" ? cfg.__round : void 0,
2200
+ // objectui#2902: the node's record-lock policy. The lock is enforced
2201
+ // server-side in `lifecycle-hooks.ts` off THIS SAME snapshot with the
2202
+ // same `!== false` default, so the flag a client renders and the rule the
2203
+ // server applies can never drift. Without it a console can only see
2204
+ // "a pending request exists" and has to assume the record is locked —
2205
+ // which mislabels every `lockRecord: false` node as locked and hides an
2206
+ // edit the server would have accepted.
2207
+ lock_record: cfg?.lockRecord !== false,
2208
+ // #3447 P2: the node's author-declared decision outputs, surfaced so a
2209
+ // decision UI can render input fields for them and POST `outputs` on
2210
+ // approve/reject. Per-request (each node declares its own), which is why
2211
+ // this rides the row instead of the static action params. Two shapes for
2212
+ // version skew: `decision_outputs` stays the bare KEY list an older
2213
+ // console renders as text inputs; `decision_output_defs` carries the
2214
+ // normalized typed declarations a picker-aware console prefers.
2215
+ ...(() => {
2216
+ const defs = normalizeDecisionOutputs(cfg?.decisionOutputs);
2217
+ return defs.length ? { decision_outputs: defs.map((d) => d.key), decision_output_defs: defs } : {};
2218
+ })()
1547
2219
  };
1548
2220
  }
1549
2221
  function slaDueAt(createdAt, cfg) {
@@ -1553,7 +2225,29 @@ function slaDueAt(createdAt, cfg) {
1553
2225
  if (Number.isNaN(t)) return void 0;
1554
2226
  return new Date(t + hours * 36e5).toISOString();
1555
2227
  }
2228
+ function normalizeActionAttachment(entry) {
2229
+ if (entry == null) return void 0;
2230
+ if (typeof entry === "string") {
2231
+ const id = entry.trim();
2232
+ if (!id) return void 0;
2233
+ return isFileIdToken(id) ? { id } : { id, url: id };
2234
+ }
2235
+ if (typeof entry === "object") {
2236
+ const id = entry.id ?? entry.file_id;
2237
+ if (id == null || String(id) === "") return void 0;
2238
+ const mimeType = entry.mimeType ?? entry.mime_type;
2239
+ return {
2240
+ id: String(id),
2241
+ name: typeof entry.name === "string" ? entry.name : void 0,
2242
+ url: typeof entry.url === "string" ? entry.url : void 0,
2243
+ mimeType: typeof mimeType === "string" ? mimeType : void 0,
2244
+ size: typeof entry.size === "number" ? entry.size : void 0
2245
+ };
2246
+ }
2247
+ return void 0;
2248
+ }
1556
2249
  function rowFromAction(row) {
2250
+ const attachments = Array.isArray(row.attachments) ? row.attachments.map(normalizeActionAttachment).filter((a) => !!a) : [];
1557
2251
  return {
1558
2252
  id: String(row.id),
1559
2253
  request_id: String(row.request_id),
@@ -1562,10 +2256,12 @@ function rowFromAction(row) {
1562
2256
  action: row.action,
1563
2257
  actor_id: row.actor_id ?? void 0,
1564
2258
  comment: row.comment ?? void 0,
1565
- // Decision attachments (#3266). The column shipped in #3268 but this
1566
- // contract mapping didn't — the raw engine row carried the fileIds while
1567
- // every consumer of listActions saw none (caught by browser verification).
1568
- attachments: Array.isArray(row.attachments) && row.attachments.length ? row.attachments.map(String) : void 0,
2259
+ // Structured reassign hand-off parties (#4365).
2260
+ reassign_from: row.reassign_from ?? void 0,
2261
+ reassign_to: row.reassign_to ?? void 0,
2262
+ // Decision attachments (#3266): rich descriptors carrying the display name +
2263
+ // download URL, so consumers label/open them without reading `sys_file`.
2264
+ attachments: attachments.length ? attachments : void 0,
1569
2265
  created_at: row.created_at ?? void 0
1570
2266
  };
1571
2267
  }
@@ -1577,6 +2273,33 @@ var _ApprovalService = class _ApprovalService {
1577
2273
  this.automation = opts.automation;
1578
2274
  this.messaging = opts.messaging;
1579
2275
  this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
2276
+ this.tenancyPosture = opts.tenancyPosture;
2277
+ }
2278
+ /** Attach (or replace) the ADR-0105 D9 posture provider. */
2279
+ attachTenancyPosture(provider) {
2280
+ this.tenancyPosture = provider;
2281
+ }
2282
+ /** Deps bundle for the ADR-0105 D9 org-scope helpers. */
2283
+ get orgScopeDeps() {
2284
+ return {
2285
+ engine: this.engine,
2286
+ posture: this.tenancyPosture,
2287
+ logger: this.logger
2288
+ };
2289
+ }
2290
+ /**
2291
+ * [ADR-0105 D9] Which organization's directory resolves ONE approver spec.
2292
+ * Absent declaration ⇒ the request's own organization (unchanged, no reads).
2293
+ */
2294
+ async directoryOrgFor(a, requestOrgId) {
2295
+ const rawType = String(a?.type ?? "");
2296
+ return resolveApproverDirectoryOrg(
2297
+ this.orgScopeDeps,
2298
+ a?.organization,
2299
+ requestOrgId,
2300
+ rawType,
2301
+ approverTypeIsOrgScoped(rawType)
2302
+ );
1580
2303
  }
1581
2304
  /** Attach (or replace) the automation surface used to resume flow runs. */
1582
2305
  attachAutomation(automation) {
@@ -1611,13 +2334,108 @@ var _ApprovalService = class _ApprovalService {
1611
2334
  const rows = await this.engine.find("sys_approval_request", {
1612
2335
  where: { id: requestId },
1613
2336
  limit: 1,
1614
- context: SYSTEM_CTX
2337
+ context: SYSTEM_CTX2
1615
2338
  });
1616
2339
  const raw = Array.isArray(rows) ? rows[0] : null;
1617
2340
  if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
1618
2341
  if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
1619
2342
  return raw;
1620
2343
  }
2344
+ /**
2345
+ * Privileged-override gate (#3424). A stuck approval — one routed to a
2346
+ * position/team with no holders (so its `pending_approvers` is only an
2347
+ * unresolvable `type:value` literal) or to approvers who have all since left —
2348
+ * is otherwise undecidable: no concrete user is in the slate, so every normal
2349
+ * `decide` / `reassign` / `recall` is `FORBIDDEN` and (with `lockRecord`) the
2350
+ * record stays locked forever with no in-product recovery. A platform or
2351
+ * tenant admin — the same posture the engine's superuser bypass already
2352
+ * trusts — may always act on a PENDING request to release it: approve, reject,
2353
+ * reassign it to a real approver, or recall it.
2354
+ *
2355
+ * A platform admin crosses the tenant wall (matching the unscoped
2356
+ * `admin_full_access` evidence); a tenant admin may override only within their
2357
+ * own org (or an org-less request). A system context always passes. Signals are
2358
+ * read defensively off the resolved exec context (`permissions` / `positions` /
2359
+ * the derived `posture`, ADR-0095) so any transport that resolves through the
2360
+ * shared authz resolver lights this up without extra wiring.
2361
+ */
2362
+ isOverrideActor(context, requestOrg) {
2363
+ if (!context) return false;
2364
+ if (context.isSystem) return true;
2365
+ const perms = Array.isArray(context.permissions) ? context.permissions : [];
2366
+ const positions = Array.isArray(context.positions) ? context.positions : [];
2367
+ const posture = context.posture;
2368
+ const isPlatformAdmin = posture === "PLATFORM_ADMIN" || perms.includes(ADMIN_FULL_ACCESS) || positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN);
2369
+ if (isPlatformAdmin) return true;
2370
+ const isTenantAdmin = posture === "TENANT_ADMIN" || ORGANIZATION_ADMIN_GRANTS.some((n) => perms.includes(n)) || positions.includes(BUILTIN_IDENTITY_ORG_OWNER) || positions.includes(BUILTIN_IDENTITY_ORG_ADMIN);
2371
+ if (!isTenantAdmin) return false;
2372
+ const actorTenant = context.tenantId ?? context.organizationId ?? null;
2373
+ return requestOrg == null || actorTenant != null && String(requestOrg) === String(actorTenant);
2374
+ }
2375
+ /**
2376
+ * Pin the acting identity to the AUTHENTICATED CALLER (#3800).
2377
+ *
2378
+ * Every public entrypoint accepts an `actorId`, and the REST routes fill it
2379
+ * from `body.actorId ?? body.actor_id ?? context.userId` — so before this
2380
+ * gate the body won. The authorization checks downstream all read that value
2381
+ * (`pending_approvers.includes(input.actorId)`, `submitter_id === actorId`),
2382
+ * which made the body-supplied string not merely the audit label but the key
2383
+ * that opens the door: any authenticated user could name a pending approver
2384
+ * and have that approver's decision recorded and the owning flow resumed.
2385
+ * #3783 drew this line for the data-write identity ({@link actingUserId});
2386
+ * this closes the authorization half.
2387
+ *
2388
+ * A caller may still name an identity OTHER than their bare user id, because
2389
+ * a slot legitimately can be keyed by one: `resolveApproverSpec` stores the
2390
+ * `type:value` literal when a graph lookup yields nothing, and an author may
2391
+ * write an email as a `user` approver. So the rule is not "actorId must equal
2392
+ * userId" — it is **"actorId must be an identity the SERVER can prove belongs
2393
+ * to the caller"**. Anything else is `FORBIDDEN`.
2394
+ *
2395
+ * A system context is exempt and keeps its explicit actor: the SLA sweep
2396
+ * passes the reserved {@link SLA_ACTOR_ID} sentinel, and the ADR-0043 action
2397
+ * link passes the approver its single-use token is cryptographically bound to
2398
+ * (having also put them on the context). Those are the only two callers that
2399
+ * hold a trustworthy actor with no session behind them.
2400
+ *
2401
+ * A caller with NO identity at all cannot act. Belt-and-suspenders: the REST
2402
+ * anonymous-deny now denies every anonymous request (#3963), but this service
2403
+ * must not rely on a caller upstream — an anonymous actor could otherwise
2404
+ * decide approvals outright by naming one.
2405
+ */
2406
+ async resolveActor(actorId, context) {
2407
+ if (context?.isSystem) {
2408
+ if (!actorId) throw new Error("VALIDATION_FAILED: actorId is required");
2409
+ return actorId;
2410
+ }
2411
+ const uid2 = actingUserId(context);
2412
+ if (!uid2) {
2413
+ throw new Error("FORBIDDEN: an approval action requires an authenticated caller");
2414
+ }
2415
+ if (!actorId || String(actorId) === uid2) return uid2;
2416
+ const named = String(actorId);
2417
+ for (const position of context.positions ?? []) {
2418
+ if (named === `position:${position}` || named === `role:${position}`) return named;
2419
+ }
2420
+ if (named.includes("@") && await this.callerHasEmail(uid2, named)) return named;
2421
+ throw new Error(
2422
+ `FORBIDDEN: cannot act as '${named}' \u2014 an approval action is recorded against the authenticated caller`
2423
+ );
2424
+ }
2425
+ /** Does `userId`'s own account carry `email`? (Slots keyed by email, #3800.) */
2426
+ async callerHasEmail(userId, email) {
2427
+ try {
2428
+ const rows = await this.engine.find("sys_user", {
2429
+ where: { id: userId },
2430
+ limit: 1,
2431
+ context: SYSTEM_CTX2
2432
+ });
2433
+ const row = Array.isArray(rows) ? rows[0] : null;
2434
+ return !!row?.email && String(row.email).toLowerCase() === email.toLowerCase();
2435
+ } catch {
2436
+ return false;
2437
+ }
2438
+ }
1621
2439
  /**
1622
2440
  * Expand the approvers on an Approval node into user IDs by querying the
1623
2441
  * graph tables for `team:` / `department:` / `position:` /
@@ -1652,7 +2470,7 @@ var _ApprovalService = class _ApprovalService {
1652
2470
  * `opts.substitutions` collector to record the hops for audit + notification.
1653
2471
  */
1654
2472
  async expandApprovers(step, record, organizationId, opts) {
1655
- var _a;
2473
+ var _a, _b, _c;
1656
2474
  if (!step || !Array.isArray(step.approvers)) return [];
1657
2475
  const now = opts?.now ?? this.clock.now().getTime();
1658
2476
  const out = [];
@@ -1660,12 +2478,34 @@ var _ApprovalService = class _ApprovalService {
1660
2478
  for (let idx = 0; idx < specs.length; idx++) {
1661
2479
  const a = specs[idx];
1662
2480
  if (!a) continue;
1663
- const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
1664
2481
  const groupKey = a.group != null && String(a.group) !== "" ? String(a.group) : `#${idx}`;
2482
+ if (canonicalApproverType(String(a.type)) === "expression") {
2483
+ const resolved = await this.resolveExpressionApprovers(
2484
+ a,
2485
+ record,
2486
+ organizationId,
2487
+ now,
2488
+ opts?.substitutions,
2489
+ opts?.exprCtx
2490
+ );
2491
+ if (opts?.resolvedFrom) opts.resolvedFrom[`expression#${idx}`] = resolved.raw;
2492
+ for (const entry of resolved.slots) {
2493
+ if (!entry.id) continue;
2494
+ out.push(entry.id);
2495
+ if (opts?.groups) {
2496
+ ((_a = opts.groups)[_b = entry.id] ?? (_a[_b] = [])).push(entry.subGroup ? `${groupKey}:${entry.subGroup}` : groupKey);
2497
+ }
2498
+ }
2499
+ continue;
2500
+ }
2501
+ if (opts?.resolvedFrom && canonicalApproverType(String(a.type)) === "field" && a.value != null) {
2502
+ opts.resolvedFrom[`field:${a.value}`] = record?.[a.value] ?? null;
2503
+ }
2504
+ const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
1665
2505
  for (const u of ids) {
1666
2506
  if (!u) continue;
1667
2507
  out.push(u);
1668
- if (opts?.groups) ((_a = opts.groups)[u] ?? (_a[u] = [])).push(groupKey);
2508
+ if (opts?.groups) ((_c = opts.groups)[u] ?? (_c[u] = [])).push(groupKey);
1669
2509
  }
1670
2510
  }
1671
2511
  return out.filter(Boolean);
@@ -1686,24 +2526,35 @@ var _ApprovalService = class _ApprovalService {
1686
2526
  { deprecated: a.type, canonical: type }
1687
2527
  );
1688
2528
  }
2529
+ const directoryOrg = await this.directoryOrgFor(a, organizationId);
2530
+ const crossOrg = directoryOrg !== organizationId;
1689
2531
  if (type === "user") {
1690
2532
  return this.applyOooDelegation(String(a.value), now, organizationId, substitutions);
1691
2533
  }
1692
2534
  if (type === "field" && record) {
1693
- return this.applyOooDelegation(String(record[a.value] ?? ""), now, organizationId, substitutions);
2535
+ const out = [];
2536
+ for (const id of csvSplit(record[a.value])) {
2537
+ out.push(...await this.applyOooDelegation(id, now, organizationId, substitutions));
2538
+ }
2539
+ return out;
1694
2540
  }
2541
+ const bounded = async (users) => crossOrg ? filterApproversWhoCanRead(this.orgScopeDeps, users, organizationId, {
2542
+ approverType: type,
2543
+ value: a.value != null ? String(a.value) : void 0,
2544
+ directoryOrgId: directoryOrg
2545
+ }) : users;
1695
2546
  try {
1696
2547
  if (type === "team") {
1697
2548
  const users = await this.expandTeamUsers(String(a.value));
1698
2549
  if (users.length) return users;
1699
2550
  } else if (type === "department" || type === "business_unit" || type === "bu") {
1700
- const users = await this.expandBusinessUnitUsers(String(a.value), organizationId);
2551
+ const users = await bounded(await this.expandBusinessUnitUsers(String(a.value), directoryOrg));
1701
2552
  if (users.length) return users;
1702
2553
  } else if (type === "position") {
1703
- const users = await this.expandPositionUsers(String(a.value), organizationId);
2554
+ const users = await bounded(await this.expandPositionUsers(String(a.value), directoryOrg));
1704
2555
  if (users.length) return users;
1705
2556
  } else if (type === "org_membership_level") {
1706
- const users = await this.expandMembershipTierUsers(String(a.value), organizationId);
2557
+ const users = await bounded(await this.expandMembershipTierUsers(String(a.value), directoryOrg));
1707
2558
  if (users.length) return users;
1708
2559
  } else if (type === "manager" && record) {
1709
2560
  const subject = record[a.value] ?? record.owner_id;
@@ -1714,33 +2565,171 @@ var _ApprovalService = class _ApprovalService {
1714
2565
  }
1715
2566
  } catch {
1716
2567
  }
2568
+ if (type === "queue") {
2569
+ this.logger?.warn?.(
2570
+ `[approvals] approver type 'queue' is not implemented \u2014 the slot resolves to nobody (#3508)`,
2571
+ { value: a.value }
2572
+ );
2573
+ } else if (GRAPH_APPROVER_TYPES.has(type)) {
2574
+ this.logger?.warn?.(
2575
+ `[approvals] approver '${type}:${a.value}' expanded to nobody \u2014 the slot routes to no one and the request cannot advance until someone is added or the approver is re-pointed (#3807)`,
2576
+ { type, value: a.value, organizationId: organizationId ?? null }
2577
+ );
2578
+ }
1717
2579
  return [`${a.type}:${a.value}`];
1718
2580
  }
2581
+ /**
2582
+ * Resolve an `expression` approver (#3447 P2): evaluate its CEL source at
2583
+ * node entry against the three explicit roots — `current` (live record),
2584
+ * `trigger` (submit snapshot), `vars` (flow variables) — then expand the
2585
+ * result into people per `resolveAs`.
2586
+ *
2587
+ * Every failure here THROWS (config/parse errors as `VALIDATION_FAILED`,
2588
+ * evaluation faults as `EXPRESSION_FAILED`) so the approval node fails
2589
+ * loudly instead of opening a request routed to nobody — an approver
2590
+ * expression that cannot run is a routing bug, never "condition not met".
2591
+ * Error messages carry the correct spelling because their primary reader is
2592
+ * the AI author fixing the flow on the next validate pass.
2593
+ *
2594
+ * Returns `slots` (approver id + optional per_group sub-key) and `raw` (the
2595
+ * expression's own values, pre-expansion) for the `__resolvedFrom` audit.
2596
+ */
2597
+ async resolveExpressionApprovers(a, liveRecord, organizationId, now, substitutions, exprCtx) {
2598
+ const source = String(a.value ?? "").trim();
2599
+ if (!source) {
2600
+ throw new Error("VALIDATION_FAILED: expression approver has an empty expression");
2601
+ }
2602
+ const parsed = collectCelRootIdentifiers(source);
2603
+ if (!parsed.ok) {
2604
+ throw new Error(`VALIDATION_FAILED: expression approver does not parse: ${parsed.error} \u2014 source: \`${source}\``);
2605
+ }
2606
+ const illegal = parsed.roots.filter((r) => !APPROVER_EXPRESSION_ROOTS.has(r));
2607
+ if (illegal.length) {
2608
+ const hint = illegal.includes("record") || illegal.includes("previous") ? `\`record\`/\`previous\` are not bound here \u2014 write \`current.<field>\` for the record's live state at node entry, or \`trigger.<field>\` for the submit-time snapshot (\`vars.previous\` carries the pre-update row)` : `did you mean \`current.<field>\` (live record), \`trigger.<field>\` (submit snapshot), or \`vars.<name>\` (flow variable)?`;
2609
+ throw new Error(
2610
+ `VALIDATION_FAILED: expression approver references \`${illegal.join("`, `")}\` \u2014 only \`current.*\`, \`trigger.*\` and \`vars.*\` are available; ${hint}. Source: \`${source}\``
2611
+ );
2612
+ }
2613
+ const result = ExpressionEngine.evaluate(
2614
+ { dialect: "cel", source },
2615
+ { extra: { current: liveRecord ?? {}, trigger: exprCtx?.trigger ?? {}, vars: exprCtx?.vars ?? {} } }
2616
+ );
2617
+ if (!result.ok) {
2618
+ throw new Error(
2619
+ `EXPRESSION_FAILED: expression approver failed to evaluate (${result.error.kind}): ${result.error.message} \u2014 source: \`${source}\``
2620
+ );
2621
+ }
2622
+ const value = result.value;
2623
+ let raw;
2624
+ if (value == null || value === "") {
2625
+ raw = [];
2626
+ } else if (typeof value === "string") {
2627
+ raw = csvSplit(value);
2628
+ } else if (Array.isArray(value)) {
2629
+ const bad = value.find((v) => v != null && typeof v !== "string" && typeof v !== "number");
2630
+ if (bad !== void 0) {
2631
+ throw new Error(
2632
+ `EXPRESSION_FAILED: expression approver must yield ids (string / CSV / string array), got an array containing ${typeof bad} \u2014 source: \`${source}\``
2633
+ );
2634
+ }
2635
+ raw = value.map((v) => String(v ?? "").trim()).filter(Boolean);
2636
+ } else {
2637
+ throw new Error(
2638
+ `EXPRESSION_FAILED: expression approver must yield ids (string / CSV / string array), got ${typeof value} \u2014 source: \`${source}\``
2639
+ );
2640
+ }
2641
+ const resolveAs = String(a.resolveAs ?? "user");
2642
+ if (resolveAs === "user") {
2643
+ const slots2 = [];
2644
+ for (const id of raw) {
2645
+ for (const routed of await this.applyOooDelegation(id, now, organizationId, substitutions)) {
2646
+ slots2.push({ id: routed });
2647
+ }
2648
+ }
2649
+ return { slots: slots2, raw };
2650
+ }
2651
+ const directoryOrg = await this.directoryOrgFor(a, organizationId);
2652
+ const crossOrg = directoryOrg !== organizationId;
2653
+ const slots = [];
2654
+ for (const key of raw) {
2655
+ let users = [];
2656
+ try {
2657
+ if (resolveAs === "department") users = await this.expandBusinessUnitUsers(key, directoryOrg);
2658
+ else if (resolveAs === "position") users = await this.expandPositionUsers(key, directoryOrg);
2659
+ else if (resolveAs === "team") users = await this.expandTeamUsers(key);
2660
+ else {
2661
+ throw new Error(
2662
+ `VALIDATION_FAILED: expression approver has unknown resolveAs '${resolveAs}' \u2014 use 'user', 'department', 'position', or 'team'`
2663
+ );
2664
+ }
2665
+ } catch (err) {
2666
+ if (String(err?.message ?? "").startsWith("VALIDATION_FAILED")) throw err;
2667
+ users = [];
2668
+ }
2669
+ if (crossOrg && users.length) {
2670
+ users = await filterApproversWhoCanRead(this.orgScopeDeps, users, organizationId, {
2671
+ approverType: resolveAs,
2672
+ value: key,
2673
+ directoryOrgId: directoryOrg
2674
+ });
2675
+ }
2676
+ if (!users.length) {
2677
+ slots.push({ id: `${resolveAs}:${key}`, subGroup: key });
2678
+ continue;
2679
+ }
2680
+ for (const u of users) slots.push({ id: u, subGroup: key });
2681
+ }
2682
+ return { slots, raw };
2683
+ }
1719
2684
  /** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
1720
2685
  async expandTeamUsers(teamId) {
1721
2686
  if (!teamId) return [];
1722
2687
  let rows = [];
1723
2688
  try {
1724
2689
  rows = await this.engine.find("sys_team_member", {
1725
- filter: { team_id: teamId },
2690
+ where: { team_id: teamId },
1726
2691
  fields: ["user_id"],
1727
2692
  limit: 1e4,
1728
- context: SYSTEM_CTX
2693
+ context: SYSTEM_CTX2
1729
2694
  });
1730
2695
  } catch {
1731
2696
  rows = [];
1732
2697
  }
1733
2698
  return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
1734
2699
  }
2700
+ /**
2701
+ * Tenant scope for a `sys_business_unit` read that may legitimately be
2702
+ * env-wide (#3807).
2703
+ *
2704
+ * `organization_id = null` on a platform object means "owned by no
2705
+ * organization" — a row written by a seed, the file layer, or bootstrap,
2706
+ * i.e. before (or outside) any org exists. A strict
2707
+ * `organization_id = <request org>` equality made every such row invisible:
2708
+ * the seed check below found nothing, the whole expansion returned `[]`, and
2709
+ * the approver fell back to the dead `department:<id>` literal that routes to
2710
+ * nobody. That is not an edge case — an app's org tree is normally seeded
2711
+ * (a seed cannot know the org id the runtime mints at boot) while the
2712
+ * approval request always carries one, so EVERY department approver a
2713
+ * designer could pick resolved to nobody.
2714
+ *
2715
+ * Widen to "this org ∪ env-wide", the same predicate `sys_metadata`'s
2716
+ * pending-draft listing settled on for the identical reason. Another org's
2717
+ * unit still fails the match, so the wall between two organizations is
2718
+ * unchanged — only rows belonging to no org at all become visible.
2719
+ */
2720
+ businessUnitOrgScope(filter, organizationId) {
2721
+ if (!organizationId) return filter;
2722
+ return { ...filter, $or: [{ organization_id: organizationId }, { organization_id: null }] };
2723
+ }
1735
2724
  /** Recursive department — walks `sys_business_unit.parent_business_unit_id`. */
1736
2725
  async expandBusinessUnitUsers(businessUnitId, organizationId) {
1737
2726
  if (!businessUnitId) return [];
1738
2727
  try {
1739
2728
  const seed = await this.engine.find("sys_business_unit", {
1740
- filter: organizationId ? { id: businessUnitId, organization_id: organizationId } : { id: businessUnitId },
2729
+ where: this.businessUnitOrgScope({ id: businessUnitId }, organizationId),
1741
2730
  fields: ["id", "active"],
1742
2731
  limit: 1,
1743
- context: SYSTEM_CTX
2732
+ context: SYSTEM_CTX2
1744
2733
  });
1745
2734
  const seedRow = Array.isArray(seed) ? seed[0] : null;
1746
2735
  if (!seedRow || seedRow.active === false) return [];
@@ -1753,9 +2742,11 @@ var _ApprovalService = class _ApprovalService {
1753
2742
  const parent = queue.shift();
1754
2743
  let kids = [];
1755
2744
  try {
1756
- const filter = { parent_business_unit_id: parent, active: { $ne: false } };
1757
- if (organizationId) filter.organization_id = organizationId;
1758
- kids = await this.engine.find("sys_business_unit", { filter, fields: ["id"], limit: 1e3, context: SYSTEM_CTX });
2745
+ const filter = this.businessUnitOrgScope(
2746
+ { parent_business_unit_id: parent, active: { $ne: false } },
2747
+ organizationId
2748
+ );
2749
+ kids = await this.engine.find("sys_business_unit", { filter, fields: ["id"], limit: 1e3, context: SYSTEM_CTX2 });
1759
2750
  } catch {
1760
2751
  kids = [];
1761
2752
  }
@@ -1770,10 +2761,10 @@ var _ApprovalService = class _ApprovalService {
1770
2761
  let rows = [];
1771
2762
  try {
1772
2763
  rows = await this.engine.find("sys_business_unit_member", {
1773
- filter: { business_unit_id: { $in: Array.from(seen) } },
2764
+ where: { business_unit_id: { $in: Array.from(seen) } },
1774
2765
  fields: ["user_id"],
1775
2766
  limit: 1e4,
1776
- context: SYSTEM_CTX
2767
+ context: SYSTEM_CTX2
1777
2768
  });
1778
2769
  } catch {
1779
2770
  rows = [];
@@ -1798,7 +2789,7 @@ var _ApprovalService = class _ApprovalService {
1798
2789
  filter,
1799
2790
  fields: ["user_id"],
1800
2791
  limit: 1e4,
1801
- context: SYSTEM_CTX
2792
+ context: SYSTEM_CTX2
1802
2793
  });
1803
2794
  for (const r of rows ?? []) {
1804
2795
  const uid2 = String(r.user_id ?? "");
@@ -1821,7 +2812,7 @@ var _ApprovalService = class _ApprovalService {
1821
2812
  if (organizationId) filter.organization_id = organizationId;
1822
2813
  let rows = [];
1823
2814
  try {
1824
- rows = await this.engine.find("sys_member", { filter, fields: ["user_id"], limit: 1e4, context: SYSTEM_CTX });
2815
+ rows = await this.engine.find("sys_member", { filter, fields: ["user_id"], limit: 1e4, context: SYSTEM_CTX2 });
1825
2816
  } catch {
1826
2817
  rows = [];
1827
2818
  }
@@ -1830,10 +2821,10 @@ var _ApprovalService = class _ApprovalService {
1830
2821
  async lookupManager(userId) {
1831
2822
  try {
1832
2823
  const rows = await this.engine.find("sys_user", {
1833
- filter: { id: userId },
2824
+ where: { id: userId },
1834
2825
  fields: ["id", "manager_id"],
1835
2826
  limit: 1,
1836
- context: SYSTEM_CTX
2827
+ context: SYSTEM_CTX2
1837
2828
  });
1838
2829
  const row = Array.isArray(rows) ? rows[0] : null;
1839
2830
  return row?.manager_id ? String(row.manager_id) : null;
@@ -1880,10 +2871,10 @@ var _ApprovalService = class _ApprovalService {
1880
2871
  let rows = [];
1881
2872
  try {
1882
2873
  rows = await this.engine.find("sys_approval_delegation", {
1883
- filter: { delegator_id: delegatorId },
2874
+ where: { delegator_id: delegatorId },
1884
2875
  fields: ["id", "delegator_id", "delegate_id", "valid_from", "valid_until", "reason", "organization_id"],
1885
2876
  limit: 50,
1886
- context: SYSTEM_CTX
2877
+ context: SYSTEM_CTX2
1887
2878
  });
1888
2879
  } catch {
1889
2880
  return null;
@@ -1897,14 +2888,86 @@ var _ApprovalService = class _ApprovalService {
1897
2888
  });
1898
2889
  return active[0];
1899
2890
  }
1900
- /** Mirror a request status onto a business-object field, if configured. */
1901
- async mirrorStatusField(object, recordId, field, status) {
2891
+ /**
2892
+ * Mirror a request status onto a business-object field, if configured.
2893
+ *
2894
+ * **Elevated, but not anonymous (#3783).** The write stays `isSystem`: the
2895
+ * record is normally LOCKED while its approval is live and the submitter
2896
+ * cannot edit it, so only a platform write can land the status — that is what
2897
+ * the lock hook's system exemption (`lifecycle-hooks.ts`) is for. What it must
2898
+ * NOT do is throw away *who* caused the transition. Every status below is
2899
+ * something a specific human just did — a submitter submitting or recalling,
2900
+ * an approver deciding or sending back — and this write is what fires the
2901
+ * target object's record-change flows. With no `userId` on it those cascades
2902
+ * inherit no trigger user, and since #3760 a `runAs:'user'` run with no trigger
2903
+ * user has its data ops REFUSED — so "when the invoice is approved, do X", the
2904
+ * most natural approvals automation there is, had to declare `runAs:'system'`
2905
+ * and take blanket elevation for a case where a perfectly good scoped identity
2906
+ * existed. Re-attaching the actor lets those cascades run as the deciding user
2907
+ * with RLS enforced. Same shape the approval node already uses when it calls
2908
+ * into this service (`approval-node.ts`).
2909
+ *
2910
+ * `actorId` is `null` for the genuinely machine-driven transitions (the SLA
2911
+ * escalation's auto-decision, the dead-run sweep). There is no human to name
2912
+ * there, and naming a sentinel would put a non-user in `updated_by` and in
2913
+ * every downstream flow's identity. Those cascades stay user-less — a flow
2914
+ * that wants to react to them still has to declare `runAs:'system'`, which is
2915
+ * the honest answer rather than an oversight.
2916
+ *
2917
+ * Deliberately carries `userId` ONLY, not the request's org. On an
2918
+ * ExecutionContext `tenantId` is a driver-scoping knob, not attribution
2919
+ * (`buildDriverOptions` turns it into a tenant predicate on the update), so
2920
+ * passing it would newly org-scope this write and silently no-op the mirror on
2921
+ * a record whose org differs from the request's — while buying nothing: the
2922
+ * automation engine back-fills the run's `tenantId` from the resolved user's
2923
+ * own grants.
2924
+ */
2925
+ async mirrorStatusField(object, recordId, field, status, actorId) {
1902
2926
  try {
1903
- await this.engine.update(object, { id: recordId, [field]: status }, { context: SYSTEM_CTX });
2927
+ const context = actorId ? { ...SYSTEM_CTX2, userId: actorId } : SYSTEM_CTX2;
2928
+ await this.engine.update(object, { id: recordId, [field]: status }, { context });
1904
2929
  } catch (err) {
1905
2930
  this.logger?.warn?.(`[approvals] mirrorStatusField failed: ${err?.message ?? err}`);
1906
2931
  }
1907
2932
  }
2933
+ /**
2934
+ * Re-read a business record's CURRENT state by id so approver resolution binds
2935
+ * to live data at node entry, not the trigger snapshot the flow froze into
2936
+ * `$record` at submit time (#3447). A `field` / `manager` approver names *who*
2937
+ * decides, and an earlier node — or the approver of an earlier step — may have
2938
+ * written that routing field after submit (e.g. a lead reviewer picking which
2939
+ * departments co-review). Graph approvers (team / position / …) already query
2940
+ * live; this brings the in-record types into line.
2941
+ *
2942
+ * Read under system identity: approver routing is a platform concern and the
2943
+ * record is the flow's own subject, so the submitter's RLS/FLS must not narrow
2944
+ * it. Degrades to `fallback` (the snapshot) when the record can't be re-read —
2945
+ * hard-deleted between submit and node entry, or an object whose backend can't
2946
+ * serve a point read — warning rather than throwing so a transient miss can't
2947
+ * wedge an approval. That "warn but proceed" stance matches the
2948
+ * no-concrete-approver guard (#3424) and the "record is gone" enrichment path
2949
+ * that already falls back to the payload snapshot.
2950
+ */
2951
+ async loadLiveRecord(object, recordId, fallback) {
2952
+ try {
2953
+ const rows = await this.engine.find(object, {
2954
+ where: { id: recordId },
2955
+ limit: 1,
2956
+ context: SYSTEM_CTX2
2957
+ });
2958
+ const live = Array.isArray(rows) ? rows[0] : rows;
2959
+ if (live) return live;
2960
+ this.logger?.warn?.(
2961
+ `[approvals] live record ${object}/${recordId} not found at node entry \u2014 resolving approvers against the trigger snapshot (#3447 fallback).`,
2962
+ { object, recordId }
2963
+ );
2964
+ } catch (err) {
2965
+ this.logger?.warn?.(
2966
+ `[approvals] live record re-read failed for ${object}/${recordId}: ${err?.message ?? err} \u2014 resolving approvers against the trigger snapshot (#3447 fallback).`
2967
+ );
2968
+ }
2969
+ return fallback ?? {};
2970
+ }
1908
2971
  // ── ADR-0019: Approval-as-flow-node ──────────────────────────
1909
2972
  //
1910
2973
  // A flow's Approval node opens a request via `openNodeRequest` (carrying its
@@ -1916,6 +2979,10 @@ var _ApprovalService = class _ApprovalService {
1916
2979
  * Open a pending approval request on behalf of a flow's Approval node. The
1917
2980
  * node config (approvers / behavior / status field) is snapshotted on the row
1918
2981
  * so a decision can be made without any process to resolve against.
2982
+ *
2983
+ * #3447 P2: may instead return an {@link ApprovalNodeAutoOutcome} — no
2984
+ * request opened — when the slate resolves empty and the node's
2985
+ * `onEmptyApprovers` policy is `auto_approve`.
1919
2986
  */
1920
2987
  async openNodeRequest(input, context) {
1921
2988
  if (!input.object) throw new Error("VALIDATION_FAILED: object is required");
@@ -1924,7 +2991,7 @@ var _ApprovalService = class _ApprovalService {
1924
2991
  const existing = await this.engine.find("sys_approval_request", {
1925
2992
  where: { object_name: input.object, record_id: input.recordId, status: "pending" },
1926
2993
  limit: 1,
1927
- context: SYSTEM_CTX
2994
+ context: SYSTEM_CTX2
1928
2995
  });
1929
2996
  if (Array.isArray(existing) && existing[0]) {
1930
2997
  throw new Error(`DUPLICATE_REQUEST: a pending approval already exists for ${input.object}/${input.recordId}`);
@@ -1933,26 +3000,56 @@ var _ApprovalService = class _ApprovalService {
1933
3000
  const nowDate = this.clock.now();
1934
3001
  const substitutions = [];
1935
3002
  const groups = {};
3003
+ const liveRecord = await this.loadLiveRecord(input.object, input.recordId, input.record);
3004
+ const resolvedFrom = {};
1936
3005
  const approvers = await this.expandApprovers(
1937
3006
  { approvers: input.config.approvers },
1938
- input.record,
3007
+ liveRecord,
1939
3008
  ctxOrg,
1940
- { now: nowDate.getTime(), substitutions, groups }
3009
+ {
3010
+ now: nowDate.getTime(),
3011
+ substitutions,
3012
+ groups,
3013
+ exprCtx: { trigger: input.record ?? null, vars: input.variables ?? null },
3014
+ resolvedFrom
3015
+ }
1941
3016
  );
3017
+ if (!approvers.some((a) => a && !a.includes(":"))) {
3018
+ const emptyPolicy = input.config.onEmptyApprovers ?? "admin_rescue";
3019
+ if (emptyPolicy === "fail") {
3020
+ throw new Error(
3021
+ `NO_APPROVERS: approval node '${input.nodeId}' on ${input.object}/${input.recordId} resolved to no concrete approver and its onEmptyApprovers policy is 'fail'. Check that the approver target(s) are staffed / the routing field or expression yields user ids at node entry.`
3022
+ );
3023
+ }
3024
+ if (emptyPolicy === "auto_approve") {
3025
+ this.logger?.warn?.(
3026
+ `[approvals] approval node '${input.nodeId}' on ${input.object}/${input.recordId} resolved to no concrete approver \u2014 auto-approving per onEmptyApprovers: 'auto_approve' (no request opened).`,
3027
+ { object: input.object, recordId: input.recordId, node: input.nodeId, resolved: approvers }
3028
+ );
3029
+ return { autoApproved: true, reason: "empty_approvers" };
3030
+ }
3031
+ this.logger?.warn?.(
3032
+ `[approvals] approval node '${input.nodeId}' on ${input.object}/${input.recordId} resolved to no concrete approver \u2014 the request is decidable only by a privileged admin. Check that the approver target(s) are staffed.`,
3033
+ { object: input.object, recordId: input.recordId, node: input.nodeId, resolved: approvers }
3034
+ );
3035
+ }
1942
3036
  const now = nowDate.toISOString();
1943
3037
  const id = uid("areq");
1944
3038
  const processName = `flow:${input.flowName ?? input.nodeId}`;
1945
3039
  const configSnapshot = { ...input.config };
1946
3040
  if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
1947
3041
  if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
1948
- if (input.config.behavior === "quorum" || input.config.behavior === "per_group") {
3042
+ if (input.config.behavior && input.config.behavior !== "first_response") {
1949
3043
  configSnapshot.__approverGroups = groups;
1950
3044
  }
3045
+ if (Object.keys(resolvedFrom).length) {
3046
+ configSnapshot.__resolvedFrom = resolvedFrom;
3047
+ }
1951
3048
  try {
1952
3049
  const prior = await this.engine.find("sys_approval_request", {
1953
3050
  where: { flow_run_id: input.runId, flow_node_id: input.nodeId },
1954
3051
  limit: 500,
1955
- context: SYSTEM_CTX
3052
+ context: SYSTEM_CTX2
1956
3053
  });
1957
3054
  const n = Array.isArray(prior) ? prior.length : 0;
1958
3055
  if (n > 0) configSnapshot.__round = n + 1;
@@ -1976,7 +3073,7 @@ var _ApprovalService = class _ApprovalService {
1976
3073
  created_at: now,
1977
3074
  updated_at: now
1978
3075
  };
1979
- await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX });
3076
+ await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX2 });
1980
3077
  await this.syncApproverIndex(id, approvers, ctxOrg, now);
1981
3078
  await this.engine.insert("sys_approval_action", {
1982
3079
  id: uid("aact"),
@@ -1988,7 +3085,7 @@ var _ApprovalService = class _ApprovalService {
1988
3085
  actor_id: input.submitterId ?? context.userId ?? null,
1989
3086
  comment: null,
1990
3087
  created_at: now
1991
- }, { context: SYSTEM_CTX });
3088
+ }, { context: SYSTEM_CTX2 });
1992
3089
  for (const sub of substitutions) {
1993
3090
  await this.engine.insert("sys_approval_action", {
1994
3091
  id: uid("aact"),
@@ -2000,7 +3097,7 @@ var _ApprovalService = class _ApprovalService {
2000
3097
  actor_id: null,
2001
3098
  comment: `${sub.from} \u2192 ${sub.to}${sub.reason ? ` \u2014 ${sub.reason}` : ""}`,
2002
3099
  created_at: now
2003
- }, { context: SYSTEM_CTX });
3100
+ }, { context: SYSTEM_CTX2 });
2004
3101
  await this.notify({
2005
3102
  topic: "approval.ooo_substituted",
2006
3103
  audience: [sub.to],
@@ -2025,7 +3122,13 @@ var _ApprovalService = class _ApprovalService {
2025
3122
  });
2026
3123
  }
2027
3124
  if (input.config.approvalStatusField) {
2028
- await this.mirrorStatusField(input.object, input.recordId, input.config.approvalStatusField, "pending");
3125
+ await this.mirrorStatusField(
3126
+ input.object,
3127
+ input.recordId,
3128
+ input.config.approvalStatusField,
3129
+ "pending",
3130
+ row.submitter_id ?? null
3131
+ );
2029
3132
  }
2030
3133
  return rowFromRequest(row);
2031
3134
  }
@@ -2069,27 +3172,61 @@ var _ApprovalService = class _ApprovalService {
2069
3172
  */
2070
3173
  async decideNode(requestId, input, context) {
2071
3174
  if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
2072
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3175
+ const actorId = await this.resolveActor(input?.actorId, context);
2073
3176
  if (input.decision !== "approve" && input.decision !== "reject") {
2074
3177
  throw new Error("VALIDATION_FAILED: decision must be approve|reject");
2075
3178
  }
2076
3179
  const rawRows = await this.engine.find("sys_approval_request", {
2077
3180
  where: { id: requestId },
2078
3181
  limit: 1,
2079
- context: SYSTEM_CTX
3182
+ context: SYSTEM_CTX2
2080
3183
  });
2081
3184
  const raw = Array.isArray(rawRows) ? rawRows[0] : null;
2082
3185
  if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
2083
3186
  if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
2084
3187
  const pendingApprovers = csvSplit(raw.pending_approvers);
2085
- if (!context.isSystem && !pendingApprovers.includes(input.actorId)) {
2086
- throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
3188
+ const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
3189
+ const isSlotHolder = pendingApprovers.includes(actorId);
3190
+ if (!isSlotHolder && !isOverride) {
3191
+ throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
2087
3192
  }
2088
3193
  const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
2089
3194
  const org = raw.organization_id ?? null;
2090
3195
  const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
2091
3196
  const runId = raw.flow_run_id ?? null;
2092
3197
  const now = this.clock.now().toISOString();
3198
+ const outputKeys = input.outputs ? Object.keys(input.outputs) : [];
3199
+ let acceptedOutputs;
3200
+ const declaredDefs = normalizeDecisionOutputs(config.decisionOutputs);
3201
+ if (outputKeys.length) {
3202
+ const declared = declaredDefs.map((d) => d.key);
3203
+ if (!declared.length) {
3204
+ throw new Error(
3205
+ `VALIDATION_FAILED: this approval node declares no decisionOutputs \u2014 outputs are not accepted. Declare the keys on the node config (decisionOutputs: [${outputKeys.map((k) => `'${k}'`).join(", ")}]) to let approvers hand them to the flow.`
3206
+ );
3207
+ }
3208
+ const reserved = outputKeys.filter((k) => k === "decision" || k === "requestId");
3209
+ if (reserved.length) {
3210
+ throw new Error(
3211
+ `VALIDATION_FAILED: decision output key(s) \`${reserved.join("`, `")}\` are reserved by the resume envelope \u2014 pick different names.`
3212
+ );
3213
+ }
3214
+ const undeclared = outputKeys.filter((k) => !declared.includes(k));
3215
+ if (undeclared.length) {
3216
+ throw new Error(
3217
+ `VALIDATION_FAILED: decision output key(s) \`${undeclared.join("`, `")}\` are not declared on this node \u2014 declared keys: ${declared.map((k) => `'${k}'`).join(", ") || "(none)"}.`
3218
+ );
3219
+ }
3220
+ acceptedOutputs = { ...input.outputs };
3221
+ }
3222
+ if (input.decision === "approve") {
3223
+ const missing = declaredDefs.filter((d) => d.required === true && isBlankDecisionOutput(input.outputs?.[d.key])).map((d) => d.key);
3224
+ if (missing.length) {
3225
+ throw new Error(
3226
+ `VALIDATION_FAILED: decision output(s) \`${missing.join("`, `")}\` are required to approve this request \u2014 open the approval and fill them in before approving.`
3227
+ );
3228
+ }
3229
+ }
2093
3230
  await this.engine.insert("sys_approval_action", {
2094
3231
  id: uid("aact"),
2095
3232
  request_id: requestId,
@@ -2097,23 +3234,23 @@ var _ApprovalService = class _ApprovalService {
2097
3234
  step_name: nodeId,
2098
3235
  step_index: 0,
2099
3236
  action: input.decision,
2100
- actor_id: input.actorId,
3237
+ actor_id: actorId,
2101
3238
  comment: input.comment ?? null,
2102
3239
  attachments: input.attachments?.length ? input.attachments : null,
2103
3240
  created_at: now
2104
- }, { context: SYSTEM_CTX });
3241
+ }, { context: SYSTEM_CTX2 });
2105
3242
  const behavior = config.behavior ?? "first_response";
2106
- if (input.decision === "approve" && behavior !== "first_response") {
3243
+ if (input.decision === "approve" && behavior !== "first_response" && isSlotHolder) {
2107
3244
  const acts = await this.engine.find("sys_approval_action", {
2108
3245
  where: { request_id: requestId, step_index: 0, action: "approve" },
2109
3246
  limit: 1e3,
2110
- context: SYSTEM_CTX
3247
+ context: SYSTEM_CTX2
2111
3248
  });
2112
3249
  const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
2113
3250
  const snapshotGroups = config.__approverGroups;
2114
3251
  let original;
2115
3252
  let groupMap;
2116
- if (snapshotGroups && (behavior === "quorum" || behavior === "per_group")) {
3253
+ if (snapshotGroups) {
2117
3254
  groupMap = snapshotGroups;
2118
3255
  original = Object.keys(snapshotGroups);
2119
3256
  } else {
@@ -2129,27 +3266,65 @@ var _ApprovalService = class _ApprovalService {
2129
3266
  await this.engine.update("sys_approval_request", {
2130
3267
  id: requestId,
2131
3268
  pending_approvers: stillPending.join(","),
2132
- updated_at: now
2133
- }, { context: SYSTEM_CTX });
3269
+ updated_at: now,
3270
+ // #3447 P2: a mid-tally approval may carry outputs too (unanimous /
3271
+ // per_group co-sign, each approver contributing their declared keys)
3272
+ // — accumulate them on the snapshot so the FINALIZING decision hands
3273
+ // the merged set to the flow.
3274
+ ...acceptedOutputs ? {
3275
+ node_config_json: JSON.stringify({
3276
+ ...config,
3277
+ __decisionOutputs: { ...config.__decisionOutputs ?? {}, ...acceptedOutputs }
3278
+ })
3279
+ } : {}
3280
+ }, { context: SYSTEM_CTX2 });
2134
3281
  await this.syncApproverIndex(requestId, stillPending, org, now);
2135
- const fresh2 = await this.getRequest(requestId, context);
3282
+ const fresh2 = await this.readBackRequest(requestId, context);
2136
3283
  return { request: fresh2, runId, nodeId, finalized: false, decision: input.decision };
2137
3284
  }
2138
3285
  }
2139
3286
  const finalStatus = input.decision === "approve" ? "approved" : "rejected";
3287
+ const mergedOutputs = acceptedOutputs || config.__decisionOutputs ? { ...config.__decisionOutputs ?? {}, ...acceptedOutputs ?? {} } : void 0;
2140
3288
  await this.engine.update("sys_approval_request", {
2141
3289
  id: requestId,
2142
3290
  status: finalStatus,
2143
3291
  pending_approvers: null,
2144
3292
  completed_at: now,
2145
- updated_at: now
2146
- }, { context: SYSTEM_CTX });
3293
+ updated_at: now,
3294
+ ...mergedOutputs ? {
3295
+ node_config_json: JSON.stringify({ ...config, __decisionOutputs: mergedOutputs })
3296
+ } : {}
3297
+ }, { context: SYSTEM_CTX2 });
2147
3298
  await this.syncApproverIndex(requestId, [], org, now);
2148
3299
  if (config.approvalStatusField) {
2149
- await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, finalStatus);
3300
+ await this.mirrorStatusField(
3301
+ raw.object_name,
3302
+ raw.record_id,
3303
+ config.approvalStatusField,
3304
+ finalStatus,
3305
+ actingUserId(context)
3306
+ );
2150
3307
  }
2151
- const fresh = await this.getRequest(requestId, context);
2152
- return { request: fresh, runId, nodeId, finalized: true, decision: input.decision };
3308
+ const fresh = await this.readBackRequest(requestId, context);
3309
+ return { request: fresh, runId, nodeId, finalized: true, decision: input.decision, outputs: mergedOutputs };
3310
+ }
3311
+ /**
3312
+ * Continue the owning flow run after an outcome this service has already
3313
+ * authorized and written down (#3801).
3314
+ *
3315
+ * The `approval` node declares `resumeAuthority: 'service'`, so the engine
3316
+ * refuses any resume of an approval suspension that does not carry
3317
+ * {@link RESUME_AUTHORITY_SERVICE}. Every approvals-side resume goes through
3318
+ * here so the marker is stamped in ONE place — a new outcome path cannot
3319
+ * quietly ship a resume that the gate then rejects at runtime, and nothing
3320
+ * in this file hands the marker to a caller-supplied signal.
3321
+ *
3322
+ * Callers still guard on `typeof this.automation?.resume === 'function'`
3323
+ * (approvals runs fine with no automation attached) and keep their own
3324
+ * try/catch, because what a failed resume means differs per path.
3325
+ */
3326
+ async serviceResume(runId, signal) {
3327
+ await this.automation.resume(runId, { ...signal, [RESUME_AUTHORITY_SERVICE]: true });
2153
3328
  }
2154
3329
  /**
2155
3330
  * Public contract entrypoint (ADR-0019). Records a decision on a node-driven
@@ -2162,9 +3337,14 @@ var _ApprovalService = class _ApprovalService {
2162
3337
  if (result.finalized && result.runId && typeof this.automation?.resume === "function") {
2163
3338
  const branchLabel = result.decision === "approve" ? APPROVAL_BRANCH_LABELS.approve : APPROVAL_BRANCH_LABELS.reject;
2164
3339
  try {
2165
- await this.automation.resume(result.runId, {
3340
+ await this.serviceResume(result.runId, {
2166
3341
  branchLabel,
2167
- output: { decision: result.decision, requestId }
3342
+ // #3447 P2: accepted decision outputs ride the resume envelope and
3343
+ // land as `<nodeId>.<key>` flow variables — a later approval node's
3344
+ // `expression` approver reads them as `vars.<nodeId>.<key>`.
3345
+ // Reserved keys are spread LAST so no output can shadow them (the
3346
+ // whitelist already rejects them; this is defense in depth).
3347
+ output: { ...result.outputs ?? {}, decision: result.decision, requestId }
2168
3348
  });
2169
3349
  resumed = true;
2170
3350
  } catch (err) {
@@ -2198,11 +3378,11 @@ var _ApprovalService = class _ApprovalService {
2198
3378
  */
2199
3379
  async recall(requestId, input, context) {
2200
3380
  if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
2201
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3381
+ const actorId = await this.resolveActor(input?.actorId, context);
2202
3382
  const rawRows = await this.engine.find("sys_approval_request", {
2203
3383
  where: { id: requestId },
2204
3384
  limit: 1,
2205
- context: SYSTEM_CTX
3385
+ context: SYSTEM_CTX2
2206
3386
  });
2207
3387
  const raw = Array.isArray(rawRows) ? rawRows[0] : null;
2208
3388
  if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
@@ -2210,7 +3390,7 @@ var _ApprovalService = class _ApprovalService {
2210
3390
  if (raw.status !== "pending" && !inReviseWindow) {
2211
3391
  throw new Error(`INVALID_STATE: request is ${raw.status}`);
2212
3392
  }
2213
- if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
3393
+ if (!this.isOverrideActor(context, raw.organization_id ?? null) && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
2214
3394
  throw new Error(`FORBIDDEN: only the submitter may recall this request`);
2215
3395
  }
2216
3396
  if (inReviseWindow) await this.assertLatestForRun(raw);
@@ -2226,20 +3406,26 @@ var _ApprovalService = class _ApprovalService {
2226
3406
  step_name: nodeId,
2227
3407
  step_index: 0,
2228
3408
  action: "recall",
2229
- actor_id: input.actorId,
3409
+ actor_id: actorId,
2230
3410
  comment: input.comment ?? null,
2231
3411
  created_at: now
2232
- }, { context: SYSTEM_CTX });
3412
+ }, { context: SYSTEM_CTX2 });
2233
3413
  await this.engine.update("sys_approval_request", {
2234
3414
  id: requestId,
2235
3415
  status: "recalled",
2236
3416
  pending_approvers: null,
2237
3417
  completed_at: now,
2238
3418
  updated_at: now
2239
- }, { context: SYSTEM_CTX });
3419
+ }, { context: SYSTEM_CTX2 });
2240
3420
  await this.syncApproverIndex(requestId, [], org, now);
2241
3421
  if (config.approvalStatusField) {
2242
- await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, "recalled");
3422
+ await this.mirrorStatusField(
3423
+ raw.object_name,
3424
+ raw.record_id,
3425
+ config.approvalStatusField,
3426
+ "recalled",
3427
+ actingUserId(context)
3428
+ );
2243
3429
  }
2244
3430
  let resumed = false;
2245
3431
  if (inReviseWindow) {
@@ -2256,7 +3442,7 @@ var _ApprovalService = class _ApprovalService {
2256
3442
  }
2257
3443
  } else if (runId && typeof this.automation?.resume === "function") {
2258
3444
  try {
2259
- await this.automation.resume(runId, {
3445
+ await this.serviceResume(runId, {
2260
3446
  branchLabel: APPROVAL_BRANCH_LABELS.reject,
2261
3447
  output: { decision: "recall", requestId }
2262
3448
  });
@@ -2269,7 +3455,7 @@ var _ApprovalService = class _ApprovalService {
2269
3455
  });
2270
3456
  }
2271
3457
  }
2272
- const fresh = await this.getRequest(requestId, context);
3458
+ const fresh = await this.readBackRequest(requestId, context);
2273
3459
  return { request: fresh, runId, resumed };
2274
3460
  }
2275
3461
  // ── Send back for revision / resubmit (ADR-0044) ─────────────
@@ -2287,11 +3473,11 @@ var _ApprovalService = class _ApprovalService {
2287
3473
  * `output.autoRejected = true`) so instances cannot orbit forever.
2288
3474
  */
2289
3475
  async sendBack(requestId, input, context) {
2290
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3476
+ const actorId = await this.resolveActor(input?.actorId, context);
2291
3477
  const raw = await this.loadPendingRow(requestId);
2292
3478
  const pending = csvSplit(raw.pending_approvers);
2293
- if (!context.isSystem && !pending.includes(input.actorId)) {
2294
- throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
3479
+ if (!context.isSystem && !pending.includes(actorId)) {
3480
+ throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
2295
3481
  }
2296
3482
  const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
2297
3483
  const org = raw.organization_id ?? null;
@@ -2305,7 +3491,7 @@ var _ApprovalService = class _ApprovalService {
2305
3491
  const siblings = await this.engine.find("sys_approval_request", {
2306
3492
  where: { flow_run_id: runId, flow_node_id: nodeId, status: "returned" },
2307
3493
  limit: 500,
2308
- context: SYSTEM_CTX
3494
+ context: SYSTEM_CTX2
2309
3495
  });
2310
3496
  priorSendBacks = Array.isArray(siblings) ? siblings.length : 0;
2311
3497
  }
@@ -2316,10 +3502,10 @@ var _ApprovalService = class _ApprovalService {
2316
3502
  step_name: nodeId,
2317
3503
  step_index: 0,
2318
3504
  action: "revise",
2319
- actor_id: input.actorId,
3505
+ actor_id: actorId,
2320
3506
  comment: input.comment ?? null,
2321
3507
  created_at: now
2322
- }, { context: SYSTEM_CTX });
3508
+ }, { context: SYSTEM_CTX2 });
2323
3509
  if (priorSendBacks >= maxRevisions) {
2324
3510
  await this.engine.insert("sys_approval_action", {
2325
3511
  id: uid("aact"),
@@ -2328,25 +3514,31 @@ var _ApprovalService = class _ApprovalService {
2328
3514
  step_name: nodeId,
2329
3515
  step_index: 0,
2330
3516
  action: "reject",
2331
- actor_id: input.actorId,
3517
+ actor_id: actorId,
2332
3518
  comment: `Auto-rejected: revision limit (${maxRevisions}) exceeded`,
2333
3519
  created_at: now
2334
- }, { context: SYSTEM_CTX });
3520
+ }, { context: SYSTEM_CTX2 });
2335
3521
  await this.engine.update("sys_approval_request", {
2336
3522
  id: requestId,
2337
3523
  status: "rejected",
2338
3524
  pending_approvers: null,
2339
3525
  completed_at: now,
2340
3526
  updated_at: now
2341
- }, { context: SYSTEM_CTX });
3527
+ }, { context: SYSTEM_CTX2 });
2342
3528
  await this.syncApproverIndex(requestId, [], org, now);
2343
3529
  if (config.approvalStatusField) {
2344
- await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, "rejected");
3530
+ await this.mirrorStatusField(
3531
+ raw.object_name,
3532
+ raw.record_id,
3533
+ config.approvalStatusField,
3534
+ "rejected",
3535
+ actingUserId(context)
3536
+ );
2345
3537
  }
2346
3538
  let resumed2 = false;
2347
3539
  if (runId && typeof this.automation?.resume === "function") {
2348
3540
  try {
2349
- await this.automation.resume(runId, {
3541
+ await this.serviceResume(runId, {
2350
3542
  branchLabel: APPROVAL_BRANCH_LABELS.reject,
2351
3543
  output: { decision: "reject", autoRejected: true, requestId }
2352
3544
  });
@@ -2363,7 +3555,7 @@ var _ApprovalService = class _ApprovalService {
2363
3555
  await this.notify({
2364
3556
  topic: "approval.returned",
2365
3557
  audience: [String(raw.submitter_id)],
2366
- actorId: input.actorId,
3558
+ actorId,
2367
3559
  source: { object: "sys_approval_request", id: requestId },
2368
3560
  payload: {
2369
3561
  title: "Approval auto-rejected",
@@ -2372,7 +3564,7 @@ var _ApprovalService = class _ApprovalService {
2372
3564
  }
2373
3565
  });
2374
3566
  }
2375
- const fresh2 = await this.getRequest(requestId, context);
3567
+ const fresh2 = await this.readBackRequest(requestId, context);
2376
3568
  return { request: fresh2, runId, resumed: resumed2, autoRejected: true };
2377
3569
  }
2378
3570
  await this.engine.update("sys_approval_request", {
@@ -2381,15 +3573,21 @@ var _ApprovalService = class _ApprovalService {
2381
3573
  pending_approvers: null,
2382
3574
  completed_at: now,
2383
3575
  updated_at: now
2384
- }, { context: SYSTEM_CTX });
3576
+ }, { context: SYSTEM_CTX2 });
2385
3577
  await this.syncApproverIndex(requestId, [], org, now);
2386
3578
  if (config.approvalStatusField) {
2387
- await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, "returned");
3579
+ await this.mirrorStatusField(
3580
+ raw.object_name,
3581
+ raw.record_id,
3582
+ config.approvalStatusField,
3583
+ "returned",
3584
+ actingUserId(context)
3585
+ );
2388
3586
  }
2389
3587
  let resumed = false;
2390
3588
  if (runId && typeof this.automation?.resume === "function") {
2391
3589
  try {
2392
- await this.automation.resume(runId, {
3590
+ await this.serviceResume(runId, {
2393
3591
  branchLabel: APPROVAL_BRANCH_LABELS.revise,
2394
3592
  output: { decision: "revise", requestId }
2395
3593
  });
@@ -2406,7 +3604,7 @@ var _ApprovalService = class _ApprovalService {
2406
3604
  await this.notify({
2407
3605
  topic: "approval.returned",
2408
3606
  audience: [String(raw.submitter_id)],
2409
- actorId: input.actorId,
3607
+ actorId,
2410
3608
  source: { object: "sys_approval_request", id: requestId },
2411
3609
  payload: {
2412
3610
  title: "Sent back for revision",
@@ -2415,7 +3613,7 @@ var _ApprovalService = class _ApprovalService {
2415
3613
  }
2416
3614
  });
2417
3615
  }
2418
- const fresh = await this.getRequest(requestId, context);
3616
+ const fresh = await this.readBackRequest(requestId, context);
2419
3617
  return { request: fresh, runId, resumed };
2420
3618
  }
2421
3619
  /**
@@ -2426,25 +3624,25 @@ var _ApprovalService = class _ApprovalService {
2426
3624
  * round-N+1 request — fresh approver slate, record re-locks.
2427
3625
  */
2428
3626
  async resubmit(requestId, input, context) {
2429
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3627
+ const actorId = await this.resolveActor(input?.actorId, context);
2430
3628
  const rawRows = await this.engine.find("sys_approval_request", {
2431
3629
  where: { id: requestId },
2432
3630
  limit: 1,
2433
- context: SYSTEM_CTX
3631
+ context: SYSTEM_CTX2
2434
3632
  });
2435
3633
  const raw = Array.isArray(rawRows) ? rawRows[0] : null;
2436
3634
  if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
2437
3635
  if (raw.status !== "returned") {
2438
3636
  throw new Error(`INVALID_STATE: request is ${raw.status} (resubmit applies to returned requests)`);
2439
3637
  }
2440
- if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
3638
+ if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
2441
3639
  throw new Error("FORBIDDEN: only the submitter may resubmit");
2442
3640
  }
2443
3641
  await this.assertLatestForRun(raw);
2444
3642
  const colliding = await this.engine.find("sys_approval_request", {
2445
3643
  where: { object_name: raw.object_name, record_id: raw.record_id, status: "pending" },
2446
3644
  limit: 1,
2447
- context: SYSTEM_CTX
3645
+ context: SYSTEM_CTX2
2448
3646
  });
2449
3647
  if (Array.isArray(colliding) && colliding[0]) {
2450
3648
  throw new Error(
@@ -2462,14 +3660,14 @@ var _ApprovalService = class _ApprovalService {
2462
3660
  step_name: nodeId,
2463
3661
  step_index: 0,
2464
3662
  action: "resubmit",
2465
- actor_id: input.actorId,
3663
+ actor_id: actorId,
2466
3664
  comment: input.comment ?? null,
2467
3665
  created_at: now
2468
- }, { context: SYSTEM_CTX });
3666
+ }, { context: SYSTEM_CTX2 });
2469
3667
  let resumed = false;
2470
3668
  if (runId && typeof this.automation?.resume === "function") {
2471
3669
  try {
2472
- await this.automation.resume(runId, {
3670
+ await this.serviceResume(runId, {
2473
3671
  branchLabel: APPROVAL_BRANCH_LABELS.resubmit,
2474
3672
  output: { resubmitted: true, requestId }
2475
3673
  });
@@ -2482,7 +3680,7 @@ var _ApprovalService = class _ApprovalService {
2482
3680
  });
2483
3681
  }
2484
3682
  }
2485
- const fresh = await this.getRequest(requestId, context);
3683
+ const fresh = await this.readBackRequest(requestId, context);
2486
3684
  return { request: fresh, runId, resumed };
2487
3685
  }
2488
3686
  /**
@@ -2517,7 +3715,7 @@ var _ApprovalService = class _ApprovalService {
2517
3715
  where: { flow_run_id: runId },
2518
3716
  orderBy: [{ field: "created_at", order: "desc" }],
2519
3717
  limit: 1,
2520
- context: SYSTEM_CTX
3718
+ context: SYSTEM_CTX2
2521
3719
  });
2522
3720
  const latest = Array.isArray(rows) ? rows[0] : null;
2523
3721
  if (latest && String(latest.id) !== String(raw.id)) {
@@ -2528,25 +3726,33 @@ var _ApprovalService = class _ApprovalService {
2528
3726
  /**
2529
3727
  * Hand a pending-approver slot to someone else. `from` defaults to the
2530
3728
  * actor itself; the actor must hold the slot being handed over (or be a
2531
- * system caller). Audits `reassign` and notifies the new approver.
3729
+ * system caller). A privileged admin (#3424) may reassign a request whose
3730
+ * slate holds no real user — an unstaffed-position literal — by handing the
3731
+ * whole request to a real approver, rescuing it from the locked dead-end.
3732
+ * Audits `reassign` and notifies the new approver.
2532
3733
  */
2533
3734
  async reassign(requestId, input, context) {
2534
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3735
+ const actorId = await this.resolveActor(input?.actorId, context);
2535
3736
  const to = String(input?.to ?? "").trim();
2536
3737
  if (!to) throw new Error("VALIDATION_FAILED: `to` (new approver) is required");
2537
3738
  const raw = await this.loadPendingRow(requestId);
2538
3739
  const pending = csvSplit(raw.pending_approvers);
2539
- const from = String(input.from ?? input.actorId).trim();
2540
- if (!pending.includes(from)) {
2541
- throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`);
2542
- }
2543
- if (!context.isSystem && input.actorId !== from && !pending.includes(input.actorId)) {
2544
- throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
2545
- }
2546
3740
  if (pending.includes(to)) {
2547
3741
  throw new Error(`VALIDATION_FAILED: '${to}' is already a pending approver`);
2548
3742
  }
2549
- const next = pending.map((a) => a === from ? to : a);
3743
+ const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
3744
+ const from = String(input.from ?? actorId).trim();
3745
+ let next;
3746
+ if (pending.includes(from)) {
3747
+ if (!context.isSystem && !isOverride && actorId !== from && !pending.includes(actorId)) {
3748
+ throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
3749
+ }
3750
+ next = pending.map((a) => a === from ? to : a);
3751
+ } else if (isOverride) {
3752
+ next = [to];
3753
+ } else {
3754
+ throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`);
3755
+ }
2550
3756
  const now = this.clock.now().toISOString();
2551
3757
  await this.engine.insert("sys_approval_action", {
2552
3758
  id: uid("aact"),
@@ -2555,10 +3761,15 @@ var _ApprovalService = class _ApprovalService {
2555
3761
  step_name: raw.flow_node_id ?? raw.current_step ?? null,
2556
3762
  step_index: 0,
2557
3763
  action: "reassign",
2558
- actor_id: input.actorId,
2559
- comment: input.comment ?? `${from} \u2192 ${to}`,
3764
+ // The hand-off parties are STRUCTURED fields (#4365) — the old default
3765
+ // comment (`"<from> <to>"`) baked raw user ids into user-facing text.
3766
+ // `comment` is pure user input: absent unless the actor wrote one.
3767
+ actor_id: actorId,
3768
+ reassign_from: from,
3769
+ reassign_to: to,
3770
+ comment: input.comment ?? null,
2560
3771
  created_at: now
2561
- }, { context: SYSTEM_CTX });
3772
+ }, { context: SYSTEM_CTX2 });
2562
3773
  let configPatch = {};
2563
3774
  try {
2564
3775
  const cfg = parseJson(raw.node_config_json, null);
@@ -2575,12 +3786,12 @@ var _ApprovalService = class _ApprovalService {
2575
3786
  pending_approvers: next.join(","),
2576
3787
  updated_at: now,
2577
3788
  ...configPatch
2578
- }, { context: SYSTEM_CTX });
3789
+ }, { context: SYSTEM_CTX2 });
2579
3790
  await this.syncApproverIndex(requestId, next, raw.organization_id ?? null, now);
2580
3791
  await this.notify({
2581
3792
  topic: "approval.reassigned",
2582
3793
  audience: [to],
2583
- actorId: input.actorId,
3794
+ actorId,
2584
3795
  source: { object: "sys_approval_request", id: requestId },
2585
3796
  dedupKey: `approval-reassign-${requestId}-${to}`,
2586
3797
  payload: {
@@ -2589,7 +3800,7 @@ var _ApprovalService = class _ApprovalService {
2589
3800
  actionUrl: "/system/approvals"
2590
3801
  }
2591
3802
  });
2592
- const fresh = await this.getRequest(requestId, context);
3803
+ const fresh = await this.readBackRequest(requestId, context);
2593
3804
  return { request: fresh };
2594
3805
  }
2595
3806
  /**
@@ -2597,16 +3808,16 @@ var _ApprovalService = class _ApprovalService {
2597
3808
  * reminder per {@link REMIND_COOLDOWN_MS} per request.
2598
3809
  */
2599
3810
  async remind(requestId, input, context) {
2600
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3811
+ const actorId = await this.resolveActor(input?.actorId, context);
2601
3812
  const raw = await this.loadPendingRow(requestId);
2602
- if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
3813
+ if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
2603
3814
  throw new Error("FORBIDDEN: only the submitter may send reminders");
2604
3815
  }
2605
3816
  const acts = await this.engine.find("sys_approval_action", {
2606
3817
  where: { request_id: requestId, action: "remind" },
2607
3818
  orderBy: [{ field: "created_at", order: "desc" }],
2608
3819
  limit: 1,
2609
- context: SYSTEM_CTX
3820
+ context: SYSTEM_CTX2
2610
3821
  });
2611
3822
  const last = Array.isArray(acts) ? acts[0] : null;
2612
3823
  const now = this.clock.now();
@@ -2622,10 +3833,10 @@ var _ApprovalService = class _ApprovalService {
2622
3833
  step_name: raw.flow_node_id ?? raw.current_step ?? null,
2623
3834
  step_index: 0,
2624
3835
  action: "remind",
2625
- actor_id: input.actorId,
3836
+ actor_id: actorId,
2626
3837
  comment: input.comment ?? null,
2627
3838
  created_at: nowIso
2628
- }, { context: SYSTEM_CTX });
3839
+ }, { context: SYSTEM_CTX2 });
2629
3840
  let notified = 0;
2630
3841
  const concrete = pending.filter((a) => a && !a.includes(":"));
2631
3842
  const literals = pending.filter((a) => a && a.includes(":"));
@@ -2635,7 +3846,7 @@ var _ApprovalService = class _ApprovalService {
2635
3846
  notified += await this.notify({
2636
3847
  topic: "approval.reminder",
2637
3848
  audience: [approver],
2638
- actorId: input.actorId,
3849
+ actorId,
2639
3850
  source: { object: "sys_approval_request", id: requestId },
2640
3851
  dedupKey: `approval-remind-${requestId}-${nowIso}-${approver}`,
2641
3852
  payload: {
@@ -2660,7 +3871,7 @@ var _ApprovalService = class _ApprovalService {
2660
3871
  notified += await this.notify({
2661
3872
  topic: "approval.reminder",
2662
3873
  audience: literals,
2663
- actorId: input.actorId,
3874
+ actorId,
2664
3875
  source: { object: "sys_approval_request", id: requestId },
2665
3876
  dedupKey: `approval-remind-${requestId}-${nowIso}`,
2666
3877
  payload: {
@@ -2670,7 +3881,7 @@ var _ApprovalService = class _ApprovalService {
2670
3881
  }
2671
3882
  });
2672
3883
  }
2673
- const fresh = await this.getRequest(requestId, context);
3884
+ const fresh = await this.readBackRequest(requestId, context);
2674
3885
  return { request: fresh, notified };
2675
3886
  }
2676
3887
  // ── Actionable links (ADR-0043) ──────────────────────────────
@@ -2705,7 +3916,7 @@ var _ApprovalService = class _ApprovalService {
2705
3916
  expires_at: expires,
2706
3917
  consumed_at: null,
2707
3918
  created_at: now.toISOString()
2708
- }, { context: SYSTEM_CTX });
3919
+ }, { context: SYSTEM_CTX2 });
2709
3920
  out[action] = rawToken;
2710
3921
  }
2711
3922
  return out;
@@ -2718,7 +3929,7 @@ var _ApprovalService = class _ApprovalService {
2718
3929
  const rows = await this.engine.find("sys_approval_token", {
2719
3930
  where: { token_hash: hash },
2720
3931
  limit: 1,
2721
- context: SYSTEM_CTX
3932
+ context: SYSTEM_CTX2
2722
3933
  });
2723
3934
  const token = Array.isArray(rows) ? rows[0] : null;
2724
3935
  if (!token) return { ok: false, reason: "invalid" };
@@ -2726,7 +3937,7 @@ var _ApprovalService = class _ApprovalService {
2726
3937
  if (Date.parse(token.expires_at) < this.clock.now().getTime()) {
2727
3938
  return { ok: false, reason: "expired" };
2728
3939
  }
2729
- const request = await this.getRequest(token.request_id, SYSTEM_CTX);
3940
+ const request = await this.getRequest(token.request_id, SYSTEM_CTX2);
2730
3941
  if (!request || request.status !== "pending") {
2731
3942
  return { ok: false, reason: "not_pending", request: request ?? void 0 };
2732
3943
  }
@@ -2751,12 +3962,19 @@ var _ApprovalService = class _ApprovalService {
2751
3962
  await this.engine.update("sys_approval_token", {
2752
3963
  id: res.token.id,
2753
3964
  consumed_at: this.clock.now().toISOString()
2754
- }, { context: SYSTEM_CTX });
3965
+ }, { context: SYSTEM_CTX2 });
2755
3966
  const out = await this.decide(res.token.request_id, {
2756
3967
  decision: res.token.action,
2757
3968
  actorId: res.token.approver_id,
2758
3969
  comment: "Via action link"
2759
- }, SYSTEM_CTX);
3970
+ // The token IS the authentication (#3783): it is single-use, hashed at
3971
+ // rest and bound to one approver, who `resolveActionToken` has just
3972
+ // re-checked still holds a pending slot. So this decision has a real
3973
+ // acting user even though no session carried it — name them on the
3974
+ // context, so the status mirror and every flow it cascades into are
3975
+ // attributed exactly like a decision made through the UI. Elevation is
3976
+ // unchanged: `isSystem` still stands in for the missing session.
3977
+ }, { ...SYSTEM_CTX2, userId: res.token.approver_id });
2760
3978
  return { ok: true, action: res.token.action, request: out.request, approverId: res.token.approver_id };
2761
3979
  }
2762
3980
  /**
@@ -2764,12 +3982,12 @@ var _ApprovalService = class _ApprovalService {
2764
3982
  * pending — a thread interaction, not a flow decision.
2765
3983
  */
2766
3984
  async requestInfo(requestId, input, context) {
2767
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3985
+ const actorId = await this.resolveActor(input?.actorId, context);
2768
3986
  if (!input?.comment?.trim()) throw new Error("VALIDATION_FAILED: comment is required");
2769
3987
  const raw = await this.loadPendingRow(requestId);
2770
3988
  const pending = csvSplit(raw.pending_approvers);
2771
- if (!context.isSystem && !pending.includes(input.actorId)) {
2772
- throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
3989
+ if (!context.isSystem && !pending.includes(actorId)) {
3990
+ throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
2773
3991
  }
2774
3992
  const now = this.clock.now().toISOString();
2775
3993
  await this.engine.insert("sys_approval_action", {
@@ -2779,15 +3997,15 @@ var _ApprovalService = class _ApprovalService {
2779
3997
  step_name: raw.flow_node_id ?? raw.current_step ?? null,
2780
3998
  step_index: 0,
2781
3999
  action: "request_info",
2782
- actor_id: input.actorId,
4000
+ actor_id: actorId,
2783
4001
  comment: input.comment.trim(),
2784
4002
  created_at: now
2785
- }, { context: SYSTEM_CTX });
4003
+ }, { context: SYSTEM_CTX2 });
2786
4004
  if (raw.submitter_id) {
2787
4005
  await this.notify({
2788
4006
  topic: "approval.request_info",
2789
4007
  audience: [String(raw.submitter_id)],
2790
- actorId: input.actorId,
4008
+ actorId,
2791
4009
  source: { object: "sys_approval_request", id: requestId },
2792
4010
  payload: {
2793
4011
  title: "More information requested",
@@ -2796,18 +4014,18 @@ var _ApprovalService = class _ApprovalService {
2796
4014
  }
2797
4015
  });
2798
4016
  }
2799
- const fresh = await this.getRequest(requestId, context);
4017
+ const fresh = await this.readBackRequest(requestId, context);
2800
4018
  return { request: fresh };
2801
4019
  }
2802
4020
  /** Free-form reply on the thread (submitter or any pending approver). */
2803
4021
  async comment(requestId, input, context) {
2804
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
4022
+ const actorId = await this.resolveActor(input?.actorId, context);
2805
4023
  if (!input?.comment?.trim()) throw new Error("VALIDATION_FAILED: comment is required");
2806
4024
  const raw = await this.loadPendingRow(requestId);
2807
4025
  const pending = csvSplit(raw.pending_approvers);
2808
- const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(input.actorId);
2809
- if (!context.isSystem && !isSubmitter && !pending.includes(input.actorId)) {
2810
- throw new Error(`FORBIDDEN: actor '${input.actorId}' is not on this request`);
4026
+ const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(actorId);
4027
+ if (!context.isSystem && !isSubmitter && !pending.includes(actorId)) {
4028
+ throw new Error(`FORBIDDEN: actor '${actorId}' is not on this request`);
2811
4029
  }
2812
4030
  const now = this.clock.now().toISOString();
2813
4031
  await this.engine.insert("sys_approval_action", {
@@ -2817,16 +4035,16 @@ var _ApprovalService = class _ApprovalService {
2817
4035
  step_name: raw.flow_node_id ?? raw.current_step ?? null,
2818
4036
  step_index: 0,
2819
4037
  action: "comment",
2820
- actor_id: input.actorId,
4038
+ actor_id: actorId,
2821
4039
  comment: input.comment.trim(),
2822
4040
  attachments: input.attachments?.length ? input.attachments : null,
2823
4041
  created_at: now
2824
- }, { context: SYSTEM_CTX });
4042
+ }, { context: SYSTEM_CTX2 });
2825
4043
  const audience = isSubmitter ? pending : [String(raw.submitter_id ?? "")].filter(Boolean);
2826
4044
  await this.notify({
2827
4045
  topic: "approval.comment",
2828
4046
  audience,
2829
- actorId: input.actorId,
4047
+ actorId,
2830
4048
  source: { object: "sys_approval_request", id: requestId },
2831
4049
  payload: {
2832
4050
  title: "New comment on an approval",
@@ -2834,7 +4052,7 @@ var _ApprovalService = class _ApprovalService {
2834
4052
  actionUrl: "/system/approvals"
2835
4053
  }
2836
4054
  });
2837
- const fresh = await this.getRequest(requestId, context);
4055
+ const fresh = await this.readBackRequest(requestId, context);
2838
4056
  return { request: fresh };
2839
4057
  }
2840
4058
  // ── SLA escalation (ADR-0042) ─────────────────────────────────
@@ -2851,7 +4069,7 @@ var _ApprovalService = class _ApprovalService {
2851
4069
  rows = await this.engine.find("sys_approval_request", {
2852
4070
  where: { status: "pending" },
2853
4071
  limit: 500,
2854
- context: SYSTEM_CTX
4072
+ context: SYSTEM_CTX2
2855
4073
  }) ?? [];
2856
4074
  } catch (err) {
2857
4075
  this.logger?.warn?.("[approvals] escalation scan failed to list requests", {
@@ -2870,7 +4088,7 @@ var _ApprovalService = class _ApprovalService {
2870
4088
  const prior = await this.engine.find("sys_approval_action", {
2871
4089
  where: { request_id: raw.id, action: "escalate" },
2872
4090
  limit: 1,
2873
- context: SYSTEM_CTX
4091
+ context: SYSTEM_CTX2
2874
4092
  });
2875
4093
  if (Array.isArray(prior) && prior[0]) continue;
2876
4094
  await this.escalateRequest(raw, esc2);
@@ -2887,6 +4105,130 @@ var _ApprovalService = class _ApprovalService {
2887
4105
  }
2888
4106
  return { scanned: rows.length, escalated };
2889
4107
  }
4108
+ // ── Dead-run release (#3456) ──────────────────────────────────
4109
+ /**
4110
+ * One dead-run sweep: a pending request whose owning flow run has reached a
4111
+ * TERMINAL state can never be decided — nothing is left to resume — so the
4112
+ * request is finalised as `recalled` and, with `lockRecord`, the record it was
4113
+ * holding is released.
4114
+ *
4115
+ * This is the recovery half of #3456. The prevention half is the record lock's
4116
+ * owning-run exemption (`lifecycle-hooks.ts`), which stops a run from killing
4117
+ * itself on its own lock in the first place; this sweep cleans up the runs that
4118
+ * still die — for any reason, including a process crash, which no in-band
4119
+ * handler can catch because the process that would have run it is gone.
4120
+ *
4121
+ * **Fail-safe by construction.** It acts only on an explicit terminal status
4122
+ * from a closed set. Every other answer — `paused` (the normal state of a run
4123
+ * waiting on its approval), `running`, an unrecognised status, `null` (unknown
4124
+ * run, evicted log, no durable store), a `getRun` that throws, or no automation
4125
+ * engine at all — is read as "still alive" and left strictly alone. The failure
4126
+ * mode is therefore "a dead run's lock survives until an admin recalls it"
4127
+ * (today's behaviour, #3424), never "a live approval is destroyed".
4128
+ *
4129
+ * `recalled` is the finalisation because it is the platform's existing terminal
4130
+ * state for *a live request that ended without a decision*; the audit row names
4131
+ * the real cause and {@link DEAD_RUN_ACTOR_ID} the real actor, so a dead-run
4132
+ * release is never mistaken for a submitter's withdrawal.
4133
+ */
4134
+ async releaseDeadRunRequests() {
4135
+ if (typeof this.automation?.getRun !== "function") return { scanned: 0, released: 0 };
4136
+ let rows = [];
4137
+ try {
4138
+ rows = await this.engine.find("sys_approval_request", {
4139
+ where: { status: "pending" },
4140
+ limit: 500,
4141
+ context: SYSTEM_CTX2
4142
+ }) ?? [];
4143
+ } catch (err) {
4144
+ this.logger?.warn?.("[approvals] dead-run sweep failed to list requests", {
4145
+ error: err?.message ?? String(err)
4146
+ });
4147
+ return { scanned: 0, released: 0 };
4148
+ }
4149
+ let released = 0;
4150
+ for (const raw of rows) {
4151
+ try {
4152
+ const runId = raw?.flow_run_id ? String(raw.flow_run_id) : "";
4153
+ if (!runId) continue;
4154
+ let status;
4155
+ try {
4156
+ const run = await this.automation.getRun(runId);
4157
+ status = typeof run?.status === "string" ? run.status : void 0;
4158
+ } catch (err) {
4159
+ this.logger?.warn?.("[approvals] dead-run sweep could not read run status", {
4160
+ request: raw?.id,
4161
+ run: runId,
4162
+ error: err?.message ?? String(err)
4163
+ });
4164
+ continue;
4165
+ }
4166
+ if (!status || !TERMINAL_RUN_STATUSES.has(status)) continue;
4167
+ await this.abandonForDeadRun(raw, runId, status);
4168
+ released++;
4169
+ } catch (err) {
4170
+ this.logger?.warn?.("[approvals] dead-run release failed for request", {
4171
+ request: raw?.id,
4172
+ error: err?.message ?? String(err)
4173
+ });
4174
+ }
4175
+ }
4176
+ if (released > 0) {
4177
+ this.logger?.info?.("[approvals] dead-run sweep", { scanned: rows.length, released });
4178
+ }
4179
+ return { scanned: rows.length, released };
4180
+ }
4181
+ /**
4182
+ * Finalise one pending request whose owning run is terminal. Mirrors the
4183
+ * shape of {@link recall} — audit row first (so a crash mid-release leaves a
4184
+ * trace of the intent), then the status transition, approver-index sync and
4185
+ * the optional status-field mirror. No resume/cancel of the run: it is already
4186
+ * terminal, which is precisely why we are here.
4187
+ */
4188
+ async abandonForDeadRun(raw, runId, runStatus) {
4189
+ const org = raw.organization_id ?? null;
4190
+ const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
4191
+ const now = this.clock.now().toISOString();
4192
+ await this.engine.insert("sys_approval_action", {
4193
+ id: uid("aact"),
4194
+ request_id: raw.id,
4195
+ organization_id: org,
4196
+ step_name: nodeId,
4197
+ step_index: 0,
4198
+ action: "recall",
4199
+ actor_id: DEAD_RUN_ACTOR_ID,
4200
+ comment: `owning flow run ${runId} is ${runStatus} \u2014 request abandoned and record lock released`,
4201
+ created_at: now
4202
+ }, { context: SYSTEM_CTX2 });
4203
+ await this.engine.update("sys_approval_request", {
4204
+ id: raw.id,
4205
+ status: "recalled",
4206
+ pending_approvers: null,
4207
+ completed_at: now,
4208
+ updated_at: now
4209
+ }, { context: SYSTEM_CTX2 });
4210
+ await this.syncApproverIndex(raw.id, [], org, now);
4211
+ const config = parseJson(
4212
+ raw.node_config_json,
4213
+ { approvers: [], behavior: "first_response" }
4214
+ );
4215
+ if (config.approvalStatusField) {
4216
+ await this.mirrorStatusField(
4217
+ raw.object_name,
4218
+ raw.record_id,
4219
+ config.approvalStatusField,
4220
+ "recalled",
4221
+ null
4222
+ );
4223
+ }
4224
+ this.logger?.warn?.("[approvals] released a record held by a dead approval run", {
4225
+ request: raw.id,
4226
+ run: runId,
4227
+ runStatus,
4228
+ object: raw.object_name,
4229
+ record: raw.record_id
4230
+ });
4231
+ }
2890
4232
  /** Execute the configured escalation action for one overdue request. */
2891
4233
  async escalateRequest(raw, esc2) {
2892
4234
  const action = esc2.action ?? "notify";
@@ -2912,13 +4254,13 @@ var _ApprovalService = class _ApprovalService {
2912
4254
  actor_id: SLA_ACTOR_ID,
2913
4255
  comment: `${action}${escalateTo ? ` \u2192 ${escalateTo}` : ""}`,
2914
4256
  created_at: now
2915
- }, { context: SYSTEM_CTX });
4257
+ }, { context: SYSTEM_CTX2 });
2916
4258
  if (action === "reassign" && escalatees.length) {
2917
4259
  await this.engine.update("sys_approval_request", {
2918
4260
  id: raw.id,
2919
4261
  pending_approvers: escalatees.join(","),
2920
4262
  updated_at: now
2921
- }, { context: SYSTEM_CTX });
4263
+ }, { context: SYSTEM_CTX2 });
2922
4264
  await this.syncApproverIndex(raw.id, escalatees, raw.organization_id ?? null, now);
2923
4265
  await this.notify({
2924
4266
  topic: "approval.escalated",
@@ -2936,7 +4278,7 @@ var _ApprovalService = class _ApprovalService {
2936
4278
  decision: action === "auto_approve" ? "approve" : "reject",
2937
4279
  actorId: SLA_ACTOR_ID,
2938
4280
  comment: "SLA escalation"
2939
- }, SYSTEM_CTX);
4281
+ }, SYSTEM_CTX2);
2940
4282
  } else {
2941
4283
  await this.notify({
2942
4284
  topic: "approval.sla_breached",
@@ -3004,7 +4346,7 @@ var _ApprovalService = class _ApprovalService {
3004
4346
  where: { id: { $in: targets } },
3005
4347
  fields: ["id", "name", "email"],
3006
4348
  limit: targets.length,
3007
- context: SYSTEM_CTX
4349
+ context: SYSTEM_CTX2
3008
4350
  });
3009
4351
  for (const u of users ?? []) {
3010
4352
  if (u?.id && (u.name || u.email)) names.set(String(u.id), String(u.name ?? u.email));
@@ -3018,7 +4360,7 @@ var _ApprovalService = class _ApprovalService {
3018
4360
  where: { email: { $in: unresolvedEmails } },
3019
4361
  fields: ["email", "name"],
3020
4362
  limit: unresolvedEmails.length,
3021
- context: SYSTEM_CTX
4363
+ context: SYSTEM_CTX2
3022
4364
  });
3023
4365
  for (const u of users ?? []) {
3024
4366
  if (u?.email && u.name) names.set(String(u.email), String(u.name));
@@ -3044,11 +4386,32 @@ var _ApprovalService = class _ApprovalService {
3044
4386
  return [];
3045
4387
  }
3046
4388
  }
4389
+ /**
4390
+ * Field key → display label for an object's schema. Lets the inbox summary
4391
+ * show a human field name ("考核状态") instead of a title-cased machine key
4392
+ * ("Assessment Status"). For a single-locale project the schema label already
4393
+ * IS the localized string; symmetric with `resolveDisplayField`/lookup
4394
+ * resolution that power `payload_display`.
4395
+ */
4396
+ resolveFieldLabels(object) {
4397
+ try {
4398
+ const schema = this.engine.getSchema?.(object);
4399
+ const fields = schema?.fields ?? {};
4400
+ const out = {};
4401
+ for (const [key, f] of Object.entries(fields)) {
4402
+ if (f?.label) out[key] = String(f.label);
4403
+ }
4404
+ return out;
4405
+ } catch {
4406
+ return {};
4407
+ }
4408
+ }
3047
4409
  /**
3048
4410
  * Attach inbox display fields to rows so clients never render a raw
3049
4411
  * identifier: `record_title`, `submitter_name`, `object_label`,
3050
- * `pending_approver_names` (user-id approvers), and `payload_display`
3051
- * (lookup foreign keys in the snapshot → referenced record titles).
4412
+ * `pending_approver_names` (user-id approvers), `payload_display`
4413
+ * (lookup foreign keys in the snapshot → referenced record titles), and
4414
+ * `payload_labels` (snapshot field keys → the target object's field labels).
3052
4415
  * Batched: one query per distinct object (target + referenced) plus one
3053
4416
  * `sys_user` lookup. Best-effort — a deleted record falls back to the
3054
4417
  * payload snapshot, and any failure leaves the field unset rather than
@@ -3080,7 +4443,7 @@ var _ApprovalService = class _ApprovalService {
3080
4443
  const recs = await this.engine.find(object, {
3081
4444
  where: { id: { $in: ids } },
3082
4445
  limit: ids.length,
3083
- context: SYSTEM_CTX
4446
+ context: SYSTEM_CTX2
3084
4447
  });
3085
4448
  for (const rec of recs ?? []) {
3086
4449
  const title = _ApprovalService.pickTitle(rec, displayField);
@@ -3090,9 +4453,12 @@ var _ApprovalService = class _ApprovalService {
3090
4453
  }
3091
4454
  }
3092
4455
  const lookupFieldsByObject = /* @__PURE__ */ new Map();
4456
+ const fieldLabelsByObject = /* @__PURE__ */ new Map();
3093
4457
  for (const object of byObject.keys()) {
3094
4458
  const lookups = this.resolveLookupFields(object);
3095
4459
  if (lookups.length) lookupFieldsByObject.set(object, lookups);
4460
+ const labels = this.resolveFieldLabels(object);
4461
+ if (Object.keys(labels).length) fieldLabelsByObject.set(object, labels);
3096
4462
  }
3097
4463
  const refIds = /* @__PURE__ */ new Map();
3098
4464
  for (const r of rows) {
@@ -3118,7 +4484,7 @@ var _ApprovalService = class _ApprovalService {
3118
4484
  const recs = await this.engine.find(object, {
3119
4485
  where: { id: { $in: ids } },
3120
4486
  limit: ids.length,
3121
- context: SYSTEM_CTX
4487
+ context: SYSTEM_CTX2
3122
4488
  });
3123
4489
  for (const rec of recs ?? []) {
3124
4490
  const title = _ApprovalService.pickTitle(rec, displayField);
@@ -3159,6 +4525,15 @@ var _ApprovalService = class _ApprovalService {
3159
4525
  }
3160
4526
  if (Object.keys(display).length) r.payload_display = display;
3161
4527
  }
4528
+ const fieldLabels = fieldLabelsByObject.get(r.object_name);
4529
+ if (fieldLabels && r.payload && typeof r.payload === "object") {
4530
+ const labels = {};
4531
+ for (const key of Object.keys(r.payload)) {
4532
+ const l = fieldLabels[key];
4533
+ if (l) labels[key] = l;
4534
+ }
4535
+ if (Object.keys(labels).length) r.payload_labels = labels;
4536
+ }
3162
4537
  }
3163
4538
  }
3164
4539
  // ── Pending-approver index (issue #1745) ─────────────────────
@@ -3174,12 +4549,12 @@ var _ApprovalService = class _ApprovalService {
3174
4549
  const existing = await this.engine.find("sys_approval_approver", {
3175
4550
  where: { request_id: requestId },
3176
4551
  limit: 500,
3177
- context: SYSTEM_CTX
4552
+ context: SYSTEM_CTX2
3178
4553
  });
3179
4554
  const rows = Array.isArray(existing) ? existing : [];
3180
4555
  for (const row of rows) {
3181
4556
  if (desired.has(String(row.approver))) desired.delete(String(row.approver));
3182
- else await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX });
4557
+ else await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX2 });
3183
4558
  }
3184
4559
  for (const approver of desired) {
3185
4560
  await this.engine.insert("sys_approval_approver", {
@@ -3188,7 +4563,7 @@ var _ApprovalService = class _ApprovalService {
3188
4563
  approver,
3189
4564
  organization_id: org,
3190
4565
  created_at: now
3191
- }, { context: SYSTEM_CTX });
4566
+ }, { context: SYSTEM_CTX2 });
3192
4567
  }
3193
4568
  }
3194
4569
  /**
@@ -3200,35 +4575,28 @@ var _ApprovalService = class _ApprovalService {
3200
4575
  async rebuildApproverIndex() {
3201
4576
  const desired = /* @__PURE__ */ new Map();
3202
4577
  const PAGE = 500;
3203
- for (let offset = 0; ; offset += PAGE) {
3204
- const batch = await this.engine.find("sys_approval_request", {
3205
- where: { status: "pending" },
4578
+ const requests = keysetWalk(
4579
+ (q) => this.engine.find("sys_approval_request", {
4580
+ ...q,
3206
4581
  fields: ["id", "pending_approvers", "organization_id"],
3207
- limit: PAGE,
3208
- offset,
3209
- context: SYSTEM_CTX
3210
- });
3211
- const rows = Array.isArray(batch) ? batch : [];
4582
+ context: SYSTEM_CTX2
4583
+ }),
4584
+ { where: { status: "pending" }, pageSize: PAGE }
4585
+ );
4586
+ for await (const rows of requests.pages()) {
3212
4587
  for (const r of rows) {
3213
4588
  desired.set(String(r.id), {
3214
4589
  approvers: new Set(csvSplit(r.pending_approvers)),
3215
4590
  org: r.organization_id ?? null
3216
4591
  });
3217
4592
  }
3218
- if (rows.length < PAGE) break;
3219
4593
  }
3220
4594
  const indexRows = [];
3221
- for (let offset = 0; ; offset += PAGE) {
3222
- const batch = await this.engine.find("sys_approval_approver", {
3223
- orderBy: [{ field: "created_at", order: "asc" }],
3224
- limit: PAGE,
3225
- offset,
3226
- context: SYSTEM_CTX
3227
- });
3228
- const rows = Array.isArray(batch) ? batch : [];
3229
- indexRows.push(...rows);
3230
- if (rows.length < PAGE) break;
3231
- }
4595
+ const index = keysetWalk(
4596
+ (q) => this.engine.find("sys_approval_approver", { ...q, context: SYSTEM_CTX2 }),
4597
+ { pageSize: PAGE }
4598
+ );
4599
+ for await (const rows of index.pages()) indexRows.push(...rows);
3232
4600
  let inserted = 0;
3233
4601
  let deleted = 0;
3234
4602
  const seen = /* @__PURE__ */ new Map();
@@ -3237,7 +4605,7 @@ var _ApprovalService = class _ApprovalService {
3237
4605
  const want = desired.get(reqId);
3238
4606
  const have = seen.get(reqId) ?? seen.set(reqId, /* @__PURE__ */ new Set()).get(reqId);
3239
4607
  if (!want || !want.approvers.has(String(row.approver)) || have.has(String(row.approver))) {
3240
- await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX });
4608
+ await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX2 });
3241
4609
  deleted++;
3242
4610
  continue;
3243
4611
  }
@@ -3254,7 +4622,7 @@ var _ApprovalService = class _ApprovalService {
3254
4622
  approver,
3255
4623
  organization_id: want.org,
3256
4624
  created_at: now
3257
- }, { context: SYSTEM_CTX });
4625
+ }, { context: SYSTEM_CTX2 });
3258
4626
  inserted++;
3259
4627
  }
3260
4628
  }
@@ -3304,7 +4672,7 @@ var _ApprovalService = class _ApprovalService {
3304
4672
  where,
3305
4673
  fields: ["request_id"],
3306
4674
  limit: _ApprovalService.APPROVER_INDEX_CAP,
3307
- context: SYSTEM_CTX
4675
+ context: SYSTEM_CTX2
3308
4676
  });
3309
4677
  const list = Array.isArray(rows) ? rows : [];
3310
4678
  if (list.length >= _ApprovalService.APPROVER_INDEX_CAP) {
@@ -3315,6 +4683,88 @@ var _ApprovalService = class _ApprovalService {
3315
4683
  }
3316
4684
  return [...new Set(list.map((r) => String(r.request_id)))];
3317
4685
  }
4686
+ /**
4687
+ * The request ids this caller is a PARTICIPANT of, or `null` for a caller
4688
+ * who may see everything in scope (#3590).
4689
+ *
4690
+ * These reads deliberately run with `SYSTEM_CTX` to bypass RLS — the
4691
+ * approver-visibility rule spans several identity forms that RLS cannot model
4692
+ * cleanly, which is why it has to be expressed here. Until now only the
4693
+ * TENANT half of that rule was applied, so any authenticated user could read
4694
+ * any request in their tenant (and, once attachments derived their access
4695
+ * from the request, its files too). This adds the participant half.
4696
+ *
4697
+ * A participant is the submitter, a current approver, or someone who has
4698
+ * already acted on the request (a past approver whose slot has moved on, a
4699
+ * commenter). Admins with override authority keep the unrestricted view the
4700
+ * "all requests" console surface depends on.
4701
+ *
4702
+ * Keying on the concrete user id is sufficient rather than an approximation:
4703
+ * position/team/manager/field approvers are resolved to concrete user ids at
4704
+ * open time, and the `type:value` literal is only the fallback for a spec
4705
+ * that resolved to NOBODY — a slot no one can act on either way (`can_act`
4706
+ * is a plain membership test over the resolved ids). So this cannot hide a
4707
+ * request from someone who could actually act on it.
4708
+ */
4709
+ async visibleRequestIds(context, tenantOrg) {
4710
+ if (this.isOverrideActor(context, tenantOrg)) return null;
4711
+ const uid2 = context?.userId != null ? String(context.userId) : "";
4712
+ if (!uid2) return /* @__PURE__ */ new Set();
4713
+ const ids = /* @__PURE__ */ new Set();
4714
+ const cap = _ApprovalService.APPROVER_INDEX_CAP;
4715
+ const add = (rows, key) => {
4716
+ const list = Array.isArray(rows) ? rows : [];
4717
+ for (const r of list) if (r?.[key] != null) ids.add(String(r[key]));
4718
+ if (list.length >= cap) {
4719
+ this.logger?.warn?.(
4720
+ "[approvals] participant-visibility probe hit its window \u2014 some requests may be hidden from a legitimate participant",
4721
+ { cap, key }
4722
+ );
4723
+ }
4724
+ };
4725
+ try {
4726
+ for (const id of await this.approverRequestIds([uid2], tenantOrg) ?? []) ids.add(id);
4727
+ const orgWhere = tenantOrg ? { organization_id: tenantOrg } : {};
4728
+ add(
4729
+ await this.engine.find("sys_approval_request", {
4730
+ where: { submitter_id: uid2, ...orgWhere },
4731
+ fields: ["id"],
4732
+ limit: cap,
4733
+ context: SYSTEM_CTX2
4734
+ }),
4735
+ "id"
4736
+ );
4737
+ add(
4738
+ await this.engine.find("sys_approval_action", {
4739
+ where: { actor_id: uid2 },
4740
+ fields: ["request_id"],
4741
+ limit: cap,
4742
+ context: SYSTEM_CTX2
4743
+ }),
4744
+ "request_id"
4745
+ );
4746
+ } catch (err) {
4747
+ this.logger?.warn?.("[approvals] participant-visibility probe failed", {
4748
+ error: err instanceof Error ? err.message : String(err)
4749
+ });
4750
+ }
4751
+ return ids;
4752
+ }
4753
+ /** Intersect an existing `where.id` constraint with the participant set. */
4754
+ applyVisibility(where, visible) {
4755
+ if (!visible) return true;
4756
+ if (visible.size === 0) return false;
4757
+ let allowed = [...visible];
4758
+ const current = where.id;
4759
+ if (typeof current === "string") allowed = allowed.filter((x) => x === current);
4760
+ else if (current && typeof current === "object" && Array.isArray(current.$in)) {
4761
+ const set = new Set(current.$in.map((v) => String(v)));
4762
+ allowed = allowed.filter((x) => set.has(x));
4763
+ }
4764
+ if (allowed.length === 0) return false;
4765
+ where.id = allowed.length === 1 ? allowed[0] : { $in: allowed };
4766
+ return true;
4767
+ }
3318
4768
  async listRequests(filter, context) {
3319
4769
  const { where, tenantOrg } = this.buildRequestWhere(filter, context);
3320
4770
  const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
@@ -3323,10 +4773,11 @@ var _ApprovalService = class _ApprovalService {
3323
4773
  if (ids.length === 0) return [];
3324
4774
  where.id = ids.length === 1 ? ids[0] : { $in: ids };
3325
4775
  }
4776
+ if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return [];
3326
4777
  const findOpts = {
3327
4778
  where,
3328
4779
  orderBy: [{ field: "created_at", order: "desc" }],
3329
- context: SYSTEM_CTX
4780
+ context: SYSTEM_CTX2
3330
4781
  };
3331
4782
  if (filter?.limit != null || filter?.offset != null) {
3332
4783
  findOpts.limit = Math.min(Math.max(filter?.limit ?? 50, 1), 200);
@@ -3348,10 +4799,11 @@ var _ApprovalService = class _ApprovalService {
3348
4799
  if (ids.length === 0) return 0;
3349
4800
  where.id = ids.length === 1 ? ids[0] : { $in: ids };
3350
4801
  }
4802
+ if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return 0;
3351
4803
  const countFn = this.engine.count;
3352
4804
  if (typeof countFn === "function") {
3353
4805
  try {
3354
- const n = await countFn.call(this.engine, "sys_approval_request", { where, context: SYSTEM_CTX });
4806
+ const n = await countFn.call(this.engine, "sys_approval_request", { where, context: SYSTEM_CTX2 });
3355
4807
  if (typeof n === "number") return n;
3356
4808
  } catch {
3357
4809
  }
@@ -3360,11 +4812,28 @@ var _ApprovalService = class _ApprovalService {
3360
4812
  where,
3361
4813
  fields: ["id"],
3362
4814
  limit: ids ? Math.max(500, ids.length) : 500,
3363
- context: SYSTEM_CTX
4815
+ context: SYSTEM_CTX2
3364
4816
  });
3365
4817
  return Array.isArray(rows) ? rows.length : 0;
3366
4818
  }
4819
+ /**
4820
+ * Read the request a write path just changed, to echo back as its result.
4821
+ *
4822
+ * NOT participant-gated (#3590), deliberately: the operation authorized
4823
+ * itself by its own rule before writing, so re-asking "may you see this?"
4824
+ * for the echo answers a question that has already been settled — and would
4825
+ * answer it WRONG for a caller context that carries no `userId` (a
4826
+ * flow-driven resume, a service-to-service call), turning a successful write
4827
+ * into a `null` result. Gating belongs on the read API, not on an
4828
+ * operation's own return value.
4829
+ */
4830
+ async readBackRequest(requestId, context) {
4831
+ return this.loadRequest(requestId, context, false);
4832
+ }
3367
4833
  async getRequest(requestId, context) {
4834
+ return this.loadRequest(requestId, context, true);
4835
+ }
4836
+ async loadRequest(requestId, context, enforceVisibility) {
3368
4837
  if (!requestId) return null;
3369
4838
  const where = { id: requestId };
3370
4839
  const tenantOrg = context?.organizationId ?? context?.tenantId;
@@ -3372,9 +4841,13 @@ var _ApprovalService = class _ApprovalService {
3372
4841
  const rows = await this.engine.find("sys_approval_request", {
3373
4842
  where,
3374
4843
  limit: 1,
3375
- context: SYSTEM_CTX
4844
+ context: SYSTEM_CTX2
3376
4845
  });
3377
4846
  if (!Array.isArray(rows) || !rows[0]) return null;
4847
+ if (enforceVisibility) {
4848
+ const visible = await this.visibleRequestIds(context, tenantOrg ?? null);
4849
+ if (visible && !visible.has(String(rows[0].id))) return null;
4850
+ }
3378
4851
  const row = rowFromRequest(rows[0]);
3379
4852
  await this.enrichRows([row]);
3380
4853
  await this.attachFlowSteps(row);
@@ -3401,7 +4874,7 @@ var _ApprovalService = class _ApprovalService {
3401
4874
  const acts = await this.engine.find("sys_approval_action", {
3402
4875
  where: { request_id: row.id, step_index: 0, action: "approve" },
3403
4876
  limit: 1e3,
3404
- context: SYSTEM_CTX
4877
+ context: SYSTEM_CTX2
3405
4878
  });
3406
4879
  const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
3407
4880
  const snapshot = cfg?.__approverGroups;
@@ -3422,6 +4895,14 @@ var _ApprovalService = class _ApprovalService {
3422
4895
  });
3423
4896
  progress.got = progress.groups.filter((g) => g.satisfied).length;
3424
4897
  progress.need = progress.groups.length;
4898
+ const pendingGroups = {};
4899
+ for (const a of row.pending_approvers ?? []) {
4900
+ const named = (snapshot[a] ?? []).filter((g) => !/^#\d+$/.test(g));
4901
+ if (named.length) pendingGroups[a] = named;
4902
+ }
4903
+ if (Object.keys(pendingGroups).length) {
4904
+ row.pending_approver_groups = pendingGroups;
4905
+ }
3425
4906
  }
3426
4907
  row.decision_progress = progress;
3427
4908
  } catch {
@@ -3433,8 +4914,13 @@ var _ApprovalService = class _ApprovalService {
3433
4914
  * caller's user id is in the resolved `pending_approvers` while the request is
3434
4915
  * still `pending` (position/team/manager approvers are already resolved to
3435
4916
  * concrete user ids at open time, so a plain membership test is faithful).
3436
- * `is_submitter` is a straight owner check. System/tokenless contexts get a
3437
- * both-false block. Cheap + synchronoussafe on list reads.
4917
+ * `is_submitter` is a straight owner check. `can_override` (#3424) is true for
4918
+ * a platform/tenant admin on a PENDING request the recovery path for an
4919
+ * approval routed to an unstaffed position or to approvers who have all left;
4920
+ * clients OR it into the decision actions' `visible` gate so an admin can act
4921
+ * even when they hold no slot. System/tokenless contexts get a both-false
4922
+ * `can_act`/`is_submitter` block (system gets `can_override` too — it may act
4923
+ * on anything). Cheap + synchronous — safe on list reads.
3438
4924
  */
3439
4925
  attachViewers(rows, context) {
3440
4926
  const uid2 = context?.userId != null ? String(context.userId) : null;
@@ -3442,7 +4928,8 @@ var _ApprovalService = class _ApprovalService {
3442
4928
  const pending = row.pending_approvers ?? [];
3443
4929
  row.viewer = {
3444
4930
  can_act: row.status === "pending" && !!uid2 && pending.includes(uid2),
3445
- is_submitter: !!uid2 && row.submitter_id != null && String(row.submitter_id) === uid2
4931
+ is_submitter: !!uid2 && row.submitter_id != null && String(row.submitter_id) === uid2,
4932
+ can_override: row.status === "pending" && this.isOverrideActor(context, row.organization_id ?? null)
3446
4933
  };
3447
4934
  }
3448
4935
  }
@@ -3489,18 +4976,52 @@ var _ApprovalService = class _ApprovalService {
3489
4976
  where: { request_id: requestId },
3490
4977
  limit: 500,
3491
4978
  orderBy: [{ field: "created_at", order: "asc" }],
3492
- context: SYSTEM_CTX
4979
+ context: SYSTEM_CTX2
3493
4980
  });
3494
4981
  const actions = Array.isArray(rows) ? rows.map(rowFromAction) : [];
3495
4982
  const names = await this.resolveUserNames(
3496
- actions.map((a) => a.actor_id).filter((id) => id && !id.includes(":"))
4983
+ actions.flatMap((a) => [a.actor_id, a.reassign_from, a.reassign_to]).filter((id) => id && !id.includes(":"))
3497
4984
  );
3498
4985
  for (const a of actions) {
3499
4986
  const n = a.actor_id ? names.get(String(a.actor_id)) : void 0;
3500
4987
  if (n) a.actor_name = n;
4988
+ const fromName = a.reassign_from ? names.get(String(a.reassign_from)) : void 0;
4989
+ if (fromName) a.reassign_from_name = fromName;
4990
+ const toName = a.reassign_to ? names.get(String(a.reassign_to)) : void 0;
4991
+ if (toName) a.reassign_to_name = toName;
3501
4992
  }
3502
4993
  return actions;
3503
4994
  }
4995
+ /**
4996
+ * `IFileAccessDelegate` — may this caller download a decision attachment?
4997
+ * (ADR-0104 D3 wave 2; declared by `sys_approval_action.fileAccessDelegate`.)
4998
+ *
4999
+ * A file referenced by `sys_approval_action.attachments` is owned by that
5000
+ * audit row, so the storage service would otherwise authorize the download by
5001
+ * testing whether the caller can READ the row. It cannot: `sys_approval_action`
5002
+ * is deliberately closed to ordinary approver positions, so that test denies
5003
+ * the very approver the attachment was filed for.
5004
+ *
5005
+ * The rule that actually governs seeing a decision is the one `listActions`
5006
+ * applies — can the caller see the PARENT REQUEST? — so this reuses it
5007
+ * exactly, rather than inventing a second, looser rule for the bytes. Fails
5008
+ * closed on any error.
5009
+ */
5010
+ async authorizeFileRead(actionId, context) {
5011
+ if (!actionId) return false;
5012
+ try {
5013
+ const rows = await this.engine.find("sys_approval_action", {
5014
+ where: { id: actionId },
5015
+ limit: 1,
5016
+ context: SYSTEM_CTX2
5017
+ });
5018
+ const requestId = (Array.isArray(rows) ? rows[0] : void 0)?.request_id;
5019
+ if (!requestId) return false;
5020
+ return !!await this.getRequest(String(requestId), context);
5021
+ } catch {
5022
+ return false;
5023
+ }
5024
+ }
3504
5025
  };
3505
5026
  /** Window the approver-index probe — pending queues live far below this. */
3506
5027
  _ApprovalService.APPROVER_INDEX_CAP = 1e4;
@@ -3695,6 +5216,8 @@ function bindApprovalLockHook(engine, logger) {
3695
5216
  if (Array.isArray(roles) && roles.includes("admin")) return;
3696
5217
  const pending = await pendingRequestFor(engine, object, id);
3697
5218
  if (!pending) return;
5219
+ const writerRun = ctx?.provenance?.flowRunId;
5220
+ if (writerRun && pending.flow_run_id && String(writerRun) === String(pending.flow_run_id)) return;
3698
5221
  const config = parseJson2(pending.node_config_json, {});
3699
5222
  if (config?.lockRecord === false) return;
3700
5223
  const mirror = config?.approvalStatusField;
@@ -3751,7 +5274,22 @@ import {
3751
5274
  getApprovalNodeConfigJsonSchema,
3752
5275
  APPROVAL_NODE_TYPE
3753
5276
  } from "@objectstack/spec/automation";
3754
- var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
5277
+ var SYSTEM_CTX3 = { isSystem: true, positions: [], permissions: [] };
5278
+ function nestVariables(variables) {
5279
+ const vars = {};
5280
+ for (const [key, value] of variables) {
5281
+ const segs = key.split(".");
5282
+ let cursor = vars;
5283
+ for (let i = 0; i < segs.length - 1; i++) {
5284
+ if (typeof cursor[segs[i]] !== "object" || cursor[segs[i]] === null) {
5285
+ cursor[segs[i]] = {};
5286
+ }
5287
+ cursor = cursor[segs[i]];
5288
+ }
5289
+ cursor[segs[segs.length - 1]] = value;
5290
+ }
5291
+ return vars;
5292
+ }
3755
5293
  function registerApprovalNode(automation, service, logger) {
3756
5294
  automation.registerNodeExecutor({
3757
5295
  type: APPROVAL_NODE_TYPE,
@@ -3767,6 +5305,15 @@ function registerApprovalNode(automation, service, logger) {
3767
5305
  // Human decision: the run suspends here awaiting an external reply.
3768
5306
  supportsPause: true,
3769
5307
  isAsync: true,
5308
+ // #3801: this pause is NOT resumable through the generic run-resume
5309
+ // route. Continuing an approval is a side effect of a DECISION, and the
5310
+ // decision is the thing that must be authorized (the approver slate),
5311
+ // recorded (`sys_approval_action`) and mirrored (the status field) —
5312
+ // all of which lives in `ApprovalService.decide`. The engine now refuses
5313
+ // any resume of an approval suspension that does not carry the service's
5314
+ // in-process marker, so "decide via the approvals API, never a raw engine
5315
+ // resume" is enforced rather than merely documented.
5316
+ resumeAuthority: "service",
3770
5317
  // Publish the node's config contract (ADR-0018 §configSchema) so the
3771
5318
  // Studio flow designer renders the Approval property form from the engine
3772
5319
  // rather than a hardcoded client form — the engine owns the shape.
@@ -3800,13 +5347,28 @@ function registerApprovalNode(automation, service, logger) {
3800
5347
  nodeLabel: typeof node.label === "string" ? node.label : void 0,
3801
5348
  submitterId: context?.userId ?? null,
3802
5349
  record,
3803
- organizationId: context?.organizationId ?? context?.tenantId ?? null
5350
+ organizationId: context?.organizationId ?? context?.tenantId ?? null,
5351
+ // #3447 P2: flow variables (nested, as CEL conditions see them) — the
5352
+ // `vars.*` root for `expression` approvers; `record` above doubles as
5353
+ // their `trigger.*` snapshot root.
5354
+ variables: nestVariables(variables)
3804
5355
  }, {
3805
- ...SYSTEM_CTX2,
5356
+ ...SYSTEM_CTX3,
3806
5357
  userId: context?.userId,
3807
5358
  organizationId: context?.organizationId,
3808
5359
  tenantId: context?.tenantId
3809
5360
  });
5361
+ if ("autoApproved" in request) {
5362
+ logger?.info?.("[approvals] approval node auto-approved (empty approver slate)", {
5363
+ node: node.id,
5364
+ run: String(runId)
5365
+ });
5366
+ return {
5367
+ success: true,
5368
+ branchLabel: "approve",
5369
+ output: { decision: "approve", autoApproved: true }
5370
+ };
5371
+ }
3810
5372
  logger?.info?.("[approvals] approval node suspended run", {
3811
5373
  node: node.id,
3812
5374
  request: request.id,
@@ -3892,7 +5454,21 @@ var ApprovalsServicePlugin = class {
3892
5454
  this.service = new ApprovalService({
3893
5455
  engine,
3894
5456
  logger: ctx.logger,
3895
- publicBaseUrl: this.options.publicBaseUrl
5457
+ publicBaseUrl: this.options.publicBaseUrl,
5458
+ // [ADR-0105 D9] Cross-organization approver targeting is a `group`-posture
5459
+ // capability. Read LAZILY (not captured at start) because the tenancy
5460
+ // service resolves its posture during its own start, which may not have
5461
+ // run yet; an unresolvable posture reads as "unknown" and the guard
5462
+ // stands down rather than refusing a legitimate flow on a minimal stack.
5463
+ tenancyPosture: () => {
5464
+ try {
5465
+ const tenancy = ctx.getService("tenancy");
5466
+ const posture = tenancy?.posture;
5467
+ return typeof posture === "string" && posture ? posture : void 0;
5468
+ } catch {
5469
+ return void 0;
5470
+ }
5471
+ }
3896
5472
  });
3897
5473
  if (!this.options.disableAutoHooks) {
3898
5474
  try {
@@ -3918,12 +5494,23 @@ var ApprovalsServicePlugin = class {
3918
5494
  if (!jobs || typeof jobs.schedule !== "function" || !this.service) return;
3919
5495
  const svc = this.service;
3920
5496
  const intervalMs = this.options.escalationScanIntervalMs ?? ESCALATION_SCAN_INTERVAL_MS;
3921
- await jobs.schedule(ESCALATION_JOB_NAME, { type: "interval", intervalMs }, async () => {
3922
- await svc.runEscalations();
3923
- });
5497
+ const sweep = async () => {
5498
+ const results = await Promise.allSettled([
5499
+ svc.runEscalations(),
5500
+ svc.releaseDeadRunRequests()
5501
+ ]);
5502
+ for (const r of results) {
5503
+ if (r.status === "rejected") {
5504
+ ctx.logger.warn?.("[approvals] periodic sweep leg failed", {
5505
+ error: r.reason?.message ?? String(r.reason)
5506
+ });
5507
+ }
5508
+ }
5509
+ };
5510
+ await jobs.schedule(ESCALATION_JOB_NAME, { type: "interval", intervalMs }, sweep);
3924
5511
  this.escalationJobScheduled = true;
3925
- void svc.runEscalations().catch((err) => {
3926
- ctx.logger.warn?.("[approvals] boot escalation sweep failed", { error: err?.message });
5512
+ void sweep().catch((err) => {
5513
+ ctx.logger.warn?.("[approvals] boot sweep failed", { error: err?.message });
3927
5514
  });
3928
5515
  ctx.logger.info("ApprovalsServicePlugin: SLA escalation scan scheduled", { intervalMs });
3929
5516
  } catch {