@objectstack/plugin-approvals 16.0.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 +11 -11
  2. package/CHANGELOG.md +1037 -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.js CHANGED
@@ -66,7 +66,8 @@ var init_en_objects_generated = __esm({
66
66
  pending: "pending",
67
67
  approved: "approved",
68
68
  rejected: "rejected",
69
- recalled: "recalled"
69
+ recalled: "recalled",
70
+ returned: "returned"
70
71
  }
71
72
  },
72
73
  current_step: {
@@ -108,7 +109,11 @@ var init_en_objects_generated = __esm({
108
109
  },
109
110
  _views: {
110
111
  my_pending: {
111
- label: "My Pending"
112
+ label: "My Pending",
113
+ emptyState: {
114
+ title: "No pending approvals",
115
+ message: "You're all caught up."
116
+ }
112
117
  },
113
118
  submitted_by_me: {
114
119
  label: "I Submitted"
@@ -119,6 +124,92 @@ var init_en_objects_generated = __esm({
119
124
  all_requests: {
120
125
  label: "All"
121
126
  }
127
+ },
128
+ _actions: {
129
+ approval_approve: {
130
+ label: "Approve",
131
+ successMessage: "Approved.",
132
+ params: {
133
+ comment: {
134
+ label: "Comment"
135
+ },
136
+ attachments: {
137
+ label: "Attachments"
138
+ }
139
+ }
140
+ },
141
+ approval_reject: {
142
+ label: "Reject",
143
+ confirmText: "Reject this request? A rejection is final for every approver.",
144
+ successMessage: "Rejected.",
145
+ params: {
146
+ comment: {
147
+ label: "Comment"
148
+ },
149
+ attachments: {
150
+ label: "Attachments"
151
+ }
152
+ }
153
+ },
154
+ approval_reassign: {
155
+ label: "Reassign",
156
+ successMessage: "Reassigned.",
157
+ params: {
158
+ to: {
159
+ label: "New approver",
160
+ helpText: "User to hand this step to"
161
+ },
162
+ comment: {
163
+ label: "Comment"
164
+ }
165
+ }
166
+ },
167
+ approval_send_back: {
168
+ label: "Send back",
169
+ successMessage: "Sent back for revision.",
170
+ params: {
171
+ comment: {
172
+ label: "Reason"
173
+ }
174
+ }
175
+ },
176
+ approval_request_info: {
177
+ label: "Request info",
178
+ successMessage: "Information requested.",
179
+ params: {
180
+ comment: {
181
+ label: "What do you need?"
182
+ }
183
+ }
184
+ },
185
+ approval_remind: {
186
+ label: "Send reminder",
187
+ successMessage: "Reminder sent.",
188
+ params: {
189
+ comment: {
190
+ label: "Note"
191
+ }
192
+ }
193
+ },
194
+ approval_recall: {
195
+ label: "Recall",
196
+ confirmText: "Recall this request? Approvers can no longer act on it and the record is unlocked.",
197
+ successMessage: "Recalled.",
198
+ params: {
199
+ comment: {
200
+ label: "Comment"
201
+ }
202
+ }
203
+ },
204
+ approval_resubmit: {
205
+ label: "Resubmit",
206
+ successMessage: "Resubmitted.",
207
+ params: {
208
+ comment: {
209
+ label: "What changed?"
210
+ }
211
+ }
212
+ }
122
213
  }
123
214
  },
124
215
  sys_approval_action: {
@@ -150,7 +241,14 @@ var init_en_objects_generated = __esm({
150
241
  approve: "approve",
151
242
  reject: "reject",
152
243
  recall: "recall",
153
- escalate: "escalate"
244
+ escalate: "escalate",
245
+ reassign: "reassign",
246
+ remind: "remind",
247
+ request_info: "request_info",
248
+ comment: "comment",
249
+ revise: "revise",
250
+ resubmit: "resubmit",
251
+ ooo_substitute: "ooo_substitute"
154
252
  }
155
253
  },
156
254
  actor_id: {
@@ -159,13 +257,21 @@ var init_en_objects_generated = __esm({
159
257
  comment: {
160
258
  label: "Comment"
161
259
  },
260
+ attachments: {
261
+ label: "Attachments",
262
+ help: "Files supporting this action \u2014 e.g. a signed contract or evidence (#3266)."
263
+ },
162
264
  created_at: {
163
265
  label: "Created At"
164
266
  }
165
267
  },
166
268
  _views: {
167
269
  recent: {
168
- label: "Recent"
270
+ label: "Recent",
271
+ emptyState: {
272
+ title: "No approval actions yet",
273
+ message: "Actions are logged automatically when approvals progress."
274
+ }
169
275
  },
170
276
  by_actor: {
171
277
  label: "By Actor"
@@ -269,7 +375,8 @@ var init_zh_CN_objects_generated = __esm({
269
375
  pending: "\u5F85\u5904\u7406",
270
376
  approved: "\u5DF2\u6279\u51C6",
271
377
  rejected: "\u5DF2\u62D2\u7EDD",
272
- recalled: "\u5DF2\u64A4\u56DE"
378
+ recalled: "\u5DF2\u64A4\u56DE",
379
+ returned: "\u5DF2\u9000\u56DE\u4FEE\u6539"
273
380
  }
274
381
  },
275
382
  current_step: {
@@ -311,7 +418,11 @@ var init_zh_CN_objects_generated = __esm({
311
418
  },
312
419
  _views: {
313
420
  my_pending: {
314
- label: "\u6211\u7684\u5F85\u529E"
421
+ label: "\u6211\u7684\u5F85\u529E",
422
+ emptyState: {
423
+ title: "\u6682\u65E0\u5F85\u529E\u5BA1\u6279",
424
+ message: "\u5168\u90E8\u5904\u7406\u5B8C\u6BD5\uFF0C\u6CA1\u6709\u7B49\u5F85\u4F60\u5BA1\u6279\u7684\u8BF7\u6C42\u3002"
425
+ }
315
426
  },
316
427
  submitted_by_me: {
317
428
  label: "\u6211\u63D0\u4EA4\u7684"
@@ -322,6 +433,92 @@ var init_zh_CN_objects_generated = __esm({
322
433
  all_requests: {
323
434
  label: "\u5168\u90E8"
324
435
  }
436
+ },
437
+ _actions: {
438
+ approval_approve: {
439
+ label: "\u901A\u8FC7",
440
+ successMessage: "\u5DF2\u901A\u8FC7\u3002",
441
+ params: {
442
+ comment: {
443
+ label: "\u5BA1\u6279\u610F\u89C1"
444
+ },
445
+ attachments: {
446
+ label: "\u9644\u4EF6"
447
+ }
448
+ }
449
+ },
450
+ approval_reject: {
451
+ label: "\u62D2\u7EDD",
452
+ confirmText: "\u62D2\u7EDD\u8BE5\u8BF7\u6C42\uFF1F\u62D2\u7EDD\u5BF9\u6240\u6709\u5BA1\u6279\u4EBA\u7ACB\u5373\u751F\u6548\u3002",
453
+ successMessage: "\u5DF2\u62D2\u7EDD\u3002",
454
+ params: {
455
+ comment: {
456
+ label: "\u5BA1\u6279\u610F\u89C1"
457
+ },
458
+ attachments: {
459
+ label: "\u9644\u4EF6"
460
+ }
461
+ }
462
+ },
463
+ approval_reassign: {
464
+ label: "\u8F6C\u7B7E",
465
+ successMessage: "\u5DF2\u8F6C\u7B7E\u3002",
466
+ params: {
467
+ to: {
468
+ label: "\u65B0\u5BA1\u6279\u4EBA",
469
+ helpText: "\u5C06\u6B64\u5BA1\u6279\u6B65\u9AA4\u8F6C\u4EA4\u7ED9\u7684\u7528\u6237"
470
+ },
471
+ comment: {
472
+ label: "\u5907\u6CE8"
473
+ }
474
+ }
475
+ },
476
+ approval_send_back: {
477
+ label: "\u9000\u56DE\u4FEE\u6539",
478
+ successMessage: "\u5DF2\u9000\u56DE\u4FEE\u6539\u3002",
479
+ params: {
480
+ comment: {
481
+ label: "\u539F\u56E0"
482
+ }
483
+ }
484
+ },
485
+ approval_request_info: {
486
+ label: "\u9000\u56DE\u8865\u5145",
487
+ successMessage: "\u5DF2\u9000\u56DE\u7ED9\u7533\u8BF7\u4EBA\u8865\u5145\u6750\u6599\u3002",
488
+ params: {
489
+ comment: {
490
+ label: "\u9700\u8981\u7533\u8BF7\u4EBA\u8865\u5145\u4EC0\u4E48\uFF1F"
491
+ }
492
+ }
493
+ },
494
+ approval_remind: {
495
+ label: "\u50AC\u529E",
496
+ successMessage: "\u5DF2\u53D1\u9001\u50AC\u529E\u3002",
497
+ params: {
498
+ comment: {
499
+ label: "\u5907\u6CE8"
500
+ }
501
+ }
502
+ },
503
+ approval_recall: {
504
+ label: "\u64A4\u56DE",
505
+ 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",
506
+ successMessage: "\u5DF2\u64A4\u56DE\u3002",
507
+ params: {
508
+ comment: {
509
+ label: "\u5907\u6CE8"
510
+ }
511
+ }
512
+ },
513
+ approval_resubmit: {
514
+ label: "\u91CD\u65B0\u63D0\u4EA4",
515
+ successMessage: "\u5DF2\u91CD\u65B0\u63D0\u4EA4\u3002",
516
+ params: {
517
+ comment: {
518
+ label: "\u4F60\u4FEE\u6539\u4E86\u4EC0\u4E48\uFF1F"
519
+ }
520
+ }
521
+ }
325
522
  }
326
523
  },
327
524
  sys_approval_action: {
@@ -353,7 +550,14 @@ var init_zh_CN_objects_generated = __esm({
353
550
  approve: "\u6279\u51C6",
354
551
  reject: "\u62D2\u7EDD",
355
552
  recall: "\u64A4\u56DE",
356
- escalate: "\u5347\u7EA7"
553
+ escalate: "\u5347\u7EA7",
554
+ reassign: "\u8F6C\u7B7E",
555
+ remind: "\u50AC\u529E",
556
+ request_info: "\u8981\u6C42\u8865\u5145\u6750\u6599",
557
+ comment: "\u8BC4\u8BBA",
558
+ revise: "\u9000\u56DE\u4FEE\u6539",
559
+ resubmit: "\u91CD\u65B0\u63D0\u4EA4",
560
+ ooo_substitute: "\u4E0D\u5728\u5C97\u6539\u6D3E"
357
561
  }
358
562
  },
359
563
  actor_id: {
@@ -362,13 +566,21 @@ var init_zh_CN_objects_generated = __esm({
362
566
  comment: {
363
567
  label: "\u8BC4\u8BBA"
364
568
  },
569
+ attachments: {
570
+ label: "\u9644\u4EF6",
571
+ 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"
572
+ },
365
573
  created_at: {
366
574
  label: "\u521B\u5EFA\u65F6\u95F4"
367
575
  }
368
576
  },
369
577
  _views: {
370
578
  recent: {
371
- label: "\u6700\u8FD1"
579
+ label: "\u6700\u8FD1",
580
+ emptyState: {
581
+ title: "\u6682\u65E0\u5BA1\u6279\u64CD\u4F5C\u8BB0\u5F55",
582
+ message: "\u5BA1\u6279\u63A8\u8FDB\u65F6\u4F1A\u81EA\u52A8\u8BB0\u5F55\u64CD\u4F5C\u3002"
583
+ }
372
584
  },
373
585
  by_actor: {
374
586
  label: "\u6309\u6267\u884C\u4EBA"
@@ -472,7 +684,8 @@ var init_ja_JP_objects_generated = __esm({
472
684
  pending: "\u4FDD\u7559\u4E2D",
473
685
  approved: "\u627F\u8A8D\u6E08\u307F",
474
686
  rejected: "\u5374\u4E0B\u6E08\u307F",
475
- recalled: "\u53D6\u308A\u6D88\u3057\u6E08\u307F"
687
+ recalled: "\u53D6\u308A\u6D88\u3057\u6E08\u307F",
688
+ returned: "\u5DEE\u3057\u623B\u3057\u6E08\u307F"
476
689
  }
477
690
  },
478
691
  current_step: {
@@ -514,7 +727,11 @@ var init_ja_JP_objects_generated = __esm({
514
727
  },
515
728
  _views: {
516
729
  my_pending: {
517
- label: "\u81EA\u5206\u306E\u4FDD\u7559\u4E2D"
730
+ label: "\u81EA\u5206\u306E\u4FDD\u7559\u4E2D",
731
+ emptyState: {
732
+ title: "\u627F\u8A8D\u5F85\u3061\u306F\u3042\u308A\u307E\u305B\u3093",
733
+ 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"
734
+ }
518
735
  },
519
736
  submitted_by_me: {
520
737
  label: "\u81EA\u5206\u304C\u9001\u4FE1"
@@ -525,6 +742,92 @@ var init_ja_JP_objects_generated = __esm({
525
742
  all_requests: {
526
743
  label: "\u3059\u3079\u3066"
527
744
  }
745
+ },
746
+ _actions: {
747
+ approval_approve: {
748
+ label: "\u627F\u8A8D",
749
+ successMessage: "\u627F\u8A8D\u3057\u307E\u3057\u305F\u3002",
750
+ params: {
751
+ comment: {
752
+ label: "\u30B3\u30E1\u30F3\u30C8"
753
+ },
754
+ attachments: {
755
+ label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB"
756
+ }
757
+ }
758
+ },
759
+ approval_reject: {
760
+ label: "\u5374\u4E0B",
761
+ 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",
762
+ successMessage: "\u5374\u4E0B\u3057\u307E\u3057\u305F\u3002",
763
+ params: {
764
+ comment: {
765
+ label: "\u30B3\u30E1\u30F3\u30C8"
766
+ },
767
+ attachments: {
768
+ label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB"
769
+ }
770
+ }
771
+ },
772
+ approval_reassign: {
773
+ label: "\u518D\u5272\u308A\u5F53\u3066",
774
+ successMessage: "\u518D\u5272\u308A\u5F53\u3066\u3057\u307E\u3057\u305F\u3002",
775
+ params: {
776
+ to: {
777
+ label: "\u65B0\u3057\u3044\u627F\u8A8D\u8005",
778
+ helpText: "\u3053\u306E\u30B9\u30C6\u30C3\u30D7\u3092\u5F15\u304D\u7D99\u3050\u30E6\u30FC\u30B6\u30FC"
779
+ },
780
+ comment: {
781
+ label: "\u30B3\u30E1\u30F3\u30C8"
782
+ }
783
+ }
784
+ },
785
+ approval_send_back: {
786
+ label: "\u5DEE\u3057\u623B\u3057",
787
+ successMessage: "\u4FEE\u6B63\u306E\u305F\u3081\u5DEE\u3057\u623B\u3057\u307E\u3057\u305F\u3002",
788
+ params: {
789
+ comment: {
790
+ label: "\u7406\u7531"
791
+ }
792
+ }
793
+ },
794
+ approval_request_info: {
795
+ label: "\u8FFD\u52A0\u60C5\u5831\u3092\u4F9D\u983C",
796
+ successMessage: "\u8FFD\u52A0\u60C5\u5831\u3092\u4F9D\u983C\u3057\u307E\u3057\u305F\u3002",
797
+ params: {
798
+ comment: {
799
+ label: "\u4F55\u304C\u5FC5\u8981\u3067\u3059\u304B\uFF1F"
800
+ }
801
+ }
802
+ },
803
+ approval_remind: {
804
+ label: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC\u9001\u4FE1",
805
+ successMessage: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC\u3092\u9001\u4FE1\u3057\u307E\u3057\u305F\u3002",
806
+ params: {
807
+ comment: {
808
+ label: "\u30E1\u30E2"
809
+ }
810
+ }
811
+ },
812
+ approval_recall: {
813
+ label: "\u53D6\u308A\u4E0B\u3052",
814
+ 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",
815
+ successMessage: "\u53D6\u308A\u4E0B\u3052\u307E\u3057\u305F\u3002",
816
+ params: {
817
+ comment: {
818
+ label: "\u30B3\u30E1\u30F3\u30C8"
819
+ }
820
+ }
821
+ },
822
+ approval_resubmit: {
823
+ label: "\u518D\u63D0\u51FA",
824
+ successMessage: "\u518D\u63D0\u51FA\u3057\u307E\u3057\u305F\u3002",
825
+ params: {
826
+ comment: {
827
+ label: "\u4F55\u3092\u5909\u66F4\u3057\u307E\u3057\u305F\u304B\uFF1F"
828
+ }
829
+ }
830
+ }
528
831
  }
529
832
  },
530
833
  sys_approval_action: {
@@ -556,7 +859,14 @@ var init_ja_JP_objects_generated = __esm({
556
859
  approve: "\u627F\u8A8D",
557
860
  reject: "\u5374\u4E0B",
558
861
  recall: "\u53D6\u6D88",
559
- escalate: "\u30A8\u30B9\u30AB\u30EC\u30FC\u30B7\u30E7\u30F3"
862
+ escalate: "\u30A8\u30B9\u30AB\u30EC\u30FC\u30B7\u30E7\u30F3",
863
+ reassign: "\u518D\u5272\u308A\u5F53\u3066",
864
+ remind: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC",
865
+ request_info: "\u8FFD\u52A0\u60C5\u5831\u4F9D\u983C",
866
+ comment: "\u30B3\u30E1\u30F3\u30C8",
867
+ revise: "\u5DEE\u3057\u623B\u3057",
868
+ resubmit: "\u518D\u63D0\u51FA",
869
+ ooo_substitute: "\u4E0D\u5728\u6642\u4EE3\u7406"
560
870
  }
561
871
  },
562
872
  actor_id: {
@@ -565,13 +875,21 @@ var init_ja_JP_objects_generated = __esm({
565
875
  comment: {
566
876
  label: "\u30B3\u30E1\u30F3\u30C8"
567
877
  },
878
+ attachments: {
879
+ label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB",
880
+ 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"
881
+ },
568
882
  created_at: {
569
883
  label: "\u4F5C\u6210\u65E5\u6642"
570
884
  }
571
885
  },
572
886
  _views: {
573
887
  recent: {
574
- label: "\u6700\u8FD1"
888
+ label: "\u6700\u8FD1",
889
+ emptyState: {
890
+ title: "\u627F\u8A8D\u64CD\u4F5C\u306F\u307E\u3060\u3042\u308A\u307E\u305B\u3093",
891
+ message: "\u627F\u8A8D\u304C\u9032\u3080\u3068\u64CD\u4F5C\u306F\u81EA\u52D5\u7684\u306B\u8A18\u9332\u3055\u308C\u307E\u3059\u3002"
892
+ }
575
893
  },
576
894
  by_actor: {
577
895
  label: "\u64CD\u4F5C\u8005\u5225"
@@ -675,7 +993,8 @@ var init_es_ES_objects_generated = __esm({
675
993
  pending: "Pendiente",
676
994
  approved: "Aprobada",
677
995
  rejected: "Rechazada",
678
- recalled: "Retirada"
996
+ recalled: "Retirada",
997
+ returned: "Devuelta para revisi\xF3n"
679
998
  }
680
999
  },
681
1000
  current_step: {
@@ -717,7 +1036,11 @@ var init_es_ES_objects_generated = __esm({
717
1036
  },
718
1037
  _views: {
719
1038
  my_pending: {
720
- label: "Mis pendientes"
1039
+ label: "Mis pendientes",
1040
+ emptyState: {
1041
+ title: "Sin aprobaciones pendientes",
1042
+ message: "Est\xE1s al d\xEDa: nada espera tu aprobaci\xF3n."
1043
+ }
721
1044
  },
722
1045
  submitted_by_me: {
723
1046
  label: "Enviadas por m\xED"
@@ -728,6 +1051,92 @@ var init_es_ES_objects_generated = __esm({
728
1051
  all_requests: {
729
1052
  label: "Todas"
730
1053
  }
1054
+ },
1055
+ _actions: {
1056
+ approval_approve: {
1057
+ label: "Aprobar",
1058
+ successMessage: "Aprobada.",
1059
+ params: {
1060
+ comment: {
1061
+ label: "Comentario"
1062
+ },
1063
+ attachments: {
1064
+ label: "Adjuntos"
1065
+ }
1066
+ }
1067
+ },
1068
+ approval_reject: {
1069
+ label: "Rechazar",
1070
+ confirmText: "\xBFRechazar esta solicitud? Un rechazo es definitivo para todos los aprobadores.",
1071
+ successMessage: "Rechazada.",
1072
+ params: {
1073
+ comment: {
1074
+ label: "Comentario"
1075
+ },
1076
+ attachments: {
1077
+ label: "Adjuntos"
1078
+ }
1079
+ }
1080
+ },
1081
+ approval_reassign: {
1082
+ label: "Reasignar",
1083
+ successMessage: "Reasignada.",
1084
+ params: {
1085
+ to: {
1086
+ label: "Nuevo aprobador",
1087
+ helpText: "Usuario al que se entrega este paso"
1088
+ },
1089
+ comment: {
1090
+ label: "Comentario"
1091
+ }
1092
+ }
1093
+ },
1094
+ approval_send_back: {
1095
+ label: "Devolver",
1096
+ successMessage: "Devuelta para revisi\xF3n.",
1097
+ params: {
1098
+ comment: {
1099
+ label: "Motivo"
1100
+ }
1101
+ }
1102
+ },
1103
+ approval_request_info: {
1104
+ label: "Pedir informaci\xF3n",
1105
+ successMessage: "Informaci\xF3n solicitada.",
1106
+ params: {
1107
+ comment: {
1108
+ label: "\xBFQu\xE9 necesitas?"
1109
+ }
1110
+ }
1111
+ },
1112
+ approval_remind: {
1113
+ label: "Enviar recordatorio",
1114
+ successMessage: "Recordatorio enviado.",
1115
+ params: {
1116
+ comment: {
1117
+ label: "Nota"
1118
+ }
1119
+ }
1120
+ },
1121
+ approval_recall: {
1122
+ label: "Retirar",
1123
+ confirmText: "\xBFRetirar esta solicitud? Los aprobadores ya no podr\xE1n actuar sobre ella y el registro se desbloquear\xE1.",
1124
+ successMessage: "Retirada.",
1125
+ params: {
1126
+ comment: {
1127
+ label: "Comentario"
1128
+ }
1129
+ }
1130
+ },
1131
+ approval_resubmit: {
1132
+ label: "Reenviar",
1133
+ successMessage: "Reenviada.",
1134
+ params: {
1135
+ comment: {
1136
+ label: "\xBFQu\xE9 ha cambiado?"
1137
+ }
1138
+ }
1139
+ }
731
1140
  }
732
1141
  },
733
1142
  sys_approval_action: {
@@ -759,7 +1168,14 @@ var init_es_ES_objects_generated = __esm({
759
1168
  approve: "Aprobar",
760
1169
  reject: "Rechazar",
761
1170
  recall: "Retirar",
762
- escalate: "Escalar"
1171
+ escalate: "Escalar",
1172
+ reassign: "Reasignaci\xF3n",
1173
+ remind: "Recordatorio",
1174
+ request_info: "Petici\xF3n de informaci\xF3n",
1175
+ comment: "Comentario",
1176
+ revise: "Devoluci\xF3n",
1177
+ resubmit: "Reenv\xEDo",
1178
+ ooo_substitute: "Sustituci\xF3n por ausencia"
763
1179
  }
764
1180
  },
765
1181
  actor_id: {
@@ -768,13 +1184,21 @@ var init_es_ES_objects_generated = __esm({
768
1184
  comment: {
769
1185
  label: "Comentario"
770
1186
  },
1187
+ attachments: {
1188
+ label: "Adjuntos",
1189
+ help: "Archivos que respaldan esta acci\xF3n, p. ej. un contrato firmado o pruebas (#3266)."
1190
+ },
771
1191
  created_at: {
772
1192
  label: "Creado el"
773
1193
  }
774
1194
  },
775
1195
  _views: {
776
1196
  recent: {
777
- label: "Recientes"
1197
+ label: "Recientes",
1198
+ emptyState: {
1199
+ title: "A\xFAn no hay acciones de aprobaci\xF3n",
1200
+ message: "Las acciones se registran autom\xE1ticamente a medida que avanza la aprobaci\xF3n."
1201
+ }
778
1202
  },
779
1203
  by_actor: {
780
1204
  label: "Por actor"
@@ -1072,13 +1496,22 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1072
1496
  // per-viewer block (#3310): approver actions on `record.viewer.can_act`
1073
1497
  // (the caller is a current pending approver — same check the service
1074
1498
  // authorizes a decision with, so position/team approvers resolve correctly),
1075
- // submitter actions on `record.viewer.is_submitter`. `viewer` is attached by
1076
- // getRequest/listRequests; where it is absent the predicate fails closed.
1499
+ // submitter actions on `record.viewer.is_submitter`. The core decision levers
1500
+ // (approve/reject/reassign) additionally OR in `record.viewer.can_override`
1501
+ // (#3424) so a platform/tenant admin can rescue a request routed to an
1502
+ // unstaffed position — otherwise undecidable, locking the record forever — by
1503
+ // approving, rejecting, or reassigning it to a real approver. `viewer` is
1504
+ // attached by getRequest/listRequests; where it is absent the predicate fails
1505
+ // closed.
1077
1506
  actions: [
1078
1507
  {
1079
1508
  name: "approval_approve",
1080
1509
  label: "Approve",
1081
1510
  icon: "check-circle",
1511
+ // Primary decision — the console renders this filled/highlighted so it
1512
+ // stands out from the secondary levers in the drawer's action bar,
1513
+ // matching the mobile card hierarchy (objectui#2762 P1-5).
1514
+ variant: "primary",
1082
1515
  type: "api",
1083
1516
  method: "POST",
1084
1517
  target: "/api/v1/approvals/requests/{id}/approve",
@@ -1089,7 +1522,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1089
1522
  // string[]`; the decision route persists them on `sys_approval_action`.
1090
1523
  { name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
1091
1524
  ],
1092
- visible: "record.viewer.can_act",
1525
+ visible: "record.viewer.can_act || record.viewer.can_override",
1093
1526
  locations: ["record_section", "list_item"],
1094
1527
  successMessage: "Approved.",
1095
1528
  refreshAfter: true
@@ -1098,6 +1531,9 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1098
1531
  name: "approval_reject",
1099
1532
  label: "Reject",
1100
1533
  icon: "x-circle",
1534
+ // Destructive decision — rendered in the console's danger styling so it
1535
+ // reads as the irreversible action it is (objectui#2762 P1-5).
1536
+ variant: "danger",
1101
1537
  type: "api",
1102
1538
  method: "POST",
1103
1539
  target: "/api/v1/approvals/requests/{id}/reject",
@@ -1105,7 +1541,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1105
1541
  { name: "comment", label: "Comment", type: "textarea", required: false },
1106
1542
  { name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
1107
1543
  ],
1108
- visible: "record.viewer.can_act",
1544
+ visible: "record.viewer.can_act || record.viewer.can_override",
1109
1545
  confirmText: "Reject this request? A rejection is final for every approver.",
1110
1546
  locations: ["record_section", "list_item"],
1111
1547
  successMessage: "Rejected.",
@@ -1127,7 +1563,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1127
1563
  { field: "submitter_id", name: "to", label: "New approver", required: true, helpText: "User to hand this step to" },
1128
1564
  { name: "comment", label: "Comment", type: "textarea", required: false }
1129
1565
  ],
1130
- visible: "record.viewer.can_act",
1566
+ visible: "record.viewer.can_act || record.viewer.can_override",
1131
1567
  locations: ["record_section"],
1132
1568
  successMessage: "Reassigned.",
1133
1569
  refreshAfter: true
@@ -1244,6 +1680,14 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
1244
1680
  // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
1245
1681
  titleFormat: "{action} \xB7 {step_name}",
1246
1682
  highlightFields: ["request_id", "step_name", "action", "actor_id", "created_at"],
1683
+ // ADR-0104 D3 wave 2. `attachments` is a media field, so the files it holds
1684
+ // are OWNED by this row — and the storage service would otherwise authorize
1685
+ // their download by testing whether the caller can READ this row. It cannot:
1686
+ // this table is deliberately closed to ordinary approver positions, so that
1687
+ // test denies the very approver the attachment was filed for. The approvals
1688
+ // service already owns the rule for seeing a decision (visibility of the
1689
+ // parent request, exactly as `listActions` applies it), so it answers.
1690
+ fileAccessDelegate: "approvals",
1247
1691
  listViews: {
1248
1692
  recent: {
1249
1693
  type: "grid",
@@ -1495,23 +1939,166 @@ var SysApprovalDelegation = import_data4.ObjectSchema.create({
1495
1939
  trackHistory: true,
1496
1940
  searchable: true,
1497
1941
  apiEnabled: true,
1498
- apiMethods: ["get", "list", "create", "update", "delete"],
1499
- trash: true,
1500
- mru: false
1942
+ // `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child`
1943
+ // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026).
1944
+ apiMethods: ["get", "list", "create", "update", "delete", "bulk"]
1501
1945
  }
1502
1946
  });
1503
1947
 
1504
1948
  // src/approval-service.ts
1505
1949
  var import_node_crypto = require("crypto");
1506
1950
  var import_automation = require("@objectstack/spec/automation");
1951
+ var import_formula = require("@objectstack/formula");
1952
+ var import_identity = require("@objectstack/spec/identity");
1953
+ var import_contracts = require("@objectstack/spec/contracts");
1954
+ var import_data5 = require("@objectstack/spec/data");
1507
1955
  var import_core = require("@objectstack/core");
1956
+
1957
+ // src/approver-org-scope.ts
1958
+ var MAX_ORG_DEPTH = 32;
1959
+ var SYSTEM_CTX = { isSystem: true };
1960
+ function fail(message) {
1961
+ throw new Error(`VALIDATION_FAILED: ${message}`);
1962
+ }
1963
+ async function findOrg(engine, where) {
1964
+ try {
1965
+ const rows = await engine.find("sys_organization", {
1966
+ filter: where,
1967
+ fields: ["id", "slug", "parent_organization_id"],
1968
+ limit: 1,
1969
+ context: SYSTEM_CTX
1970
+ });
1971
+ return Array.isArray(rows) && rows[0] ? rows[0] : null;
1972
+ } catch {
1973
+ return null;
1974
+ }
1975
+ }
1976
+ async function ancestorChain(engine, orgId) {
1977
+ const chain = [];
1978
+ const seen = /* @__PURE__ */ new Set();
1979
+ let cursor = orgId;
1980
+ for (let depth = 0; cursor && depth < MAX_ORG_DEPTH; depth++) {
1981
+ if (seen.has(cursor)) break;
1982
+ seen.add(cursor);
1983
+ chain.push(cursor);
1984
+ const row = await findOrg(engine, { id: cursor });
1985
+ const parent = row?.parent_organization_id;
1986
+ cursor = parent ? String(parent) : null;
1987
+ }
1988
+ return chain;
1989
+ }
1990
+ async function resolveApproverDirectoryOrg(deps, declaration, requestOrgId, approverType, isOrgScopedType) {
1991
+ const declared = typeof declaration === "string" ? declaration.trim() : "";
1992
+ if (!declared) return requestOrgId;
1993
+ if (!isOrgScopedType) {
1994
+ fail(
1995
+ `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)`
1996
+ );
1997
+ }
1998
+ const posture = deps.posture?.();
1999
+ if (posture && posture !== "group") {
2000
+ fail(
2001
+ `cross-organization approver targeting ('organization: ${declared}') requires the 'group' tenancy posture; this deployment resolves '${posture}' (ADR-0105 D9)`
2002
+ );
2003
+ }
2004
+ const requestOrg = requestOrgId ? String(requestOrgId) : "";
2005
+ if (!requestOrg) {
2006
+ fail(
2007
+ `'organization: ${declared}' cannot be resolved for a request that carries no organization \u2014 cross-organization targeting needs an organization to resolve from`
2008
+ );
2009
+ }
2010
+ const chain = await ancestorChain(deps.engine, requestOrg);
2011
+ if (declared === "$root") {
2012
+ const root = chain[chain.length - 1];
2013
+ if (root === requestOrg && chain.length === 1) {
2014
+ fail(
2015
+ `'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`
2016
+ );
2017
+ }
2018
+ return root;
2019
+ }
2020
+ if (declared === "$parent") {
2021
+ const parent = chain[1];
2022
+ if (!parent) {
2023
+ fail(
2024
+ `'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')`
2025
+ );
2026
+ }
2027
+ return parent;
2028
+ }
2029
+ const target = await findOrg(deps.engine, { slug: declared });
2030
+ if (!target?.id) {
2031
+ fail(
2032
+ `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)`
2033
+ );
2034
+ }
2035
+ const targetId = String(target.id);
2036
+ if (targetId === requestOrg) return targetId;
2037
+ const targetChain = await ancestorChain(deps.engine, targetId);
2038
+ const targetRoot = targetChain[targetChain.length - 1];
2039
+ const requestRoot = chain[chain.length - 1];
2040
+ if (!targetRoot || !requestRoot || targetRoot !== requestRoot) {
2041
+ fail(
2042
+ `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)`
2043
+ );
2044
+ }
2045
+ return targetId;
2046
+ }
2047
+ async function filterApproversWhoCanRead(deps, userIds, requestOrgId, context) {
2048
+ const requestOrg = requestOrgId ? String(requestOrgId) : "";
2049
+ if (!requestOrg || userIds.length === 0) return userIds;
2050
+ let members = [];
2051
+ try {
2052
+ members = await deps.engine.find("sys_member", {
2053
+ filter: { organization_id: requestOrg, user_id: { $in: userIds } },
2054
+ fields: ["user_id"],
2055
+ limit: 1e4,
2056
+ context: SYSTEM_CTX
2057
+ });
2058
+ } catch {
2059
+ return userIds;
2060
+ }
2061
+ const canRead = new Set(
2062
+ (members ?? []).map((m) => String(m?.user_id ?? "")).filter(Boolean)
2063
+ );
2064
+ const dropped = userIds.filter((u) => !canRead.has(u));
2065
+ if (dropped.length === 0) return userIds;
2066
+ deps.logger?.warn?.(
2067
+ `[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.`,
2068
+ { dropped, requestOrganizationId: requestOrg, directoryOrganizationId: context.directoryOrgId }
2069
+ );
2070
+ return userIds.filter((u) => canRead.has(u));
2071
+ }
2072
+
2073
+ // src/approval-service.ts
1508
2074
  var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
1509
2075
  var ESCALATION_JOB_NAME = "approvals-sla-escalation";
1510
2076
  var ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1e3;
1511
2077
  var SLA_ACTOR_ID = "system:sla";
2078
+ var DEAD_RUN_ACTOR_ID = "system:dead-run";
2079
+ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
2080
+ "completed",
2081
+ "failed",
2082
+ "cancelled",
2083
+ "timed_out"
2084
+ ]);
1512
2085
  var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
1513
- var SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
2086
+ var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
2087
+ function actingUserId(context) {
2088
+ const userId = context?.userId;
2089
+ return typeof userId === "string" && userId ? userId : null;
2090
+ }
1514
2091
  var OOO_MAX_CHAIN = 8;
2092
+ var GRAPH_APPROVER_TYPES = /* @__PURE__ */ new Set([
2093
+ "team",
2094
+ "department",
2095
+ "business_unit",
2096
+ "bu",
2097
+ "position",
2098
+ "org_membership_level",
2099
+ "manager"
2100
+ ]);
2101
+ var APPROVER_EXPRESSION_ROOTS = /* @__PURE__ */ new Set(["current", "trigger", "vars"]);
1515
2102
  function uid(prefix) {
1516
2103
  const g = globalThis;
1517
2104
  if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
@@ -1565,7 +2152,26 @@ function rowFromRequest(row) {
1565
2152
  step_label: cfg?.__nodeLabel ?? prettifyMachineName(row.current_step),
1566
2153
  sla_due_at: slaDueAt(row.created_at, cfg),
1567
2154
  // ADR-0044 revision round (rides the config snapshot; absent ⇒ round 1).
1568
- round: typeof cfg?.__round === "number" ? cfg.__round : void 0
2155
+ round: typeof cfg?.__round === "number" ? cfg.__round : void 0,
2156
+ // objectui#2902: the node's record-lock policy. The lock is enforced
2157
+ // server-side in `lifecycle-hooks.ts` off THIS SAME snapshot with the
2158
+ // same `!== false` default, so the flag a client renders and the rule the
2159
+ // server applies can never drift. Without it a console can only see
2160
+ // "a pending request exists" and has to assume the record is locked —
2161
+ // which mislabels every `lockRecord: false` node as locked and hides an
2162
+ // edit the server would have accepted.
2163
+ lock_record: cfg?.lockRecord !== false,
2164
+ // #3447 P2: the node's author-declared decision outputs, surfaced so a
2165
+ // decision UI can render input fields for them and POST `outputs` on
2166
+ // approve/reject. Per-request (each node declares its own), which is why
2167
+ // this rides the row instead of the static action params. Two shapes for
2168
+ // version skew: `decision_outputs` stays the bare KEY list an older
2169
+ // console renders as text inputs; `decision_output_defs` carries the
2170
+ // normalized typed declarations a picker-aware console prefers.
2171
+ ...(() => {
2172
+ const defs = (0, import_automation.normalizeDecisionOutputs)(cfg?.decisionOutputs);
2173
+ return defs.length ? { decision_outputs: defs.map((d) => d.key), decision_output_defs: defs } : {};
2174
+ })()
1569
2175
  };
1570
2176
  }
1571
2177
  function slaDueAt(createdAt, cfg) {
@@ -1575,7 +2181,29 @@ function slaDueAt(createdAt, cfg) {
1575
2181
  if (Number.isNaN(t)) return void 0;
1576
2182
  return new Date(t + hours * 36e5).toISOString();
1577
2183
  }
2184
+ function normalizeActionAttachment(entry) {
2185
+ if (entry == null) return void 0;
2186
+ if (typeof entry === "string") {
2187
+ const id = entry.trim();
2188
+ if (!id) return void 0;
2189
+ return (0, import_data5.isFileIdToken)(id) ? { id } : { id, url: id };
2190
+ }
2191
+ if (typeof entry === "object") {
2192
+ const id = entry.id ?? entry.file_id;
2193
+ if (id == null || String(id) === "") return void 0;
2194
+ const mimeType = entry.mimeType ?? entry.mime_type;
2195
+ return {
2196
+ id: String(id),
2197
+ name: typeof entry.name === "string" ? entry.name : void 0,
2198
+ url: typeof entry.url === "string" ? entry.url : void 0,
2199
+ mimeType: typeof mimeType === "string" ? mimeType : void 0,
2200
+ size: typeof entry.size === "number" ? entry.size : void 0
2201
+ };
2202
+ }
2203
+ return void 0;
2204
+ }
1578
2205
  function rowFromAction(row) {
2206
+ const attachments = Array.isArray(row.attachments) ? row.attachments.map(normalizeActionAttachment).filter((a) => !!a) : [];
1579
2207
  return {
1580
2208
  id: String(row.id),
1581
2209
  request_id: String(row.request_id),
@@ -1584,10 +2212,9 @@ function rowFromAction(row) {
1584
2212
  action: row.action,
1585
2213
  actor_id: row.actor_id ?? void 0,
1586
2214
  comment: row.comment ?? void 0,
1587
- // Decision attachments (#3266). The column shipped in #3268 but this
1588
- // contract mapping didn't the raw engine row carried the fileIds while
1589
- // every consumer of listActions saw none (caught by browser verification).
1590
- attachments: Array.isArray(row.attachments) && row.attachments.length ? row.attachments.map(String) : void 0,
2215
+ // Decision attachments (#3266): rich descriptors carrying the display name +
2216
+ // download URL, so consumers label/open them without reading `sys_file`.
2217
+ attachments: attachments.length ? attachments : void 0,
1591
2218
  created_at: row.created_at ?? void 0
1592
2219
  };
1593
2220
  }
@@ -1599,6 +2226,33 @@ var _ApprovalService = class _ApprovalService {
1599
2226
  this.automation = opts.automation;
1600
2227
  this.messaging = opts.messaging;
1601
2228
  this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
2229
+ this.tenancyPosture = opts.tenancyPosture;
2230
+ }
2231
+ /** Attach (or replace) the ADR-0105 D9 posture provider. */
2232
+ attachTenancyPosture(provider) {
2233
+ this.tenancyPosture = provider;
2234
+ }
2235
+ /** Deps bundle for the ADR-0105 D9 org-scope helpers. */
2236
+ get orgScopeDeps() {
2237
+ return {
2238
+ engine: this.engine,
2239
+ posture: this.tenancyPosture,
2240
+ logger: this.logger
2241
+ };
2242
+ }
2243
+ /**
2244
+ * [ADR-0105 D9] Which organization's directory resolves ONE approver spec.
2245
+ * Absent declaration ⇒ the request's own organization (unchanged, no reads).
2246
+ */
2247
+ async directoryOrgFor(a, requestOrgId) {
2248
+ const rawType = String(a?.type ?? "");
2249
+ return resolveApproverDirectoryOrg(
2250
+ this.orgScopeDeps,
2251
+ a?.organization,
2252
+ requestOrgId,
2253
+ rawType,
2254
+ (0, import_automation.approverTypeIsOrgScoped)(rawType)
2255
+ );
1602
2256
  }
1603
2257
  /** Attach (or replace) the automation surface used to resume flow runs. */
1604
2258
  attachAutomation(automation) {
@@ -1633,13 +2287,107 @@ var _ApprovalService = class _ApprovalService {
1633
2287
  const rows = await this.engine.find("sys_approval_request", {
1634
2288
  where: { id: requestId },
1635
2289
  limit: 1,
1636
- context: SYSTEM_CTX
2290
+ context: SYSTEM_CTX2
1637
2291
  });
1638
2292
  const raw = Array.isArray(rows) ? rows[0] : null;
1639
2293
  if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
1640
2294
  if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
1641
2295
  return raw;
1642
2296
  }
2297
+ /**
2298
+ * Privileged-override gate (#3424). A stuck approval — one routed to a
2299
+ * position/team with no holders (so its `pending_approvers` is only an
2300
+ * unresolvable `type:value` literal) or to approvers who have all since left —
2301
+ * is otherwise undecidable: no concrete user is in the slate, so every normal
2302
+ * `decide` / `reassign` / `recall` is `FORBIDDEN` and (with `lockRecord`) the
2303
+ * record stays locked forever with no in-product recovery. A platform or
2304
+ * tenant admin — the same posture the engine's superuser bypass already
2305
+ * trusts — may always act on a PENDING request to release it: approve, reject,
2306
+ * reassign it to a real approver, or recall it.
2307
+ *
2308
+ * A platform admin crosses the tenant wall (matching the unscoped
2309
+ * `admin_full_access` evidence); a tenant admin may override only within their
2310
+ * own org (or an org-less request). A system context always passes. Signals are
2311
+ * read defensively off the resolved exec context (`permissions` / `positions` /
2312
+ * the derived `posture`, ADR-0095) so any transport that resolves through the
2313
+ * shared authz resolver lights this up without extra wiring.
2314
+ */
2315
+ isOverrideActor(context, requestOrg) {
2316
+ if (!context) return false;
2317
+ if (context.isSystem) return true;
2318
+ const perms = Array.isArray(context.permissions) ? context.permissions : [];
2319
+ const positions = Array.isArray(context.positions) ? context.positions : [];
2320
+ const posture = context.posture;
2321
+ const isPlatformAdmin = posture === "PLATFORM_ADMIN" || perms.includes(import_identity.ADMIN_FULL_ACCESS) || positions.includes(import_identity.BUILTIN_IDENTITY_PLATFORM_ADMIN);
2322
+ if (isPlatformAdmin) return true;
2323
+ const isTenantAdmin = posture === "TENANT_ADMIN" || import_identity.ORGANIZATION_ADMIN_GRANTS.some((n) => perms.includes(n)) || positions.includes(import_identity.BUILTIN_IDENTITY_ORG_OWNER) || positions.includes(import_identity.BUILTIN_IDENTITY_ORG_ADMIN);
2324
+ if (!isTenantAdmin) return false;
2325
+ const actorTenant = context.tenantId ?? context.organizationId ?? null;
2326
+ return requestOrg == null || actorTenant != null && String(requestOrg) === String(actorTenant);
2327
+ }
2328
+ /**
2329
+ * Pin the acting identity to the AUTHENTICATED CALLER (#3800).
2330
+ *
2331
+ * Every public entrypoint accepts an `actorId`, and the REST routes fill it
2332
+ * from `body.actorId ?? body.actor_id ?? context.userId` — so before this
2333
+ * gate the body won. The authorization checks downstream all read that value
2334
+ * (`pending_approvers.includes(input.actorId)`, `submitter_id === actorId`),
2335
+ * which made the body-supplied string not merely the audit label but the key
2336
+ * that opens the door: any authenticated user could name a pending approver
2337
+ * and have that approver's decision recorded and the owning flow resumed.
2338
+ * #3783 drew this line for the data-write identity ({@link actingUserId});
2339
+ * this closes the authorization half.
2340
+ *
2341
+ * A caller may still name an identity OTHER than their bare user id, because
2342
+ * a slot legitimately can be keyed by one: `resolveApproverSpec` stores the
2343
+ * `type:value` literal when a graph lookup yields nothing, and an author may
2344
+ * write an email as a `user` approver. So the rule is not "actorId must equal
2345
+ * userId" — it is **"actorId must be an identity the SERVER can prove belongs
2346
+ * to the caller"**. Anything else is `FORBIDDEN`.
2347
+ *
2348
+ * A system context is exempt and keeps its explicit actor: the SLA sweep
2349
+ * passes the reserved {@link SLA_ACTOR_ID} sentinel, and the ADR-0043 action
2350
+ * link passes the approver its single-use token is cryptographically bound to
2351
+ * (having also put them on the context). Those are the only two callers that
2352
+ * hold a trustworthy actor with no session behind them.
2353
+ *
2354
+ * A caller with NO identity at all cannot act. That case is reachable: the
2355
+ * REST anonymous-deny only fires when `api.requireAuth` is set, so without it
2356
+ * an anonymous request previously decided approvals outright by naming one.
2357
+ */
2358
+ async resolveActor(actorId, context) {
2359
+ if (context?.isSystem) {
2360
+ if (!actorId) throw new Error("VALIDATION_FAILED: actorId is required");
2361
+ return actorId;
2362
+ }
2363
+ const uid2 = actingUserId(context);
2364
+ if (!uid2) {
2365
+ throw new Error("FORBIDDEN: an approval action requires an authenticated caller");
2366
+ }
2367
+ if (!actorId || String(actorId) === uid2) return uid2;
2368
+ const named = String(actorId);
2369
+ for (const position of context.positions ?? []) {
2370
+ if (named === `position:${position}` || named === `role:${position}`) return named;
2371
+ }
2372
+ if (named.includes("@") && await this.callerHasEmail(uid2, named)) return named;
2373
+ throw new Error(
2374
+ `FORBIDDEN: cannot act as '${named}' \u2014 an approval action is recorded against the authenticated caller`
2375
+ );
2376
+ }
2377
+ /** Does `userId`'s own account carry `email`? (Slots keyed by email, #3800.) */
2378
+ async callerHasEmail(userId, email) {
2379
+ try {
2380
+ const rows = await this.engine.find("sys_user", {
2381
+ where: { id: userId },
2382
+ limit: 1,
2383
+ context: SYSTEM_CTX2
2384
+ });
2385
+ const row = Array.isArray(rows) ? rows[0] : null;
2386
+ return !!row?.email && String(row.email).toLowerCase() === email.toLowerCase();
2387
+ } catch {
2388
+ return false;
2389
+ }
2390
+ }
1643
2391
  /**
1644
2392
  * Expand the approvers on an Approval node into user IDs by querying the
1645
2393
  * graph tables for `team:` / `department:` / `position:` /
@@ -1674,7 +2422,7 @@ var _ApprovalService = class _ApprovalService {
1674
2422
  * `opts.substitutions` collector to record the hops for audit + notification.
1675
2423
  */
1676
2424
  async expandApprovers(step, record, organizationId, opts) {
1677
- var _a;
2425
+ var _a, _b, _c;
1678
2426
  if (!step || !Array.isArray(step.approvers)) return [];
1679
2427
  const now = opts?.now ?? this.clock.now().getTime();
1680
2428
  const out = [];
@@ -1682,12 +2430,34 @@ var _ApprovalService = class _ApprovalService {
1682
2430
  for (let idx = 0; idx < specs.length; idx++) {
1683
2431
  const a = specs[idx];
1684
2432
  if (!a) continue;
1685
- const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
1686
2433
  const groupKey = a.group != null && String(a.group) !== "" ? String(a.group) : `#${idx}`;
2434
+ if ((0, import_automation.canonicalApproverType)(String(a.type)) === "expression") {
2435
+ const resolved = await this.resolveExpressionApprovers(
2436
+ a,
2437
+ record,
2438
+ organizationId,
2439
+ now,
2440
+ opts?.substitutions,
2441
+ opts?.exprCtx
2442
+ );
2443
+ if (opts?.resolvedFrom) opts.resolvedFrom[`expression#${idx}`] = resolved.raw;
2444
+ for (const entry of resolved.slots) {
2445
+ if (!entry.id) continue;
2446
+ out.push(entry.id);
2447
+ if (opts?.groups) {
2448
+ ((_a = opts.groups)[_b = entry.id] ?? (_a[_b] = [])).push(entry.subGroup ? `${groupKey}:${entry.subGroup}` : groupKey);
2449
+ }
2450
+ }
2451
+ continue;
2452
+ }
2453
+ if (opts?.resolvedFrom && (0, import_automation.canonicalApproverType)(String(a.type)) === "field" && a.value != null) {
2454
+ opts.resolvedFrom[`field:${a.value}`] = record?.[a.value] ?? null;
2455
+ }
2456
+ const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
1687
2457
  for (const u of ids) {
1688
2458
  if (!u) continue;
1689
2459
  out.push(u);
1690
- if (opts?.groups) ((_a = opts.groups)[u] ?? (_a[u] = [])).push(groupKey);
2460
+ if (opts?.groups) ((_c = opts.groups)[u] ?? (_c[u] = [])).push(groupKey);
1691
2461
  }
1692
2462
  }
1693
2463
  return out.filter(Boolean);
@@ -1708,24 +2478,35 @@ var _ApprovalService = class _ApprovalService {
1708
2478
  { deprecated: a.type, canonical: type }
1709
2479
  );
1710
2480
  }
2481
+ const directoryOrg = await this.directoryOrgFor(a, organizationId);
2482
+ const crossOrg = directoryOrg !== organizationId;
1711
2483
  if (type === "user") {
1712
2484
  return this.applyOooDelegation(String(a.value), now, organizationId, substitutions);
1713
2485
  }
1714
2486
  if (type === "field" && record) {
1715
- return this.applyOooDelegation(String(record[a.value] ?? ""), now, organizationId, substitutions);
2487
+ const out = [];
2488
+ for (const id of csvSplit(record[a.value])) {
2489
+ out.push(...await this.applyOooDelegation(id, now, organizationId, substitutions));
2490
+ }
2491
+ return out;
1716
2492
  }
2493
+ const bounded = async (users) => crossOrg ? filterApproversWhoCanRead(this.orgScopeDeps, users, organizationId, {
2494
+ approverType: type,
2495
+ value: a.value != null ? String(a.value) : void 0,
2496
+ directoryOrgId: directoryOrg
2497
+ }) : users;
1717
2498
  try {
1718
2499
  if (type === "team") {
1719
2500
  const users = await this.expandTeamUsers(String(a.value));
1720
2501
  if (users.length) return users;
1721
2502
  } else if (type === "department" || type === "business_unit" || type === "bu") {
1722
- const users = await this.expandBusinessUnitUsers(String(a.value), organizationId);
2503
+ const users = await bounded(await this.expandBusinessUnitUsers(String(a.value), directoryOrg));
1723
2504
  if (users.length) return users;
1724
2505
  } else if (type === "position") {
1725
- const users = await this.expandPositionUsers(String(a.value), organizationId);
2506
+ const users = await bounded(await this.expandPositionUsers(String(a.value), directoryOrg));
1726
2507
  if (users.length) return users;
1727
2508
  } else if (type === "org_membership_level") {
1728
- const users = await this.expandMembershipTierUsers(String(a.value), organizationId);
2509
+ const users = await bounded(await this.expandMembershipTierUsers(String(a.value), directoryOrg));
1729
2510
  if (users.length) return users;
1730
2511
  } else if (type === "manager" && record) {
1731
2512
  const subject = record[a.value] ?? record.owner_id;
@@ -1736,8 +2517,122 @@ var _ApprovalService = class _ApprovalService {
1736
2517
  }
1737
2518
  } catch {
1738
2519
  }
2520
+ if (type === "queue") {
2521
+ this.logger?.warn?.(
2522
+ `[approvals] approver type 'queue' is not implemented \u2014 the slot resolves to nobody (#3508)`,
2523
+ { value: a.value }
2524
+ );
2525
+ } else if (GRAPH_APPROVER_TYPES.has(type)) {
2526
+ this.logger?.warn?.(
2527
+ `[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)`,
2528
+ { type, value: a.value, organizationId: organizationId ?? null }
2529
+ );
2530
+ }
1739
2531
  return [`${a.type}:${a.value}`];
1740
2532
  }
2533
+ /**
2534
+ * Resolve an `expression` approver (#3447 P2): evaluate its CEL source at
2535
+ * node entry against the three explicit roots — `current` (live record),
2536
+ * `trigger` (submit snapshot), `vars` (flow variables) — then expand the
2537
+ * result into people per `resolveAs`.
2538
+ *
2539
+ * Every failure here THROWS (config/parse errors as `VALIDATION_FAILED`,
2540
+ * evaluation faults as `EXPRESSION_FAILED`) so the approval node fails
2541
+ * loudly instead of opening a request routed to nobody — an approver
2542
+ * expression that cannot run is a routing bug, never "condition not met".
2543
+ * Error messages carry the correct spelling because their primary reader is
2544
+ * the AI author fixing the flow on the next validate pass.
2545
+ *
2546
+ * Returns `slots` (approver id + optional per_group sub-key) and `raw` (the
2547
+ * expression's own values, pre-expansion) for the `__resolvedFrom` audit.
2548
+ */
2549
+ async resolveExpressionApprovers(a, liveRecord, organizationId, now, substitutions, exprCtx) {
2550
+ const source = String(a.value ?? "").trim();
2551
+ if (!source) {
2552
+ throw new Error("VALIDATION_FAILED: expression approver has an empty expression");
2553
+ }
2554
+ const parsed = (0, import_formula.collectCelRootIdentifiers)(source);
2555
+ if (!parsed.ok) {
2556
+ throw new Error(`VALIDATION_FAILED: expression approver does not parse: ${parsed.error} \u2014 source: \`${source}\``);
2557
+ }
2558
+ const illegal = parsed.roots.filter((r) => !APPROVER_EXPRESSION_ROOTS.has(r));
2559
+ if (illegal.length) {
2560
+ 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)?`;
2561
+ throw new Error(
2562
+ `VALIDATION_FAILED: expression approver references \`${illegal.join("`, `")}\` \u2014 only \`current.*\`, \`trigger.*\` and \`vars.*\` are available; ${hint}. Source: \`${source}\``
2563
+ );
2564
+ }
2565
+ const result = import_formula.ExpressionEngine.evaluate(
2566
+ { dialect: "cel", source },
2567
+ { extra: { current: liveRecord ?? {}, trigger: exprCtx?.trigger ?? {}, vars: exprCtx?.vars ?? {} } }
2568
+ );
2569
+ if (!result.ok) {
2570
+ throw new Error(
2571
+ `EXPRESSION_FAILED: expression approver failed to evaluate (${result.error.kind}): ${result.error.message} \u2014 source: \`${source}\``
2572
+ );
2573
+ }
2574
+ const value = result.value;
2575
+ let raw;
2576
+ if (value == null || value === "") {
2577
+ raw = [];
2578
+ } else if (typeof value === "string") {
2579
+ raw = csvSplit(value);
2580
+ } else if (Array.isArray(value)) {
2581
+ const bad = value.find((v) => v != null && typeof v !== "string" && typeof v !== "number");
2582
+ if (bad !== void 0) {
2583
+ throw new Error(
2584
+ `EXPRESSION_FAILED: expression approver must yield ids (string / CSV / string array), got an array containing ${typeof bad} \u2014 source: \`${source}\``
2585
+ );
2586
+ }
2587
+ raw = value.map((v) => String(v ?? "").trim()).filter(Boolean);
2588
+ } else {
2589
+ throw new Error(
2590
+ `EXPRESSION_FAILED: expression approver must yield ids (string / CSV / string array), got ${typeof value} \u2014 source: \`${source}\``
2591
+ );
2592
+ }
2593
+ const resolveAs = String(a.resolveAs ?? "user");
2594
+ if (resolveAs === "user") {
2595
+ const slots2 = [];
2596
+ for (const id of raw) {
2597
+ for (const routed of await this.applyOooDelegation(id, now, organizationId, substitutions)) {
2598
+ slots2.push({ id: routed });
2599
+ }
2600
+ }
2601
+ return { slots: slots2, raw };
2602
+ }
2603
+ const directoryOrg = await this.directoryOrgFor(a, organizationId);
2604
+ const crossOrg = directoryOrg !== organizationId;
2605
+ const slots = [];
2606
+ for (const key of raw) {
2607
+ let users = [];
2608
+ try {
2609
+ if (resolveAs === "department") users = await this.expandBusinessUnitUsers(key, directoryOrg);
2610
+ else if (resolveAs === "position") users = await this.expandPositionUsers(key, directoryOrg);
2611
+ else if (resolveAs === "team") users = await this.expandTeamUsers(key);
2612
+ else {
2613
+ throw new Error(
2614
+ `VALIDATION_FAILED: expression approver has unknown resolveAs '${resolveAs}' \u2014 use 'user', 'department', 'position', or 'team'`
2615
+ );
2616
+ }
2617
+ } catch (err) {
2618
+ if (String(err?.message ?? "").startsWith("VALIDATION_FAILED")) throw err;
2619
+ users = [];
2620
+ }
2621
+ if (crossOrg && users.length) {
2622
+ users = await filterApproversWhoCanRead(this.orgScopeDeps, users, organizationId, {
2623
+ approverType: resolveAs,
2624
+ value: key,
2625
+ directoryOrgId: directoryOrg
2626
+ });
2627
+ }
2628
+ if (!users.length) {
2629
+ slots.push({ id: `${resolveAs}:${key}`, subGroup: key });
2630
+ continue;
2631
+ }
2632
+ for (const u of users) slots.push({ id: u, subGroup: key });
2633
+ }
2634
+ return { slots, raw };
2635
+ }
1741
2636
  /** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
1742
2637
  async expandTeamUsers(teamId) {
1743
2638
  if (!teamId) return [];
@@ -1747,22 +2642,46 @@ var _ApprovalService = class _ApprovalService {
1747
2642
  filter: { team_id: teamId },
1748
2643
  fields: ["user_id"],
1749
2644
  limit: 1e4,
1750
- context: SYSTEM_CTX
2645
+ context: SYSTEM_CTX2
1751
2646
  });
1752
2647
  } catch {
1753
2648
  rows = [];
1754
2649
  }
1755
2650
  return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
1756
2651
  }
2652
+ /**
2653
+ * Tenant scope for a `sys_business_unit` read that may legitimately be
2654
+ * env-wide (#3807).
2655
+ *
2656
+ * `organization_id = null` on a platform object means "owned by no
2657
+ * organization" — a row written by a seed, the file layer, or bootstrap,
2658
+ * i.e. before (or outside) any org exists. A strict
2659
+ * `organization_id = <request org>` equality made every such row invisible:
2660
+ * the seed check below found nothing, the whole expansion returned `[]`, and
2661
+ * the approver fell back to the dead `department:<id>` literal that routes to
2662
+ * nobody. That is not an edge case — an app's org tree is normally seeded
2663
+ * (a seed cannot know the org id the runtime mints at boot) while the
2664
+ * approval request always carries one, so EVERY department approver a
2665
+ * designer could pick resolved to nobody.
2666
+ *
2667
+ * Widen to "this org ∪ env-wide", the same predicate `sys_metadata`'s
2668
+ * pending-draft listing settled on for the identical reason. Another org's
2669
+ * unit still fails the match, so the wall between two organizations is
2670
+ * unchanged — only rows belonging to no org at all become visible.
2671
+ */
2672
+ businessUnitOrgScope(filter, organizationId) {
2673
+ if (!organizationId) return filter;
2674
+ return { ...filter, $or: [{ organization_id: organizationId }, { organization_id: null }] };
2675
+ }
1757
2676
  /** Recursive department — walks `sys_business_unit.parent_business_unit_id`. */
1758
2677
  async expandBusinessUnitUsers(businessUnitId, organizationId) {
1759
2678
  if (!businessUnitId) return [];
1760
2679
  try {
1761
2680
  const seed = await this.engine.find("sys_business_unit", {
1762
- filter: organizationId ? { id: businessUnitId, organization_id: organizationId } : { id: businessUnitId },
2681
+ filter: this.businessUnitOrgScope({ id: businessUnitId }, organizationId),
1763
2682
  fields: ["id", "active"],
1764
2683
  limit: 1,
1765
- context: SYSTEM_CTX
2684
+ context: SYSTEM_CTX2
1766
2685
  });
1767
2686
  const seedRow = Array.isArray(seed) ? seed[0] : null;
1768
2687
  if (!seedRow || seedRow.active === false) return [];
@@ -1775,9 +2694,11 @@ var _ApprovalService = class _ApprovalService {
1775
2694
  const parent = queue.shift();
1776
2695
  let kids = [];
1777
2696
  try {
1778
- const filter = { parent_business_unit_id: parent, active: { $ne: false } };
1779
- if (organizationId) filter.organization_id = organizationId;
1780
- kids = await this.engine.find("sys_business_unit", { filter, fields: ["id"], limit: 1e3, context: SYSTEM_CTX });
2697
+ const filter = this.businessUnitOrgScope(
2698
+ { parent_business_unit_id: parent, active: { $ne: false } },
2699
+ organizationId
2700
+ );
2701
+ kids = await this.engine.find("sys_business_unit", { filter, fields: ["id"], limit: 1e3, context: SYSTEM_CTX2 });
1781
2702
  } catch {
1782
2703
  kids = [];
1783
2704
  }
@@ -1795,7 +2716,7 @@ var _ApprovalService = class _ApprovalService {
1795
2716
  filter: { business_unit_id: { $in: Array.from(seen) } },
1796
2717
  fields: ["user_id"],
1797
2718
  limit: 1e4,
1798
- context: SYSTEM_CTX
2719
+ context: SYSTEM_CTX2
1799
2720
  });
1800
2721
  } catch {
1801
2722
  rows = [];
@@ -1820,7 +2741,7 @@ var _ApprovalService = class _ApprovalService {
1820
2741
  filter,
1821
2742
  fields: ["user_id"],
1822
2743
  limit: 1e4,
1823
- context: SYSTEM_CTX
2744
+ context: SYSTEM_CTX2
1824
2745
  });
1825
2746
  for (const r of rows ?? []) {
1826
2747
  const uid2 = String(r.user_id ?? "");
@@ -1843,7 +2764,7 @@ var _ApprovalService = class _ApprovalService {
1843
2764
  if (organizationId) filter.organization_id = organizationId;
1844
2765
  let rows = [];
1845
2766
  try {
1846
- rows = await this.engine.find("sys_member", { filter, fields: ["user_id"], limit: 1e4, context: SYSTEM_CTX });
2767
+ rows = await this.engine.find("sys_member", { filter, fields: ["user_id"], limit: 1e4, context: SYSTEM_CTX2 });
1847
2768
  } catch {
1848
2769
  rows = [];
1849
2770
  }
@@ -1855,7 +2776,7 @@ var _ApprovalService = class _ApprovalService {
1855
2776
  filter: { id: userId },
1856
2777
  fields: ["id", "manager_id"],
1857
2778
  limit: 1,
1858
- context: SYSTEM_CTX
2779
+ context: SYSTEM_CTX2
1859
2780
  });
1860
2781
  const row = Array.isArray(rows) ? rows[0] : null;
1861
2782
  return row?.manager_id ? String(row.manager_id) : null;
@@ -1905,7 +2826,7 @@ var _ApprovalService = class _ApprovalService {
1905
2826
  filter: { delegator_id: delegatorId },
1906
2827
  fields: ["id", "delegator_id", "delegate_id", "valid_from", "valid_until", "reason", "organization_id"],
1907
2828
  limit: 50,
1908
- context: SYSTEM_CTX
2829
+ context: SYSTEM_CTX2
1909
2830
  });
1910
2831
  } catch {
1911
2832
  return null;
@@ -1919,14 +2840,86 @@ var _ApprovalService = class _ApprovalService {
1919
2840
  });
1920
2841
  return active[0];
1921
2842
  }
1922
- /** Mirror a request status onto a business-object field, if configured. */
1923
- async mirrorStatusField(object, recordId, field, status) {
2843
+ /**
2844
+ * Mirror a request status onto a business-object field, if configured.
2845
+ *
2846
+ * **Elevated, but not anonymous (#3783).** The write stays `isSystem`: the
2847
+ * record is normally LOCKED while its approval is live and the submitter
2848
+ * cannot edit it, so only a platform write can land the status — that is what
2849
+ * the lock hook's system exemption (`lifecycle-hooks.ts`) is for. What it must
2850
+ * NOT do is throw away *who* caused the transition. Every status below is
2851
+ * something a specific human just did — a submitter submitting or recalling,
2852
+ * an approver deciding or sending back — and this write is what fires the
2853
+ * target object's record-change flows. With no `userId` on it those cascades
2854
+ * inherit no trigger user, and since #3760 a `runAs:'user'` run with no trigger
2855
+ * user has its data ops REFUSED — so "when the invoice is approved, do X", the
2856
+ * most natural approvals automation there is, had to declare `runAs:'system'`
2857
+ * and take blanket elevation for a case where a perfectly good scoped identity
2858
+ * existed. Re-attaching the actor lets those cascades run as the deciding user
2859
+ * with RLS enforced. Same shape the approval node already uses when it calls
2860
+ * into this service (`approval-node.ts`).
2861
+ *
2862
+ * `actorId` is `null` for the genuinely machine-driven transitions (the SLA
2863
+ * escalation's auto-decision, the dead-run sweep). There is no human to name
2864
+ * there, and naming a sentinel would put a non-user in `updated_by` and in
2865
+ * every downstream flow's identity. Those cascades stay user-less — a flow
2866
+ * that wants to react to them still has to declare `runAs:'system'`, which is
2867
+ * the honest answer rather than an oversight.
2868
+ *
2869
+ * Deliberately carries `userId` ONLY, not the request's org. On an
2870
+ * ExecutionContext `tenantId` is a driver-scoping knob, not attribution
2871
+ * (`buildDriverOptions` turns it into a tenant predicate on the update), so
2872
+ * passing it would newly org-scope this write and silently no-op the mirror on
2873
+ * a record whose org differs from the request's — while buying nothing: the
2874
+ * automation engine back-fills the run's `tenantId` from the resolved user's
2875
+ * own grants.
2876
+ */
2877
+ async mirrorStatusField(object, recordId, field, status, actorId) {
1924
2878
  try {
1925
- await this.engine.update(object, { id: recordId, [field]: status }, { context: SYSTEM_CTX });
2879
+ const context = actorId ? { ...SYSTEM_CTX2, userId: actorId } : SYSTEM_CTX2;
2880
+ await this.engine.update(object, { id: recordId, [field]: status }, { context });
1926
2881
  } catch (err) {
1927
2882
  this.logger?.warn?.(`[approvals] mirrorStatusField failed: ${err?.message ?? err}`);
1928
2883
  }
1929
2884
  }
2885
+ /**
2886
+ * Re-read a business record's CURRENT state by id so approver resolution binds
2887
+ * to live data at node entry, not the trigger snapshot the flow froze into
2888
+ * `$record` at submit time (#3447). A `field` / `manager` approver names *who*
2889
+ * decides, and an earlier node — or the approver of an earlier step — may have
2890
+ * written that routing field after submit (e.g. a lead reviewer picking which
2891
+ * departments co-review). Graph approvers (team / position / …) already query
2892
+ * live; this brings the in-record types into line.
2893
+ *
2894
+ * Read under system identity: approver routing is a platform concern and the
2895
+ * record is the flow's own subject, so the submitter's RLS/FLS must not narrow
2896
+ * it. Degrades to `fallback` (the snapshot) when the record can't be re-read —
2897
+ * hard-deleted between submit and node entry, or an object whose backend can't
2898
+ * serve a point read — warning rather than throwing so a transient miss can't
2899
+ * wedge an approval. That "warn but proceed" stance matches the
2900
+ * no-concrete-approver guard (#3424) and the "record is gone" enrichment path
2901
+ * that already falls back to the payload snapshot.
2902
+ */
2903
+ async loadLiveRecord(object, recordId, fallback) {
2904
+ try {
2905
+ const rows = await this.engine.find(object, {
2906
+ where: { id: recordId },
2907
+ limit: 1,
2908
+ context: SYSTEM_CTX2
2909
+ });
2910
+ const live = Array.isArray(rows) ? rows[0] : rows;
2911
+ if (live) return live;
2912
+ this.logger?.warn?.(
2913
+ `[approvals] live record ${object}/${recordId} not found at node entry \u2014 resolving approvers against the trigger snapshot (#3447 fallback).`,
2914
+ { object, recordId }
2915
+ );
2916
+ } catch (err) {
2917
+ this.logger?.warn?.(
2918
+ `[approvals] live record re-read failed for ${object}/${recordId}: ${err?.message ?? err} \u2014 resolving approvers against the trigger snapshot (#3447 fallback).`
2919
+ );
2920
+ }
2921
+ return fallback ?? {};
2922
+ }
1930
2923
  // ── ADR-0019: Approval-as-flow-node ──────────────────────────
1931
2924
  //
1932
2925
  // A flow's Approval node opens a request via `openNodeRequest` (carrying its
@@ -1938,6 +2931,10 @@ var _ApprovalService = class _ApprovalService {
1938
2931
  * Open a pending approval request on behalf of a flow's Approval node. The
1939
2932
  * node config (approvers / behavior / status field) is snapshotted on the row
1940
2933
  * so a decision can be made without any process to resolve against.
2934
+ *
2935
+ * #3447 P2: may instead return an {@link ApprovalNodeAutoOutcome} — no
2936
+ * request opened — when the slate resolves empty and the node's
2937
+ * `onEmptyApprovers` policy is `auto_approve`.
1941
2938
  */
1942
2939
  async openNodeRequest(input, context) {
1943
2940
  if (!input.object) throw new Error("VALIDATION_FAILED: object is required");
@@ -1946,7 +2943,7 @@ var _ApprovalService = class _ApprovalService {
1946
2943
  const existing = await this.engine.find("sys_approval_request", {
1947
2944
  where: { object_name: input.object, record_id: input.recordId, status: "pending" },
1948
2945
  limit: 1,
1949
- context: SYSTEM_CTX
2946
+ context: SYSTEM_CTX2
1950
2947
  });
1951
2948
  if (Array.isArray(existing) && existing[0]) {
1952
2949
  throw new Error(`DUPLICATE_REQUEST: a pending approval already exists for ${input.object}/${input.recordId}`);
@@ -1955,26 +2952,56 @@ var _ApprovalService = class _ApprovalService {
1955
2952
  const nowDate = this.clock.now();
1956
2953
  const substitutions = [];
1957
2954
  const groups = {};
2955
+ const liveRecord = await this.loadLiveRecord(input.object, input.recordId, input.record);
2956
+ const resolvedFrom = {};
1958
2957
  const approvers = await this.expandApprovers(
1959
2958
  { approvers: input.config.approvers },
1960
- input.record,
2959
+ liveRecord,
1961
2960
  ctxOrg,
1962
- { now: nowDate.getTime(), substitutions, groups }
2961
+ {
2962
+ now: nowDate.getTime(),
2963
+ substitutions,
2964
+ groups,
2965
+ exprCtx: { trigger: input.record ?? null, vars: input.variables ?? null },
2966
+ resolvedFrom
2967
+ }
1963
2968
  );
2969
+ if (!approvers.some((a) => a && !a.includes(":"))) {
2970
+ const emptyPolicy = input.config.onEmptyApprovers ?? "admin_rescue";
2971
+ if (emptyPolicy === "fail") {
2972
+ throw new Error(
2973
+ `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.`
2974
+ );
2975
+ }
2976
+ if (emptyPolicy === "auto_approve") {
2977
+ this.logger?.warn?.(
2978
+ `[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).`,
2979
+ { object: input.object, recordId: input.recordId, node: input.nodeId, resolved: approvers }
2980
+ );
2981
+ return { autoApproved: true, reason: "empty_approvers" };
2982
+ }
2983
+ this.logger?.warn?.(
2984
+ `[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.`,
2985
+ { object: input.object, recordId: input.recordId, node: input.nodeId, resolved: approvers }
2986
+ );
2987
+ }
1964
2988
  const now = nowDate.toISOString();
1965
2989
  const id = uid("areq");
1966
2990
  const processName = `flow:${input.flowName ?? input.nodeId}`;
1967
2991
  const configSnapshot = { ...input.config };
1968
2992
  if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
1969
2993
  if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
1970
- if (input.config.behavior === "quorum" || input.config.behavior === "per_group") {
2994
+ if (input.config.behavior && input.config.behavior !== "first_response") {
1971
2995
  configSnapshot.__approverGroups = groups;
1972
2996
  }
2997
+ if (Object.keys(resolvedFrom).length) {
2998
+ configSnapshot.__resolvedFrom = resolvedFrom;
2999
+ }
1973
3000
  try {
1974
3001
  const prior = await this.engine.find("sys_approval_request", {
1975
3002
  where: { flow_run_id: input.runId, flow_node_id: input.nodeId },
1976
3003
  limit: 500,
1977
- context: SYSTEM_CTX
3004
+ context: SYSTEM_CTX2
1978
3005
  });
1979
3006
  const n = Array.isArray(prior) ? prior.length : 0;
1980
3007
  if (n > 0) configSnapshot.__round = n + 1;
@@ -1998,7 +3025,7 @@ var _ApprovalService = class _ApprovalService {
1998
3025
  created_at: now,
1999
3026
  updated_at: now
2000
3027
  };
2001
- await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX });
3028
+ await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX2 });
2002
3029
  await this.syncApproverIndex(id, approvers, ctxOrg, now);
2003
3030
  await this.engine.insert("sys_approval_action", {
2004
3031
  id: uid("aact"),
@@ -2010,7 +3037,7 @@ var _ApprovalService = class _ApprovalService {
2010
3037
  actor_id: input.submitterId ?? context.userId ?? null,
2011
3038
  comment: null,
2012
3039
  created_at: now
2013
- }, { context: SYSTEM_CTX });
3040
+ }, { context: SYSTEM_CTX2 });
2014
3041
  for (const sub of substitutions) {
2015
3042
  await this.engine.insert("sys_approval_action", {
2016
3043
  id: uid("aact"),
@@ -2022,7 +3049,7 @@ var _ApprovalService = class _ApprovalService {
2022
3049
  actor_id: null,
2023
3050
  comment: `${sub.from} \u2192 ${sub.to}${sub.reason ? ` \u2014 ${sub.reason}` : ""}`,
2024
3051
  created_at: now
2025
- }, { context: SYSTEM_CTX });
3052
+ }, { context: SYSTEM_CTX2 });
2026
3053
  await this.notify({
2027
3054
  topic: "approval.ooo_substituted",
2028
3055
  audience: [sub.to],
@@ -2047,7 +3074,13 @@ var _ApprovalService = class _ApprovalService {
2047
3074
  });
2048
3075
  }
2049
3076
  if (input.config.approvalStatusField) {
2050
- await this.mirrorStatusField(input.object, input.recordId, input.config.approvalStatusField, "pending");
3077
+ await this.mirrorStatusField(
3078
+ input.object,
3079
+ input.recordId,
3080
+ input.config.approvalStatusField,
3081
+ "pending",
3082
+ row.submitter_id ?? null
3083
+ );
2051
3084
  }
2052
3085
  return rowFromRequest(row);
2053
3086
  }
@@ -2091,27 +3124,52 @@ var _ApprovalService = class _ApprovalService {
2091
3124
  */
2092
3125
  async decideNode(requestId, input, context) {
2093
3126
  if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
2094
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3127
+ const actorId = await this.resolveActor(input?.actorId, context);
2095
3128
  if (input.decision !== "approve" && input.decision !== "reject") {
2096
3129
  throw new Error("VALIDATION_FAILED: decision must be approve|reject");
2097
3130
  }
2098
3131
  const rawRows = await this.engine.find("sys_approval_request", {
2099
3132
  where: { id: requestId },
2100
3133
  limit: 1,
2101
- context: SYSTEM_CTX
3134
+ context: SYSTEM_CTX2
2102
3135
  });
2103
3136
  const raw = Array.isArray(rawRows) ? rawRows[0] : null;
2104
3137
  if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
2105
3138
  if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
2106
3139
  const pendingApprovers = csvSplit(raw.pending_approvers);
2107
- if (!context.isSystem && !pendingApprovers.includes(input.actorId)) {
2108
- throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
3140
+ const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
3141
+ const isSlotHolder = pendingApprovers.includes(actorId);
3142
+ if (!isSlotHolder && !isOverride) {
3143
+ throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
2109
3144
  }
2110
3145
  const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
2111
3146
  const org = raw.organization_id ?? null;
2112
3147
  const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
2113
3148
  const runId = raw.flow_run_id ?? null;
2114
3149
  const now = this.clock.now().toISOString();
3150
+ const outputKeys = input.outputs ? Object.keys(input.outputs) : [];
3151
+ let acceptedOutputs;
3152
+ if (outputKeys.length) {
3153
+ const declared = (0, import_automation.normalizeDecisionOutputs)(config.decisionOutputs).map((d) => d.key);
3154
+ if (!declared.length) {
3155
+ throw new Error(
3156
+ `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.`
3157
+ );
3158
+ }
3159
+ const reserved = outputKeys.filter((k) => k === "decision" || k === "requestId");
3160
+ if (reserved.length) {
3161
+ throw new Error(
3162
+ `VALIDATION_FAILED: decision output key(s) \`${reserved.join("`, `")}\` are reserved by the resume envelope \u2014 pick different names.`
3163
+ );
3164
+ }
3165
+ const undeclared = outputKeys.filter((k) => !declared.includes(k));
3166
+ if (undeclared.length) {
3167
+ throw new Error(
3168
+ `VALIDATION_FAILED: decision output key(s) \`${undeclared.join("`, `")}\` are not declared on this node \u2014 declared keys: ${declared.map((k) => `'${k}'`).join(", ") || "(none)"}.`
3169
+ );
3170
+ }
3171
+ acceptedOutputs = { ...input.outputs };
3172
+ }
2115
3173
  await this.engine.insert("sys_approval_action", {
2116
3174
  id: uid("aact"),
2117
3175
  request_id: requestId,
@@ -2119,23 +3177,23 @@ var _ApprovalService = class _ApprovalService {
2119
3177
  step_name: nodeId,
2120
3178
  step_index: 0,
2121
3179
  action: input.decision,
2122
- actor_id: input.actorId,
3180
+ actor_id: actorId,
2123
3181
  comment: input.comment ?? null,
2124
3182
  attachments: input.attachments?.length ? input.attachments : null,
2125
3183
  created_at: now
2126
- }, { context: SYSTEM_CTX });
3184
+ }, { context: SYSTEM_CTX2 });
2127
3185
  const behavior = config.behavior ?? "first_response";
2128
- if (input.decision === "approve" && behavior !== "first_response") {
3186
+ if (input.decision === "approve" && behavior !== "first_response" && isSlotHolder) {
2129
3187
  const acts = await this.engine.find("sys_approval_action", {
2130
3188
  where: { request_id: requestId, step_index: 0, action: "approve" },
2131
3189
  limit: 1e3,
2132
- context: SYSTEM_CTX
3190
+ context: SYSTEM_CTX2
2133
3191
  });
2134
3192
  const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
2135
3193
  const snapshotGroups = config.__approverGroups;
2136
3194
  let original;
2137
3195
  let groupMap;
2138
- if (snapshotGroups && (behavior === "quorum" || behavior === "per_group")) {
3196
+ if (snapshotGroups) {
2139
3197
  groupMap = snapshotGroups;
2140
3198
  original = Object.keys(snapshotGroups);
2141
3199
  } else {
@@ -2151,27 +3209,65 @@ var _ApprovalService = class _ApprovalService {
2151
3209
  await this.engine.update("sys_approval_request", {
2152
3210
  id: requestId,
2153
3211
  pending_approvers: stillPending.join(","),
2154
- updated_at: now
2155
- }, { context: SYSTEM_CTX });
3212
+ updated_at: now,
3213
+ // #3447 P2: a mid-tally approval may carry outputs too (unanimous /
3214
+ // per_group co-sign, each approver contributing their declared keys)
3215
+ // — accumulate them on the snapshot so the FINALIZING decision hands
3216
+ // the merged set to the flow.
3217
+ ...acceptedOutputs ? {
3218
+ node_config_json: JSON.stringify({
3219
+ ...config,
3220
+ __decisionOutputs: { ...config.__decisionOutputs ?? {}, ...acceptedOutputs }
3221
+ })
3222
+ } : {}
3223
+ }, { context: SYSTEM_CTX2 });
2156
3224
  await this.syncApproverIndex(requestId, stillPending, org, now);
2157
- const fresh2 = await this.getRequest(requestId, context);
3225
+ const fresh2 = await this.readBackRequest(requestId, context);
2158
3226
  return { request: fresh2, runId, nodeId, finalized: false, decision: input.decision };
2159
3227
  }
2160
3228
  }
2161
3229
  const finalStatus = input.decision === "approve" ? "approved" : "rejected";
3230
+ const mergedOutputs = acceptedOutputs || config.__decisionOutputs ? { ...config.__decisionOutputs ?? {}, ...acceptedOutputs ?? {} } : void 0;
2162
3231
  await this.engine.update("sys_approval_request", {
2163
3232
  id: requestId,
2164
3233
  status: finalStatus,
2165
3234
  pending_approvers: null,
2166
3235
  completed_at: now,
2167
- updated_at: now
2168
- }, { context: SYSTEM_CTX });
3236
+ updated_at: now,
3237
+ ...mergedOutputs ? {
3238
+ node_config_json: JSON.stringify({ ...config, __decisionOutputs: mergedOutputs })
3239
+ } : {}
3240
+ }, { context: SYSTEM_CTX2 });
2169
3241
  await this.syncApproverIndex(requestId, [], org, now);
2170
3242
  if (config.approvalStatusField) {
2171
- await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, finalStatus);
3243
+ await this.mirrorStatusField(
3244
+ raw.object_name,
3245
+ raw.record_id,
3246
+ config.approvalStatusField,
3247
+ finalStatus,
3248
+ actingUserId(context)
3249
+ );
2172
3250
  }
2173
- const fresh = await this.getRequest(requestId, context);
2174
- return { request: fresh, runId, nodeId, finalized: true, decision: input.decision };
3251
+ const fresh = await this.readBackRequest(requestId, context);
3252
+ return { request: fresh, runId, nodeId, finalized: true, decision: input.decision, outputs: mergedOutputs };
3253
+ }
3254
+ /**
3255
+ * Continue the owning flow run after an outcome this service has already
3256
+ * authorized and written down (#3801).
3257
+ *
3258
+ * The `approval` node declares `resumeAuthority: 'service'`, so the engine
3259
+ * refuses any resume of an approval suspension that does not carry
3260
+ * {@link RESUME_AUTHORITY_SERVICE}. Every approvals-side resume goes through
3261
+ * here so the marker is stamped in ONE place — a new outcome path cannot
3262
+ * quietly ship a resume that the gate then rejects at runtime, and nothing
3263
+ * in this file hands the marker to a caller-supplied signal.
3264
+ *
3265
+ * Callers still guard on `typeof this.automation?.resume === 'function'`
3266
+ * (approvals runs fine with no automation attached) and keep their own
3267
+ * try/catch, because what a failed resume means differs per path.
3268
+ */
3269
+ async serviceResume(runId, signal) {
3270
+ await this.automation.resume(runId, { ...signal, [import_contracts.RESUME_AUTHORITY_SERVICE]: true });
2175
3271
  }
2176
3272
  /**
2177
3273
  * Public contract entrypoint (ADR-0019). Records a decision on a node-driven
@@ -2184,9 +3280,14 @@ var _ApprovalService = class _ApprovalService {
2184
3280
  if (result.finalized && result.runId && typeof this.automation?.resume === "function") {
2185
3281
  const branchLabel = result.decision === "approve" ? import_automation.APPROVAL_BRANCH_LABELS.approve : import_automation.APPROVAL_BRANCH_LABELS.reject;
2186
3282
  try {
2187
- await this.automation.resume(result.runId, {
3283
+ await this.serviceResume(result.runId, {
2188
3284
  branchLabel,
2189
- output: { decision: result.decision, requestId }
3285
+ // #3447 P2: accepted decision outputs ride the resume envelope and
3286
+ // land as `<nodeId>.<key>` flow variables — a later approval node's
3287
+ // `expression` approver reads them as `vars.<nodeId>.<key>`.
3288
+ // Reserved keys are spread LAST so no output can shadow them (the
3289
+ // whitelist already rejects them; this is defense in depth).
3290
+ output: { ...result.outputs ?? {}, decision: result.decision, requestId }
2190
3291
  });
2191
3292
  resumed = true;
2192
3293
  } catch (err) {
@@ -2220,11 +3321,11 @@ var _ApprovalService = class _ApprovalService {
2220
3321
  */
2221
3322
  async recall(requestId, input, context) {
2222
3323
  if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
2223
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3324
+ const actorId = await this.resolveActor(input?.actorId, context);
2224
3325
  const rawRows = await this.engine.find("sys_approval_request", {
2225
3326
  where: { id: requestId },
2226
3327
  limit: 1,
2227
- context: SYSTEM_CTX
3328
+ context: SYSTEM_CTX2
2228
3329
  });
2229
3330
  const raw = Array.isArray(rawRows) ? rawRows[0] : null;
2230
3331
  if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
@@ -2232,7 +3333,7 @@ var _ApprovalService = class _ApprovalService {
2232
3333
  if (raw.status !== "pending" && !inReviseWindow) {
2233
3334
  throw new Error(`INVALID_STATE: request is ${raw.status}`);
2234
3335
  }
2235
- if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
3336
+ if (!this.isOverrideActor(context, raw.organization_id ?? null) && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
2236
3337
  throw new Error(`FORBIDDEN: only the submitter may recall this request`);
2237
3338
  }
2238
3339
  if (inReviseWindow) await this.assertLatestForRun(raw);
@@ -2248,20 +3349,26 @@ var _ApprovalService = class _ApprovalService {
2248
3349
  step_name: nodeId,
2249
3350
  step_index: 0,
2250
3351
  action: "recall",
2251
- actor_id: input.actorId,
3352
+ actor_id: actorId,
2252
3353
  comment: input.comment ?? null,
2253
3354
  created_at: now
2254
- }, { context: SYSTEM_CTX });
3355
+ }, { context: SYSTEM_CTX2 });
2255
3356
  await this.engine.update("sys_approval_request", {
2256
3357
  id: requestId,
2257
3358
  status: "recalled",
2258
3359
  pending_approvers: null,
2259
3360
  completed_at: now,
2260
3361
  updated_at: now
2261
- }, { context: SYSTEM_CTX });
3362
+ }, { context: SYSTEM_CTX2 });
2262
3363
  await this.syncApproverIndex(requestId, [], org, now);
2263
3364
  if (config.approvalStatusField) {
2264
- await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, "recalled");
3365
+ await this.mirrorStatusField(
3366
+ raw.object_name,
3367
+ raw.record_id,
3368
+ config.approvalStatusField,
3369
+ "recalled",
3370
+ actingUserId(context)
3371
+ );
2265
3372
  }
2266
3373
  let resumed = false;
2267
3374
  if (inReviseWindow) {
@@ -2278,7 +3385,7 @@ var _ApprovalService = class _ApprovalService {
2278
3385
  }
2279
3386
  } else if (runId && typeof this.automation?.resume === "function") {
2280
3387
  try {
2281
- await this.automation.resume(runId, {
3388
+ await this.serviceResume(runId, {
2282
3389
  branchLabel: import_automation.APPROVAL_BRANCH_LABELS.reject,
2283
3390
  output: { decision: "recall", requestId }
2284
3391
  });
@@ -2291,7 +3398,7 @@ var _ApprovalService = class _ApprovalService {
2291
3398
  });
2292
3399
  }
2293
3400
  }
2294
- const fresh = await this.getRequest(requestId, context);
3401
+ const fresh = await this.readBackRequest(requestId, context);
2295
3402
  return { request: fresh, runId, resumed };
2296
3403
  }
2297
3404
  // ── Send back for revision / resubmit (ADR-0044) ─────────────
@@ -2309,11 +3416,11 @@ var _ApprovalService = class _ApprovalService {
2309
3416
  * `output.autoRejected = true`) so instances cannot orbit forever.
2310
3417
  */
2311
3418
  async sendBack(requestId, input, context) {
2312
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3419
+ const actorId = await this.resolveActor(input?.actorId, context);
2313
3420
  const raw = await this.loadPendingRow(requestId);
2314
3421
  const pending = csvSplit(raw.pending_approvers);
2315
- if (!context.isSystem && !pending.includes(input.actorId)) {
2316
- throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
3422
+ if (!context.isSystem && !pending.includes(actorId)) {
3423
+ throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
2317
3424
  }
2318
3425
  const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
2319
3426
  const org = raw.organization_id ?? null;
@@ -2327,7 +3434,7 @@ var _ApprovalService = class _ApprovalService {
2327
3434
  const siblings = await this.engine.find("sys_approval_request", {
2328
3435
  where: { flow_run_id: runId, flow_node_id: nodeId, status: "returned" },
2329
3436
  limit: 500,
2330
- context: SYSTEM_CTX
3437
+ context: SYSTEM_CTX2
2331
3438
  });
2332
3439
  priorSendBacks = Array.isArray(siblings) ? siblings.length : 0;
2333
3440
  }
@@ -2338,10 +3445,10 @@ var _ApprovalService = class _ApprovalService {
2338
3445
  step_name: nodeId,
2339
3446
  step_index: 0,
2340
3447
  action: "revise",
2341
- actor_id: input.actorId,
3448
+ actor_id: actorId,
2342
3449
  comment: input.comment ?? null,
2343
3450
  created_at: now
2344
- }, { context: SYSTEM_CTX });
3451
+ }, { context: SYSTEM_CTX2 });
2345
3452
  if (priorSendBacks >= maxRevisions) {
2346
3453
  await this.engine.insert("sys_approval_action", {
2347
3454
  id: uid("aact"),
@@ -2350,25 +3457,31 @@ var _ApprovalService = class _ApprovalService {
2350
3457
  step_name: nodeId,
2351
3458
  step_index: 0,
2352
3459
  action: "reject",
2353
- actor_id: input.actorId,
3460
+ actor_id: actorId,
2354
3461
  comment: `Auto-rejected: revision limit (${maxRevisions}) exceeded`,
2355
3462
  created_at: now
2356
- }, { context: SYSTEM_CTX });
3463
+ }, { context: SYSTEM_CTX2 });
2357
3464
  await this.engine.update("sys_approval_request", {
2358
3465
  id: requestId,
2359
3466
  status: "rejected",
2360
3467
  pending_approvers: null,
2361
3468
  completed_at: now,
2362
3469
  updated_at: now
2363
- }, { context: SYSTEM_CTX });
3470
+ }, { context: SYSTEM_CTX2 });
2364
3471
  await this.syncApproverIndex(requestId, [], org, now);
2365
3472
  if (config.approvalStatusField) {
2366
- await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, "rejected");
3473
+ await this.mirrorStatusField(
3474
+ raw.object_name,
3475
+ raw.record_id,
3476
+ config.approvalStatusField,
3477
+ "rejected",
3478
+ actingUserId(context)
3479
+ );
2367
3480
  }
2368
3481
  let resumed2 = false;
2369
3482
  if (runId && typeof this.automation?.resume === "function") {
2370
3483
  try {
2371
- await this.automation.resume(runId, {
3484
+ await this.serviceResume(runId, {
2372
3485
  branchLabel: import_automation.APPROVAL_BRANCH_LABELS.reject,
2373
3486
  output: { decision: "reject", autoRejected: true, requestId }
2374
3487
  });
@@ -2385,7 +3498,7 @@ var _ApprovalService = class _ApprovalService {
2385
3498
  await this.notify({
2386
3499
  topic: "approval.returned",
2387
3500
  audience: [String(raw.submitter_id)],
2388
- actorId: input.actorId,
3501
+ actorId,
2389
3502
  source: { object: "sys_approval_request", id: requestId },
2390
3503
  payload: {
2391
3504
  title: "Approval auto-rejected",
@@ -2394,7 +3507,7 @@ var _ApprovalService = class _ApprovalService {
2394
3507
  }
2395
3508
  });
2396
3509
  }
2397
- const fresh2 = await this.getRequest(requestId, context);
3510
+ const fresh2 = await this.readBackRequest(requestId, context);
2398
3511
  return { request: fresh2, runId, resumed: resumed2, autoRejected: true };
2399
3512
  }
2400
3513
  await this.engine.update("sys_approval_request", {
@@ -2403,15 +3516,21 @@ var _ApprovalService = class _ApprovalService {
2403
3516
  pending_approvers: null,
2404
3517
  completed_at: now,
2405
3518
  updated_at: now
2406
- }, { context: SYSTEM_CTX });
3519
+ }, { context: SYSTEM_CTX2 });
2407
3520
  await this.syncApproverIndex(requestId, [], org, now);
2408
3521
  if (config.approvalStatusField) {
2409
- await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, "returned");
3522
+ await this.mirrorStatusField(
3523
+ raw.object_name,
3524
+ raw.record_id,
3525
+ config.approvalStatusField,
3526
+ "returned",
3527
+ actingUserId(context)
3528
+ );
2410
3529
  }
2411
3530
  let resumed = false;
2412
3531
  if (runId && typeof this.automation?.resume === "function") {
2413
3532
  try {
2414
- await this.automation.resume(runId, {
3533
+ await this.serviceResume(runId, {
2415
3534
  branchLabel: import_automation.APPROVAL_BRANCH_LABELS.revise,
2416
3535
  output: { decision: "revise", requestId }
2417
3536
  });
@@ -2428,7 +3547,7 @@ var _ApprovalService = class _ApprovalService {
2428
3547
  await this.notify({
2429
3548
  topic: "approval.returned",
2430
3549
  audience: [String(raw.submitter_id)],
2431
- actorId: input.actorId,
3550
+ actorId,
2432
3551
  source: { object: "sys_approval_request", id: requestId },
2433
3552
  payload: {
2434
3553
  title: "Sent back for revision",
@@ -2437,7 +3556,7 @@ var _ApprovalService = class _ApprovalService {
2437
3556
  }
2438
3557
  });
2439
3558
  }
2440
- const fresh = await this.getRequest(requestId, context);
3559
+ const fresh = await this.readBackRequest(requestId, context);
2441
3560
  return { request: fresh, runId, resumed };
2442
3561
  }
2443
3562
  /**
@@ -2448,25 +3567,25 @@ var _ApprovalService = class _ApprovalService {
2448
3567
  * round-N+1 request — fresh approver slate, record re-locks.
2449
3568
  */
2450
3569
  async resubmit(requestId, input, context) {
2451
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3570
+ const actorId = await this.resolveActor(input?.actorId, context);
2452
3571
  const rawRows = await this.engine.find("sys_approval_request", {
2453
3572
  where: { id: requestId },
2454
3573
  limit: 1,
2455
- context: SYSTEM_CTX
3574
+ context: SYSTEM_CTX2
2456
3575
  });
2457
3576
  const raw = Array.isArray(rawRows) ? rawRows[0] : null;
2458
3577
  if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
2459
3578
  if (raw.status !== "returned") {
2460
3579
  throw new Error(`INVALID_STATE: request is ${raw.status} (resubmit applies to returned requests)`);
2461
3580
  }
2462
- if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
3581
+ if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
2463
3582
  throw new Error("FORBIDDEN: only the submitter may resubmit");
2464
3583
  }
2465
3584
  await this.assertLatestForRun(raw);
2466
3585
  const colliding = await this.engine.find("sys_approval_request", {
2467
3586
  where: { object_name: raw.object_name, record_id: raw.record_id, status: "pending" },
2468
3587
  limit: 1,
2469
- context: SYSTEM_CTX
3588
+ context: SYSTEM_CTX2
2470
3589
  });
2471
3590
  if (Array.isArray(colliding) && colliding[0]) {
2472
3591
  throw new Error(
@@ -2484,14 +3603,14 @@ var _ApprovalService = class _ApprovalService {
2484
3603
  step_name: nodeId,
2485
3604
  step_index: 0,
2486
3605
  action: "resubmit",
2487
- actor_id: input.actorId,
3606
+ actor_id: actorId,
2488
3607
  comment: input.comment ?? null,
2489
3608
  created_at: now
2490
- }, { context: SYSTEM_CTX });
3609
+ }, { context: SYSTEM_CTX2 });
2491
3610
  let resumed = false;
2492
3611
  if (runId && typeof this.automation?.resume === "function") {
2493
3612
  try {
2494
- await this.automation.resume(runId, {
3613
+ await this.serviceResume(runId, {
2495
3614
  branchLabel: import_automation.APPROVAL_BRANCH_LABELS.resubmit,
2496
3615
  output: { resubmitted: true, requestId }
2497
3616
  });
@@ -2504,7 +3623,7 @@ var _ApprovalService = class _ApprovalService {
2504
3623
  });
2505
3624
  }
2506
3625
  }
2507
- const fresh = await this.getRequest(requestId, context);
3626
+ const fresh = await this.readBackRequest(requestId, context);
2508
3627
  return { request: fresh, runId, resumed };
2509
3628
  }
2510
3629
  /**
@@ -2539,7 +3658,7 @@ var _ApprovalService = class _ApprovalService {
2539
3658
  where: { flow_run_id: runId },
2540
3659
  orderBy: [{ field: "created_at", order: "desc" }],
2541
3660
  limit: 1,
2542
- context: SYSTEM_CTX
3661
+ context: SYSTEM_CTX2
2543
3662
  });
2544
3663
  const latest = Array.isArray(rows) ? rows[0] : null;
2545
3664
  if (latest && String(latest.id) !== String(raw.id)) {
@@ -2550,25 +3669,33 @@ var _ApprovalService = class _ApprovalService {
2550
3669
  /**
2551
3670
  * Hand a pending-approver slot to someone else. `from` defaults to the
2552
3671
  * actor itself; the actor must hold the slot being handed over (or be a
2553
- * system caller). Audits `reassign` and notifies the new approver.
3672
+ * system caller). A privileged admin (#3424) may reassign a request whose
3673
+ * slate holds no real user — an unstaffed-position literal — by handing the
3674
+ * whole request to a real approver, rescuing it from the locked dead-end.
3675
+ * Audits `reassign` and notifies the new approver.
2554
3676
  */
2555
3677
  async reassign(requestId, input, context) {
2556
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3678
+ const actorId = await this.resolveActor(input?.actorId, context);
2557
3679
  const to = String(input?.to ?? "").trim();
2558
3680
  if (!to) throw new Error("VALIDATION_FAILED: `to` (new approver) is required");
2559
3681
  const raw = await this.loadPendingRow(requestId);
2560
3682
  const pending = csvSplit(raw.pending_approvers);
2561
- const from = String(input.from ?? input.actorId).trim();
2562
- if (!pending.includes(from)) {
2563
- throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`);
2564
- }
2565
- if (!context.isSystem && input.actorId !== from && !pending.includes(input.actorId)) {
2566
- throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
2567
- }
2568
3683
  if (pending.includes(to)) {
2569
3684
  throw new Error(`VALIDATION_FAILED: '${to}' is already a pending approver`);
2570
3685
  }
2571
- const next = pending.map((a) => a === from ? to : a);
3686
+ const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
3687
+ const from = String(input.from ?? actorId).trim();
3688
+ let next;
3689
+ if (pending.includes(from)) {
3690
+ if (!context.isSystem && !isOverride && actorId !== from && !pending.includes(actorId)) {
3691
+ throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
3692
+ }
3693
+ next = pending.map((a) => a === from ? to : a);
3694
+ } else if (isOverride) {
3695
+ next = [to];
3696
+ } else {
3697
+ throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`);
3698
+ }
2572
3699
  const now = this.clock.now().toISOString();
2573
3700
  await this.engine.insert("sys_approval_action", {
2574
3701
  id: uid("aact"),
@@ -2577,10 +3704,10 @@ var _ApprovalService = class _ApprovalService {
2577
3704
  step_name: raw.flow_node_id ?? raw.current_step ?? null,
2578
3705
  step_index: 0,
2579
3706
  action: "reassign",
2580
- actor_id: input.actorId,
3707
+ actor_id: actorId,
2581
3708
  comment: input.comment ?? `${from} \u2192 ${to}`,
2582
3709
  created_at: now
2583
- }, { context: SYSTEM_CTX });
3710
+ }, { context: SYSTEM_CTX2 });
2584
3711
  let configPatch = {};
2585
3712
  try {
2586
3713
  const cfg = parseJson(raw.node_config_json, null);
@@ -2597,12 +3724,12 @@ var _ApprovalService = class _ApprovalService {
2597
3724
  pending_approvers: next.join(","),
2598
3725
  updated_at: now,
2599
3726
  ...configPatch
2600
- }, { context: SYSTEM_CTX });
3727
+ }, { context: SYSTEM_CTX2 });
2601
3728
  await this.syncApproverIndex(requestId, next, raw.organization_id ?? null, now);
2602
3729
  await this.notify({
2603
3730
  topic: "approval.reassigned",
2604
3731
  audience: [to],
2605
- actorId: input.actorId,
3732
+ actorId,
2606
3733
  source: { object: "sys_approval_request", id: requestId },
2607
3734
  dedupKey: `approval-reassign-${requestId}-${to}`,
2608
3735
  payload: {
@@ -2611,7 +3738,7 @@ var _ApprovalService = class _ApprovalService {
2611
3738
  actionUrl: "/system/approvals"
2612
3739
  }
2613
3740
  });
2614
- const fresh = await this.getRequest(requestId, context);
3741
+ const fresh = await this.readBackRequest(requestId, context);
2615
3742
  return { request: fresh };
2616
3743
  }
2617
3744
  /**
@@ -2619,16 +3746,16 @@ var _ApprovalService = class _ApprovalService {
2619
3746
  * reminder per {@link REMIND_COOLDOWN_MS} per request.
2620
3747
  */
2621
3748
  async remind(requestId, input, context) {
2622
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3749
+ const actorId = await this.resolveActor(input?.actorId, context);
2623
3750
  const raw = await this.loadPendingRow(requestId);
2624
- if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
3751
+ if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
2625
3752
  throw new Error("FORBIDDEN: only the submitter may send reminders");
2626
3753
  }
2627
3754
  const acts = await this.engine.find("sys_approval_action", {
2628
3755
  where: { request_id: requestId, action: "remind" },
2629
3756
  orderBy: [{ field: "created_at", order: "desc" }],
2630
3757
  limit: 1,
2631
- context: SYSTEM_CTX
3758
+ context: SYSTEM_CTX2
2632
3759
  });
2633
3760
  const last = Array.isArray(acts) ? acts[0] : null;
2634
3761
  const now = this.clock.now();
@@ -2644,10 +3771,10 @@ var _ApprovalService = class _ApprovalService {
2644
3771
  step_name: raw.flow_node_id ?? raw.current_step ?? null,
2645
3772
  step_index: 0,
2646
3773
  action: "remind",
2647
- actor_id: input.actorId,
3774
+ actor_id: actorId,
2648
3775
  comment: input.comment ?? null,
2649
3776
  created_at: nowIso
2650
- }, { context: SYSTEM_CTX });
3777
+ }, { context: SYSTEM_CTX2 });
2651
3778
  let notified = 0;
2652
3779
  const concrete = pending.filter((a) => a && !a.includes(":"));
2653
3780
  const literals = pending.filter((a) => a && a.includes(":"));
@@ -2657,7 +3784,7 @@ var _ApprovalService = class _ApprovalService {
2657
3784
  notified += await this.notify({
2658
3785
  topic: "approval.reminder",
2659
3786
  audience: [approver],
2660
- actorId: input.actorId,
3787
+ actorId,
2661
3788
  source: { object: "sys_approval_request", id: requestId },
2662
3789
  dedupKey: `approval-remind-${requestId}-${nowIso}-${approver}`,
2663
3790
  payload: {
@@ -2682,7 +3809,7 @@ var _ApprovalService = class _ApprovalService {
2682
3809
  notified += await this.notify({
2683
3810
  topic: "approval.reminder",
2684
3811
  audience: literals,
2685
- actorId: input.actorId,
3812
+ actorId,
2686
3813
  source: { object: "sys_approval_request", id: requestId },
2687
3814
  dedupKey: `approval-remind-${requestId}-${nowIso}`,
2688
3815
  payload: {
@@ -2692,7 +3819,7 @@ var _ApprovalService = class _ApprovalService {
2692
3819
  }
2693
3820
  });
2694
3821
  }
2695
- const fresh = await this.getRequest(requestId, context);
3822
+ const fresh = await this.readBackRequest(requestId, context);
2696
3823
  return { request: fresh, notified };
2697
3824
  }
2698
3825
  // ── Actionable links (ADR-0043) ──────────────────────────────
@@ -2727,7 +3854,7 @@ var _ApprovalService = class _ApprovalService {
2727
3854
  expires_at: expires,
2728
3855
  consumed_at: null,
2729
3856
  created_at: now.toISOString()
2730
- }, { context: SYSTEM_CTX });
3857
+ }, { context: SYSTEM_CTX2 });
2731
3858
  out[action] = rawToken;
2732
3859
  }
2733
3860
  return out;
@@ -2740,7 +3867,7 @@ var _ApprovalService = class _ApprovalService {
2740
3867
  const rows = await this.engine.find("sys_approval_token", {
2741
3868
  where: { token_hash: hash },
2742
3869
  limit: 1,
2743
- context: SYSTEM_CTX
3870
+ context: SYSTEM_CTX2
2744
3871
  });
2745
3872
  const token = Array.isArray(rows) ? rows[0] : null;
2746
3873
  if (!token) return { ok: false, reason: "invalid" };
@@ -2748,7 +3875,7 @@ var _ApprovalService = class _ApprovalService {
2748
3875
  if (Date.parse(token.expires_at) < this.clock.now().getTime()) {
2749
3876
  return { ok: false, reason: "expired" };
2750
3877
  }
2751
- const request = await this.getRequest(token.request_id, SYSTEM_CTX);
3878
+ const request = await this.getRequest(token.request_id, SYSTEM_CTX2);
2752
3879
  if (!request || request.status !== "pending") {
2753
3880
  return { ok: false, reason: "not_pending", request: request ?? void 0 };
2754
3881
  }
@@ -2773,12 +3900,19 @@ var _ApprovalService = class _ApprovalService {
2773
3900
  await this.engine.update("sys_approval_token", {
2774
3901
  id: res.token.id,
2775
3902
  consumed_at: this.clock.now().toISOString()
2776
- }, { context: SYSTEM_CTX });
3903
+ }, { context: SYSTEM_CTX2 });
2777
3904
  const out = await this.decide(res.token.request_id, {
2778
3905
  decision: res.token.action,
2779
3906
  actorId: res.token.approver_id,
2780
3907
  comment: "Via action link"
2781
- }, SYSTEM_CTX);
3908
+ // The token IS the authentication (#3783): it is single-use, hashed at
3909
+ // rest and bound to one approver, who `resolveActionToken` has just
3910
+ // re-checked still holds a pending slot. So this decision has a real
3911
+ // acting user even though no session carried it — name them on the
3912
+ // context, so the status mirror and every flow it cascades into are
3913
+ // attributed exactly like a decision made through the UI. Elevation is
3914
+ // unchanged: `isSystem` still stands in for the missing session.
3915
+ }, { ...SYSTEM_CTX2, userId: res.token.approver_id });
2782
3916
  return { ok: true, action: res.token.action, request: out.request, approverId: res.token.approver_id };
2783
3917
  }
2784
3918
  /**
@@ -2786,12 +3920,12 @@ var _ApprovalService = class _ApprovalService {
2786
3920
  * pending — a thread interaction, not a flow decision.
2787
3921
  */
2788
3922
  async requestInfo(requestId, input, context) {
2789
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3923
+ const actorId = await this.resolveActor(input?.actorId, context);
2790
3924
  if (!input?.comment?.trim()) throw new Error("VALIDATION_FAILED: comment is required");
2791
3925
  const raw = await this.loadPendingRow(requestId);
2792
3926
  const pending = csvSplit(raw.pending_approvers);
2793
- if (!context.isSystem && !pending.includes(input.actorId)) {
2794
- throw new Error(`FORBIDDEN: actor '${input.actorId}' is not a pending approver`);
3927
+ if (!context.isSystem && !pending.includes(actorId)) {
3928
+ throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
2795
3929
  }
2796
3930
  const now = this.clock.now().toISOString();
2797
3931
  await this.engine.insert("sys_approval_action", {
@@ -2801,15 +3935,15 @@ var _ApprovalService = class _ApprovalService {
2801
3935
  step_name: raw.flow_node_id ?? raw.current_step ?? null,
2802
3936
  step_index: 0,
2803
3937
  action: "request_info",
2804
- actor_id: input.actorId,
3938
+ actor_id: actorId,
2805
3939
  comment: input.comment.trim(),
2806
3940
  created_at: now
2807
- }, { context: SYSTEM_CTX });
3941
+ }, { context: SYSTEM_CTX2 });
2808
3942
  if (raw.submitter_id) {
2809
3943
  await this.notify({
2810
3944
  topic: "approval.request_info",
2811
3945
  audience: [String(raw.submitter_id)],
2812
- actorId: input.actorId,
3946
+ actorId,
2813
3947
  source: { object: "sys_approval_request", id: requestId },
2814
3948
  payload: {
2815
3949
  title: "More information requested",
@@ -2818,18 +3952,18 @@ var _ApprovalService = class _ApprovalService {
2818
3952
  }
2819
3953
  });
2820
3954
  }
2821
- const fresh = await this.getRequest(requestId, context);
3955
+ const fresh = await this.readBackRequest(requestId, context);
2822
3956
  return { request: fresh };
2823
3957
  }
2824
3958
  /** Free-form reply on the thread (submitter or any pending approver). */
2825
3959
  async comment(requestId, input, context) {
2826
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3960
+ const actorId = await this.resolveActor(input?.actorId, context);
2827
3961
  if (!input?.comment?.trim()) throw new Error("VALIDATION_FAILED: comment is required");
2828
3962
  const raw = await this.loadPendingRow(requestId);
2829
3963
  const pending = csvSplit(raw.pending_approvers);
2830
- const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(input.actorId);
2831
- if (!context.isSystem && !isSubmitter && !pending.includes(input.actorId)) {
2832
- throw new Error(`FORBIDDEN: actor '${input.actorId}' is not on this request`);
3964
+ const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(actorId);
3965
+ if (!context.isSystem && !isSubmitter && !pending.includes(actorId)) {
3966
+ throw new Error(`FORBIDDEN: actor '${actorId}' is not on this request`);
2833
3967
  }
2834
3968
  const now = this.clock.now().toISOString();
2835
3969
  await this.engine.insert("sys_approval_action", {
@@ -2839,16 +3973,16 @@ var _ApprovalService = class _ApprovalService {
2839
3973
  step_name: raw.flow_node_id ?? raw.current_step ?? null,
2840
3974
  step_index: 0,
2841
3975
  action: "comment",
2842
- actor_id: input.actorId,
3976
+ actor_id: actorId,
2843
3977
  comment: input.comment.trim(),
2844
3978
  attachments: input.attachments?.length ? input.attachments : null,
2845
3979
  created_at: now
2846
- }, { context: SYSTEM_CTX });
3980
+ }, { context: SYSTEM_CTX2 });
2847
3981
  const audience = isSubmitter ? pending : [String(raw.submitter_id ?? "")].filter(Boolean);
2848
3982
  await this.notify({
2849
3983
  topic: "approval.comment",
2850
3984
  audience,
2851
- actorId: input.actorId,
3985
+ actorId,
2852
3986
  source: { object: "sys_approval_request", id: requestId },
2853
3987
  payload: {
2854
3988
  title: "New comment on an approval",
@@ -2856,7 +3990,7 @@ var _ApprovalService = class _ApprovalService {
2856
3990
  actionUrl: "/system/approvals"
2857
3991
  }
2858
3992
  });
2859
- const fresh = await this.getRequest(requestId, context);
3993
+ const fresh = await this.readBackRequest(requestId, context);
2860
3994
  return { request: fresh };
2861
3995
  }
2862
3996
  // ── SLA escalation (ADR-0042) ─────────────────────────────────
@@ -2873,7 +4007,7 @@ var _ApprovalService = class _ApprovalService {
2873
4007
  rows = await this.engine.find("sys_approval_request", {
2874
4008
  where: { status: "pending" },
2875
4009
  limit: 500,
2876
- context: SYSTEM_CTX
4010
+ context: SYSTEM_CTX2
2877
4011
  }) ?? [];
2878
4012
  } catch (err) {
2879
4013
  this.logger?.warn?.("[approvals] escalation scan failed to list requests", {
@@ -2892,7 +4026,7 @@ var _ApprovalService = class _ApprovalService {
2892
4026
  const prior = await this.engine.find("sys_approval_action", {
2893
4027
  where: { request_id: raw.id, action: "escalate" },
2894
4028
  limit: 1,
2895
- context: SYSTEM_CTX
4029
+ context: SYSTEM_CTX2
2896
4030
  });
2897
4031
  if (Array.isArray(prior) && prior[0]) continue;
2898
4032
  await this.escalateRequest(raw, esc2);
@@ -2909,6 +4043,130 @@ var _ApprovalService = class _ApprovalService {
2909
4043
  }
2910
4044
  return { scanned: rows.length, escalated };
2911
4045
  }
4046
+ // ── Dead-run release (#3456) ──────────────────────────────────
4047
+ /**
4048
+ * One dead-run sweep: a pending request whose owning flow run has reached a
4049
+ * TERMINAL state can never be decided — nothing is left to resume — so the
4050
+ * request is finalised as `recalled` and, with `lockRecord`, the record it was
4051
+ * holding is released.
4052
+ *
4053
+ * This is the recovery half of #3456. The prevention half is the record lock's
4054
+ * owning-run exemption (`lifecycle-hooks.ts`), which stops a run from killing
4055
+ * itself on its own lock in the first place; this sweep cleans up the runs that
4056
+ * still die — for any reason, including a process crash, which no in-band
4057
+ * handler can catch because the process that would have run it is gone.
4058
+ *
4059
+ * **Fail-safe by construction.** It acts only on an explicit terminal status
4060
+ * from a closed set. Every other answer — `paused` (the normal state of a run
4061
+ * waiting on its approval), `running`, an unrecognised status, `null` (unknown
4062
+ * run, evicted log, no durable store), a `getRun` that throws, or no automation
4063
+ * engine at all — is read as "still alive" and left strictly alone. The failure
4064
+ * mode is therefore "a dead run's lock survives until an admin recalls it"
4065
+ * (today's behaviour, #3424), never "a live approval is destroyed".
4066
+ *
4067
+ * `recalled` is the finalisation because it is the platform's existing terminal
4068
+ * state for *a live request that ended without a decision*; the audit row names
4069
+ * the real cause and {@link DEAD_RUN_ACTOR_ID} the real actor, so a dead-run
4070
+ * release is never mistaken for a submitter's withdrawal.
4071
+ */
4072
+ async releaseDeadRunRequests() {
4073
+ if (typeof this.automation?.getRun !== "function") return { scanned: 0, released: 0 };
4074
+ let rows = [];
4075
+ try {
4076
+ rows = await this.engine.find("sys_approval_request", {
4077
+ where: { status: "pending" },
4078
+ limit: 500,
4079
+ context: SYSTEM_CTX2
4080
+ }) ?? [];
4081
+ } catch (err) {
4082
+ this.logger?.warn?.("[approvals] dead-run sweep failed to list requests", {
4083
+ error: err?.message ?? String(err)
4084
+ });
4085
+ return { scanned: 0, released: 0 };
4086
+ }
4087
+ let released = 0;
4088
+ for (const raw of rows) {
4089
+ try {
4090
+ const runId = raw?.flow_run_id ? String(raw.flow_run_id) : "";
4091
+ if (!runId) continue;
4092
+ let status;
4093
+ try {
4094
+ const run = await this.automation.getRun(runId);
4095
+ status = typeof run?.status === "string" ? run.status : void 0;
4096
+ } catch (err) {
4097
+ this.logger?.warn?.("[approvals] dead-run sweep could not read run status", {
4098
+ request: raw?.id,
4099
+ run: runId,
4100
+ error: err?.message ?? String(err)
4101
+ });
4102
+ continue;
4103
+ }
4104
+ if (!status || !TERMINAL_RUN_STATUSES.has(status)) continue;
4105
+ await this.abandonForDeadRun(raw, runId, status);
4106
+ released++;
4107
+ } catch (err) {
4108
+ this.logger?.warn?.("[approvals] dead-run release failed for request", {
4109
+ request: raw?.id,
4110
+ error: err?.message ?? String(err)
4111
+ });
4112
+ }
4113
+ }
4114
+ if (released > 0) {
4115
+ this.logger?.info?.("[approvals] dead-run sweep", { scanned: rows.length, released });
4116
+ }
4117
+ return { scanned: rows.length, released };
4118
+ }
4119
+ /**
4120
+ * Finalise one pending request whose owning run is terminal. Mirrors the
4121
+ * shape of {@link recall} — audit row first (so a crash mid-release leaves a
4122
+ * trace of the intent), then the status transition, approver-index sync and
4123
+ * the optional status-field mirror. No resume/cancel of the run: it is already
4124
+ * terminal, which is precisely why we are here.
4125
+ */
4126
+ async abandonForDeadRun(raw, runId, runStatus) {
4127
+ const org = raw.organization_id ?? null;
4128
+ const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
4129
+ const now = this.clock.now().toISOString();
4130
+ await this.engine.insert("sys_approval_action", {
4131
+ id: uid("aact"),
4132
+ request_id: raw.id,
4133
+ organization_id: org,
4134
+ step_name: nodeId,
4135
+ step_index: 0,
4136
+ action: "recall",
4137
+ actor_id: DEAD_RUN_ACTOR_ID,
4138
+ comment: `owning flow run ${runId} is ${runStatus} \u2014 request abandoned and record lock released`,
4139
+ created_at: now
4140
+ }, { context: SYSTEM_CTX2 });
4141
+ await this.engine.update("sys_approval_request", {
4142
+ id: raw.id,
4143
+ status: "recalled",
4144
+ pending_approvers: null,
4145
+ completed_at: now,
4146
+ updated_at: now
4147
+ }, { context: SYSTEM_CTX2 });
4148
+ await this.syncApproverIndex(raw.id, [], org, now);
4149
+ const config = parseJson(
4150
+ raw.node_config_json,
4151
+ { approvers: [], behavior: "first_response" }
4152
+ );
4153
+ if (config.approvalStatusField) {
4154
+ await this.mirrorStatusField(
4155
+ raw.object_name,
4156
+ raw.record_id,
4157
+ config.approvalStatusField,
4158
+ "recalled",
4159
+ null
4160
+ );
4161
+ }
4162
+ this.logger?.warn?.("[approvals] released a record held by a dead approval run", {
4163
+ request: raw.id,
4164
+ run: runId,
4165
+ runStatus,
4166
+ object: raw.object_name,
4167
+ record: raw.record_id
4168
+ });
4169
+ }
2912
4170
  /** Execute the configured escalation action for one overdue request. */
2913
4171
  async escalateRequest(raw, esc2) {
2914
4172
  const action = esc2.action ?? "notify";
@@ -2934,13 +4192,13 @@ var _ApprovalService = class _ApprovalService {
2934
4192
  actor_id: SLA_ACTOR_ID,
2935
4193
  comment: `${action}${escalateTo ? ` \u2192 ${escalateTo}` : ""}`,
2936
4194
  created_at: now
2937
- }, { context: SYSTEM_CTX });
4195
+ }, { context: SYSTEM_CTX2 });
2938
4196
  if (action === "reassign" && escalatees.length) {
2939
4197
  await this.engine.update("sys_approval_request", {
2940
4198
  id: raw.id,
2941
4199
  pending_approvers: escalatees.join(","),
2942
4200
  updated_at: now
2943
- }, { context: SYSTEM_CTX });
4201
+ }, { context: SYSTEM_CTX2 });
2944
4202
  await this.syncApproverIndex(raw.id, escalatees, raw.organization_id ?? null, now);
2945
4203
  await this.notify({
2946
4204
  topic: "approval.escalated",
@@ -2958,7 +4216,7 @@ var _ApprovalService = class _ApprovalService {
2958
4216
  decision: action === "auto_approve" ? "approve" : "reject",
2959
4217
  actorId: SLA_ACTOR_ID,
2960
4218
  comment: "SLA escalation"
2961
- }, SYSTEM_CTX);
4219
+ }, SYSTEM_CTX2);
2962
4220
  } else {
2963
4221
  await this.notify({
2964
4222
  topic: "approval.sla_breached",
@@ -3026,7 +4284,7 @@ var _ApprovalService = class _ApprovalService {
3026
4284
  where: { id: { $in: targets } },
3027
4285
  fields: ["id", "name", "email"],
3028
4286
  limit: targets.length,
3029
- context: SYSTEM_CTX
4287
+ context: SYSTEM_CTX2
3030
4288
  });
3031
4289
  for (const u of users ?? []) {
3032
4290
  if (u?.id && (u.name || u.email)) names.set(String(u.id), String(u.name ?? u.email));
@@ -3040,7 +4298,7 @@ var _ApprovalService = class _ApprovalService {
3040
4298
  where: { email: { $in: unresolvedEmails } },
3041
4299
  fields: ["email", "name"],
3042
4300
  limit: unresolvedEmails.length,
3043
- context: SYSTEM_CTX
4301
+ context: SYSTEM_CTX2
3044
4302
  });
3045
4303
  for (const u of users ?? []) {
3046
4304
  if (u?.email && u.name) names.set(String(u.email), String(u.name));
@@ -3066,11 +4324,32 @@ var _ApprovalService = class _ApprovalService {
3066
4324
  return [];
3067
4325
  }
3068
4326
  }
4327
+ /**
4328
+ * Field key → display label for an object's schema. Lets the inbox summary
4329
+ * show a human field name ("考核状态") instead of a title-cased machine key
4330
+ * ("Assessment Status"). For a single-locale project the schema label already
4331
+ * IS the localized string; symmetric with `resolveDisplayField`/lookup
4332
+ * resolution that power `payload_display`.
4333
+ */
4334
+ resolveFieldLabels(object) {
4335
+ try {
4336
+ const schema = this.engine.getSchema?.(object);
4337
+ const fields = schema?.fields ?? {};
4338
+ const out = {};
4339
+ for (const [key, f] of Object.entries(fields)) {
4340
+ if (f?.label) out[key] = String(f.label);
4341
+ }
4342
+ return out;
4343
+ } catch {
4344
+ return {};
4345
+ }
4346
+ }
3069
4347
  /**
3070
4348
  * Attach inbox display fields to rows so clients never render a raw
3071
4349
  * identifier: `record_title`, `submitter_name`, `object_label`,
3072
- * `pending_approver_names` (user-id approvers), and `payload_display`
3073
- * (lookup foreign keys in the snapshot → referenced record titles).
4350
+ * `pending_approver_names` (user-id approvers), `payload_display`
4351
+ * (lookup foreign keys in the snapshot → referenced record titles), and
4352
+ * `payload_labels` (snapshot field keys → the target object's field labels).
3074
4353
  * Batched: one query per distinct object (target + referenced) plus one
3075
4354
  * `sys_user` lookup. Best-effort — a deleted record falls back to the
3076
4355
  * payload snapshot, and any failure leaves the field unset rather than
@@ -3102,7 +4381,7 @@ var _ApprovalService = class _ApprovalService {
3102
4381
  const recs = await this.engine.find(object, {
3103
4382
  where: { id: { $in: ids } },
3104
4383
  limit: ids.length,
3105
- context: SYSTEM_CTX
4384
+ context: SYSTEM_CTX2
3106
4385
  });
3107
4386
  for (const rec of recs ?? []) {
3108
4387
  const title = _ApprovalService.pickTitle(rec, displayField);
@@ -3112,9 +4391,12 @@ var _ApprovalService = class _ApprovalService {
3112
4391
  }
3113
4392
  }
3114
4393
  const lookupFieldsByObject = /* @__PURE__ */ new Map();
4394
+ const fieldLabelsByObject = /* @__PURE__ */ new Map();
3115
4395
  for (const object of byObject.keys()) {
3116
4396
  const lookups = this.resolveLookupFields(object);
3117
4397
  if (lookups.length) lookupFieldsByObject.set(object, lookups);
4398
+ const labels = this.resolveFieldLabels(object);
4399
+ if (Object.keys(labels).length) fieldLabelsByObject.set(object, labels);
3118
4400
  }
3119
4401
  const refIds = /* @__PURE__ */ new Map();
3120
4402
  for (const r of rows) {
@@ -3140,7 +4422,7 @@ var _ApprovalService = class _ApprovalService {
3140
4422
  const recs = await this.engine.find(object, {
3141
4423
  where: { id: { $in: ids } },
3142
4424
  limit: ids.length,
3143
- context: SYSTEM_CTX
4425
+ context: SYSTEM_CTX2
3144
4426
  });
3145
4427
  for (const rec of recs ?? []) {
3146
4428
  const title = _ApprovalService.pickTitle(rec, displayField);
@@ -3181,6 +4463,15 @@ var _ApprovalService = class _ApprovalService {
3181
4463
  }
3182
4464
  if (Object.keys(display).length) r.payload_display = display;
3183
4465
  }
4466
+ const fieldLabels = fieldLabelsByObject.get(r.object_name);
4467
+ if (fieldLabels && r.payload && typeof r.payload === "object") {
4468
+ const labels = {};
4469
+ for (const key of Object.keys(r.payload)) {
4470
+ const l = fieldLabels[key];
4471
+ if (l) labels[key] = l;
4472
+ }
4473
+ if (Object.keys(labels).length) r.payload_labels = labels;
4474
+ }
3184
4475
  }
3185
4476
  }
3186
4477
  // ── Pending-approver index (issue #1745) ─────────────────────
@@ -3196,12 +4487,12 @@ var _ApprovalService = class _ApprovalService {
3196
4487
  const existing = await this.engine.find("sys_approval_approver", {
3197
4488
  where: { request_id: requestId },
3198
4489
  limit: 500,
3199
- context: SYSTEM_CTX
4490
+ context: SYSTEM_CTX2
3200
4491
  });
3201
4492
  const rows = Array.isArray(existing) ? existing : [];
3202
4493
  for (const row of rows) {
3203
4494
  if (desired.has(String(row.approver))) desired.delete(String(row.approver));
3204
- else await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX });
4495
+ else await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX2 });
3205
4496
  }
3206
4497
  for (const approver of desired) {
3207
4498
  await this.engine.insert("sys_approval_approver", {
@@ -3210,7 +4501,7 @@ var _ApprovalService = class _ApprovalService {
3210
4501
  approver,
3211
4502
  organization_id: org,
3212
4503
  created_at: now
3213
- }, { context: SYSTEM_CTX });
4504
+ }, { context: SYSTEM_CTX2 });
3214
4505
  }
3215
4506
  }
3216
4507
  /**
@@ -3228,7 +4519,7 @@ var _ApprovalService = class _ApprovalService {
3228
4519
  fields: ["id", "pending_approvers", "organization_id"],
3229
4520
  limit: PAGE,
3230
4521
  offset,
3231
- context: SYSTEM_CTX
4522
+ context: SYSTEM_CTX2
3232
4523
  });
3233
4524
  const rows = Array.isArray(batch) ? batch : [];
3234
4525
  for (const r of rows) {
@@ -3245,7 +4536,7 @@ var _ApprovalService = class _ApprovalService {
3245
4536
  orderBy: [{ field: "created_at", order: "asc" }],
3246
4537
  limit: PAGE,
3247
4538
  offset,
3248
- context: SYSTEM_CTX
4539
+ context: SYSTEM_CTX2
3249
4540
  });
3250
4541
  const rows = Array.isArray(batch) ? batch : [];
3251
4542
  indexRows.push(...rows);
@@ -3259,7 +4550,7 @@ var _ApprovalService = class _ApprovalService {
3259
4550
  const want = desired.get(reqId);
3260
4551
  const have = seen.get(reqId) ?? seen.set(reqId, /* @__PURE__ */ new Set()).get(reqId);
3261
4552
  if (!want || !want.approvers.has(String(row.approver)) || have.has(String(row.approver))) {
3262
- await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX });
4553
+ await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX2 });
3263
4554
  deleted++;
3264
4555
  continue;
3265
4556
  }
@@ -3276,7 +4567,7 @@ var _ApprovalService = class _ApprovalService {
3276
4567
  approver,
3277
4568
  organization_id: want.org,
3278
4569
  created_at: now
3279
- }, { context: SYSTEM_CTX });
4570
+ }, { context: SYSTEM_CTX2 });
3280
4571
  inserted++;
3281
4572
  }
3282
4573
  }
@@ -3326,7 +4617,7 @@ var _ApprovalService = class _ApprovalService {
3326
4617
  where,
3327
4618
  fields: ["request_id"],
3328
4619
  limit: _ApprovalService.APPROVER_INDEX_CAP,
3329
- context: SYSTEM_CTX
4620
+ context: SYSTEM_CTX2
3330
4621
  });
3331
4622
  const list = Array.isArray(rows) ? rows : [];
3332
4623
  if (list.length >= _ApprovalService.APPROVER_INDEX_CAP) {
@@ -3337,6 +4628,88 @@ var _ApprovalService = class _ApprovalService {
3337
4628
  }
3338
4629
  return [...new Set(list.map((r) => String(r.request_id)))];
3339
4630
  }
4631
+ /**
4632
+ * The request ids this caller is a PARTICIPANT of, or `null` for a caller
4633
+ * who may see everything in scope (#3590).
4634
+ *
4635
+ * These reads deliberately run with `SYSTEM_CTX` to bypass RLS — the
4636
+ * approver-visibility rule spans several identity forms that RLS cannot model
4637
+ * cleanly, which is why it has to be expressed here. Until now only the
4638
+ * TENANT half of that rule was applied, so any authenticated user could read
4639
+ * any request in their tenant (and, once attachments derived their access
4640
+ * from the request, its files too). This adds the participant half.
4641
+ *
4642
+ * A participant is the submitter, a current approver, or someone who has
4643
+ * already acted on the request (a past approver whose slot has moved on, a
4644
+ * commenter). Admins with override authority keep the unrestricted view the
4645
+ * "all requests" console surface depends on.
4646
+ *
4647
+ * Keying on the concrete user id is sufficient rather than an approximation:
4648
+ * position/team/manager/field approvers are resolved to concrete user ids at
4649
+ * open time, and the `type:value` literal is only the fallback for a spec
4650
+ * that resolved to NOBODY — a slot no one can act on either way (`can_act`
4651
+ * is a plain membership test over the resolved ids). So this cannot hide a
4652
+ * request from someone who could actually act on it.
4653
+ */
4654
+ async visibleRequestIds(context, tenantOrg) {
4655
+ if (this.isOverrideActor(context, tenantOrg)) return null;
4656
+ const uid2 = context?.userId != null ? String(context.userId) : "";
4657
+ if (!uid2) return /* @__PURE__ */ new Set();
4658
+ const ids = /* @__PURE__ */ new Set();
4659
+ const cap = _ApprovalService.APPROVER_INDEX_CAP;
4660
+ const add = (rows, key) => {
4661
+ const list = Array.isArray(rows) ? rows : [];
4662
+ for (const r of list) if (r?.[key] != null) ids.add(String(r[key]));
4663
+ if (list.length >= cap) {
4664
+ this.logger?.warn?.(
4665
+ "[approvals] participant-visibility probe hit its window \u2014 some requests may be hidden from a legitimate participant",
4666
+ { cap, key }
4667
+ );
4668
+ }
4669
+ };
4670
+ try {
4671
+ for (const id of await this.approverRequestIds([uid2], tenantOrg) ?? []) ids.add(id);
4672
+ const orgWhere = tenantOrg ? { organization_id: tenantOrg } : {};
4673
+ add(
4674
+ await this.engine.find("sys_approval_request", {
4675
+ where: { submitter_id: uid2, ...orgWhere },
4676
+ fields: ["id"],
4677
+ limit: cap,
4678
+ context: SYSTEM_CTX2
4679
+ }),
4680
+ "id"
4681
+ );
4682
+ add(
4683
+ await this.engine.find("sys_approval_action", {
4684
+ where: { actor_id: uid2 },
4685
+ fields: ["request_id"],
4686
+ limit: cap,
4687
+ context: SYSTEM_CTX2
4688
+ }),
4689
+ "request_id"
4690
+ );
4691
+ } catch (err) {
4692
+ this.logger?.warn?.("[approvals] participant-visibility probe failed", {
4693
+ error: err instanceof Error ? err.message : String(err)
4694
+ });
4695
+ }
4696
+ return ids;
4697
+ }
4698
+ /** Intersect an existing `where.id` constraint with the participant set. */
4699
+ applyVisibility(where, visible) {
4700
+ if (!visible) return true;
4701
+ if (visible.size === 0) return false;
4702
+ let allowed = [...visible];
4703
+ const current = where.id;
4704
+ if (typeof current === "string") allowed = allowed.filter((x) => x === current);
4705
+ else if (current && typeof current === "object" && Array.isArray(current.$in)) {
4706
+ const set = new Set(current.$in.map((v) => String(v)));
4707
+ allowed = allowed.filter((x) => set.has(x));
4708
+ }
4709
+ if (allowed.length === 0) return false;
4710
+ where.id = allowed.length === 1 ? allowed[0] : { $in: allowed };
4711
+ return true;
4712
+ }
3340
4713
  async listRequests(filter, context) {
3341
4714
  const { where, tenantOrg } = this.buildRequestWhere(filter, context);
3342
4715
  const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
@@ -3345,10 +4718,11 @@ var _ApprovalService = class _ApprovalService {
3345
4718
  if (ids.length === 0) return [];
3346
4719
  where.id = ids.length === 1 ? ids[0] : { $in: ids };
3347
4720
  }
4721
+ if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return [];
3348
4722
  const findOpts = {
3349
4723
  where,
3350
4724
  orderBy: [{ field: "created_at", order: "desc" }],
3351
- context: SYSTEM_CTX
4725
+ context: SYSTEM_CTX2
3352
4726
  };
3353
4727
  if (filter?.limit != null || filter?.offset != null) {
3354
4728
  findOpts.limit = Math.min(Math.max(filter?.limit ?? 50, 1), 200);
@@ -3370,10 +4744,11 @@ var _ApprovalService = class _ApprovalService {
3370
4744
  if (ids.length === 0) return 0;
3371
4745
  where.id = ids.length === 1 ? ids[0] : { $in: ids };
3372
4746
  }
4747
+ if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return 0;
3373
4748
  const countFn = this.engine.count;
3374
4749
  if (typeof countFn === "function") {
3375
4750
  try {
3376
- const n = await countFn.call(this.engine, "sys_approval_request", { where, context: SYSTEM_CTX });
4751
+ const n = await countFn.call(this.engine, "sys_approval_request", { where, context: SYSTEM_CTX2 });
3377
4752
  if (typeof n === "number") return n;
3378
4753
  } catch {
3379
4754
  }
@@ -3382,11 +4757,28 @@ var _ApprovalService = class _ApprovalService {
3382
4757
  where,
3383
4758
  fields: ["id"],
3384
4759
  limit: ids ? Math.max(500, ids.length) : 500,
3385
- context: SYSTEM_CTX
4760
+ context: SYSTEM_CTX2
3386
4761
  });
3387
4762
  return Array.isArray(rows) ? rows.length : 0;
3388
4763
  }
4764
+ /**
4765
+ * Read the request a write path just changed, to echo back as its result.
4766
+ *
4767
+ * NOT participant-gated (#3590), deliberately: the operation authorized
4768
+ * itself by its own rule before writing, so re-asking "may you see this?"
4769
+ * for the echo answers a question that has already been settled — and would
4770
+ * answer it WRONG for a caller context that carries no `userId` (a
4771
+ * flow-driven resume, a service-to-service call), turning a successful write
4772
+ * into a `null` result. Gating belongs on the read API, not on an
4773
+ * operation's own return value.
4774
+ */
4775
+ async readBackRequest(requestId, context) {
4776
+ return this.loadRequest(requestId, context, false);
4777
+ }
3389
4778
  async getRequest(requestId, context) {
4779
+ return this.loadRequest(requestId, context, true);
4780
+ }
4781
+ async loadRequest(requestId, context, enforceVisibility) {
3390
4782
  if (!requestId) return null;
3391
4783
  const where = { id: requestId };
3392
4784
  const tenantOrg = context?.organizationId ?? context?.tenantId;
@@ -3394,9 +4786,13 @@ var _ApprovalService = class _ApprovalService {
3394
4786
  const rows = await this.engine.find("sys_approval_request", {
3395
4787
  where,
3396
4788
  limit: 1,
3397
- context: SYSTEM_CTX
4789
+ context: SYSTEM_CTX2
3398
4790
  });
3399
4791
  if (!Array.isArray(rows) || !rows[0]) return null;
4792
+ if (enforceVisibility) {
4793
+ const visible = await this.visibleRequestIds(context, tenantOrg ?? null);
4794
+ if (visible && !visible.has(String(rows[0].id))) return null;
4795
+ }
3400
4796
  const row = rowFromRequest(rows[0]);
3401
4797
  await this.enrichRows([row]);
3402
4798
  await this.attachFlowSteps(row);
@@ -3423,7 +4819,7 @@ var _ApprovalService = class _ApprovalService {
3423
4819
  const acts = await this.engine.find("sys_approval_action", {
3424
4820
  where: { request_id: row.id, step_index: 0, action: "approve" },
3425
4821
  limit: 1e3,
3426
- context: SYSTEM_CTX
4822
+ context: SYSTEM_CTX2
3427
4823
  });
3428
4824
  const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
3429
4825
  const snapshot = cfg?.__approverGroups;
@@ -3444,6 +4840,14 @@ var _ApprovalService = class _ApprovalService {
3444
4840
  });
3445
4841
  progress.got = progress.groups.filter((g) => g.satisfied).length;
3446
4842
  progress.need = progress.groups.length;
4843
+ const pendingGroups = {};
4844
+ for (const a of row.pending_approvers ?? []) {
4845
+ const named = (snapshot[a] ?? []).filter((g) => !/^#\d+$/.test(g));
4846
+ if (named.length) pendingGroups[a] = named;
4847
+ }
4848
+ if (Object.keys(pendingGroups).length) {
4849
+ row.pending_approver_groups = pendingGroups;
4850
+ }
3447
4851
  }
3448
4852
  row.decision_progress = progress;
3449
4853
  } catch {
@@ -3455,8 +4859,13 @@ var _ApprovalService = class _ApprovalService {
3455
4859
  * caller's user id is in the resolved `pending_approvers` while the request is
3456
4860
  * still `pending` (position/team/manager approvers are already resolved to
3457
4861
  * concrete user ids at open time, so a plain membership test is faithful).
3458
- * `is_submitter` is a straight owner check. System/tokenless contexts get a
3459
- * both-false block. Cheap + synchronoussafe on list reads.
4862
+ * `is_submitter` is a straight owner check. `can_override` (#3424) is true for
4863
+ * a platform/tenant admin on a PENDING request the recovery path for an
4864
+ * approval routed to an unstaffed position or to approvers who have all left;
4865
+ * clients OR it into the decision actions' `visible` gate so an admin can act
4866
+ * even when they hold no slot. System/tokenless contexts get a both-false
4867
+ * `can_act`/`is_submitter` block (system gets `can_override` too — it may act
4868
+ * on anything). Cheap + synchronous — safe on list reads.
3460
4869
  */
3461
4870
  attachViewers(rows, context) {
3462
4871
  const uid2 = context?.userId != null ? String(context.userId) : null;
@@ -3464,7 +4873,8 @@ var _ApprovalService = class _ApprovalService {
3464
4873
  const pending = row.pending_approvers ?? [];
3465
4874
  row.viewer = {
3466
4875
  can_act: row.status === "pending" && !!uid2 && pending.includes(uid2),
3467
- is_submitter: !!uid2 && row.submitter_id != null && String(row.submitter_id) === uid2
4876
+ is_submitter: !!uid2 && row.submitter_id != null && String(row.submitter_id) === uid2,
4877
+ can_override: row.status === "pending" && this.isOverrideActor(context, row.organization_id ?? null)
3468
4878
  };
3469
4879
  }
3470
4880
  }
@@ -3511,7 +4921,7 @@ var _ApprovalService = class _ApprovalService {
3511
4921
  where: { request_id: requestId },
3512
4922
  limit: 500,
3513
4923
  orderBy: [{ field: "created_at", order: "asc" }],
3514
- context: SYSTEM_CTX
4924
+ context: SYSTEM_CTX2
3515
4925
  });
3516
4926
  const actions = Array.isArray(rows) ? rows.map(rowFromAction) : [];
3517
4927
  const names = await this.resolveUserNames(
@@ -3523,14 +4933,44 @@ var _ApprovalService = class _ApprovalService {
3523
4933
  }
3524
4934
  return actions;
3525
4935
  }
4936
+ /**
4937
+ * `IFileAccessDelegate` — may this caller download a decision attachment?
4938
+ * (ADR-0104 D3 wave 2; declared by `sys_approval_action.fileAccessDelegate`.)
4939
+ *
4940
+ * A file referenced by `sys_approval_action.attachments` is owned by that
4941
+ * audit row, so the storage service would otherwise authorize the download by
4942
+ * testing whether the caller can READ the row. It cannot: `sys_approval_action`
4943
+ * is deliberately closed to ordinary approver positions, so that test denies
4944
+ * the very approver the attachment was filed for.
4945
+ *
4946
+ * The rule that actually governs seeing a decision is the one `listActions`
4947
+ * applies — can the caller see the PARENT REQUEST? — so this reuses it
4948
+ * exactly, rather than inventing a second, looser rule for the bytes. Fails
4949
+ * closed on any error.
4950
+ */
4951
+ async authorizeFileRead(actionId, context) {
4952
+ if (!actionId) return false;
4953
+ try {
4954
+ const rows = await this.engine.find("sys_approval_action", {
4955
+ where: { id: actionId },
4956
+ limit: 1,
4957
+ context: SYSTEM_CTX2
4958
+ });
4959
+ const requestId = (Array.isArray(rows) ? rows[0] : void 0)?.request_id;
4960
+ if (!requestId) return false;
4961
+ return !!await this.getRequest(String(requestId), context);
4962
+ } catch {
4963
+ return false;
4964
+ }
4965
+ }
3526
4966
  };
3527
4967
  /** Window the approver-index probe — pending queues live far below this. */
3528
4968
  _ApprovalService.APPROVER_INDEX_CAP = 1e4;
3529
4969
  var ApprovalService = _ApprovalService;
3530
4970
 
3531
4971
  // src/sys-approval-token.object.ts
3532
- var import_data5 = require("@objectstack/spec/data");
3533
- var SysApprovalToken = import_data5.ObjectSchema.create({
4972
+ var import_data6 = require("@objectstack/spec/data");
4973
+ var SysApprovalToken = import_data6.ObjectSchema.create({
3534
4974
  name: "sys_approval_token",
3535
4975
  label: "Approval Action Token",
3536
4976
  pluralLabel: "Approval Action Tokens",
@@ -3542,13 +4982,13 @@ var SysApprovalToken = import_data5.ObjectSchema.create({
3542
4982
  nameField: "id",
3543
4983
  // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
3544
4984
  fields: {
3545
- id: import_data5.Field.text({ label: "Token ID", required: true, readonly: true, group: "System" }),
3546
- organization_id: import_data5.Field.lookup("sys_organization", {
4985
+ id: import_data6.Field.text({ label: "Token ID", required: true, readonly: true, group: "System" }),
4986
+ organization_id: import_data6.Field.lookup("sys_organization", {
3547
4987
  label: "Organization",
3548
4988
  required: false,
3549
4989
  group: "System"
3550
4990
  }),
3551
- token_hash: import_data5.Field.text({
4991
+ token_hash: import_data6.Field.text({
3552
4992
  label: "Token Hash",
3553
4993
  required: true,
3554
4994
  maxLength: 100,
@@ -3556,20 +4996,20 @@ var SysApprovalToken = import_data5.ObjectSchema.create({
3556
4996
  description: "SHA-256 hex of the raw token \u2014 the raw value is never stored",
3557
4997
  group: "Token"
3558
4998
  }),
3559
- request_id: import_data5.Field.text({
4999
+ request_id: import_data6.Field.text({
3560
5000
  label: "Request",
3561
5001
  required: true,
3562
5002
  maxLength: 100,
3563
5003
  readonly: true,
3564
5004
  group: "Token"
3565
5005
  }),
3566
- action: import_data5.Field.select(["approve", "reject"], {
5006
+ action: import_data6.Field.select(["approve", "reject"], {
3567
5007
  label: "Action",
3568
5008
  required: true,
3569
5009
  readonly: true,
3570
5010
  group: "Token"
3571
5011
  }),
3572
- approver_id: import_data5.Field.text({
5012
+ approver_id: import_data6.Field.text({
3573
5013
  label: "Approver",
3574
5014
  required: true,
3575
5015
  maxLength: 200,
@@ -3577,18 +5017,18 @@ var SysApprovalToken = import_data5.ObjectSchema.create({
3577
5017
  description: "Identity the token is bound to; the decision is audited as this approver",
3578
5018
  group: "Token"
3579
5019
  }),
3580
- expires_at: import_data5.Field.datetime({
5020
+ expires_at: import_data6.Field.datetime({
3581
5021
  label: "Expires At",
3582
5022
  required: true,
3583
5023
  readonly: true,
3584
5024
  group: "Lifecycle"
3585
5025
  }),
3586
- consumed_at: import_data5.Field.datetime({
5026
+ consumed_at: import_data6.Field.datetime({
3587
5027
  label: "Consumed At",
3588
5028
  required: false,
3589
5029
  group: "Lifecycle"
3590
5030
  }),
3591
- created_at: import_data5.Field.datetime({
5031
+ created_at: import_data6.Field.datetime({
3592
5032
  label: "Created At",
3593
5033
  required: true,
3594
5034
  defaultValue: "NOW()",
@@ -3717,6 +5157,8 @@ function bindApprovalLockHook(engine, logger) {
3717
5157
  if (Array.isArray(roles) && roles.includes("admin")) return;
3718
5158
  const pending = await pendingRequestFor(engine, object, id);
3719
5159
  if (!pending) return;
5160
+ const writerRun = ctx?.provenance?.flowRunId;
5161
+ if (writerRun && pending.flow_run_id && String(writerRun) === String(pending.flow_run_id)) return;
3720
5162
  const config = parseJson2(pending.node_config_json, {});
3721
5163
  if (config?.lockRecord === false) return;
3722
5164
  const mirror = config?.approvalStatusField;
@@ -3768,7 +5210,22 @@ function unbindAllHooks(engine) {
3768
5210
 
3769
5211
  // src/approval-node.ts
3770
5212
  var import_automation2 = require("@objectstack/spec/automation");
3771
- var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
5213
+ var SYSTEM_CTX3 = { isSystem: true, positions: [], permissions: [] };
5214
+ function nestVariables(variables) {
5215
+ const vars = {};
5216
+ for (const [key, value] of variables) {
5217
+ const segs = key.split(".");
5218
+ let cursor = vars;
5219
+ for (let i = 0; i < segs.length - 1; i++) {
5220
+ if (typeof cursor[segs[i]] !== "object" || cursor[segs[i]] === null) {
5221
+ cursor[segs[i]] = {};
5222
+ }
5223
+ cursor = cursor[segs[i]];
5224
+ }
5225
+ cursor[segs[segs.length - 1]] = value;
5226
+ }
5227
+ return vars;
5228
+ }
3772
5229
  function registerApprovalNode(automation, service, logger) {
3773
5230
  automation.registerNodeExecutor({
3774
5231
  type: import_automation2.APPROVAL_NODE_TYPE,
@@ -3784,6 +5241,15 @@ function registerApprovalNode(automation, service, logger) {
3784
5241
  // Human decision: the run suspends here awaiting an external reply.
3785
5242
  supportsPause: true,
3786
5243
  isAsync: true,
5244
+ // #3801: this pause is NOT resumable through the generic run-resume
5245
+ // route. Continuing an approval is a side effect of a DECISION, and the
5246
+ // decision is the thing that must be authorized (the approver slate),
5247
+ // recorded (`sys_approval_action`) and mirrored (the status field) —
5248
+ // all of which lives in `ApprovalService.decide`. The engine now refuses
5249
+ // any resume of an approval suspension that does not carry the service's
5250
+ // in-process marker, so "decide via the approvals API, never a raw engine
5251
+ // resume" is enforced rather than merely documented.
5252
+ resumeAuthority: "service",
3787
5253
  // Publish the node's config contract (ADR-0018 §configSchema) so the
3788
5254
  // Studio flow designer renders the Approval property form from the engine
3789
5255
  // rather than a hardcoded client form — the engine owns the shape.
@@ -3817,13 +5283,28 @@ function registerApprovalNode(automation, service, logger) {
3817
5283
  nodeLabel: typeof node.label === "string" ? node.label : void 0,
3818
5284
  submitterId: context?.userId ?? null,
3819
5285
  record,
3820
- organizationId: context?.organizationId ?? context?.tenantId ?? null
5286
+ organizationId: context?.organizationId ?? context?.tenantId ?? null,
5287
+ // #3447 P2: flow variables (nested, as CEL conditions see them) — the
5288
+ // `vars.*` root for `expression` approvers; `record` above doubles as
5289
+ // their `trigger.*` snapshot root.
5290
+ variables: nestVariables(variables)
3821
5291
  }, {
3822
- ...SYSTEM_CTX2,
5292
+ ...SYSTEM_CTX3,
3823
5293
  userId: context?.userId,
3824
5294
  organizationId: context?.organizationId,
3825
5295
  tenantId: context?.tenantId
3826
5296
  });
5297
+ if ("autoApproved" in request) {
5298
+ logger?.info?.("[approvals] approval node auto-approved (empty approver slate)", {
5299
+ node: node.id,
5300
+ run: String(runId)
5301
+ });
5302
+ return {
5303
+ success: true,
5304
+ branchLabel: "approve",
5305
+ output: { decision: "approve", autoApproved: true }
5306
+ };
5307
+ }
3827
5308
  logger?.info?.("[approvals] approval node suspended run", {
3828
5309
  node: node.id,
3829
5310
  request: request.id,
@@ -3909,7 +5390,21 @@ var ApprovalsServicePlugin = class {
3909
5390
  this.service = new ApprovalService({
3910
5391
  engine,
3911
5392
  logger: ctx.logger,
3912
- publicBaseUrl: this.options.publicBaseUrl
5393
+ publicBaseUrl: this.options.publicBaseUrl,
5394
+ // [ADR-0105 D9] Cross-organization approver targeting is a `group`-posture
5395
+ // capability. Read LAZILY (not captured at start) because the tenancy
5396
+ // service resolves its posture during its own start, which may not have
5397
+ // run yet; an unresolvable posture reads as "unknown" and the guard
5398
+ // stands down rather than refusing a legitimate flow on a minimal stack.
5399
+ tenancyPosture: () => {
5400
+ try {
5401
+ const tenancy = ctx.getService("tenancy");
5402
+ const posture = tenancy?.posture;
5403
+ return typeof posture === "string" && posture ? posture : void 0;
5404
+ } catch {
5405
+ return void 0;
5406
+ }
5407
+ }
3913
5408
  });
3914
5409
  if (!this.options.disableAutoHooks) {
3915
5410
  try {
@@ -3935,12 +5430,23 @@ var ApprovalsServicePlugin = class {
3935
5430
  if (!jobs || typeof jobs.schedule !== "function" || !this.service) return;
3936
5431
  const svc = this.service;
3937
5432
  const intervalMs = this.options.escalationScanIntervalMs ?? ESCALATION_SCAN_INTERVAL_MS;
3938
- await jobs.schedule(ESCALATION_JOB_NAME, { type: "interval", intervalMs }, async () => {
3939
- await svc.runEscalations();
3940
- });
5433
+ const sweep = async () => {
5434
+ const results = await Promise.allSettled([
5435
+ svc.runEscalations(),
5436
+ svc.releaseDeadRunRequests()
5437
+ ]);
5438
+ for (const r of results) {
5439
+ if (r.status === "rejected") {
5440
+ ctx.logger.warn?.("[approvals] periodic sweep leg failed", {
5441
+ error: r.reason?.message ?? String(r.reason)
5442
+ });
5443
+ }
5444
+ }
5445
+ };
5446
+ await jobs.schedule(ESCALATION_JOB_NAME, { type: "interval", intervalMs }, sweep);
3941
5447
  this.escalationJobScheduled = true;
3942
- void svc.runEscalations().catch((err) => {
3943
- ctx.logger.warn?.("[approvals] boot escalation sweep failed", { error: err?.message });
5448
+ void sweep().catch((err) => {
5449
+ ctx.logger.warn?.("[approvals] boot sweep failed", { error: err?.message });
3944
5450
  });
3945
5451
  ctx.logger.info("ApprovalsServicePlugin: SLA escalation scan scheduled", { intervalMs });
3946
5452
  } catch {