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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.turbo/turbo-build.log +10 -10
  2. package/CHANGELOG.md +1023 -0
  3. package/dist/index.d.mts +650 -535
  4. package/dist/index.d.ts +650 -535
  5. package/dist/index.js +1713 -207
  6. package/dist/index.js.map +1 -1
  7. package/dist/index.mjs +1711 -197
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +9 -7
  10. package/scripts/i18n-extract.config.ts +6 -1
  11. package/src/approval-actor-impersonation.test.ts +330 -0
  12. package/src/approval-node.test.ts +160 -0
  13. package/src/approval-node.ts +57 -0
  14. package/src/approval-revise.test.ts +41 -34
  15. package/src/approval-service.test.ts +1408 -40
  16. package/src/approval-service.ts +1364 -107
  17. package/src/approvals-plugin.ts +36 -5
  18. package/src/approver-cross-org.integration.test.ts +206 -0
  19. package/src/approver-org-scope.test.ts +201 -0
  20. package/src/approver-org-scope.ts +261 -0
  21. package/src/index.ts +3 -0
  22. package/src/lifecycle-hooks.ts +22 -0
  23. package/src/record-lock-schedule-run.integration.test.ts +206 -0
  24. package/src/status-mirror-cascade.integration.test.ts +224 -0
  25. package/src/sys-approval-action.object.ts +9 -0
  26. package/src/sys-approval-delegation.object.test.ts +42 -0
  27. package/src/sys-approval-delegation.object.ts +3 -3
  28. package/src/sys-approval-request.object.test.ts +13 -0
  29. package/src/sys-approval-request.object.ts +17 -5
  30. package/src/translations/bundle-ownership.test.ts +48 -0
  31. package/src/translations/en.objects.generated.ts +111 -5
  32. package/src/translations/es-ES.objects.generated.ts +111 -5
  33. package/src/translations/ja-JP.objects.generated.ts +111 -5
  34. package/src/translations/zh-CN.objects.generated.ts +110 -4
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,21 @@ var init_en_objects_generated = __esm({
147
245
  comment: {
148
246
  label: "Comment"
149
247
  },
248
+ attachments: {
249
+ label: "Attachments",
250
+ help: "Files supporting this action \u2014 e.g. a signed contract or evidence (#3266)."
251
+ },
150
252
  created_at: {
151
253
  label: "Created At"
152
254
  }
153
255
  },
154
256
  _views: {
155
257
  recent: {
156
- label: "Recent"
258
+ label: "Recent",
259
+ emptyState: {
260
+ title: "No approval actions yet",
261
+ message: "Actions are logged automatically when approvals progress."
262
+ }
157
263
  },
158
264
  by_actor: {
159
265
  label: "By Actor"
@@ -257,7 +363,8 @@ var init_zh_CN_objects_generated = __esm({
257
363
  pending: "\u5F85\u5904\u7406",
258
364
  approved: "\u5DF2\u6279\u51C6",
259
365
  rejected: "\u5DF2\u62D2\u7EDD",
260
- recalled: "\u5DF2\u64A4\u56DE"
366
+ recalled: "\u5DF2\u64A4\u56DE",
367
+ returned: "\u5DF2\u9000\u56DE\u4FEE\u6539"
261
368
  }
262
369
  },
263
370
  current_step: {
@@ -299,7 +406,11 @@ var init_zh_CN_objects_generated = __esm({
299
406
  },
300
407
  _views: {
301
408
  my_pending: {
302
- label: "\u6211\u7684\u5F85\u529E"
409
+ label: "\u6211\u7684\u5F85\u529E",
410
+ emptyState: {
411
+ title: "\u6682\u65E0\u5F85\u529E\u5BA1\u6279",
412
+ message: "\u5168\u90E8\u5904\u7406\u5B8C\u6BD5\uFF0C\u6CA1\u6709\u7B49\u5F85\u4F60\u5BA1\u6279\u7684\u8BF7\u6C42\u3002"
413
+ }
303
414
  },
304
415
  submitted_by_me: {
305
416
  label: "\u6211\u63D0\u4EA4\u7684"
@@ -310,6 +421,92 @@ var init_zh_CN_objects_generated = __esm({
310
421
  all_requests: {
311
422
  label: "\u5168\u90E8"
312
423
  }
424
+ },
425
+ _actions: {
426
+ approval_approve: {
427
+ label: "\u901A\u8FC7",
428
+ successMessage: "\u5DF2\u901A\u8FC7\u3002",
429
+ params: {
430
+ comment: {
431
+ label: "\u5BA1\u6279\u610F\u89C1"
432
+ },
433
+ attachments: {
434
+ label: "\u9644\u4EF6"
435
+ }
436
+ }
437
+ },
438
+ approval_reject: {
439
+ label: "\u62D2\u7EDD",
440
+ confirmText: "\u62D2\u7EDD\u8BE5\u8BF7\u6C42\uFF1F\u62D2\u7EDD\u5BF9\u6240\u6709\u5BA1\u6279\u4EBA\u7ACB\u5373\u751F\u6548\u3002",
441
+ successMessage: "\u5DF2\u62D2\u7EDD\u3002",
442
+ params: {
443
+ comment: {
444
+ label: "\u5BA1\u6279\u610F\u89C1"
445
+ },
446
+ attachments: {
447
+ label: "\u9644\u4EF6"
448
+ }
449
+ }
450
+ },
451
+ approval_reassign: {
452
+ label: "\u8F6C\u7B7E",
453
+ successMessage: "\u5DF2\u8F6C\u7B7E\u3002",
454
+ params: {
455
+ to: {
456
+ label: "\u65B0\u5BA1\u6279\u4EBA",
457
+ helpText: "\u5C06\u6B64\u5BA1\u6279\u6B65\u9AA4\u8F6C\u4EA4\u7ED9\u7684\u7528\u6237"
458
+ },
459
+ comment: {
460
+ label: "\u5907\u6CE8"
461
+ }
462
+ }
463
+ },
464
+ approval_send_back: {
465
+ label: "\u9000\u56DE\u4FEE\u6539",
466
+ successMessage: "\u5DF2\u9000\u56DE\u4FEE\u6539\u3002",
467
+ params: {
468
+ comment: {
469
+ label: "\u539F\u56E0"
470
+ }
471
+ }
472
+ },
473
+ approval_request_info: {
474
+ label: "\u9000\u56DE\u8865\u5145",
475
+ successMessage: "\u5DF2\u9000\u56DE\u7ED9\u7533\u8BF7\u4EBA\u8865\u5145\u6750\u6599\u3002",
476
+ params: {
477
+ comment: {
478
+ label: "\u9700\u8981\u7533\u8BF7\u4EBA\u8865\u5145\u4EC0\u4E48\uFF1F"
479
+ }
480
+ }
481
+ },
482
+ approval_remind: {
483
+ label: "\u50AC\u529E",
484
+ successMessage: "\u5DF2\u53D1\u9001\u50AC\u529E\u3002",
485
+ params: {
486
+ comment: {
487
+ label: "\u5907\u6CE8"
488
+ }
489
+ }
490
+ },
491
+ approval_recall: {
492
+ label: "\u64A4\u56DE",
493
+ 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",
494
+ successMessage: "\u5DF2\u64A4\u56DE\u3002",
495
+ params: {
496
+ comment: {
497
+ label: "\u5907\u6CE8"
498
+ }
499
+ }
500
+ },
501
+ approval_resubmit: {
502
+ label: "\u91CD\u65B0\u63D0\u4EA4",
503
+ successMessage: "\u5DF2\u91CD\u65B0\u63D0\u4EA4\u3002",
504
+ params: {
505
+ comment: {
506
+ label: "\u4F60\u4FEE\u6539\u4E86\u4EC0\u4E48\uFF1F"
507
+ }
508
+ }
509
+ }
313
510
  }
314
511
  },
315
512
  sys_approval_action: {
@@ -341,7 +538,14 @@ var init_zh_CN_objects_generated = __esm({
341
538
  approve: "\u6279\u51C6",
342
539
  reject: "\u62D2\u7EDD",
343
540
  recall: "\u64A4\u56DE",
344
- escalate: "\u5347\u7EA7"
541
+ escalate: "\u5347\u7EA7",
542
+ reassign: "\u8F6C\u7B7E",
543
+ remind: "\u50AC\u529E",
544
+ request_info: "\u8981\u6C42\u8865\u5145\u6750\u6599",
545
+ comment: "\u8BC4\u8BBA",
546
+ revise: "\u9000\u56DE\u4FEE\u6539",
547
+ resubmit: "\u91CD\u65B0\u63D0\u4EA4",
548
+ ooo_substitute: "\u4E0D\u5728\u5C97\u6539\u6D3E"
345
549
  }
346
550
  },
347
551
  actor_id: {
@@ -350,13 +554,21 @@ var init_zh_CN_objects_generated = __esm({
350
554
  comment: {
351
555
  label: "\u8BC4\u8BBA"
352
556
  },
557
+ attachments: {
558
+ label: "\u9644\u4EF6",
559
+ 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"
560
+ },
353
561
  created_at: {
354
562
  label: "\u521B\u5EFA\u65F6\u95F4"
355
563
  }
356
564
  },
357
565
  _views: {
358
566
  recent: {
359
- label: "\u6700\u8FD1"
567
+ label: "\u6700\u8FD1",
568
+ emptyState: {
569
+ title: "\u6682\u65E0\u5BA1\u6279\u64CD\u4F5C\u8BB0\u5F55",
570
+ message: "\u5BA1\u6279\u63A8\u8FDB\u65F6\u4F1A\u81EA\u52A8\u8BB0\u5F55\u64CD\u4F5C\u3002"
571
+ }
360
572
  },
361
573
  by_actor: {
362
574
  label: "\u6309\u6267\u884C\u4EBA"
@@ -460,7 +672,8 @@ var init_ja_JP_objects_generated = __esm({
460
672
  pending: "\u4FDD\u7559\u4E2D",
461
673
  approved: "\u627F\u8A8D\u6E08\u307F",
462
674
  rejected: "\u5374\u4E0B\u6E08\u307F",
463
- recalled: "\u53D6\u308A\u6D88\u3057\u6E08\u307F"
675
+ recalled: "\u53D6\u308A\u6D88\u3057\u6E08\u307F",
676
+ returned: "\u5DEE\u3057\u623B\u3057\u6E08\u307F"
464
677
  }
465
678
  },
466
679
  current_step: {
@@ -502,7 +715,11 @@ var init_ja_JP_objects_generated = __esm({
502
715
  },
503
716
  _views: {
504
717
  my_pending: {
505
- label: "\u81EA\u5206\u306E\u4FDD\u7559\u4E2D"
718
+ label: "\u81EA\u5206\u306E\u4FDD\u7559\u4E2D",
719
+ emptyState: {
720
+ title: "\u627F\u8A8D\u5F85\u3061\u306F\u3042\u308A\u307E\u305B\u3093",
721
+ 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"
722
+ }
506
723
  },
507
724
  submitted_by_me: {
508
725
  label: "\u81EA\u5206\u304C\u9001\u4FE1"
@@ -513,6 +730,92 @@ var init_ja_JP_objects_generated = __esm({
513
730
  all_requests: {
514
731
  label: "\u3059\u3079\u3066"
515
732
  }
733
+ },
734
+ _actions: {
735
+ approval_approve: {
736
+ label: "\u627F\u8A8D",
737
+ successMessage: "\u627F\u8A8D\u3057\u307E\u3057\u305F\u3002",
738
+ params: {
739
+ comment: {
740
+ label: "\u30B3\u30E1\u30F3\u30C8"
741
+ },
742
+ attachments: {
743
+ label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB"
744
+ }
745
+ }
746
+ },
747
+ approval_reject: {
748
+ label: "\u5374\u4E0B",
749
+ 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",
750
+ successMessage: "\u5374\u4E0B\u3057\u307E\u3057\u305F\u3002",
751
+ params: {
752
+ comment: {
753
+ label: "\u30B3\u30E1\u30F3\u30C8"
754
+ },
755
+ attachments: {
756
+ label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB"
757
+ }
758
+ }
759
+ },
760
+ approval_reassign: {
761
+ label: "\u518D\u5272\u308A\u5F53\u3066",
762
+ successMessage: "\u518D\u5272\u308A\u5F53\u3066\u3057\u307E\u3057\u305F\u3002",
763
+ params: {
764
+ to: {
765
+ label: "\u65B0\u3057\u3044\u627F\u8A8D\u8005",
766
+ helpText: "\u3053\u306E\u30B9\u30C6\u30C3\u30D7\u3092\u5F15\u304D\u7D99\u3050\u30E6\u30FC\u30B6\u30FC"
767
+ },
768
+ comment: {
769
+ label: "\u30B3\u30E1\u30F3\u30C8"
770
+ }
771
+ }
772
+ },
773
+ approval_send_back: {
774
+ label: "\u5DEE\u3057\u623B\u3057",
775
+ successMessage: "\u4FEE\u6B63\u306E\u305F\u3081\u5DEE\u3057\u623B\u3057\u307E\u3057\u305F\u3002",
776
+ params: {
777
+ comment: {
778
+ label: "\u7406\u7531"
779
+ }
780
+ }
781
+ },
782
+ approval_request_info: {
783
+ label: "\u8FFD\u52A0\u60C5\u5831\u3092\u4F9D\u983C",
784
+ successMessage: "\u8FFD\u52A0\u60C5\u5831\u3092\u4F9D\u983C\u3057\u307E\u3057\u305F\u3002",
785
+ params: {
786
+ comment: {
787
+ label: "\u4F55\u304C\u5FC5\u8981\u3067\u3059\u304B\uFF1F"
788
+ }
789
+ }
790
+ },
791
+ approval_remind: {
792
+ label: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC\u9001\u4FE1",
793
+ successMessage: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC\u3092\u9001\u4FE1\u3057\u307E\u3057\u305F\u3002",
794
+ params: {
795
+ comment: {
796
+ label: "\u30E1\u30E2"
797
+ }
798
+ }
799
+ },
800
+ approval_recall: {
801
+ label: "\u53D6\u308A\u4E0B\u3052",
802
+ 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",
803
+ successMessage: "\u53D6\u308A\u4E0B\u3052\u307E\u3057\u305F\u3002",
804
+ params: {
805
+ comment: {
806
+ label: "\u30B3\u30E1\u30F3\u30C8"
807
+ }
808
+ }
809
+ },
810
+ approval_resubmit: {
811
+ label: "\u518D\u63D0\u51FA",
812
+ successMessage: "\u518D\u63D0\u51FA\u3057\u307E\u3057\u305F\u3002",
813
+ params: {
814
+ comment: {
815
+ label: "\u4F55\u3092\u5909\u66F4\u3057\u307E\u3057\u305F\u304B\uFF1F"
816
+ }
817
+ }
818
+ }
516
819
  }
517
820
  },
518
821
  sys_approval_action: {
@@ -544,7 +847,14 @@ var init_ja_JP_objects_generated = __esm({
544
847
  approve: "\u627F\u8A8D",
545
848
  reject: "\u5374\u4E0B",
546
849
  recall: "\u53D6\u6D88",
547
- escalate: "\u30A8\u30B9\u30AB\u30EC\u30FC\u30B7\u30E7\u30F3"
850
+ escalate: "\u30A8\u30B9\u30AB\u30EC\u30FC\u30B7\u30E7\u30F3",
851
+ reassign: "\u518D\u5272\u308A\u5F53\u3066",
852
+ remind: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC",
853
+ request_info: "\u8FFD\u52A0\u60C5\u5831\u4F9D\u983C",
854
+ comment: "\u30B3\u30E1\u30F3\u30C8",
855
+ revise: "\u5DEE\u3057\u623B\u3057",
856
+ resubmit: "\u518D\u63D0\u51FA",
857
+ ooo_substitute: "\u4E0D\u5728\u6642\u4EE3\u7406"
548
858
  }
549
859
  },
550
860
  actor_id: {
@@ -553,13 +863,21 @@ var init_ja_JP_objects_generated = __esm({
553
863
  comment: {
554
864
  label: "\u30B3\u30E1\u30F3\u30C8"
555
865
  },
866
+ attachments: {
867
+ label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB",
868
+ 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"
869
+ },
556
870
  created_at: {
557
871
  label: "\u4F5C\u6210\u65E5\u6642"
558
872
  }
559
873
  },
560
874
  _views: {
561
875
  recent: {
562
- label: "\u6700\u8FD1"
876
+ label: "\u6700\u8FD1",
877
+ emptyState: {
878
+ title: "\u627F\u8A8D\u64CD\u4F5C\u306F\u307E\u3060\u3042\u308A\u307E\u305B\u3093",
879
+ message: "\u627F\u8A8D\u304C\u9032\u3080\u3068\u64CD\u4F5C\u306F\u81EA\u52D5\u7684\u306B\u8A18\u9332\u3055\u308C\u307E\u3059\u3002"
880
+ }
563
881
  },
564
882
  by_actor: {
565
883
  label: "\u64CD\u4F5C\u8005\u5225"
@@ -663,7 +981,8 @@ var init_es_ES_objects_generated = __esm({
663
981
  pending: "Pendiente",
664
982
  approved: "Aprobada",
665
983
  rejected: "Rechazada",
666
- recalled: "Retirada"
984
+ recalled: "Retirada",
985
+ returned: "Devuelta para revisi\xF3n"
667
986
  }
668
987
  },
669
988
  current_step: {
@@ -705,7 +1024,11 @@ var init_es_ES_objects_generated = __esm({
705
1024
  },
706
1025
  _views: {
707
1026
  my_pending: {
708
- label: "Mis pendientes"
1027
+ label: "Mis pendientes",
1028
+ emptyState: {
1029
+ title: "Sin aprobaciones pendientes",
1030
+ message: "Est\xE1s al d\xEDa: nada espera tu aprobaci\xF3n."
1031
+ }
709
1032
  },
710
1033
  submitted_by_me: {
711
1034
  label: "Enviadas por m\xED"
@@ -716,6 +1039,92 @@ var init_es_ES_objects_generated = __esm({
716
1039
  all_requests: {
717
1040
  label: "Todas"
718
1041
  }
1042
+ },
1043
+ _actions: {
1044
+ approval_approve: {
1045
+ label: "Aprobar",
1046
+ successMessage: "Aprobada.",
1047
+ params: {
1048
+ comment: {
1049
+ label: "Comentario"
1050
+ },
1051
+ attachments: {
1052
+ label: "Adjuntos"
1053
+ }
1054
+ }
1055
+ },
1056
+ approval_reject: {
1057
+ label: "Rechazar",
1058
+ confirmText: "\xBFRechazar esta solicitud? Un rechazo es definitivo para todos los aprobadores.",
1059
+ successMessage: "Rechazada.",
1060
+ params: {
1061
+ comment: {
1062
+ label: "Comentario"
1063
+ },
1064
+ attachments: {
1065
+ label: "Adjuntos"
1066
+ }
1067
+ }
1068
+ },
1069
+ approval_reassign: {
1070
+ label: "Reasignar",
1071
+ successMessage: "Reasignada.",
1072
+ params: {
1073
+ to: {
1074
+ label: "Nuevo aprobador",
1075
+ helpText: "Usuario al que se entrega este paso"
1076
+ },
1077
+ comment: {
1078
+ label: "Comentario"
1079
+ }
1080
+ }
1081
+ },
1082
+ approval_send_back: {
1083
+ label: "Devolver",
1084
+ successMessage: "Devuelta para revisi\xF3n.",
1085
+ params: {
1086
+ comment: {
1087
+ label: "Motivo"
1088
+ }
1089
+ }
1090
+ },
1091
+ approval_request_info: {
1092
+ label: "Pedir informaci\xF3n",
1093
+ successMessage: "Informaci\xF3n solicitada.",
1094
+ params: {
1095
+ comment: {
1096
+ label: "\xBFQu\xE9 necesitas?"
1097
+ }
1098
+ }
1099
+ },
1100
+ approval_remind: {
1101
+ label: "Enviar recordatorio",
1102
+ successMessage: "Recordatorio enviado.",
1103
+ params: {
1104
+ comment: {
1105
+ label: "Nota"
1106
+ }
1107
+ }
1108
+ },
1109
+ approval_recall: {
1110
+ label: "Retirar",
1111
+ confirmText: "\xBFRetirar esta solicitud? Los aprobadores ya no podr\xE1n actuar sobre ella y el registro se desbloquear\xE1.",
1112
+ successMessage: "Retirada.",
1113
+ params: {
1114
+ comment: {
1115
+ label: "Comentario"
1116
+ }
1117
+ }
1118
+ },
1119
+ approval_resubmit: {
1120
+ label: "Reenviar",
1121
+ successMessage: "Reenviada.",
1122
+ params: {
1123
+ comment: {
1124
+ label: "\xBFQu\xE9 ha cambiado?"
1125
+ }
1126
+ }
1127
+ }
719
1128
  }
720
1129
  },
721
1130
  sys_approval_action: {
@@ -747,7 +1156,14 @@ var init_es_ES_objects_generated = __esm({
747
1156
  approve: "Aprobar",
748
1157
  reject: "Rechazar",
749
1158
  recall: "Retirar",
750
- escalate: "Escalar"
1159
+ escalate: "Escalar",
1160
+ reassign: "Reasignaci\xF3n",
1161
+ remind: "Recordatorio",
1162
+ request_info: "Petici\xF3n de informaci\xF3n",
1163
+ comment: "Comentario",
1164
+ revise: "Devoluci\xF3n",
1165
+ resubmit: "Reenv\xEDo",
1166
+ ooo_substitute: "Sustituci\xF3n por ausencia"
751
1167
  }
752
1168
  },
753
1169
  actor_id: {
@@ -756,13 +1172,21 @@ var init_es_ES_objects_generated = __esm({
756
1172
  comment: {
757
1173
  label: "Comentario"
758
1174
  },
1175
+ attachments: {
1176
+ label: "Adjuntos",
1177
+ help: "Archivos que respaldan esta acci\xF3n, p. ej. un contrato firmado o pruebas (#3266)."
1178
+ },
759
1179
  created_at: {
760
1180
  label: "Creado el"
761
1181
  }
762
1182
  },
763
1183
  _views: {
764
1184
  recent: {
765
- label: "Recientes"
1185
+ label: "Recientes",
1186
+ emptyState: {
1187
+ title: "A\xFAn no hay acciones de aprobaci\xF3n",
1188
+ message: "Las acciones se registran autom\xE1ticamente a medida que avanza la aprobaci\xF3n."
1189
+ }
766
1190
  },
767
1191
  by_actor: {
768
1192
  label: "Por actor"
@@ -1047,13 +1471,22 @@ var SysApprovalRequest = ObjectSchema.create({
1047
1471
  // per-viewer block (#3310): approver actions on `record.viewer.can_act`
1048
1472
  // (the caller is a current pending approver — same check the service
1049
1473
  // 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.
1474
+ // submitter actions on `record.viewer.is_submitter`. The core decision levers
1475
+ // (approve/reject/reassign) additionally OR in `record.viewer.can_override`
1476
+ // (#3424) so a platform/tenant admin can rescue a request routed to an
1477
+ // unstaffed position — otherwise undecidable, locking the record forever — by
1478
+ // approving, rejecting, or reassigning it to a real approver. `viewer` is
1479
+ // attached by getRequest/listRequests; where it is absent the predicate fails
1480
+ // closed.
1052
1481
  actions: [
1053
1482
  {
1054
1483
  name: "approval_approve",
1055
1484
  label: "Approve",
1056
1485
  icon: "check-circle",
1486
+ // Primary decision — the console renders this filled/highlighted so it
1487
+ // stands out from the secondary levers in the drawer's action bar,
1488
+ // matching the mobile card hierarchy (objectui#2762 P1-5).
1489
+ variant: "primary",
1057
1490
  type: "api",
1058
1491
  method: "POST",
1059
1492
  target: "/api/v1/approvals/requests/{id}/approve",
@@ -1064,7 +1497,7 @@ var SysApprovalRequest = ObjectSchema.create({
1064
1497
  // string[]`; the decision route persists them on `sys_approval_action`.
1065
1498
  { name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
1066
1499
  ],
1067
- visible: "record.viewer.can_act",
1500
+ visible: "record.viewer.can_act || record.viewer.can_override",
1068
1501
  locations: ["record_section", "list_item"],
1069
1502
  successMessage: "Approved.",
1070
1503
  refreshAfter: true
@@ -1073,6 +1506,9 @@ var SysApprovalRequest = ObjectSchema.create({
1073
1506
  name: "approval_reject",
1074
1507
  label: "Reject",
1075
1508
  icon: "x-circle",
1509
+ // Destructive decision — rendered in the console's danger styling so it
1510
+ // reads as the irreversible action it is (objectui#2762 P1-5).
1511
+ variant: "danger",
1076
1512
  type: "api",
1077
1513
  method: "POST",
1078
1514
  target: "/api/v1/approvals/requests/{id}/reject",
@@ -1080,7 +1516,7 @@ var SysApprovalRequest = ObjectSchema.create({
1080
1516
  { name: "comment", label: "Comment", type: "textarea", required: false },
1081
1517
  { name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
1082
1518
  ],
1083
- visible: "record.viewer.can_act",
1519
+ visible: "record.viewer.can_act || record.viewer.can_override",
1084
1520
  confirmText: "Reject this request? A rejection is final for every approver.",
1085
1521
  locations: ["record_section", "list_item"],
1086
1522
  successMessage: "Rejected.",
@@ -1102,7 +1538,7 @@ var SysApprovalRequest = ObjectSchema.create({
1102
1538
  { field: "submitter_id", name: "to", label: "New approver", required: true, helpText: "User to hand this step to" },
1103
1539
  { name: "comment", label: "Comment", type: "textarea", required: false }
1104
1540
  ],
1105
- visible: "record.viewer.can_act",
1541
+ visible: "record.viewer.can_act || record.viewer.can_override",
1106
1542
  locations: ["record_section"],
1107
1543
  successMessage: "Reassigned.",
1108
1544
  refreshAfter: true
@@ -1219,6 +1655,14 @@ var SysApprovalAction = ObjectSchema2.create({
1219
1655
  // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
1220
1656
  titleFormat: "{action} \xB7 {step_name}",
1221
1657
  highlightFields: ["request_id", "step_name", "action", "actor_id", "created_at"],
1658
+ // ADR-0104 D3 wave 2. `attachments` is a media field, so the files it holds
1659
+ // are OWNED by this row — and the storage service would otherwise authorize
1660
+ // their download by testing whether the caller can READ this row. It cannot:
1661
+ // this table is deliberately closed to ordinary approver positions, so that
1662
+ // test denies the very approver the attachment was filed for. The approvals
1663
+ // service already owns the rule for seeing a decision (visibility of the
1664
+ // parent request, exactly as `listActions` applies it), so it answers.
1665
+ fileAccessDelegate: "approvals",
1222
1666
  listViews: {
1223
1667
  recent: {
1224
1668
  type: "grid",
@@ -1470,9 +1914,9 @@ var SysApprovalDelegation = ObjectSchema4.create({
1470
1914
  trackHistory: true,
1471
1915
  searchable: true,
1472
1916
  apiEnabled: true,
1473
- apiMethods: ["get", "list", "create", "update", "delete"],
1474
- trash: true,
1475
- mru: false
1917
+ // `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child`
1918
+ // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026).
1919
+ apiMethods: ["get", "list", "create", "update", "delete", "bulk"]
1476
1920
  }
1477
1921
  });
1478
1922
 
@@ -1480,16 +1924,167 @@ var SysApprovalDelegation = ObjectSchema4.create({
1480
1924
  import { createHash, randomBytes } from "crypto";
1481
1925
  import {
1482
1926
  APPROVAL_BRANCH_LABELS,
1483
- canonicalApproverType
1927
+ approverTypeIsOrgScoped,
1928
+ canonicalApproverType,
1929
+ normalizeDecisionOutputs
1484
1930
  } from "@objectstack/spec/automation";
1931
+ import { ExpressionEngine, collectCelRootIdentifiers } from "@objectstack/formula";
1932
+ import {
1933
+ ADMIN_FULL_ACCESS,
1934
+ ORGANIZATION_ADMIN_GRANTS,
1935
+ BUILTIN_IDENTITY_PLATFORM_ADMIN,
1936
+ BUILTIN_IDENTITY_ORG_OWNER,
1937
+ BUILTIN_IDENTITY_ORG_ADMIN
1938
+ } from "@objectstack/spec/identity";
1939
+ import { RESUME_AUTHORITY_SERVICE } from "@objectstack/spec/contracts";
1940
+ import { isFileIdToken } from "@objectstack/spec/data";
1485
1941
  import { isGrantActive } from "@objectstack/core";
1942
+
1943
+ // src/approver-org-scope.ts
1944
+ var MAX_ORG_DEPTH = 32;
1945
+ var SYSTEM_CTX = { isSystem: true };
1946
+ function fail(message) {
1947
+ throw new Error(`VALIDATION_FAILED: ${message}`);
1948
+ }
1949
+ async function findOrg(engine, where) {
1950
+ try {
1951
+ const rows = await engine.find("sys_organization", {
1952
+ filter: where,
1953
+ fields: ["id", "slug", "parent_organization_id"],
1954
+ limit: 1,
1955
+ context: SYSTEM_CTX
1956
+ });
1957
+ return Array.isArray(rows) && rows[0] ? rows[0] : null;
1958
+ } catch {
1959
+ return null;
1960
+ }
1961
+ }
1962
+ async function ancestorChain(engine, orgId) {
1963
+ const chain = [];
1964
+ const seen = /* @__PURE__ */ new Set();
1965
+ let cursor = orgId;
1966
+ for (let depth = 0; cursor && depth < MAX_ORG_DEPTH; depth++) {
1967
+ if (seen.has(cursor)) break;
1968
+ seen.add(cursor);
1969
+ chain.push(cursor);
1970
+ const row = await findOrg(engine, { id: cursor });
1971
+ const parent = row?.parent_organization_id;
1972
+ cursor = parent ? String(parent) : null;
1973
+ }
1974
+ return chain;
1975
+ }
1976
+ async function resolveApproverDirectoryOrg(deps, declaration, requestOrgId, approverType, isOrgScopedType) {
1977
+ const declared = typeof declaration === "string" ? declaration.trim() : "";
1978
+ if (!declared) return requestOrgId;
1979
+ if (!isOrgScopedType) {
1980
+ fail(
1981
+ `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)`
1982
+ );
1983
+ }
1984
+ const posture = deps.posture?.();
1985
+ if (posture && posture !== "group") {
1986
+ fail(
1987
+ `cross-organization approver targeting ('organization: ${declared}') requires the 'group' tenancy posture; this deployment resolves '${posture}' (ADR-0105 D9)`
1988
+ );
1989
+ }
1990
+ const requestOrg = requestOrgId ? String(requestOrgId) : "";
1991
+ if (!requestOrg) {
1992
+ fail(
1993
+ `'organization: ${declared}' cannot be resolved for a request that carries no organization \u2014 cross-organization targeting needs an organization to resolve from`
1994
+ );
1995
+ }
1996
+ const chain = await ancestorChain(deps.engine, requestOrg);
1997
+ if (declared === "$root") {
1998
+ const root = chain[chain.length - 1];
1999
+ if (root === requestOrg && chain.length === 1) {
2000
+ fail(
2001
+ `'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`
2002
+ );
2003
+ }
2004
+ return root;
2005
+ }
2006
+ if (declared === "$parent") {
2007
+ const parent = chain[1];
2008
+ if (!parent) {
2009
+ fail(
2010
+ `'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')`
2011
+ );
2012
+ }
2013
+ return parent;
2014
+ }
2015
+ const target = await findOrg(deps.engine, { slug: declared });
2016
+ if (!target?.id) {
2017
+ fail(
2018
+ `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)`
2019
+ );
2020
+ }
2021
+ const targetId = String(target.id);
2022
+ if (targetId === requestOrg) return targetId;
2023
+ const targetChain = await ancestorChain(deps.engine, targetId);
2024
+ const targetRoot = targetChain[targetChain.length - 1];
2025
+ const requestRoot = chain[chain.length - 1];
2026
+ if (!targetRoot || !requestRoot || targetRoot !== requestRoot) {
2027
+ fail(
2028
+ `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)`
2029
+ );
2030
+ }
2031
+ return targetId;
2032
+ }
2033
+ async function filterApproversWhoCanRead(deps, userIds, requestOrgId, context) {
2034
+ const requestOrg = requestOrgId ? String(requestOrgId) : "";
2035
+ if (!requestOrg || userIds.length === 0) return userIds;
2036
+ let members = [];
2037
+ try {
2038
+ members = await deps.engine.find("sys_member", {
2039
+ filter: { organization_id: requestOrg, user_id: { $in: userIds } },
2040
+ fields: ["user_id"],
2041
+ limit: 1e4,
2042
+ context: SYSTEM_CTX
2043
+ });
2044
+ } catch {
2045
+ return userIds;
2046
+ }
2047
+ const canRead = new Set(
2048
+ (members ?? []).map((m) => String(m?.user_id ?? "")).filter(Boolean)
2049
+ );
2050
+ const dropped = userIds.filter((u) => !canRead.has(u));
2051
+ if (dropped.length === 0) return userIds;
2052
+ deps.logger?.warn?.(
2053
+ `[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.`,
2054
+ { dropped, requestOrganizationId: requestOrg, directoryOrganizationId: context.directoryOrgId }
2055
+ );
2056
+ return userIds.filter((u) => canRead.has(u));
2057
+ }
2058
+
2059
+ // src/approval-service.ts
1486
2060
  var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
1487
2061
  var ESCALATION_JOB_NAME = "approvals-sla-escalation";
1488
2062
  var ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1e3;
1489
2063
  var SLA_ACTOR_ID = "system:sla";
2064
+ var DEAD_RUN_ACTOR_ID = "system:dead-run";
2065
+ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
2066
+ "completed",
2067
+ "failed",
2068
+ "cancelled",
2069
+ "timed_out"
2070
+ ]);
1490
2071
  var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
1491
- var SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
2072
+ var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
2073
+ function actingUserId(context) {
2074
+ const userId = context?.userId;
2075
+ return typeof userId === "string" && userId ? userId : null;
2076
+ }
1492
2077
  var OOO_MAX_CHAIN = 8;
2078
+ var GRAPH_APPROVER_TYPES = /* @__PURE__ */ new Set([
2079
+ "team",
2080
+ "department",
2081
+ "business_unit",
2082
+ "bu",
2083
+ "position",
2084
+ "org_membership_level",
2085
+ "manager"
2086
+ ]);
2087
+ var APPROVER_EXPRESSION_ROOTS = /* @__PURE__ */ new Set(["current", "trigger", "vars"]);
1493
2088
  function uid(prefix) {
1494
2089
  const g = globalThis;
1495
2090
  if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
@@ -1543,7 +2138,26 @@ function rowFromRequest(row) {
1543
2138
  step_label: cfg?.__nodeLabel ?? prettifyMachineName(row.current_step),
1544
2139
  sla_due_at: slaDueAt(row.created_at, cfg),
1545
2140
  // ADR-0044 revision round (rides the config snapshot; absent ⇒ round 1).
1546
- round: typeof cfg?.__round === "number" ? cfg.__round : void 0
2141
+ round: typeof cfg?.__round === "number" ? cfg.__round : void 0,
2142
+ // objectui#2902: the node's record-lock policy. The lock is enforced
2143
+ // server-side in `lifecycle-hooks.ts` off THIS SAME snapshot with the
2144
+ // same `!== false` default, so the flag a client renders and the rule the
2145
+ // server applies can never drift. Without it a console can only see
2146
+ // "a pending request exists" and has to assume the record is locked —
2147
+ // which mislabels every `lockRecord: false` node as locked and hides an
2148
+ // edit the server would have accepted.
2149
+ lock_record: cfg?.lockRecord !== false,
2150
+ // #3447 P2: the node's author-declared decision outputs, surfaced so a
2151
+ // decision UI can render input fields for them and POST `outputs` on
2152
+ // approve/reject. Per-request (each node declares its own), which is why
2153
+ // this rides the row instead of the static action params. Two shapes for
2154
+ // version skew: `decision_outputs` stays the bare KEY list an older
2155
+ // console renders as text inputs; `decision_output_defs` carries the
2156
+ // normalized typed declarations a picker-aware console prefers.
2157
+ ...(() => {
2158
+ const defs = normalizeDecisionOutputs(cfg?.decisionOutputs);
2159
+ return defs.length ? { decision_outputs: defs.map((d) => d.key), decision_output_defs: defs } : {};
2160
+ })()
1547
2161
  };
1548
2162
  }
1549
2163
  function slaDueAt(createdAt, cfg) {
@@ -1553,7 +2167,29 @@ function slaDueAt(createdAt, cfg) {
1553
2167
  if (Number.isNaN(t)) return void 0;
1554
2168
  return new Date(t + hours * 36e5).toISOString();
1555
2169
  }
2170
+ function normalizeActionAttachment(entry) {
2171
+ if (entry == null) return void 0;
2172
+ if (typeof entry === "string") {
2173
+ const id = entry.trim();
2174
+ if (!id) return void 0;
2175
+ return isFileIdToken(id) ? { id } : { id, url: id };
2176
+ }
2177
+ if (typeof entry === "object") {
2178
+ const id = entry.id ?? entry.file_id;
2179
+ if (id == null || String(id) === "") return void 0;
2180
+ const mimeType = entry.mimeType ?? entry.mime_type;
2181
+ return {
2182
+ id: String(id),
2183
+ name: typeof entry.name === "string" ? entry.name : void 0,
2184
+ url: typeof entry.url === "string" ? entry.url : void 0,
2185
+ mimeType: typeof mimeType === "string" ? mimeType : void 0,
2186
+ size: typeof entry.size === "number" ? entry.size : void 0
2187
+ };
2188
+ }
2189
+ return void 0;
2190
+ }
1556
2191
  function rowFromAction(row) {
2192
+ const attachments = Array.isArray(row.attachments) ? row.attachments.map(normalizeActionAttachment).filter((a) => !!a) : [];
1557
2193
  return {
1558
2194
  id: String(row.id),
1559
2195
  request_id: String(row.request_id),
@@ -1562,10 +2198,9 @@ function rowFromAction(row) {
1562
2198
  action: row.action,
1563
2199
  actor_id: row.actor_id ?? void 0,
1564
2200
  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,
2201
+ // Decision attachments (#3266): rich descriptors carrying the display name +
2202
+ // download URL, so consumers label/open them without reading `sys_file`.
2203
+ attachments: attachments.length ? attachments : void 0,
1569
2204
  created_at: row.created_at ?? void 0
1570
2205
  };
1571
2206
  }
@@ -1577,6 +2212,33 @@ var _ApprovalService = class _ApprovalService {
1577
2212
  this.automation = opts.automation;
1578
2213
  this.messaging = opts.messaging;
1579
2214
  this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
2215
+ this.tenancyPosture = opts.tenancyPosture;
2216
+ }
2217
+ /** Attach (or replace) the ADR-0105 D9 posture provider. */
2218
+ attachTenancyPosture(provider) {
2219
+ this.tenancyPosture = provider;
2220
+ }
2221
+ /** Deps bundle for the ADR-0105 D9 org-scope helpers. */
2222
+ get orgScopeDeps() {
2223
+ return {
2224
+ engine: this.engine,
2225
+ posture: this.tenancyPosture,
2226
+ logger: this.logger
2227
+ };
2228
+ }
2229
+ /**
2230
+ * [ADR-0105 D9] Which organization's directory resolves ONE approver spec.
2231
+ * Absent declaration ⇒ the request's own organization (unchanged, no reads).
2232
+ */
2233
+ async directoryOrgFor(a, requestOrgId) {
2234
+ const rawType = String(a?.type ?? "");
2235
+ return resolveApproverDirectoryOrg(
2236
+ this.orgScopeDeps,
2237
+ a?.organization,
2238
+ requestOrgId,
2239
+ rawType,
2240
+ approverTypeIsOrgScoped(rawType)
2241
+ );
1580
2242
  }
1581
2243
  /** Attach (or replace) the automation surface used to resume flow runs. */
1582
2244
  attachAutomation(automation) {
@@ -1611,13 +2273,107 @@ var _ApprovalService = class _ApprovalService {
1611
2273
  const rows = await this.engine.find("sys_approval_request", {
1612
2274
  where: { id: requestId },
1613
2275
  limit: 1,
1614
- context: SYSTEM_CTX
2276
+ context: SYSTEM_CTX2
1615
2277
  });
1616
2278
  const raw = Array.isArray(rows) ? rows[0] : null;
1617
2279
  if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
1618
2280
  if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
1619
2281
  return raw;
1620
2282
  }
2283
+ /**
2284
+ * Privileged-override gate (#3424). A stuck approval — one routed to a
2285
+ * position/team with no holders (so its `pending_approvers` is only an
2286
+ * unresolvable `type:value` literal) or to approvers who have all since left —
2287
+ * is otherwise undecidable: no concrete user is in the slate, so every normal
2288
+ * `decide` / `reassign` / `recall` is `FORBIDDEN` and (with `lockRecord`) the
2289
+ * record stays locked forever with no in-product recovery. A platform or
2290
+ * tenant admin — the same posture the engine's superuser bypass already
2291
+ * trusts — may always act on a PENDING request to release it: approve, reject,
2292
+ * reassign it to a real approver, or recall it.
2293
+ *
2294
+ * A platform admin crosses the tenant wall (matching the unscoped
2295
+ * `admin_full_access` evidence); a tenant admin may override only within their
2296
+ * own org (or an org-less request). A system context always passes. Signals are
2297
+ * read defensively off the resolved exec context (`permissions` / `positions` /
2298
+ * the derived `posture`, ADR-0095) so any transport that resolves through the
2299
+ * shared authz resolver lights this up without extra wiring.
2300
+ */
2301
+ isOverrideActor(context, requestOrg) {
2302
+ if (!context) return false;
2303
+ if (context.isSystem) return true;
2304
+ const perms = Array.isArray(context.permissions) ? context.permissions : [];
2305
+ const positions = Array.isArray(context.positions) ? context.positions : [];
2306
+ const posture = context.posture;
2307
+ const isPlatformAdmin = posture === "PLATFORM_ADMIN" || perms.includes(ADMIN_FULL_ACCESS) || positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN);
2308
+ if (isPlatformAdmin) return true;
2309
+ 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);
2310
+ if (!isTenantAdmin) return false;
2311
+ const actorTenant = context.tenantId ?? context.organizationId ?? null;
2312
+ return requestOrg == null || actorTenant != null && String(requestOrg) === String(actorTenant);
2313
+ }
2314
+ /**
2315
+ * Pin the acting identity to the AUTHENTICATED CALLER (#3800).
2316
+ *
2317
+ * Every public entrypoint accepts an `actorId`, and the REST routes fill it
2318
+ * from `body.actorId ?? body.actor_id ?? context.userId` — so before this
2319
+ * gate the body won. The authorization checks downstream all read that value
2320
+ * (`pending_approvers.includes(input.actorId)`, `submitter_id === actorId`),
2321
+ * which made the body-supplied string not merely the audit label but the key
2322
+ * that opens the door: any authenticated user could name a pending approver
2323
+ * and have that approver's decision recorded and the owning flow resumed.
2324
+ * #3783 drew this line for the data-write identity ({@link actingUserId});
2325
+ * this closes the authorization half.
2326
+ *
2327
+ * A caller may still name an identity OTHER than their bare user id, because
2328
+ * a slot legitimately can be keyed by one: `resolveApproverSpec` stores the
2329
+ * `type:value` literal when a graph lookup yields nothing, and an author may
2330
+ * write an email as a `user` approver. So the rule is not "actorId must equal
2331
+ * userId" — it is **"actorId must be an identity the SERVER can prove belongs
2332
+ * to the caller"**. Anything else is `FORBIDDEN`.
2333
+ *
2334
+ * A system context is exempt and keeps its explicit actor: the SLA sweep
2335
+ * passes the reserved {@link SLA_ACTOR_ID} sentinel, and the ADR-0043 action
2336
+ * link passes the approver its single-use token is cryptographically bound to
2337
+ * (having also put them on the context). Those are the only two callers that
2338
+ * hold a trustworthy actor with no session behind them.
2339
+ *
2340
+ * A caller with NO identity at all cannot act. That case is reachable: the
2341
+ * REST anonymous-deny only fires when `api.requireAuth` is set, so without it
2342
+ * an anonymous request previously decided approvals outright by naming one.
2343
+ */
2344
+ async resolveActor(actorId, context) {
2345
+ if (context?.isSystem) {
2346
+ if (!actorId) throw new Error("VALIDATION_FAILED: actorId is required");
2347
+ return actorId;
2348
+ }
2349
+ const uid2 = actingUserId(context);
2350
+ if (!uid2) {
2351
+ throw new Error("FORBIDDEN: an approval action requires an authenticated caller");
2352
+ }
2353
+ if (!actorId || String(actorId) === uid2) return uid2;
2354
+ const named = String(actorId);
2355
+ for (const position of context.positions ?? []) {
2356
+ if (named === `position:${position}` || named === `role:${position}`) return named;
2357
+ }
2358
+ if (named.includes("@") && await this.callerHasEmail(uid2, named)) return named;
2359
+ throw new Error(
2360
+ `FORBIDDEN: cannot act as '${named}' \u2014 an approval action is recorded against the authenticated caller`
2361
+ );
2362
+ }
2363
+ /** Does `userId`'s own account carry `email`? (Slots keyed by email, #3800.) */
2364
+ async callerHasEmail(userId, email) {
2365
+ try {
2366
+ const rows = await this.engine.find("sys_user", {
2367
+ where: { id: userId },
2368
+ limit: 1,
2369
+ context: SYSTEM_CTX2
2370
+ });
2371
+ const row = Array.isArray(rows) ? rows[0] : null;
2372
+ return !!row?.email && String(row.email).toLowerCase() === email.toLowerCase();
2373
+ } catch {
2374
+ return false;
2375
+ }
2376
+ }
1621
2377
  /**
1622
2378
  * Expand the approvers on an Approval node into user IDs by querying the
1623
2379
  * graph tables for `team:` / `department:` / `position:` /
@@ -1652,7 +2408,7 @@ var _ApprovalService = class _ApprovalService {
1652
2408
  * `opts.substitutions` collector to record the hops for audit + notification.
1653
2409
  */
1654
2410
  async expandApprovers(step, record, organizationId, opts) {
1655
- var _a;
2411
+ var _a, _b, _c;
1656
2412
  if (!step || !Array.isArray(step.approvers)) return [];
1657
2413
  const now = opts?.now ?? this.clock.now().getTime();
1658
2414
  const out = [];
@@ -1660,12 +2416,34 @@ var _ApprovalService = class _ApprovalService {
1660
2416
  for (let idx = 0; idx < specs.length; idx++) {
1661
2417
  const a = specs[idx];
1662
2418
  if (!a) continue;
1663
- const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
1664
2419
  const groupKey = a.group != null && String(a.group) !== "" ? String(a.group) : `#${idx}`;
2420
+ if (canonicalApproverType(String(a.type)) === "expression") {
2421
+ const resolved = await this.resolveExpressionApprovers(
2422
+ a,
2423
+ record,
2424
+ organizationId,
2425
+ now,
2426
+ opts?.substitutions,
2427
+ opts?.exprCtx
2428
+ );
2429
+ if (opts?.resolvedFrom) opts.resolvedFrom[`expression#${idx}`] = resolved.raw;
2430
+ for (const entry of resolved.slots) {
2431
+ if (!entry.id) continue;
2432
+ out.push(entry.id);
2433
+ if (opts?.groups) {
2434
+ ((_a = opts.groups)[_b = entry.id] ?? (_a[_b] = [])).push(entry.subGroup ? `${groupKey}:${entry.subGroup}` : groupKey);
2435
+ }
2436
+ }
2437
+ continue;
2438
+ }
2439
+ if (opts?.resolvedFrom && canonicalApproverType(String(a.type)) === "field" && a.value != null) {
2440
+ opts.resolvedFrom[`field:${a.value}`] = record?.[a.value] ?? null;
2441
+ }
2442
+ const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
1665
2443
  for (const u of ids) {
1666
2444
  if (!u) continue;
1667
2445
  out.push(u);
1668
- if (opts?.groups) ((_a = opts.groups)[u] ?? (_a[u] = [])).push(groupKey);
2446
+ if (opts?.groups) ((_c = opts.groups)[u] ?? (_c[u] = [])).push(groupKey);
1669
2447
  }
1670
2448
  }
1671
2449
  return out.filter(Boolean);
@@ -1686,24 +2464,35 @@ var _ApprovalService = class _ApprovalService {
1686
2464
  { deprecated: a.type, canonical: type }
1687
2465
  );
1688
2466
  }
2467
+ const directoryOrg = await this.directoryOrgFor(a, organizationId);
2468
+ const crossOrg = directoryOrg !== organizationId;
1689
2469
  if (type === "user") {
1690
2470
  return this.applyOooDelegation(String(a.value), now, organizationId, substitutions);
1691
2471
  }
1692
2472
  if (type === "field" && record) {
1693
- return this.applyOooDelegation(String(record[a.value] ?? ""), now, organizationId, substitutions);
2473
+ const out = [];
2474
+ for (const id of csvSplit(record[a.value])) {
2475
+ out.push(...await this.applyOooDelegation(id, now, organizationId, substitutions));
2476
+ }
2477
+ return out;
1694
2478
  }
2479
+ const bounded = async (users) => crossOrg ? filterApproversWhoCanRead(this.orgScopeDeps, users, organizationId, {
2480
+ approverType: type,
2481
+ value: a.value != null ? String(a.value) : void 0,
2482
+ directoryOrgId: directoryOrg
2483
+ }) : users;
1695
2484
  try {
1696
2485
  if (type === "team") {
1697
2486
  const users = await this.expandTeamUsers(String(a.value));
1698
2487
  if (users.length) return users;
1699
2488
  } else if (type === "department" || type === "business_unit" || type === "bu") {
1700
- const users = await this.expandBusinessUnitUsers(String(a.value), organizationId);
2489
+ const users = await bounded(await this.expandBusinessUnitUsers(String(a.value), directoryOrg));
1701
2490
  if (users.length) return users;
1702
2491
  } else if (type === "position") {
1703
- const users = await this.expandPositionUsers(String(a.value), organizationId);
2492
+ const users = await bounded(await this.expandPositionUsers(String(a.value), directoryOrg));
1704
2493
  if (users.length) return users;
1705
2494
  } else if (type === "org_membership_level") {
1706
- const users = await this.expandMembershipTierUsers(String(a.value), organizationId);
2495
+ const users = await bounded(await this.expandMembershipTierUsers(String(a.value), directoryOrg));
1707
2496
  if (users.length) return users;
1708
2497
  } else if (type === "manager" && record) {
1709
2498
  const subject = record[a.value] ?? record.owner_id;
@@ -1714,8 +2503,122 @@ var _ApprovalService = class _ApprovalService {
1714
2503
  }
1715
2504
  } catch {
1716
2505
  }
2506
+ if (type === "queue") {
2507
+ this.logger?.warn?.(
2508
+ `[approvals] approver type 'queue' is not implemented \u2014 the slot resolves to nobody (#3508)`,
2509
+ { value: a.value }
2510
+ );
2511
+ } else if (GRAPH_APPROVER_TYPES.has(type)) {
2512
+ this.logger?.warn?.(
2513
+ `[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)`,
2514
+ { type, value: a.value, organizationId: organizationId ?? null }
2515
+ );
2516
+ }
1717
2517
  return [`${a.type}:${a.value}`];
1718
2518
  }
2519
+ /**
2520
+ * Resolve an `expression` approver (#3447 P2): evaluate its CEL source at
2521
+ * node entry against the three explicit roots — `current` (live record),
2522
+ * `trigger` (submit snapshot), `vars` (flow variables) — then expand the
2523
+ * result into people per `resolveAs`.
2524
+ *
2525
+ * Every failure here THROWS (config/parse errors as `VALIDATION_FAILED`,
2526
+ * evaluation faults as `EXPRESSION_FAILED`) so the approval node fails
2527
+ * loudly instead of opening a request routed to nobody — an approver
2528
+ * expression that cannot run is a routing bug, never "condition not met".
2529
+ * Error messages carry the correct spelling because their primary reader is
2530
+ * the AI author fixing the flow on the next validate pass.
2531
+ *
2532
+ * Returns `slots` (approver id + optional per_group sub-key) and `raw` (the
2533
+ * expression's own values, pre-expansion) for the `__resolvedFrom` audit.
2534
+ */
2535
+ async resolveExpressionApprovers(a, liveRecord, organizationId, now, substitutions, exprCtx) {
2536
+ const source = String(a.value ?? "").trim();
2537
+ if (!source) {
2538
+ throw new Error("VALIDATION_FAILED: expression approver has an empty expression");
2539
+ }
2540
+ const parsed = collectCelRootIdentifiers(source);
2541
+ if (!parsed.ok) {
2542
+ throw new Error(`VALIDATION_FAILED: expression approver does not parse: ${parsed.error} \u2014 source: \`${source}\``);
2543
+ }
2544
+ const illegal = parsed.roots.filter((r) => !APPROVER_EXPRESSION_ROOTS.has(r));
2545
+ if (illegal.length) {
2546
+ 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)?`;
2547
+ throw new Error(
2548
+ `VALIDATION_FAILED: expression approver references \`${illegal.join("`, `")}\` \u2014 only \`current.*\`, \`trigger.*\` and \`vars.*\` are available; ${hint}. Source: \`${source}\``
2549
+ );
2550
+ }
2551
+ const result = ExpressionEngine.evaluate(
2552
+ { dialect: "cel", source },
2553
+ { extra: { current: liveRecord ?? {}, trigger: exprCtx?.trigger ?? {}, vars: exprCtx?.vars ?? {} } }
2554
+ );
2555
+ if (!result.ok) {
2556
+ throw new Error(
2557
+ `EXPRESSION_FAILED: expression approver failed to evaluate (${result.error.kind}): ${result.error.message} \u2014 source: \`${source}\``
2558
+ );
2559
+ }
2560
+ const value = result.value;
2561
+ let raw;
2562
+ if (value == null || value === "") {
2563
+ raw = [];
2564
+ } else if (typeof value === "string") {
2565
+ raw = csvSplit(value);
2566
+ } else if (Array.isArray(value)) {
2567
+ const bad = value.find((v) => v != null && typeof v !== "string" && typeof v !== "number");
2568
+ if (bad !== void 0) {
2569
+ throw new Error(
2570
+ `EXPRESSION_FAILED: expression approver must yield ids (string / CSV / string array), got an array containing ${typeof bad} \u2014 source: \`${source}\``
2571
+ );
2572
+ }
2573
+ raw = value.map((v) => String(v ?? "").trim()).filter(Boolean);
2574
+ } else {
2575
+ throw new Error(
2576
+ `EXPRESSION_FAILED: expression approver must yield ids (string / CSV / string array), got ${typeof value} \u2014 source: \`${source}\``
2577
+ );
2578
+ }
2579
+ const resolveAs = String(a.resolveAs ?? "user");
2580
+ if (resolveAs === "user") {
2581
+ const slots2 = [];
2582
+ for (const id of raw) {
2583
+ for (const routed of await this.applyOooDelegation(id, now, organizationId, substitutions)) {
2584
+ slots2.push({ id: routed });
2585
+ }
2586
+ }
2587
+ return { slots: slots2, raw };
2588
+ }
2589
+ const directoryOrg = await this.directoryOrgFor(a, organizationId);
2590
+ const crossOrg = directoryOrg !== organizationId;
2591
+ const slots = [];
2592
+ for (const key of raw) {
2593
+ let users = [];
2594
+ try {
2595
+ if (resolveAs === "department") users = await this.expandBusinessUnitUsers(key, directoryOrg);
2596
+ else if (resolveAs === "position") users = await this.expandPositionUsers(key, directoryOrg);
2597
+ else if (resolveAs === "team") users = await this.expandTeamUsers(key);
2598
+ else {
2599
+ throw new Error(
2600
+ `VALIDATION_FAILED: expression approver has unknown resolveAs '${resolveAs}' \u2014 use 'user', 'department', 'position', or 'team'`
2601
+ );
2602
+ }
2603
+ } catch (err) {
2604
+ if (String(err?.message ?? "").startsWith("VALIDATION_FAILED")) throw err;
2605
+ users = [];
2606
+ }
2607
+ if (crossOrg && users.length) {
2608
+ users = await filterApproversWhoCanRead(this.orgScopeDeps, users, organizationId, {
2609
+ approverType: resolveAs,
2610
+ value: key,
2611
+ directoryOrgId: directoryOrg
2612
+ });
2613
+ }
2614
+ if (!users.length) {
2615
+ slots.push({ id: `${resolveAs}:${key}`, subGroup: key });
2616
+ continue;
2617
+ }
2618
+ for (const u of users) slots.push({ id: u, subGroup: key });
2619
+ }
2620
+ return { slots, raw };
2621
+ }
1719
2622
  /** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
1720
2623
  async expandTeamUsers(teamId) {
1721
2624
  if (!teamId) return [];
@@ -1725,22 +2628,46 @@ var _ApprovalService = class _ApprovalService {
1725
2628
  filter: { team_id: teamId },
1726
2629
  fields: ["user_id"],
1727
2630
  limit: 1e4,
1728
- context: SYSTEM_CTX
2631
+ context: SYSTEM_CTX2
1729
2632
  });
1730
2633
  } catch {
1731
2634
  rows = [];
1732
2635
  }
1733
2636
  return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
1734
2637
  }
2638
+ /**
2639
+ * Tenant scope for a `sys_business_unit` read that may legitimately be
2640
+ * env-wide (#3807).
2641
+ *
2642
+ * `organization_id = null` on a platform object means "owned by no
2643
+ * organization" — a row written by a seed, the file layer, or bootstrap,
2644
+ * i.e. before (or outside) any org exists. A strict
2645
+ * `organization_id = <request org>` equality made every such row invisible:
2646
+ * the seed check below found nothing, the whole expansion returned `[]`, and
2647
+ * the approver fell back to the dead `department:<id>` literal that routes to
2648
+ * nobody. That is not an edge case — an app's org tree is normally seeded
2649
+ * (a seed cannot know the org id the runtime mints at boot) while the
2650
+ * approval request always carries one, so EVERY department approver a
2651
+ * designer could pick resolved to nobody.
2652
+ *
2653
+ * Widen to "this org ∪ env-wide", the same predicate `sys_metadata`'s
2654
+ * pending-draft listing settled on for the identical reason. Another org's
2655
+ * unit still fails the match, so the wall between two organizations is
2656
+ * unchanged — only rows belonging to no org at all become visible.
2657
+ */
2658
+ businessUnitOrgScope(filter, organizationId) {
2659
+ if (!organizationId) return filter;
2660
+ return { ...filter, $or: [{ organization_id: organizationId }, { organization_id: null }] };
2661
+ }
1735
2662
  /** Recursive department — walks `sys_business_unit.parent_business_unit_id`. */
1736
2663
  async expandBusinessUnitUsers(businessUnitId, organizationId) {
1737
2664
  if (!businessUnitId) return [];
1738
2665
  try {
1739
2666
  const seed = await this.engine.find("sys_business_unit", {
1740
- filter: organizationId ? { id: businessUnitId, organization_id: organizationId } : { id: businessUnitId },
2667
+ filter: this.businessUnitOrgScope({ id: businessUnitId }, organizationId),
1741
2668
  fields: ["id", "active"],
1742
2669
  limit: 1,
1743
- context: SYSTEM_CTX
2670
+ context: SYSTEM_CTX2
1744
2671
  });
1745
2672
  const seedRow = Array.isArray(seed) ? seed[0] : null;
1746
2673
  if (!seedRow || seedRow.active === false) return [];
@@ -1753,9 +2680,11 @@ var _ApprovalService = class _ApprovalService {
1753
2680
  const parent = queue.shift();
1754
2681
  let kids = [];
1755
2682
  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 });
2683
+ const filter = this.businessUnitOrgScope(
2684
+ { parent_business_unit_id: parent, active: { $ne: false } },
2685
+ organizationId
2686
+ );
2687
+ kids = await this.engine.find("sys_business_unit", { filter, fields: ["id"], limit: 1e3, context: SYSTEM_CTX2 });
1759
2688
  } catch {
1760
2689
  kids = [];
1761
2690
  }
@@ -1773,7 +2702,7 @@ var _ApprovalService = class _ApprovalService {
1773
2702
  filter: { business_unit_id: { $in: Array.from(seen) } },
1774
2703
  fields: ["user_id"],
1775
2704
  limit: 1e4,
1776
- context: SYSTEM_CTX
2705
+ context: SYSTEM_CTX2
1777
2706
  });
1778
2707
  } catch {
1779
2708
  rows = [];
@@ -1798,7 +2727,7 @@ var _ApprovalService = class _ApprovalService {
1798
2727
  filter,
1799
2728
  fields: ["user_id"],
1800
2729
  limit: 1e4,
1801
- context: SYSTEM_CTX
2730
+ context: SYSTEM_CTX2
1802
2731
  });
1803
2732
  for (const r of rows ?? []) {
1804
2733
  const uid2 = String(r.user_id ?? "");
@@ -1821,7 +2750,7 @@ var _ApprovalService = class _ApprovalService {
1821
2750
  if (organizationId) filter.organization_id = organizationId;
1822
2751
  let rows = [];
1823
2752
  try {
1824
- rows = await this.engine.find("sys_member", { filter, fields: ["user_id"], limit: 1e4, context: SYSTEM_CTX });
2753
+ rows = await this.engine.find("sys_member", { filter, fields: ["user_id"], limit: 1e4, context: SYSTEM_CTX2 });
1825
2754
  } catch {
1826
2755
  rows = [];
1827
2756
  }
@@ -1833,7 +2762,7 @@ var _ApprovalService = class _ApprovalService {
1833
2762
  filter: { id: userId },
1834
2763
  fields: ["id", "manager_id"],
1835
2764
  limit: 1,
1836
- context: SYSTEM_CTX
2765
+ context: SYSTEM_CTX2
1837
2766
  });
1838
2767
  const row = Array.isArray(rows) ? rows[0] : null;
1839
2768
  return row?.manager_id ? String(row.manager_id) : null;
@@ -1883,7 +2812,7 @@ var _ApprovalService = class _ApprovalService {
1883
2812
  filter: { delegator_id: delegatorId },
1884
2813
  fields: ["id", "delegator_id", "delegate_id", "valid_from", "valid_until", "reason", "organization_id"],
1885
2814
  limit: 50,
1886
- context: SYSTEM_CTX
2815
+ context: SYSTEM_CTX2
1887
2816
  });
1888
2817
  } catch {
1889
2818
  return null;
@@ -1897,14 +2826,86 @@ var _ApprovalService = class _ApprovalService {
1897
2826
  });
1898
2827
  return active[0];
1899
2828
  }
1900
- /** Mirror a request status onto a business-object field, if configured. */
1901
- async mirrorStatusField(object, recordId, field, status) {
2829
+ /**
2830
+ * Mirror a request status onto a business-object field, if configured.
2831
+ *
2832
+ * **Elevated, but not anonymous (#3783).** The write stays `isSystem`: the
2833
+ * record is normally LOCKED while its approval is live and the submitter
2834
+ * cannot edit it, so only a platform write can land the status — that is what
2835
+ * the lock hook's system exemption (`lifecycle-hooks.ts`) is for. What it must
2836
+ * NOT do is throw away *who* caused the transition. Every status below is
2837
+ * something a specific human just did — a submitter submitting or recalling,
2838
+ * an approver deciding or sending back — and this write is what fires the
2839
+ * target object's record-change flows. With no `userId` on it those cascades
2840
+ * inherit no trigger user, and since #3760 a `runAs:'user'` run with no trigger
2841
+ * user has its data ops REFUSED — so "when the invoice is approved, do X", the
2842
+ * most natural approvals automation there is, had to declare `runAs:'system'`
2843
+ * and take blanket elevation for a case where a perfectly good scoped identity
2844
+ * existed. Re-attaching the actor lets those cascades run as the deciding user
2845
+ * with RLS enforced. Same shape the approval node already uses when it calls
2846
+ * into this service (`approval-node.ts`).
2847
+ *
2848
+ * `actorId` is `null` for the genuinely machine-driven transitions (the SLA
2849
+ * escalation's auto-decision, the dead-run sweep). There is no human to name
2850
+ * there, and naming a sentinel would put a non-user in `updated_by` and in
2851
+ * every downstream flow's identity. Those cascades stay user-less — a flow
2852
+ * that wants to react to them still has to declare `runAs:'system'`, which is
2853
+ * the honest answer rather than an oversight.
2854
+ *
2855
+ * Deliberately carries `userId` ONLY, not the request's org. On an
2856
+ * ExecutionContext `tenantId` is a driver-scoping knob, not attribution
2857
+ * (`buildDriverOptions` turns it into a tenant predicate on the update), so
2858
+ * passing it would newly org-scope this write and silently no-op the mirror on
2859
+ * a record whose org differs from the request's — while buying nothing: the
2860
+ * automation engine back-fills the run's `tenantId` from the resolved user's
2861
+ * own grants.
2862
+ */
2863
+ async mirrorStatusField(object, recordId, field, status, actorId) {
1902
2864
  try {
1903
- await this.engine.update(object, { id: recordId, [field]: status }, { context: SYSTEM_CTX });
2865
+ const context = actorId ? { ...SYSTEM_CTX2, userId: actorId } : SYSTEM_CTX2;
2866
+ await this.engine.update(object, { id: recordId, [field]: status }, { context });
1904
2867
  } catch (err) {
1905
2868
  this.logger?.warn?.(`[approvals] mirrorStatusField failed: ${err?.message ?? err}`);
1906
2869
  }
1907
2870
  }
2871
+ /**
2872
+ * Re-read a business record's CURRENT state by id so approver resolution binds
2873
+ * to live data at node entry, not the trigger snapshot the flow froze into
2874
+ * `$record` at submit time (#3447). A `field` / `manager` approver names *who*
2875
+ * decides, and an earlier node — or the approver of an earlier step — may have
2876
+ * written that routing field after submit (e.g. a lead reviewer picking which
2877
+ * departments co-review). Graph approvers (team / position / …) already query
2878
+ * live; this brings the in-record types into line.
2879
+ *
2880
+ * Read under system identity: approver routing is a platform concern and the
2881
+ * record is the flow's own subject, so the submitter's RLS/FLS must not narrow
2882
+ * it. Degrades to `fallback` (the snapshot) when the record can't be re-read —
2883
+ * hard-deleted between submit and node entry, or an object whose backend can't
2884
+ * serve a point read — warning rather than throwing so a transient miss can't
2885
+ * wedge an approval. That "warn but proceed" stance matches the
2886
+ * no-concrete-approver guard (#3424) and the "record is gone" enrichment path
2887
+ * that already falls back to the payload snapshot.
2888
+ */
2889
+ async loadLiveRecord(object, recordId, fallback) {
2890
+ try {
2891
+ const rows = await this.engine.find(object, {
2892
+ where: { id: recordId },
2893
+ limit: 1,
2894
+ context: SYSTEM_CTX2
2895
+ });
2896
+ const live = Array.isArray(rows) ? rows[0] : rows;
2897
+ if (live) return live;
2898
+ this.logger?.warn?.(
2899
+ `[approvals] live record ${object}/${recordId} not found at node entry \u2014 resolving approvers against the trigger snapshot (#3447 fallback).`,
2900
+ { object, recordId }
2901
+ );
2902
+ } catch (err) {
2903
+ this.logger?.warn?.(
2904
+ `[approvals] live record re-read failed for ${object}/${recordId}: ${err?.message ?? err} \u2014 resolving approvers against the trigger snapshot (#3447 fallback).`
2905
+ );
2906
+ }
2907
+ return fallback ?? {};
2908
+ }
1908
2909
  // ── ADR-0019: Approval-as-flow-node ──────────────────────────
1909
2910
  //
1910
2911
  // A flow's Approval node opens a request via `openNodeRequest` (carrying its
@@ -1916,6 +2917,10 @@ var _ApprovalService = class _ApprovalService {
1916
2917
  * Open a pending approval request on behalf of a flow's Approval node. The
1917
2918
  * node config (approvers / behavior / status field) is snapshotted on the row
1918
2919
  * so a decision can be made without any process to resolve against.
2920
+ *
2921
+ * #3447 P2: may instead return an {@link ApprovalNodeAutoOutcome} — no
2922
+ * request opened — when the slate resolves empty and the node's
2923
+ * `onEmptyApprovers` policy is `auto_approve`.
1919
2924
  */
1920
2925
  async openNodeRequest(input, context) {
1921
2926
  if (!input.object) throw new Error("VALIDATION_FAILED: object is required");
@@ -1924,7 +2929,7 @@ var _ApprovalService = class _ApprovalService {
1924
2929
  const existing = await this.engine.find("sys_approval_request", {
1925
2930
  where: { object_name: input.object, record_id: input.recordId, status: "pending" },
1926
2931
  limit: 1,
1927
- context: SYSTEM_CTX
2932
+ context: SYSTEM_CTX2
1928
2933
  });
1929
2934
  if (Array.isArray(existing) && existing[0]) {
1930
2935
  throw new Error(`DUPLICATE_REQUEST: a pending approval already exists for ${input.object}/${input.recordId}`);
@@ -1933,26 +2938,56 @@ var _ApprovalService = class _ApprovalService {
1933
2938
  const nowDate = this.clock.now();
1934
2939
  const substitutions = [];
1935
2940
  const groups = {};
2941
+ const liveRecord = await this.loadLiveRecord(input.object, input.recordId, input.record);
2942
+ const resolvedFrom = {};
1936
2943
  const approvers = await this.expandApprovers(
1937
2944
  { approvers: input.config.approvers },
1938
- input.record,
2945
+ liveRecord,
1939
2946
  ctxOrg,
1940
- { now: nowDate.getTime(), substitutions, groups }
2947
+ {
2948
+ now: nowDate.getTime(),
2949
+ substitutions,
2950
+ groups,
2951
+ exprCtx: { trigger: input.record ?? null, vars: input.variables ?? null },
2952
+ resolvedFrom
2953
+ }
1941
2954
  );
2955
+ if (!approvers.some((a) => a && !a.includes(":"))) {
2956
+ const emptyPolicy = input.config.onEmptyApprovers ?? "admin_rescue";
2957
+ if (emptyPolicy === "fail") {
2958
+ throw new Error(
2959
+ `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.`
2960
+ );
2961
+ }
2962
+ if (emptyPolicy === "auto_approve") {
2963
+ this.logger?.warn?.(
2964
+ `[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).`,
2965
+ { object: input.object, recordId: input.recordId, node: input.nodeId, resolved: approvers }
2966
+ );
2967
+ return { autoApproved: true, reason: "empty_approvers" };
2968
+ }
2969
+ this.logger?.warn?.(
2970
+ `[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.`,
2971
+ { object: input.object, recordId: input.recordId, node: input.nodeId, resolved: approvers }
2972
+ );
2973
+ }
1942
2974
  const now = nowDate.toISOString();
1943
2975
  const id = uid("areq");
1944
2976
  const processName = `flow:${input.flowName ?? input.nodeId}`;
1945
2977
  const configSnapshot = { ...input.config };
1946
2978
  if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
1947
2979
  if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
1948
- if (input.config.behavior === "quorum" || input.config.behavior === "per_group") {
2980
+ if (input.config.behavior && input.config.behavior !== "first_response") {
1949
2981
  configSnapshot.__approverGroups = groups;
1950
2982
  }
2983
+ if (Object.keys(resolvedFrom).length) {
2984
+ configSnapshot.__resolvedFrom = resolvedFrom;
2985
+ }
1951
2986
  try {
1952
2987
  const prior = await this.engine.find("sys_approval_request", {
1953
2988
  where: { flow_run_id: input.runId, flow_node_id: input.nodeId },
1954
2989
  limit: 500,
1955
- context: SYSTEM_CTX
2990
+ context: SYSTEM_CTX2
1956
2991
  });
1957
2992
  const n = Array.isArray(prior) ? prior.length : 0;
1958
2993
  if (n > 0) configSnapshot.__round = n + 1;
@@ -1976,7 +3011,7 @@ var _ApprovalService = class _ApprovalService {
1976
3011
  created_at: now,
1977
3012
  updated_at: now
1978
3013
  };
1979
- await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX });
3014
+ await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX2 });
1980
3015
  await this.syncApproverIndex(id, approvers, ctxOrg, now);
1981
3016
  await this.engine.insert("sys_approval_action", {
1982
3017
  id: uid("aact"),
@@ -1988,7 +3023,7 @@ var _ApprovalService = class _ApprovalService {
1988
3023
  actor_id: input.submitterId ?? context.userId ?? null,
1989
3024
  comment: null,
1990
3025
  created_at: now
1991
- }, { context: SYSTEM_CTX });
3026
+ }, { context: SYSTEM_CTX2 });
1992
3027
  for (const sub of substitutions) {
1993
3028
  await this.engine.insert("sys_approval_action", {
1994
3029
  id: uid("aact"),
@@ -2000,7 +3035,7 @@ var _ApprovalService = class _ApprovalService {
2000
3035
  actor_id: null,
2001
3036
  comment: `${sub.from} \u2192 ${sub.to}${sub.reason ? ` \u2014 ${sub.reason}` : ""}`,
2002
3037
  created_at: now
2003
- }, { context: SYSTEM_CTX });
3038
+ }, { context: SYSTEM_CTX2 });
2004
3039
  await this.notify({
2005
3040
  topic: "approval.ooo_substituted",
2006
3041
  audience: [sub.to],
@@ -2025,7 +3060,13 @@ var _ApprovalService = class _ApprovalService {
2025
3060
  });
2026
3061
  }
2027
3062
  if (input.config.approvalStatusField) {
2028
- await this.mirrorStatusField(input.object, input.recordId, input.config.approvalStatusField, "pending");
3063
+ await this.mirrorStatusField(
3064
+ input.object,
3065
+ input.recordId,
3066
+ input.config.approvalStatusField,
3067
+ "pending",
3068
+ row.submitter_id ?? null
3069
+ );
2029
3070
  }
2030
3071
  return rowFromRequest(row);
2031
3072
  }
@@ -2069,27 +3110,52 @@ var _ApprovalService = class _ApprovalService {
2069
3110
  */
2070
3111
  async decideNode(requestId, input, context) {
2071
3112
  if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
2072
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3113
+ const actorId = await this.resolveActor(input?.actorId, context);
2073
3114
  if (input.decision !== "approve" && input.decision !== "reject") {
2074
3115
  throw new Error("VALIDATION_FAILED: decision must be approve|reject");
2075
3116
  }
2076
3117
  const rawRows = await this.engine.find("sys_approval_request", {
2077
3118
  where: { id: requestId },
2078
3119
  limit: 1,
2079
- context: SYSTEM_CTX
3120
+ context: SYSTEM_CTX2
2080
3121
  });
2081
3122
  const raw = Array.isArray(rawRows) ? rawRows[0] : null;
2082
3123
  if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
2083
3124
  if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
2084
3125
  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`);
3126
+ const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
3127
+ const isSlotHolder = pendingApprovers.includes(actorId);
3128
+ if (!isSlotHolder && !isOverride) {
3129
+ throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
2087
3130
  }
2088
3131
  const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
2089
3132
  const org = raw.organization_id ?? null;
2090
3133
  const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
2091
3134
  const runId = raw.flow_run_id ?? null;
2092
3135
  const now = this.clock.now().toISOString();
3136
+ const outputKeys = input.outputs ? Object.keys(input.outputs) : [];
3137
+ let acceptedOutputs;
3138
+ if (outputKeys.length) {
3139
+ const declared = normalizeDecisionOutputs(config.decisionOutputs).map((d) => d.key);
3140
+ if (!declared.length) {
3141
+ throw new Error(
3142
+ `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.`
3143
+ );
3144
+ }
3145
+ const reserved = outputKeys.filter((k) => k === "decision" || k === "requestId");
3146
+ if (reserved.length) {
3147
+ throw new Error(
3148
+ `VALIDATION_FAILED: decision output key(s) \`${reserved.join("`, `")}\` are reserved by the resume envelope \u2014 pick different names.`
3149
+ );
3150
+ }
3151
+ const undeclared = outputKeys.filter((k) => !declared.includes(k));
3152
+ if (undeclared.length) {
3153
+ throw new Error(
3154
+ `VALIDATION_FAILED: decision output key(s) \`${undeclared.join("`, `")}\` are not declared on this node \u2014 declared keys: ${declared.map((k) => `'${k}'`).join(", ") || "(none)"}.`
3155
+ );
3156
+ }
3157
+ acceptedOutputs = { ...input.outputs };
3158
+ }
2093
3159
  await this.engine.insert("sys_approval_action", {
2094
3160
  id: uid("aact"),
2095
3161
  request_id: requestId,
@@ -2097,23 +3163,23 @@ var _ApprovalService = class _ApprovalService {
2097
3163
  step_name: nodeId,
2098
3164
  step_index: 0,
2099
3165
  action: input.decision,
2100
- actor_id: input.actorId,
3166
+ actor_id: actorId,
2101
3167
  comment: input.comment ?? null,
2102
3168
  attachments: input.attachments?.length ? input.attachments : null,
2103
3169
  created_at: now
2104
- }, { context: SYSTEM_CTX });
3170
+ }, { context: SYSTEM_CTX2 });
2105
3171
  const behavior = config.behavior ?? "first_response";
2106
- if (input.decision === "approve" && behavior !== "first_response") {
3172
+ if (input.decision === "approve" && behavior !== "first_response" && isSlotHolder) {
2107
3173
  const acts = await this.engine.find("sys_approval_action", {
2108
3174
  where: { request_id: requestId, step_index: 0, action: "approve" },
2109
3175
  limit: 1e3,
2110
- context: SYSTEM_CTX
3176
+ context: SYSTEM_CTX2
2111
3177
  });
2112
3178
  const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
2113
3179
  const snapshotGroups = config.__approverGroups;
2114
3180
  let original;
2115
3181
  let groupMap;
2116
- if (snapshotGroups && (behavior === "quorum" || behavior === "per_group")) {
3182
+ if (snapshotGroups) {
2117
3183
  groupMap = snapshotGroups;
2118
3184
  original = Object.keys(snapshotGroups);
2119
3185
  } else {
@@ -2129,27 +3195,65 @@ var _ApprovalService = class _ApprovalService {
2129
3195
  await this.engine.update("sys_approval_request", {
2130
3196
  id: requestId,
2131
3197
  pending_approvers: stillPending.join(","),
2132
- updated_at: now
2133
- }, { context: SYSTEM_CTX });
3198
+ updated_at: now,
3199
+ // #3447 P2: a mid-tally approval may carry outputs too (unanimous /
3200
+ // per_group co-sign, each approver contributing their declared keys)
3201
+ // — accumulate them on the snapshot so the FINALIZING decision hands
3202
+ // the merged set to the flow.
3203
+ ...acceptedOutputs ? {
3204
+ node_config_json: JSON.stringify({
3205
+ ...config,
3206
+ __decisionOutputs: { ...config.__decisionOutputs ?? {}, ...acceptedOutputs }
3207
+ })
3208
+ } : {}
3209
+ }, { context: SYSTEM_CTX2 });
2134
3210
  await this.syncApproverIndex(requestId, stillPending, org, now);
2135
- const fresh2 = await this.getRequest(requestId, context);
3211
+ const fresh2 = await this.readBackRequest(requestId, context);
2136
3212
  return { request: fresh2, runId, nodeId, finalized: false, decision: input.decision };
2137
3213
  }
2138
3214
  }
2139
3215
  const finalStatus = input.decision === "approve" ? "approved" : "rejected";
3216
+ const mergedOutputs = acceptedOutputs || config.__decisionOutputs ? { ...config.__decisionOutputs ?? {}, ...acceptedOutputs ?? {} } : void 0;
2140
3217
  await this.engine.update("sys_approval_request", {
2141
3218
  id: requestId,
2142
3219
  status: finalStatus,
2143
3220
  pending_approvers: null,
2144
3221
  completed_at: now,
2145
- updated_at: now
2146
- }, { context: SYSTEM_CTX });
3222
+ updated_at: now,
3223
+ ...mergedOutputs ? {
3224
+ node_config_json: JSON.stringify({ ...config, __decisionOutputs: mergedOutputs })
3225
+ } : {}
3226
+ }, { context: SYSTEM_CTX2 });
2147
3227
  await this.syncApproverIndex(requestId, [], org, now);
2148
3228
  if (config.approvalStatusField) {
2149
- await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, finalStatus);
3229
+ await this.mirrorStatusField(
3230
+ raw.object_name,
3231
+ raw.record_id,
3232
+ config.approvalStatusField,
3233
+ finalStatus,
3234
+ actingUserId(context)
3235
+ );
2150
3236
  }
2151
- const fresh = await this.getRequest(requestId, context);
2152
- return { request: fresh, runId, nodeId, finalized: true, decision: input.decision };
3237
+ const fresh = await this.readBackRequest(requestId, context);
3238
+ return { request: fresh, runId, nodeId, finalized: true, decision: input.decision, outputs: mergedOutputs };
3239
+ }
3240
+ /**
3241
+ * Continue the owning flow run after an outcome this service has already
3242
+ * authorized and written down (#3801).
3243
+ *
3244
+ * The `approval` node declares `resumeAuthority: 'service'`, so the engine
3245
+ * refuses any resume of an approval suspension that does not carry
3246
+ * {@link RESUME_AUTHORITY_SERVICE}. Every approvals-side resume goes through
3247
+ * here so the marker is stamped in ONE place — a new outcome path cannot
3248
+ * quietly ship a resume that the gate then rejects at runtime, and nothing
3249
+ * in this file hands the marker to a caller-supplied signal.
3250
+ *
3251
+ * Callers still guard on `typeof this.automation?.resume === 'function'`
3252
+ * (approvals runs fine with no automation attached) and keep their own
3253
+ * try/catch, because what a failed resume means differs per path.
3254
+ */
3255
+ async serviceResume(runId, signal) {
3256
+ await this.automation.resume(runId, { ...signal, [RESUME_AUTHORITY_SERVICE]: true });
2153
3257
  }
2154
3258
  /**
2155
3259
  * Public contract entrypoint (ADR-0019). Records a decision on a node-driven
@@ -2162,9 +3266,14 @@ var _ApprovalService = class _ApprovalService {
2162
3266
  if (result.finalized && result.runId && typeof this.automation?.resume === "function") {
2163
3267
  const branchLabel = result.decision === "approve" ? APPROVAL_BRANCH_LABELS.approve : APPROVAL_BRANCH_LABELS.reject;
2164
3268
  try {
2165
- await this.automation.resume(result.runId, {
3269
+ await this.serviceResume(result.runId, {
2166
3270
  branchLabel,
2167
- output: { decision: result.decision, requestId }
3271
+ // #3447 P2: accepted decision outputs ride the resume envelope and
3272
+ // land as `<nodeId>.<key>` flow variables — a later approval node's
3273
+ // `expression` approver reads them as `vars.<nodeId>.<key>`.
3274
+ // Reserved keys are spread LAST so no output can shadow them (the
3275
+ // whitelist already rejects them; this is defense in depth).
3276
+ output: { ...result.outputs ?? {}, decision: result.decision, requestId }
2168
3277
  });
2169
3278
  resumed = true;
2170
3279
  } catch (err) {
@@ -2198,11 +3307,11 @@ var _ApprovalService = class _ApprovalService {
2198
3307
  */
2199
3308
  async recall(requestId, input, context) {
2200
3309
  if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
2201
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3310
+ const actorId = await this.resolveActor(input?.actorId, context);
2202
3311
  const rawRows = await this.engine.find("sys_approval_request", {
2203
3312
  where: { id: requestId },
2204
3313
  limit: 1,
2205
- context: SYSTEM_CTX
3314
+ context: SYSTEM_CTX2
2206
3315
  });
2207
3316
  const raw = Array.isArray(rawRows) ? rawRows[0] : null;
2208
3317
  if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
@@ -2210,7 +3319,7 @@ var _ApprovalService = class _ApprovalService {
2210
3319
  if (raw.status !== "pending" && !inReviseWindow) {
2211
3320
  throw new Error(`INVALID_STATE: request is ${raw.status}`);
2212
3321
  }
2213
- if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
3322
+ if (!this.isOverrideActor(context, raw.organization_id ?? null) && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
2214
3323
  throw new Error(`FORBIDDEN: only the submitter may recall this request`);
2215
3324
  }
2216
3325
  if (inReviseWindow) await this.assertLatestForRun(raw);
@@ -2226,20 +3335,26 @@ var _ApprovalService = class _ApprovalService {
2226
3335
  step_name: nodeId,
2227
3336
  step_index: 0,
2228
3337
  action: "recall",
2229
- actor_id: input.actorId,
3338
+ actor_id: actorId,
2230
3339
  comment: input.comment ?? null,
2231
3340
  created_at: now
2232
- }, { context: SYSTEM_CTX });
3341
+ }, { context: SYSTEM_CTX2 });
2233
3342
  await this.engine.update("sys_approval_request", {
2234
3343
  id: requestId,
2235
3344
  status: "recalled",
2236
3345
  pending_approvers: null,
2237
3346
  completed_at: now,
2238
3347
  updated_at: now
2239
- }, { context: SYSTEM_CTX });
3348
+ }, { context: SYSTEM_CTX2 });
2240
3349
  await this.syncApproverIndex(requestId, [], org, now);
2241
3350
  if (config.approvalStatusField) {
2242
- await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, "recalled");
3351
+ await this.mirrorStatusField(
3352
+ raw.object_name,
3353
+ raw.record_id,
3354
+ config.approvalStatusField,
3355
+ "recalled",
3356
+ actingUserId(context)
3357
+ );
2243
3358
  }
2244
3359
  let resumed = false;
2245
3360
  if (inReviseWindow) {
@@ -2256,7 +3371,7 @@ var _ApprovalService = class _ApprovalService {
2256
3371
  }
2257
3372
  } else if (runId && typeof this.automation?.resume === "function") {
2258
3373
  try {
2259
- await this.automation.resume(runId, {
3374
+ await this.serviceResume(runId, {
2260
3375
  branchLabel: APPROVAL_BRANCH_LABELS.reject,
2261
3376
  output: { decision: "recall", requestId }
2262
3377
  });
@@ -2269,7 +3384,7 @@ var _ApprovalService = class _ApprovalService {
2269
3384
  });
2270
3385
  }
2271
3386
  }
2272
- const fresh = await this.getRequest(requestId, context);
3387
+ const fresh = await this.readBackRequest(requestId, context);
2273
3388
  return { request: fresh, runId, resumed };
2274
3389
  }
2275
3390
  // ── Send back for revision / resubmit (ADR-0044) ─────────────
@@ -2287,11 +3402,11 @@ var _ApprovalService = class _ApprovalService {
2287
3402
  * `output.autoRejected = true`) so instances cannot orbit forever.
2288
3403
  */
2289
3404
  async sendBack(requestId, input, context) {
2290
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3405
+ const actorId = await this.resolveActor(input?.actorId, context);
2291
3406
  const raw = await this.loadPendingRow(requestId);
2292
3407
  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`);
3408
+ if (!context.isSystem && !pending.includes(actorId)) {
3409
+ throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
2295
3410
  }
2296
3411
  const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
2297
3412
  const org = raw.organization_id ?? null;
@@ -2305,7 +3420,7 @@ var _ApprovalService = class _ApprovalService {
2305
3420
  const siblings = await this.engine.find("sys_approval_request", {
2306
3421
  where: { flow_run_id: runId, flow_node_id: nodeId, status: "returned" },
2307
3422
  limit: 500,
2308
- context: SYSTEM_CTX
3423
+ context: SYSTEM_CTX2
2309
3424
  });
2310
3425
  priorSendBacks = Array.isArray(siblings) ? siblings.length : 0;
2311
3426
  }
@@ -2316,10 +3431,10 @@ var _ApprovalService = class _ApprovalService {
2316
3431
  step_name: nodeId,
2317
3432
  step_index: 0,
2318
3433
  action: "revise",
2319
- actor_id: input.actorId,
3434
+ actor_id: actorId,
2320
3435
  comment: input.comment ?? null,
2321
3436
  created_at: now
2322
- }, { context: SYSTEM_CTX });
3437
+ }, { context: SYSTEM_CTX2 });
2323
3438
  if (priorSendBacks >= maxRevisions) {
2324
3439
  await this.engine.insert("sys_approval_action", {
2325
3440
  id: uid("aact"),
@@ -2328,25 +3443,31 @@ var _ApprovalService = class _ApprovalService {
2328
3443
  step_name: nodeId,
2329
3444
  step_index: 0,
2330
3445
  action: "reject",
2331
- actor_id: input.actorId,
3446
+ actor_id: actorId,
2332
3447
  comment: `Auto-rejected: revision limit (${maxRevisions}) exceeded`,
2333
3448
  created_at: now
2334
- }, { context: SYSTEM_CTX });
3449
+ }, { context: SYSTEM_CTX2 });
2335
3450
  await this.engine.update("sys_approval_request", {
2336
3451
  id: requestId,
2337
3452
  status: "rejected",
2338
3453
  pending_approvers: null,
2339
3454
  completed_at: now,
2340
3455
  updated_at: now
2341
- }, { context: SYSTEM_CTX });
3456
+ }, { context: SYSTEM_CTX2 });
2342
3457
  await this.syncApproverIndex(requestId, [], org, now);
2343
3458
  if (config.approvalStatusField) {
2344
- await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, "rejected");
3459
+ await this.mirrorStatusField(
3460
+ raw.object_name,
3461
+ raw.record_id,
3462
+ config.approvalStatusField,
3463
+ "rejected",
3464
+ actingUserId(context)
3465
+ );
2345
3466
  }
2346
3467
  let resumed2 = false;
2347
3468
  if (runId && typeof this.automation?.resume === "function") {
2348
3469
  try {
2349
- await this.automation.resume(runId, {
3470
+ await this.serviceResume(runId, {
2350
3471
  branchLabel: APPROVAL_BRANCH_LABELS.reject,
2351
3472
  output: { decision: "reject", autoRejected: true, requestId }
2352
3473
  });
@@ -2363,7 +3484,7 @@ var _ApprovalService = class _ApprovalService {
2363
3484
  await this.notify({
2364
3485
  topic: "approval.returned",
2365
3486
  audience: [String(raw.submitter_id)],
2366
- actorId: input.actorId,
3487
+ actorId,
2367
3488
  source: { object: "sys_approval_request", id: requestId },
2368
3489
  payload: {
2369
3490
  title: "Approval auto-rejected",
@@ -2372,7 +3493,7 @@ var _ApprovalService = class _ApprovalService {
2372
3493
  }
2373
3494
  });
2374
3495
  }
2375
- const fresh2 = await this.getRequest(requestId, context);
3496
+ const fresh2 = await this.readBackRequest(requestId, context);
2376
3497
  return { request: fresh2, runId, resumed: resumed2, autoRejected: true };
2377
3498
  }
2378
3499
  await this.engine.update("sys_approval_request", {
@@ -2381,15 +3502,21 @@ var _ApprovalService = class _ApprovalService {
2381
3502
  pending_approvers: null,
2382
3503
  completed_at: now,
2383
3504
  updated_at: now
2384
- }, { context: SYSTEM_CTX });
3505
+ }, { context: SYSTEM_CTX2 });
2385
3506
  await this.syncApproverIndex(requestId, [], org, now);
2386
3507
  if (config.approvalStatusField) {
2387
- await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, "returned");
3508
+ await this.mirrorStatusField(
3509
+ raw.object_name,
3510
+ raw.record_id,
3511
+ config.approvalStatusField,
3512
+ "returned",
3513
+ actingUserId(context)
3514
+ );
2388
3515
  }
2389
3516
  let resumed = false;
2390
3517
  if (runId && typeof this.automation?.resume === "function") {
2391
3518
  try {
2392
- await this.automation.resume(runId, {
3519
+ await this.serviceResume(runId, {
2393
3520
  branchLabel: APPROVAL_BRANCH_LABELS.revise,
2394
3521
  output: { decision: "revise", requestId }
2395
3522
  });
@@ -2406,7 +3533,7 @@ var _ApprovalService = class _ApprovalService {
2406
3533
  await this.notify({
2407
3534
  topic: "approval.returned",
2408
3535
  audience: [String(raw.submitter_id)],
2409
- actorId: input.actorId,
3536
+ actorId,
2410
3537
  source: { object: "sys_approval_request", id: requestId },
2411
3538
  payload: {
2412
3539
  title: "Sent back for revision",
@@ -2415,7 +3542,7 @@ var _ApprovalService = class _ApprovalService {
2415
3542
  }
2416
3543
  });
2417
3544
  }
2418
- const fresh = await this.getRequest(requestId, context);
3545
+ const fresh = await this.readBackRequest(requestId, context);
2419
3546
  return { request: fresh, runId, resumed };
2420
3547
  }
2421
3548
  /**
@@ -2426,25 +3553,25 @@ var _ApprovalService = class _ApprovalService {
2426
3553
  * round-N+1 request — fresh approver slate, record re-locks.
2427
3554
  */
2428
3555
  async resubmit(requestId, input, context) {
2429
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3556
+ const actorId = await this.resolveActor(input?.actorId, context);
2430
3557
  const rawRows = await this.engine.find("sys_approval_request", {
2431
3558
  where: { id: requestId },
2432
3559
  limit: 1,
2433
- context: SYSTEM_CTX
3560
+ context: SYSTEM_CTX2
2434
3561
  });
2435
3562
  const raw = Array.isArray(rawRows) ? rawRows[0] : null;
2436
3563
  if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
2437
3564
  if (raw.status !== "returned") {
2438
3565
  throw new Error(`INVALID_STATE: request is ${raw.status} (resubmit applies to returned requests)`);
2439
3566
  }
2440
- if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
3567
+ if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
2441
3568
  throw new Error("FORBIDDEN: only the submitter may resubmit");
2442
3569
  }
2443
3570
  await this.assertLatestForRun(raw);
2444
3571
  const colliding = await this.engine.find("sys_approval_request", {
2445
3572
  where: { object_name: raw.object_name, record_id: raw.record_id, status: "pending" },
2446
3573
  limit: 1,
2447
- context: SYSTEM_CTX
3574
+ context: SYSTEM_CTX2
2448
3575
  });
2449
3576
  if (Array.isArray(colliding) && colliding[0]) {
2450
3577
  throw new Error(
@@ -2462,14 +3589,14 @@ var _ApprovalService = class _ApprovalService {
2462
3589
  step_name: nodeId,
2463
3590
  step_index: 0,
2464
3591
  action: "resubmit",
2465
- actor_id: input.actorId,
3592
+ actor_id: actorId,
2466
3593
  comment: input.comment ?? null,
2467
3594
  created_at: now
2468
- }, { context: SYSTEM_CTX });
3595
+ }, { context: SYSTEM_CTX2 });
2469
3596
  let resumed = false;
2470
3597
  if (runId && typeof this.automation?.resume === "function") {
2471
3598
  try {
2472
- await this.automation.resume(runId, {
3599
+ await this.serviceResume(runId, {
2473
3600
  branchLabel: APPROVAL_BRANCH_LABELS.resubmit,
2474
3601
  output: { resubmitted: true, requestId }
2475
3602
  });
@@ -2482,7 +3609,7 @@ var _ApprovalService = class _ApprovalService {
2482
3609
  });
2483
3610
  }
2484
3611
  }
2485
- const fresh = await this.getRequest(requestId, context);
3612
+ const fresh = await this.readBackRequest(requestId, context);
2486
3613
  return { request: fresh, runId, resumed };
2487
3614
  }
2488
3615
  /**
@@ -2517,7 +3644,7 @@ var _ApprovalService = class _ApprovalService {
2517
3644
  where: { flow_run_id: runId },
2518
3645
  orderBy: [{ field: "created_at", order: "desc" }],
2519
3646
  limit: 1,
2520
- context: SYSTEM_CTX
3647
+ context: SYSTEM_CTX2
2521
3648
  });
2522
3649
  const latest = Array.isArray(rows) ? rows[0] : null;
2523
3650
  if (latest && String(latest.id) !== String(raw.id)) {
@@ -2528,25 +3655,33 @@ var _ApprovalService = class _ApprovalService {
2528
3655
  /**
2529
3656
  * Hand a pending-approver slot to someone else. `from` defaults to the
2530
3657
  * actor itself; the actor must hold the slot being handed over (or be a
2531
- * system caller). Audits `reassign` and notifies the new approver.
3658
+ * system caller). A privileged admin (#3424) may reassign a request whose
3659
+ * slate holds no real user — an unstaffed-position literal — by handing the
3660
+ * whole request to a real approver, rescuing it from the locked dead-end.
3661
+ * Audits `reassign` and notifies the new approver.
2532
3662
  */
2533
3663
  async reassign(requestId, input, context) {
2534
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3664
+ const actorId = await this.resolveActor(input?.actorId, context);
2535
3665
  const to = String(input?.to ?? "").trim();
2536
3666
  if (!to) throw new Error("VALIDATION_FAILED: `to` (new approver) is required");
2537
3667
  const raw = await this.loadPendingRow(requestId);
2538
3668
  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
3669
  if (pending.includes(to)) {
2547
3670
  throw new Error(`VALIDATION_FAILED: '${to}' is already a pending approver`);
2548
3671
  }
2549
- const next = pending.map((a) => a === from ? to : a);
3672
+ const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
3673
+ const from = String(input.from ?? actorId).trim();
3674
+ let next;
3675
+ if (pending.includes(from)) {
3676
+ if (!context.isSystem && !isOverride && actorId !== from && !pending.includes(actorId)) {
3677
+ throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
3678
+ }
3679
+ next = pending.map((a) => a === from ? to : a);
3680
+ } else if (isOverride) {
3681
+ next = [to];
3682
+ } else {
3683
+ throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`);
3684
+ }
2550
3685
  const now = this.clock.now().toISOString();
2551
3686
  await this.engine.insert("sys_approval_action", {
2552
3687
  id: uid("aact"),
@@ -2555,10 +3690,10 @@ var _ApprovalService = class _ApprovalService {
2555
3690
  step_name: raw.flow_node_id ?? raw.current_step ?? null,
2556
3691
  step_index: 0,
2557
3692
  action: "reassign",
2558
- actor_id: input.actorId,
3693
+ actor_id: actorId,
2559
3694
  comment: input.comment ?? `${from} \u2192 ${to}`,
2560
3695
  created_at: now
2561
- }, { context: SYSTEM_CTX });
3696
+ }, { context: SYSTEM_CTX2 });
2562
3697
  let configPatch = {};
2563
3698
  try {
2564
3699
  const cfg = parseJson(raw.node_config_json, null);
@@ -2575,12 +3710,12 @@ var _ApprovalService = class _ApprovalService {
2575
3710
  pending_approvers: next.join(","),
2576
3711
  updated_at: now,
2577
3712
  ...configPatch
2578
- }, { context: SYSTEM_CTX });
3713
+ }, { context: SYSTEM_CTX2 });
2579
3714
  await this.syncApproverIndex(requestId, next, raw.organization_id ?? null, now);
2580
3715
  await this.notify({
2581
3716
  topic: "approval.reassigned",
2582
3717
  audience: [to],
2583
- actorId: input.actorId,
3718
+ actorId,
2584
3719
  source: { object: "sys_approval_request", id: requestId },
2585
3720
  dedupKey: `approval-reassign-${requestId}-${to}`,
2586
3721
  payload: {
@@ -2589,7 +3724,7 @@ var _ApprovalService = class _ApprovalService {
2589
3724
  actionUrl: "/system/approvals"
2590
3725
  }
2591
3726
  });
2592
- const fresh = await this.getRequest(requestId, context);
3727
+ const fresh = await this.readBackRequest(requestId, context);
2593
3728
  return { request: fresh };
2594
3729
  }
2595
3730
  /**
@@ -2597,16 +3732,16 @@ var _ApprovalService = class _ApprovalService {
2597
3732
  * reminder per {@link REMIND_COOLDOWN_MS} per request.
2598
3733
  */
2599
3734
  async remind(requestId, input, context) {
2600
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3735
+ const actorId = await this.resolveActor(input?.actorId, context);
2601
3736
  const raw = await this.loadPendingRow(requestId);
2602
- if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
3737
+ if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
2603
3738
  throw new Error("FORBIDDEN: only the submitter may send reminders");
2604
3739
  }
2605
3740
  const acts = await this.engine.find("sys_approval_action", {
2606
3741
  where: { request_id: requestId, action: "remind" },
2607
3742
  orderBy: [{ field: "created_at", order: "desc" }],
2608
3743
  limit: 1,
2609
- context: SYSTEM_CTX
3744
+ context: SYSTEM_CTX2
2610
3745
  });
2611
3746
  const last = Array.isArray(acts) ? acts[0] : null;
2612
3747
  const now = this.clock.now();
@@ -2622,10 +3757,10 @@ var _ApprovalService = class _ApprovalService {
2622
3757
  step_name: raw.flow_node_id ?? raw.current_step ?? null,
2623
3758
  step_index: 0,
2624
3759
  action: "remind",
2625
- actor_id: input.actorId,
3760
+ actor_id: actorId,
2626
3761
  comment: input.comment ?? null,
2627
3762
  created_at: nowIso
2628
- }, { context: SYSTEM_CTX });
3763
+ }, { context: SYSTEM_CTX2 });
2629
3764
  let notified = 0;
2630
3765
  const concrete = pending.filter((a) => a && !a.includes(":"));
2631
3766
  const literals = pending.filter((a) => a && a.includes(":"));
@@ -2635,7 +3770,7 @@ var _ApprovalService = class _ApprovalService {
2635
3770
  notified += await this.notify({
2636
3771
  topic: "approval.reminder",
2637
3772
  audience: [approver],
2638
- actorId: input.actorId,
3773
+ actorId,
2639
3774
  source: { object: "sys_approval_request", id: requestId },
2640
3775
  dedupKey: `approval-remind-${requestId}-${nowIso}-${approver}`,
2641
3776
  payload: {
@@ -2660,7 +3795,7 @@ var _ApprovalService = class _ApprovalService {
2660
3795
  notified += await this.notify({
2661
3796
  topic: "approval.reminder",
2662
3797
  audience: literals,
2663
- actorId: input.actorId,
3798
+ actorId,
2664
3799
  source: { object: "sys_approval_request", id: requestId },
2665
3800
  dedupKey: `approval-remind-${requestId}-${nowIso}`,
2666
3801
  payload: {
@@ -2670,7 +3805,7 @@ var _ApprovalService = class _ApprovalService {
2670
3805
  }
2671
3806
  });
2672
3807
  }
2673
- const fresh = await this.getRequest(requestId, context);
3808
+ const fresh = await this.readBackRequest(requestId, context);
2674
3809
  return { request: fresh, notified };
2675
3810
  }
2676
3811
  // ── Actionable links (ADR-0043) ──────────────────────────────
@@ -2705,7 +3840,7 @@ var _ApprovalService = class _ApprovalService {
2705
3840
  expires_at: expires,
2706
3841
  consumed_at: null,
2707
3842
  created_at: now.toISOString()
2708
- }, { context: SYSTEM_CTX });
3843
+ }, { context: SYSTEM_CTX2 });
2709
3844
  out[action] = rawToken;
2710
3845
  }
2711
3846
  return out;
@@ -2718,7 +3853,7 @@ var _ApprovalService = class _ApprovalService {
2718
3853
  const rows = await this.engine.find("sys_approval_token", {
2719
3854
  where: { token_hash: hash },
2720
3855
  limit: 1,
2721
- context: SYSTEM_CTX
3856
+ context: SYSTEM_CTX2
2722
3857
  });
2723
3858
  const token = Array.isArray(rows) ? rows[0] : null;
2724
3859
  if (!token) return { ok: false, reason: "invalid" };
@@ -2726,7 +3861,7 @@ var _ApprovalService = class _ApprovalService {
2726
3861
  if (Date.parse(token.expires_at) < this.clock.now().getTime()) {
2727
3862
  return { ok: false, reason: "expired" };
2728
3863
  }
2729
- const request = await this.getRequest(token.request_id, SYSTEM_CTX);
3864
+ const request = await this.getRequest(token.request_id, SYSTEM_CTX2);
2730
3865
  if (!request || request.status !== "pending") {
2731
3866
  return { ok: false, reason: "not_pending", request: request ?? void 0 };
2732
3867
  }
@@ -2751,12 +3886,19 @@ var _ApprovalService = class _ApprovalService {
2751
3886
  await this.engine.update("sys_approval_token", {
2752
3887
  id: res.token.id,
2753
3888
  consumed_at: this.clock.now().toISOString()
2754
- }, { context: SYSTEM_CTX });
3889
+ }, { context: SYSTEM_CTX2 });
2755
3890
  const out = await this.decide(res.token.request_id, {
2756
3891
  decision: res.token.action,
2757
3892
  actorId: res.token.approver_id,
2758
3893
  comment: "Via action link"
2759
- }, SYSTEM_CTX);
3894
+ // The token IS the authentication (#3783): it is single-use, hashed at
3895
+ // rest and bound to one approver, who `resolveActionToken` has just
3896
+ // re-checked still holds a pending slot. So this decision has a real
3897
+ // acting user even though no session carried it — name them on the
3898
+ // context, so the status mirror and every flow it cascades into are
3899
+ // attributed exactly like a decision made through the UI. Elevation is
3900
+ // unchanged: `isSystem` still stands in for the missing session.
3901
+ }, { ...SYSTEM_CTX2, userId: res.token.approver_id });
2760
3902
  return { ok: true, action: res.token.action, request: out.request, approverId: res.token.approver_id };
2761
3903
  }
2762
3904
  /**
@@ -2764,12 +3906,12 @@ var _ApprovalService = class _ApprovalService {
2764
3906
  * pending — a thread interaction, not a flow decision.
2765
3907
  */
2766
3908
  async requestInfo(requestId, input, context) {
2767
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3909
+ const actorId = await this.resolveActor(input?.actorId, context);
2768
3910
  if (!input?.comment?.trim()) throw new Error("VALIDATION_FAILED: comment is required");
2769
3911
  const raw = await this.loadPendingRow(requestId);
2770
3912
  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`);
3913
+ if (!context.isSystem && !pending.includes(actorId)) {
3914
+ throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
2773
3915
  }
2774
3916
  const now = this.clock.now().toISOString();
2775
3917
  await this.engine.insert("sys_approval_action", {
@@ -2779,15 +3921,15 @@ var _ApprovalService = class _ApprovalService {
2779
3921
  step_name: raw.flow_node_id ?? raw.current_step ?? null,
2780
3922
  step_index: 0,
2781
3923
  action: "request_info",
2782
- actor_id: input.actorId,
3924
+ actor_id: actorId,
2783
3925
  comment: input.comment.trim(),
2784
3926
  created_at: now
2785
- }, { context: SYSTEM_CTX });
3927
+ }, { context: SYSTEM_CTX2 });
2786
3928
  if (raw.submitter_id) {
2787
3929
  await this.notify({
2788
3930
  topic: "approval.request_info",
2789
3931
  audience: [String(raw.submitter_id)],
2790
- actorId: input.actorId,
3932
+ actorId,
2791
3933
  source: { object: "sys_approval_request", id: requestId },
2792
3934
  payload: {
2793
3935
  title: "More information requested",
@@ -2796,18 +3938,18 @@ var _ApprovalService = class _ApprovalService {
2796
3938
  }
2797
3939
  });
2798
3940
  }
2799
- const fresh = await this.getRequest(requestId, context);
3941
+ const fresh = await this.readBackRequest(requestId, context);
2800
3942
  return { request: fresh };
2801
3943
  }
2802
3944
  /** Free-form reply on the thread (submitter or any pending approver). */
2803
3945
  async comment(requestId, input, context) {
2804
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3946
+ const actorId = await this.resolveActor(input?.actorId, context);
2805
3947
  if (!input?.comment?.trim()) throw new Error("VALIDATION_FAILED: comment is required");
2806
3948
  const raw = await this.loadPendingRow(requestId);
2807
3949
  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`);
3950
+ const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(actorId);
3951
+ if (!context.isSystem && !isSubmitter && !pending.includes(actorId)) {
3952
+ throw new Error(`FORBIDDEN: actor '${actorId}' is not on this request`);
2811
3953
  }
2812
3954
  const now = this.clock.now().toISOString();
2813
3955
  await this.engine.insert("sys_approval_action", {
@@ -2817,16 +3959,16 @@ var _ApprovalService = class _ApprovalService {
2817
3959
  step_name: raw.flow_node_id ?? raw.current_step ?? null,
2818
3960
  step_index: 0,
2819
3961
  action: "comment",
2820
- actor_id: input.actorId,
3962
+ actor_id: actorId,
2821
3963
  comment: input.comment.trim(),
2822
3964
  attachments: input.attachments?.length ? input.attachments : null,
2823
3965
  created_at: now
2824
- }, { context: SYSTEM_CTX });
3966
+ }, { context: SYSTEM_CTX2 });
2825
3967
  const audience = isSubmitter ? pending : [String(raw.submitter_id ?? "")].filter(Boolean);
2826
3968
  await this.notify({
2827
3969
  topic: "approval.comment",
2828
3970
  audience,
2829
- actorId: input.actorId,
3971
+ actorId,
2830
3972
  source: { object: "sys_approval_request", id: requestId },
2831
3973
  payload: {
2832
3974
  title: "New comment on an approval",
@@ -2834,7 +3976,7 @@ var _ApprovalService = class _ApprovalService {
2834
3976
  actionUrl: "/system/approvals"
2835
3977
  }
2836
3978
  });
2837
- const fresh = await this.getRequest(requestId, context);
3979
+ const fresh = await this.readBackRequest(requestId, context);
2838
3980
  return { request: fresh };
2839
3981
  }
2840
3982
  // ── SLA escalation (ADR-0042) ─────────────────────────────────
@@ -2851,7 +3993,7 @@ var _ApprovalService = class _ApprovalService {
2851
3993
  rows = await this.engine.find("sys_approval_request", {
2852
3994
  where: { status: "pending" },
2853
3995
  limit: 500,
2854
- context: SYSTEM_CTX
3996
+ context: SYSTEM_CTX2
2855
3997
  }) ?? [];
2856
3998
  } catch (err) {
2857
3999
  this.logger?.warn?.("[approvals] escalation scan failed to list requests", {
@@ -2870,7 +4012,7 @@ var _ApprovalService = class _ApprovalService {
2870
4012
  const prior = await this.engine.find("sys_approval_action", {
2871
4013
  where: { request_id: raw.id, action: "escalate" },
2872
4014
  limit: 1,
2873
- context: SYSTEM_CTX
4015
+ context: SYSTEM_CTX2
2874
4016
  });
2875
4017
  if (Array.isArray(prior) && prior[0]) continue;
2876
4018
  await this.escalateRequest(raw, esc2);
@@ -2887,6 +4029,130 @@ var _ApprovalService = class _ApprovalService {
2887
4029
  }
2888
4030
  return { scanned: rows.length, escalated };
2889
4031
  }
4032
+ // ── Dead-run release (#3456) ──────────────────────────────────
4033
+ /**
4034
+ * One dead-run sweep: a pending request whose owning flow run has reached a
4035
+ * TERMINAL state can never be decided — nothing is left to resume — so the
4036
+ * request is finalised as `recalled` and, with `lockRecord`, the record it was
4037
+ * holding is released.
4038
+ *
4039
+ * This is the recovery half of #3456. The prevention half is the record lock's
4040
+ * owning-run exemption (`lifecycle-hooks.ts`), which stops a run from killing
4041
+ * itself on its own lock in the first place; this sweep cleans up the runs that
4042
+ * still die — for any reason, including a process crash, which no in-band
4043
+ * handler can catch because the process that would have run it is gone.
4044
+ *
4045
+ * **Fail-safe by construction.** It acts only on an explicit terminal status
4046
+ * from a closed set. Every other answer — `paused` (the normal state of a run
4047
+ * waiting on its approval), `running`, an unrecognised status, `null` (unknown
4048
+ * run, evicted log, no durable store), a `getRun` that throws, or no automation
4049
+ * engine at all — is read as "still alive" and left strictly alone. The failure
4050
+ * mode is therefore "a dead run's lock survives until an admin recalls it"
4051
+ * (today's behaviour, #3424), never "a live approval is destroyed".
4052
+ *
4053
+ * `recalled` is the finalisation because it is the platform's existing terminal
4054
+ * state for *a live request that ended without a decision*; the audit row names
4055
+ * the real cause and {@link DEAD_RUN_ACTOR_ID} the real actor, so a dead-run
4056
+ * release is never mistaken for a submitter's withdrawal.
4057
+ */
4058
+ async releaseDeadRunRequests() {
4059
+ if (typeof this.automation?.getRun !== "function") return { scanned: 0, released: 0 };
4060
+ let rows = [];
4061
+ try {
4062
+ rows = await this.engine.find("sys_approval_request", {
4063
+ where: { status: "pending" },
4064
+ limit: 500,
4065
+ context: SYSTEM_CTX2
4066
+ }) ?? [];
4067
+ } catch (err) {
4068
+ this.logger?.warn?.("[approvals] dead-run sweep failed to list requests", {
4069
+ error: err?.message ?? String(err)
4070
+ });
4071
+ return { scanned: 0, released: 0 };
4072
+ }
4073
+ let released = 0;
4074
+ for (const raw of rows) {
4075
+ try {
4076
+ const runId = raw?.flow_run_id ? String(raw.flow_run_id) : "";
4077
+ if (!runId) continue;
4078
+ let status;
4079
+ try {
4080
+ const run = await this.automation.getRun(runId);
4081
+ status = typeof run?.status === "string" ? run.status : void 0;
4082
+ } catch (err) {
4083
+ this.logger?.warn?.("[approvals] dead-run sweep could not read run status", {
4084
+ request: raw?.id,
4085
+ run: runId,
4086
+ error: err?.message ?? String(err)
4087
+ });
4088
+ continue;
4089
+ }
4090
+ if (!status || !TERMINAL_RUN_STATUSES.has(status)) continue;
4091
+ await this.abandonForDeadRun(raw, runId, status);
4092
+ released++;
4093
+ } catch (err) {
4094
+ this.logger?.warn?.("[approvals] dead-run release failed for request", {
4095
+ request: raw?.id,
4096
+ error: err?.message ?? String(err)
4097
+ });
4098
+ }
4099
+ }
4100
+ if (released > 0) {
4101
+ this.logger?.info?.("[approvals] dead-run sweep", { scanned: rows.length, released });
4102
+ }
4103
+ return { scanned: rows.length, released };
4104
+ }
4105
+ /**
4106
+ * Finalise one pending request whose owning run is terminal. Mirrors the
4107
+ * shape of {@link recall} — audit row first (so a crash mid-release leaves a
4108
+ * trace of the intent), then the status transition, approver-index sync and
4109
+ * the optional status-field mirror. No resume/cancel of the run: it is already
4110
+ * terminal, which is precisely why we are here.
4111
+ */
4112
+ async abandonForDeadRun(raw, runId, runStatus) {
4113
+ const org = raw.organization_id ?? null;
4114
+ const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
4115
+ const now = this.clock.now().toISOString();
4116
+ await this.engine.insert("sys_approval_action", {
4117
+ id: uid("aact"),
4118
+ request_id: raw.id,
4119
+ organization_id: org,
4120
+ step_name: nodeId,
4121
+ step_index: 0,
4122
+ action: "recall",
4123
+ actor_id: DEAD_RUN_ACTOR_ID,
4124
+ comment: `owning flow run ${runId} is ${runStatus} \u2014 request abandoned and record lock released`,
4125
+ created_at: now
4126
+ }, { context: SYSTEM_CTX2 });
4127
+ await this.engine.update("sys_approval_request", {
4128
+ id: raw.id,
4129
+ status: "recalled",
4130
+ pending_approvers: null,
4131
+ completed_at: now,
4132
+ updated_at: now
4133
+ }, { context: SYSTEM_CTX2 });
4134
+ await this.syncApproverIndex(raw.id, [], org, now);
4135
+ const config = parseJson(
4136
+ raw.node_config_json,
4137
+ { approvers: [], behavior: "first_response" }
4138
+ );
4139
+ if (config.approvalStatusField) {
4140
+ await this.mirrorStatusField(
4141
+ raw.object_name,
4142
+ raw.record_id,
4143
+ config.approvalStatusField,
4144
+ "recalled",
4145
+ null
4146
+ );
4147
+ }
4148
+ this.logger?.warn?.("[approvals] released a record held by a dead approval run", {
4149
+ request: raw.id,
4150
+ run: runId,
4151
+ runStatus,
4152
+ object: raw.object_name,
4153
+ record: raw.record_id
4154
+ });
4155
+ }
2890
4156
  /** Execute the configured escalation action for one overdue request. */
2891
4157
  async escalateRequest(raw, esc2) {
2892
4158
  const action = esc2.action ?? "notify";
@@ -2912,13 +4178,13 @@ var _ApprovalService = class _ApprovalService {
2912
4178
  actor_id: SLA_ACTOR_ID,
2913
4179
  comment: `${action}${escalateTo ? ` \u2192 ${escalateTo}` : ""}`,
2914
4180
  created_at: now
2915
- }, { context: SYSTEM_CTX });
4181
+ }, { context: SYSTEM_CTX2 });
2916
4182
  if (action === "reassign" && escalatees.length) {
2917
4183
  await this.engine.update("sys_approval_request", {
2918
4184
  id: raw.id,
2919
4185
  pending_approvers: escalatees.join(","),
2920
4186
  updated_at: now
2921
- }, { context: SYSTEM_CTX });
4187
+ }, { context: SYSTEM_CTX2 });
2922
4188
  await this.syncApproverIndex(raw.id, escalatees, raw.organization_id ?? null, now);
2923
4189
  await this.notify({
2924
4190
  topic: "approval.escalated",
@@ -2936,7 +4202,7 @@ var _ApprovalService = class _ApprovalService {
2936
4202
  decision: action === "auto_approve" ? "approve" : "reject",
2937
4203
  actorId: SLA_ACTOR_ID,
2938
4204
  comment: "SLA escalation"
2939
- }, SYSTEM_CTX);
4205
+ }, SYSTEM_CTX2);
2940
4206
  } else {
2941
4207
  await this.notify({
2942
4208
  topic: "approval.sla_breached",
@@ -3004,7 +4270,7 @@ var _ApprovalService = class _ApprovalService {
3004
4270
  where: { id: { $in: targets } },
3005
4271
  fields: ["id", "name", "email"],
3006
4272
  limit: targets.length,
3007
- context: SYSTEM_CTX
4273
+ context: SYSTEM_CTX2
3008
4274
  });
3009
4275
  for (const u of users ?? []) {
3010
4276
  if (u?.id && (u.name || u.email)) names.set(String(u.id), String(u.name ?? u.email));
@@ -3018,7 +4284,7 @@ var _ApprovalService = class _ApprovalService {
3018
4284
  where: { email: { $in: unresolvedEmails } },
3019
4285
  fields: ["email", "name"],
3020
4286
  limit: unresolvedEmails.length,
3021
- context: SYSTEM_CTX
4287
+ context: SYSTEM_CTX2
3022
4288
  });
3023
4289
  for (const u of users ?? []) {
3024
4290
  if (u?.email && u.name) names.set(String(u.email), String(u.name));
@@ -3044,11 +4310,32 @@ var _ApprovalService = class _ApprovalService {
3044
4310
  return [];
3045
4311
  }
3046
4312
  }
4313
+ /**
4314
+ * Field key → display label for an object's schema. Lets the inbox summary
4315
+ * show a human field name ("考核状态") instead of a title-cased machine key
4316
+ * ("Assessment Status"). For a single-locale project the schema label already
4317
+ * IS the localized string; symmetric with `resolveDisplayField`/lookup
4318
+ * resolution that power `payload_display`.
4319
+ */
4320
+ resolveFieldLabels(object) {
4321
+ try {
4322
+ const schema = this.engine.getSchema?.(object);
4323
+ const fields = schema?.fields ?? {};
4324
+ const out = {};
4325
+ for (const [key, f] of Object.entries(fields)) {
4326
+ if (f?.label) out[key] = String(f.label);
4327
+ }
4328
+ return out;
4329
+ } catch {
4330
+ return {};
4331
+ }
4332
+ }
3047
4333
  /**
3048
4334
  * Attach inbox display fields to rows so clients never render a raw
3049
4335
  * 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).
4336
+ * `pending_approver_names` (user-id approvers), `payload_display`
4337
+ * (lookup foreign keys in the snapshot → referenced record titles), and
4338
+ * `payload_labels` (snapshot field keys → the target object's field labels).
3052
4339
  * Batched: one query per distinct object (target + referenced) plus one
3053
4340
  * `sys_user` lookup. Best-effort — a deleted record falls back to the
3054
4341
  * payload snapshot, and any failure leaves the field unset rather than
@@ -3080,7 +4367,7 @@ var _ApprovalService = class _ApprovalService {
3080
4367
  const recs = await this.engine.find(object, {
3081
4368
  where: { id: { $in: ids } },
3082
4369
  limit: ids.length,
3083
- context: SYSTEM_CTX
4370
+ context: SYSTEM_CTX2
3084
4371
  });
3085
4372
  for (const rec of recs ?? []) {
3086
4373
  const title = _ApprovalService.pickTitle(rec, displayField);
@@ -3090,9 +4377,12 @@ var _ApprovalService = class _ApprovalService {
3090
4377
  }
3091
4378
  }
3092
4379
  const lookupFieldsByObject = /* @__PURE__ */ new Map();
4380
+ const fieldLabelsByObject = /* @__PURE__ */ new Map();
3093
4381
  for (const object of byObject.keys()) {
3094
4382
  const lookups = this.resolveLookupFields(object);
3095
4383
  if (lookups.length) lookupFieldsByObject.set(object, lookups);
4384
+ const labels = this.resolveFieldLabels(object);
4385
+ if (Object.keys(labels).length) fieldLabelsByObject.set(object, labels);
3096
4386
  }
3097
4387
  const refIds = /* @__PURE__ */ new Map();
3098
4388
  for (const r of rows) {
@@ -3118,7 +4408,7 @@ var _ApprovalService = class _ApprovalService {
3118
4408
  const recs = await this.engine.find(object, {
3119
4409
  where: { id: { $in: ids } },
3120
4410
  limit: ids.length,
3121
- context: SYSTEM_CTX
4411
+ context: SYSTEM_CTX2
3122
4412
  });
3123
4413
  for (const rec of recs ?? []) {
3124
4414
  const title = _ApprovalService.pickTitle(rec, displayField);
@@ -3159,6 +4449,15 @@ var _ApprovalService = class _ApprovalService {
3159
4449
  }
3160
4450
  if (Object.keys(display).length) r.payload_display = display;
3161
4451
  }
4452
+ const fieldLabels = fieldLabelsByObject.get(r.object_name);
4453
+ if (fieldLabels && r.payload && typeof r.payload === "object") {
4454
+ const labels = {};
4455
+ for (const key of Object.keys(r.payload)) {
4456
+ const l = fieldLabels[key];
4457
+ if (l) labels[key] = l;
4458
+ }
4459
+ if (Object.keys(labels).length) r.payload_labels = labels;
4460
+ }
3162
4461
  }
3163
4462
  }
3164
4463
  // ── Pending-approver index (issue #1745) ─────────────────────
@@ -3174,12 +4473,12 @@ var _ApprovalService = class _ApprovalService {
3174
4473
  const existing = await this.engine.find("sys_approval_approver", {
3175
4474
  where: { request_id: requestId },
3176
4475
  limit: 500,
3177
- context: SYSTEM_CTX
4476
+ context: SYSTEM_CTX2
3178
4477
  });
3179
4478
  const rows = Array.isArray(existing) ? existing : [];
3180
4479
  for (const row of rows) {
3181
4480
  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 });
4481
+ else await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX2 });
3183
4482
  }
3184
4483
  for (const approver of desired) {
3185
4484
  await this.engine.insert("sys_approval_approver", {
@@ -3188,7 +4487,7 @@ var _ApprovalService = class _ApprovalService {
3188
4487
  approver,
3189
4488
  organization_id: org,
3190
4489
  created_at: now
3191
- }, { context: SYSTEM_CTX });
4490
+ }, { context: SYSTEM_CTX2 });
3192
4491
  }
3193
4492
  }
3194
4493
  /**
@@ -3206,7 +4505,7 @@ var _ApprovalService = class _ApprovalService {
3206
4505
  fields: ["id", "pending_approvers", "organization_id"],
3207
4506
  limit: PAGE,
3208
4507
  offset,
3209
- context: SYSTEM_CTX
4508
+ context: SYSTEM_CTX2
3210
4509
  });
3211
4510
  const rows = Array.isArray(batch) ? batch : [];
3212
4511
  for (const r of rows) {
@@ -3223,7 +4522,7 @@ var _ApprovalService = class _ApprovalService {
3223
4522
  orderBy: [{ field: "created_at", order: "asc" }],
3224
4523
  limit: PAGE,
3225
4524
  offset,
3226
- context: SYSTEM_CTX
4525
+ context: SYSTEM_CTX2
3227
4526
  });
3228
4527
  const rows = Array.isArray(batch) ? batch : [];
3229
4528
  indexRows.push(...rows);
@@ -3237,7 +4536,7 @@ var _ApprovalService = class _ApprovalService {
3237
4536
  const want = desired.get(reqId);
3238
4537
  const have = seen.get(reqId) ?? seen.set(reqId, /* @__PURE__ */ new Set()).get(reqId);
3239
4538
  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 });
4539
+ await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX2 });
3241
4540
  deleted++;
3242
4541
  continue;
3243
4542
  }
@@ -3254,7 +4553,7 @@ var _ApprovalService = class _ApprovalService {
3254
4553
  approver,
3255
4554
  organization_id: want.org,
3256
4555
  created_at: now
3257
- }, { context: SYSTEM_CTX });
4556
+ }, { context: SYSTEM_CTX2 });
3258
4557
  inserted++;
3259
4558
  }
3260
4559
  }
@@ -3304,7 +4603,7 @@ var _ApprovalService = class _ApprovalService {
3304
4603
  where,
3305
4604
  fields: ["request_id"],
3306
4605
  limit: _ApprovalService.APPROVER_INDEX_CAP,
3307
- context: SYSTEM_CTX
4606
+ context: SYSTEM_CTX2
3308
4607
  });
3309
4608
  const list = Array.isArray(rows) ? rows : [];
3310
4609
  if (list.length >= _ApprovalService.APPROVER_INDEX_CAP) {
@@ -3315,6 +4614,88 @@ var _ApprovalService = class _ApprovalService {
3315
4614
  }
3316
4615
  return [...new Set(list.map((r) => String(r.request_id)))];
3317
4616
  }
4617
+ /**
4618
+ * The request ids this caller is a PARTICIPANT of, or `null` for a caller
4619
+ * who may see everything in scope (#3590).
4620
+ *
4621
+ * These reads deliberately run with `SYSTEM_CTX` to bypass RLS — the
4622
+ * approver-visibility rule spans several identity forms that RLS cannot model
4623
+ * cleanly, which is why it has to be expressed here. Until now only the
4624
+ * TENANT half of that rule was applied, so any authenticated user could read
4625
+ * any request in their tenant (and, once attachments derived their access
4626
+ * from the request, its files too). This adds the participant half.
4627
+ *
4628
+ * A participant is the submitter, a current approver, or someone who has
4629
+ * already acted on the request (a past approver whose slot has moved on, a
4630
+ * commenter). Admins with override authority keep the unrestricted view the
4631
+ * "all requests" console surface depends on.
4632
+ *
4633
+ * Keying on the concrete user id is sufficient rather than an approximation:
4634
+ * position/team/manager/field approvers are resolved to concrete user ids at
4635
+ * open time, and the `type:value` literal is only the fallback for a spec
4636
+ * that resolved to NOBODY — a slot no one can act on either way (`can_act`
4637
+ * is a plain membership test over the resolved ids). So this cannot hide a
4638
+ * request from someone who could actually act on it.
4639
+ */
4640
+ async visibleRequestIds(context, tenantOrg) {
4641
+ if (this.isOverrideActor(context, tenantOrg)) return null;
4642
+ const uid2 = context?.userId != null ? String(context.userId) : "";
4643
+ if (!uid2) return /* @__PURE__ */ new Set();
4644
+ const ids = /* @__PURE__ */ new Set();
4645
+ const cap = _ApprovalService.APPROVER_INDEX_CAP;
4646
+ const add = (rows, key) => {
4647
+ const list = Array.isArray(rows) ? rows : [];
4648
+ for (const r of list) if (r?.[key] != null) ids.add(String(r[key]));
4649
+ if (list.length >= cap) {
4650
+ this.logger?.warn?.(
4651
+ "[approvals] participant-visibility probe hit its window \u2014 some requests may be hidden from a legitimate participant",
4652
+ { cap, key }
4653
+ );
4654
+ }
4655
+ };
4656
+ try {
4657
+ for (const id of await this.approverRequestIds([uid2], tenantOrg) ?? []) ids.add(id);
4658
+ const orgWhere = tenantOrg ? { organization_id: tenantOrg } : {};
4659
+ add(
4660
+ await this.engine.find("sys_approval_request", {
4661
+ where: { submitter_id: uid2, ...orgWhere },
4662
+ fields: ["id"],
4663
+ limit: cap,
4664
+ context: SYSTEM_CTX2
4665
+ }),
4666
+ "id"
4667
+ );
4668
+ add(
4669
+ await this.engine.find("sys_approval_action", {
4670
+ where: { actor_id: uid2 },
4671
+ fields: ["request_id"],
4672
+ limit: cap,
4673
+ context: SYSTEM_CTX2
4674
+ }),
4675
+ "request_id"
4676
+ );
4677
+ } catch (err) {
4678
+ this.logger?.warn?.("[approvals] participant-visibility probe failed", {
4679
+ error: err instanceof Error ? err.message : String(err)
4680
+ });
4681
+ }
4682
+ return ids;
4683
+ }
4684
+ /** Intersect an existing `where.id` constraint with the participant set. */
4685
+ applyVisibility(where, visible) {
4686
+ if (!visible) return true;
4687
+ if (visible.size === 0) return false;
4688
+ let allowed = [...visible];
4689
+ const current = where.id;
4690
+ if (typeof current === "string") allowed = allowed.filter((x) => x === current);
4691
+ else if (current && typeof current === "object" && Array.isArray(current.$in)) {
4692
+ const set = new Set(current.$in.map((v) => String(v)));
4693
+ allowed = allowed.filter((x) => set.has(x));
4694
+ }
4695
+ if (allowed.length === 0) return false;
4696
+ where.id = allowed.length === 1 ? allowed[0] : { $in: allowed };
4697
+ return true;
4698
+ }
3318
4699
  async listRequests(filter, context) {
3319
4700
  const { where, tenantOrg } = this.buildRequestWhere(filter, context);
3320
4701
  const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
@@ -3323,10 +4704,11 @@ var _ApprovalService = class _ApprovalService {
3323
4704
  if (ids.length === 0) return [];
3324
4705
  where.id = ids.length === 1 ? ids[0] : { $in: ids };
3325
4706
  }
4707
+ if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return [];
3326
4708
  const findOpts = {
3327
4709
  where,
3328
4710
  orderBy: [{ field: "created_at", order: "desc" }],
3329
- context: SYSTEM_CTX
4711
+ context: SYSTEM_CTX2
3330
4712
  };
3331
4713
  if (filter?.limit != null || filter?.offset != null) {
3332
4714
  findOpts.limit = Math.min(Math.max(filter?.limit ?? 50, 1), 200);
@@ -3348,10 +4730,11 @@ var _ApprovalService = class _ApprovalService {
3348
4730
  if (ids.length === 0) return 0;
3349
4731
  where.id = ids.length === 1 ? ids[0] : { $in: ids };
3350
4732
  }
4733
+ if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return 0;
3351
4734
  const countFn = this.engine.count;
3352
4735
  if (typeof countFn === "function") {
3353
4736
  try {
3354
- const n = await countFn.call(this.engine, "sys_approval_request", { where, context: SYSTEM_CTX });
4737
+ const n = await countFn.call(this.engine, "sys_approval_request", { where, context: SYSTEM_CTX2 });
3355
4738
  if (typeof n === "number") return n;
3356
4739
  } catch {
3357
4740
  }
@@ -3360,11 +4743,28 @@ var _ApprovalService = class _ApprovalService {
3360
4743
  where,
3361
4744
  fields: ["id"],
3362
4745
  limit: ids ? Math.max(500, ids.length) : 500,
3363
- context: SYSTEM_CTX
4746
+ context: SYSTEM_CTX2
3364
4747
  });
3365
4748
  return Array.isArray(rows) ? rows.length : 0;
3366
4749
  }
4750
+ /**
4751
+ * Read the request a write path just changed, to echo back as its result.
4752
+ *
4753
+ * NOT participant-gated (#3590), deliberately: the operation authorized
4754
+ * itself by its own rule before writing, so re-asking "may you see this?"
4755
+ * for the echo answers a question that has already been settled — and would
4756
+ * answer it WRONG for a caller context that carries no `userId` (a
4757
+ * flow-driven resume, a service-to-service call), turning a successful write
4758
+ * into a `null` result. Gating belongs on the read API, not on an
4759
+ * operation's own return value.
4760
+ */
4761
+ async readBackRequest(requestId, context) {
4762
+ return this.loadRequest(requestId, context, false);
4763
+ }
3367
4764
  async getRequest(requestId, context) {
4765
+ return this.loadRequest(requestId, context, true);
4766
+ }
4767
+ async loadRequest(requestId, context, enforceVisibility) {
3368
4768
  if (!requestId) return null;
3369
4769
  const where = { id: requestId };
3370
4770
  const tenantOrg = context?.organizationId ?? context?.tenantId;
@@ -3372,9 +4772,13 @@ var _ApprovalService = class _ApprovalService {
3372
4772
  const rows = await this.engine.find("sys_approval_request", {
3373
4773
  where,
3374
4774
  limit: 1,
3375
- context: SYSTEM_CTX
4775
+ context: SYSTEM_CTX2
3376
4776
  });
3377
4777
  if (!Array.isArray(rows) || !rows[0]) return null;
4778
+ if (enforceVisibility) {
4779
+ const visible = await this.visibleRequestIds(context, tenantOrg ?? null);
4780
+ if (visible && !visible.has(String(rows[0].id))) return null;
4781
+ }
3378
4782
  const row = rowFromRequest(rows[0]);
3379
4783
  await this.enrichRows([row]);
3380
4784
  await this.attachFlowSteps(row);
@@ -3401,7 +4805,7 @@ var _ApprovalService = class _ApprovalService {
3401
4805
  const acts = await this.engine.find("sys_approval_action", {
3402
4806
  where: { request_id: row.id, step_index: 0, action: "approve" },
3403
4807
  limit: 1e3,
3404
- context: SYSTEM_CTX
4808
+ context: SYSTEM_CTX2
3405
4809
  });
3406
4810
  const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
3407
4811
  const snapshot = cfg?.__approverGroups;
@@ -3422,6 +4826,14 @@ var _ApprovalService = class _ApprovalService {
3422
4826
  });
3423
4827
  progress.got = progress.groups.filter((g) => g.satisfied).length;
3424
4828
  progress.need = progress.groups.length;
4829
+ const pendingGroups = {};
4830
+ for (const a of row.pending_approvers ?? []) {
4831
+ const named = (snapshot[a] ?? []).filter((g) => !/^#\d+$/.test(g));
4832
+ if (named.length) pendingGroups[a] = named;
4833
+ }
4834
+ if (Object.keys(pendingGroups).length) {
4835
+ row.pending_approver_groups = pendingGroups;
4836
+ }
3425
4837
  }
3426
4838
  row.decision_progress = progress;
3427
4839
  } catch {
@@ -3433,8 +4845,13 @@ var _ApprovalService = class _ApprovalService {
3433
4845
  * caller's user id is in the resolved `pending_approvers` while the request is
3434
4846
  * still `pending` (position/team/manager approvers are already resolved to
3435
4847
  * 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.
4848
+ * `is_submitter` is a straight owner check. `can_override` (#3424) is true for
4849
+ * a platform/tenant admin on a PENDING request the recovery path for an
4850
+ * approval routed to an unstaffed position or to approvers who have all left;
4851
+ * clients OR it into the decision actions' `visible` gate so an admin can act
4852
+ * even when they hold no slot. System/tokenless contexts get a both-false
4853
+ * `can_act`/`is_submitter` block (system gets `can_override` too — it may act
4854
+ * on anything). Cheap + synchronous — safe on list reads.
3438
4855
  */
3439
4856
  attachViewers(rows, context) {
3440
4857
  const uid2 = context?.userId != null ? String(context.userId) : null;
@@ -3442,7 +4859,8 @@ var _ApprovalService = class _ApprovalService {
3442
4859
  const pending = row.pending_approvers ?? [];
3443
4860
  row.viewer = {
3444
4861
  can_act: row.status === "pending" && !!uid2 && pending.includes(uid2),
3445
- is_submitter: !!uid2 && row.submitter_id != null && String(row.submitter_id) === uid2
4862
+ is_submitter: !!uid2 && row.submitter_id != null && String(row.submitter_id) === uid2,
4863
+ can_override: row.status === "pending" && this.isOverrideActor(context, row.organization_id ?? null)
3446
4864
  };
3447
4865
  }
3448
4866
  }
@@ -3489,7 +4907,7 @@ var _ApprovalService = class _ApprovalService {
3489
4907
  where: { request_id: requestId },
3490
4908
  limit: 500,
3491
4909
  orderBy: [{ field: "created_at", order: "asc" }],
3492
- context: SYSTEM_CTX
4910
+ context: SYSTEM_CTX2
3493
4911
  });
3494
4912
  const actions = Array.isArray(rows) ? rows.map(rowFromAction) : [];
3495
4913
  const names = await this.resolveUserNames(
@@ -3501,6 +4919,36 @@ var _ApprovalService = class _ApprovalService {
3501
4919
  }
3502
4920
  return actions;
3503
4921
  }
4922
+ /**
4923
+ * `IFileAccessDelegate` — may this caller download a decision attachment?
4924
+ * (ADR-0104 D3 wave 2; declared by `sys_approval_action.fileAccessDelegate`.)
4925
+ *
4926
+ * A file referenced by `sys_approval_action.attachments` is owned by that
4927
+ * audit row, so the storage service would otherwise authorize the download by
4928
+ * testing whether the caller can READ the row. It cannot: `sys_approval_action`
4929
+ * is deliberately closed to ordinary approver positions, so that test denies
4930
+ * the very approver the attachment was filed for.
4931
+ *
4932
+ * The rule that actually governs seeing a decision is the one `listActions`
4933
+ * applies — can the caller see the PARENT REQUEST? — so this reuses it
4934
+ * exactly, rather than inventing a second, looser rule for the bytes. Fails
4935
+ * closed on any error.
4936
+ */
4937
+ async authorizeFileRead(actionId, context) {
4938
+ if (!actionId) return false;
4939
+ try {
4940
+ const rows = await this.engine.find("sys_approval_action", {
4941
+ where: { id: actionId },
4942
+ limit: 1,
4943
+ context: SYSTEM_CTX2
4944
+ });
4945
+ const requestId = (Array.isArray(rows) ? rows[0] : void 0)?.request_id;
4946
+ if (!requestId) return false;
4947
+ return !!await this.getRequest(String(requestId), context);
4948
+ } catch {
4949
+ return false;
4950
+ }
4951
+ }
3504
4952
  };
3505
4953
  /** Window the approver-index probe — pending queues live far below this. */
3506
4954
  _ApprovalService.APPROVER_INDEX_CAP = 1e4;
@@ -3695,6 +5143,8 @@ function bindApprovalLockHook(engine, logger) {
3695
5143
  if (Array.isArray(roles) && roles.includes("admin")) return;
3696
5144
  const pending = await pendingRequestFor(engine, object, id);
3697
5145
  if (!pending) return;
5146
+ const writerRun = ctx?.provenance?.flowRunId;
5147
+ if (writerRun && pending.flow_run_id && String(writerRun) === String(pending.flow_run_id)) return;
3698
5148
  const config = parseJson2(pending.node_config_json, {});
3699
5149
  if (config?.lockRecord === false) return;
3700
5150
  const mirror = config?.approvalStatusField;
@@ -3751,7 +5201,22 @@ import {
3751
5201
  getApprovalNodeConfigJsonSchema,
3752
5202
  APPROVAL_NODE_TYPE
3753
5203
  } from "@objectstack/spec/automation";
3754
- var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
5204
+ var SYSTEM_CTX3 = { isSystem: true, positions: [], permissions: [] };
5205
+ function nestVariables(variables) {
5206
+ const vars = {};
5207
+ for (const [key, value] of variables) {
5208
+ const segs = key.split(".");
5209
+ let cursor = vars;
5210
+ for (let i = 0; i < segs.length - 1; i++) {
5211
+ if (typeof cursor[segs[i]] !== "object" || cursor[segs[i]] === null) {
5212
+ cursor[segs[i]] = {};
5213
+ }
5214
+ cursor = cursor[segs[i]];
5215
+ }
5216
+ cursor[segs[segs.length - 1]] = value;
5217
+ }
5218
+ return vars;
5219
+ }
3755
5220
  function registerApprovalNode(automation, service, logger) {
3756
5221
  automation.registerNodeExecutor({
3757
5222
  type: APPROVAL_NODE_TYPE,
@@ -3767,6 +5232,15 @@ function registerApprovalNode(automation, service, logger) {
3767
5232
  // Human decision: the run suspends here awaiting an external reply.
3768
5233
  supportsPause: true,
3769
5234
  isAsync: true,
5235
+ // #3801: this pause is NOT resumable through the generic run-resume
5236
+ // route. Continuing an approval is a side effect of a DECISION, and the
5237
+ // decision is the thing that must be authorized (the approver slate),
5238
+ // recorded (`sys_approval_action`) and mirrored (the status field) —
5239
+ // all of which lives in `ApprovalService.decide`. The engine now refuses
5240
+ // any resume of an approval suspension that does not carry the service's
5241
+ // in-process marker, so "decide via the approvals API, never a raw engine
5242
+ // resume" is enforced rather than merely documented.
5243
+ resumeAuthority: "service",
3770
5244
  // Publish the node's config contract (ADR-0018 §configSchema) so the
3771
5245
  // Studio flow designer renders the Approval property form from the engine
3772
5246
  // rather than a hardcoded client form — the engine owns the shape.
@@ -3800,13 +5274,28 @@ function registerApprovalNode(automation, service, logger) {
3800
5274
  nodeLabel: typeof node.label === "string" ? node.label : void 0,
3801
5275
  submitterId: context?.userId ?? null,
3802
5276
  record,
3803
- organizationId: context?.organizationId ?? context?.tenantId ?? null
5277
+ organizationId: context?.organizationId ?? context?.tenantId ?? null,
5278
+ // #3447 P2: flow variables (nested, as CEL conditions see them) — the
5279
+ // `vars.*` root for `expression` approvers; `record` above doubles as
5280
+ // their `trigger.*` snapshot root.
5281
+ variables: nestVariables(variables)
3804
5282
  }, {
3805
- ...SYSTEM_CTX2,
5283
+ ...SYSTEM_CTX3,
3806
5284
  userId: context?.userId,
3807
5285
  organizationId: context?.organizationId,
3808
5286
  tenantId: context?.tenantId
3809
5287
  });
5288
+ if ("autoApproved" in request) {
5289
+ logger?.info?.("[approvals] approval node auto-approved (empty approver slate)", {
5290
+ node: node.id,
5291
+ run: String(runId)
5292
+ });
5293
+ return {
5294
+ success: true,
5295
+ branchLabel: "approve",
5296
+ output: { decision: "approve", autoApproved: true }
5297
+ };
5298
+ }
3810
5299
  logger?.info?.("[approvals] approval node suspended run", {
3811
5300
  node: node.id,
3812
5301
  request: request.id,
@@ -3892,7 +5381,21 @@ var ApprovalsServicePlugin = class {
3892
5381
  this.service = new ApprovalService({
3893
5382
  engine,
3894
5383
  logger: ctx.logger,
3895
- publicBaseUrl: this.options.publicBaseUrl
5384
+ publicBaseUrl: this.options.publicBaseUrl,
5385
+ // [ADR-0105 D9] Cross-organization approver targeting is a `group`-posture
5386
+ // capability. Read LAZILY (not captured at start) because the tenancy
5387
+ // service resolves its posture during its own start, which may not have
5388
+ // run yet; an unresolvable posture reads as "unknown" and the guard
5389
+ // stands down rather than refusing a legitimate flow on a minimal stack.
5390
+ tenancyPosture: () => {
5391
+ try {
5392
+ const tenancy = ctx.getService("tenancy");
5393
+ const posture = tenancy?.posture;
5394
+ return typeof posture === "string" && posture ? posture : void 0;
5395
+ } catch {
5396
+ return void 0;
5397
+ }
5398
+ }
3896
5399
  });
3897
5400
  if (!this.options.disableAutoHooks) {
3898
5401
  try {
@@ -3918,12 +5421,23 @@ var ApprovalsServicePlugin = class {
3918
5421
  if (!jobs || typeof jobs.schedule !== "function" || !this.service) return;
3919
5422
  const svc = this.service;
3920
5423
  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
- });
5424
+ const sweep = async () => {
5425
+ const results = await Promise.allSettled([
5426
+ svc.runEscalations(),
5427
+ svc.releaseDeadRunRequests()
5428
+ ]);
5429
+ for (const r of results) {
5430
+ if (r.status === "rejected") {
5431
+ ctx.logger.warn?.("[approvals] periodic sweep leg failed", {
5432
+ error: r.reason?.message ?? String(r.reason)
5433
+ });
5434
+ }
5435
+ }
5436
+ };
5437
+ await jobs.schedule(ESCALATION_JOB_NAME, { type: "interval", intervalMs }, sweep);
3924
5438
  this.escalationJobScheduled = true;
3925
- void svc.runEscalations().catch((err) => {
3926
- ctx.logger.warn?.("[approvals] boot escalation sweep failed", { error: err?.message });
5439
+ void sweep().catch((err) => {
5440
+ ctx.logger.warn?.("[approvals] boot sweep failed", { error: err?.message });
3927
5441
  });
3928
5442
  ctx.logger.info("ApprovalsServicePlugin: SLA escalation scan scheduled", { intervalMs });
3929
5443
  } catch {