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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.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,29 @@ var init_en_objects_generated = __esm({
159
257
  comment: {
160
258
  label: "Comment"
161
259
  },
260
+ reassign_from: {
261
+ label: "Reassigned From",
262
+ help: "User whose pending-approver slot was handed over (reassign actions only)"
263
+ },
264
+ reassign_to: {
265
+ label: "Reassigned To",
266
+ help: "User who received the pending-approver slot (reassign actions only)"
267
+ },
268
+ attachments: {
269
+ label: "Attachments",
270
+ help: "Files supporting this action \u2014 e.g. a signed contract or evidence (#3266)."
271
+ },
162
272
  created_at: {
163
273
  label: "Created At"
164
274
  }
165
275
  },
166
276
  _views: {
167
277
  recent: {
168
- label: "Recent"
278
+ label: "Recent",
279
+ emptyState: {
280
+ title: "No approval actions yet",
281
+ message: "Actions are logged automatically when approvals progress."
282
+ }
169
283
  },
170
284
  by_actor: {
171
285
  label: "By Actor"
@@ -269,7 +383,8 @@ var init_zh_CN_objects_generated = __esm({
269
383
  pending: "\u5F85\u5904\u7406",
270
384
  approved: "\u5DF2\u6279\u51C6",
271
385
  rejected: "\u5DF2\u62D2\u7EDD",
272
- recalled: "\u5DF2\u64A4\u56DE"
386
+ recalled: "\u5DF2\u64A4\u56DE",
387
+ returned: "\u5DF2\u9000\u56DE\u4FEE\u6539"
273
388
  }
274
389
  },
275
390
  current_step: {
@@ -311,7 +426,11 @@ var init_zh_CN_objects_generated = __esm({
311
426
  },
312
427
  _views: {
313
428
  my_pending: {
314
- label: "\u6211\u7684\u5F85\u529E"
429
+ label: "\u6211\u7684\u5F85\u529E",
430
+ emptyState: {
431
+ title: "\u6682\u65E0\u5F85\u529E\u5BA1\u6279",
432
+ message: "\u5168\u90E8\u5904\u7406\u5B8C\u6BD5\uFF0C\u6CA1\u6709\u7B49\u5F85\u4F60\u5BA1\u6279\u7684\u8BF7\u6C42\u3002"
433
+ }
315
434
  },
316
435
  submitted_by_me: {
317
436
  label: "\u6211\u63D0\u4EA4\u7684"
@@ -322,6 +441,92 @@ var init_zh_CN_objects_generated = __esm({
322
441
  all_requests: {
323
442
  label: "\u5168\u90E8"
324
443
  }
444
+ },
445
+ _actions: {
446
+ approval_approve: {
447
+ label: "\u901A\u8FC7",
448
+ successMessage: "\u5DF2\u901A\u8FC7\u3002",
449
+ params: {
450
+ comment: {
451
+ label: "\u5BA1\u6279\u610F\u89C1"
452
+ },
453
+ attachments: {
454
+ label: "\u9644\u4EF6"
455
+ }
456
+ }
457
+ },
458
+ approval_reject: {
459
+ label: "\u62D2\u7EDD",
460
+ confirmText: "\u62D2\u7EDD\u8BE5\u8BF7\u6C42\uFF1F\u62D2\u7EDD\u5BF9\u6240\u6709\u5BA1\u6279\u4EBA\u7ACB\u5373\u751F\u6548\u3002",
461
+ successMessage: "\u5DF2\u62D2\u7EDD\u3002",
462
+ params: {
463
+ comment: {
464
+ label: "\u5BA1\u6279\u610F\u89C1"
465
+ },
466
+ attachments: {
467
+ label: "\u9644\u4EF6"
468
+ }
469
+ }
470
+ },
471
+ approval_reassign: {
472
+ label: "\u8F6C\u7B7E",
473
+ successMessage: "\u5DF2\u8F6C\u7B7E\u3002",
474
+ params: {
475
+ to: {
476
+ label: "\u65B0\u5BA1\u6279\u4EBA",
477
+ helpText: "\u5C06\u6B64\u5BA1\u6279\u6B65\u9AA4\u8F6C\u4EA4\u7ED9\u7684\u7528\u6237"
478
+ },
479
+ comment: {
480
+ label: "\u5907\u6CE8"
481
+ }
482
+ }
483
+ },
484
+ approval_send_back: {
485
+ label: "\u9000\u56DE\u4FEE\u6539",
486
+ successMessage: "\u5DF2\u9000\u56DE\u4FEE\u6539\u3002",
487
+ params: {
488
+ comment: {
489
+ label: "\u539F\u56E0"
490
+ }
491
+ }
492
+ },
493
+ approval_request_info: {
494
+ label: "\u9000\u56DE\u8865\u5145",
495
+ successMessage: "\u5DF2\u9000\u56DE\u7ED9\u7533\u8BF7\u4EBA\u8865\u5145\u6750\u6599\u3002",
496
+ params: {
497
+ comment: {
498
+ label: "\u9700\u8981\u7533\u8BF7\u4EBA\u8865\u5145\u4EC0\u4E48\uFF1F"
499
+ }
500
+ }
501
+ },
502
+ approval_remind: {
503
+ label: "\u50AC\u529E",
504
+ successMessage: "\u5DF2\u53D1\u9001\u50AC\u529E\u3002",
505
+ params: {
506
+ comment: {
507
+ label: "\u5907\u6CE8"
508
+ }
509
+ }
510
+ },
511
+ approval_recall: {
512
+ label: "\u64A4\u56DE",
513
+ 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",
514
+ successMessage: "\u5DF2\u64A4\u56DE\u3002",
515
+ params: {
516
+ comment: {
517
+ label: "\u5907\u6CE8"
518
+ }
519
+ }
520
+ },
521
+ approval_resubmit: {
522
+ label: "\u91CD\u65B0\u63D0\u4EA4",
523
+ successMessage: "\u5DF2\u91CD\u65B0\u63D0\u4EA4\u3002",
524
+ params: {
525
+ comment: {
526
+ label: "\u4F60\u4FEE\u6539\u4E86\u4EC0\u4E48\uFF1F"
527
+ }
528
+ }
529
+ }
325
530
  }
326
531
  },
327
532
  sys_approval_action: {
@@ -353,7 +558,14 @@ var init_zh_CN_objects_generated = __esm({
353
558
  approve: "\u6279\u51C6",
354
559
  reject: "\u62D2\u7EDD",
355
560
  recall: "\u64A4\u56DE",
356
- escalate: "\u5347\u7EA7"
561
+ escalate: "\u5347\u7EA7",
562
+ reassign: "\u8F6C\u7B7E",
563
+ remind: "\u50AC\u529E",
564
+ request_info: "\u8981\u6C42\u8865\u5145\u6750\u6599",
565
+ comment: "\u8BC4\u8BBA",
566
+ revise: "\u9000\u56DE\u4FEE\u6539",
567
+ resubmit: "\u91CD\u65B0\u63D0\u4EA4",
568
+ ooo_substitute: "\u4E0D\u5728\u5C97\u6539\u6D3E"
357
569
  }
358
570
  },
359
571
  actor_id: {
@@ -362,13 +574,29 @@ var init_zh_CN_objects_generated = __esm({
362
574
  comment: {
363
575
  label: "\u8BC4\u8BBA"
364
576
  },
577
+ reassign_from: {
578
+ label: "\u8F6C\u51FA\u4EBA",
579
+ help: "\u88AB\u79FB\u4EA4\u5F85\u5BA1\u6279\u69FD\u4F4D\u7684\u7528\u6237\uFF08\u4EC5\u8F6C\u7B7E\u64CD\u4F5C\uFF09"
580
+ },
581
+ reassign_to: {
582
+ label: "\u8F6C\u5165\u4EBA",
583
+ help: "\u63A5\u6536\u5F85\u5BA1\u6279\u69FD\u4F4D\u7684\u7528\u6237\uFF08\u4EC5\u8F6C\u7B7E\u64CD\u4F5C\uFF09"
584
+ },
585
+ attachments: {
586
+ label: "\u9644\u4EF6",
587
+ 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"
588
+ },
365
589
  created_at: {
366
590
  label: "\u521B\u5EFA\u65F6\u95F4"
367
591
  }
368
592
  },
369
593
  _views: {
370
594
  recent: {
371
- label: "\u6700\u8FD1"
595
+ label: "\u6700\u8FD1",
596
+ emptyState: {
597
+ title: "\u6682\u65E0\u5BA1\u6279\u64CD\u4F5C\u8BB0\u5F55",
598
+ message: "\u5BA1\u6279\u63A8\u8FDB\u65F6\u4F1A\u81EA\u52A8\u8BB0\u5F55\u64CD\u4F5C\u3002"
599
+ }
372
600
  },
373
601
  by_actor: {
374
602
  label: "\u6309\u6267\u884C\u4EBA"
@@ -472,7 +700,8 @@ var init_ja_JP_objects_generated = __esm({
472
700
  pending: "\u4FDD\u7559\u4E2D",
473
701
  approved: "\u627F\u8A8D\u6E08\u307F",
474
702
  rejected: "\u5374\u4E0B\u6E08\u307F",
475
- recalled: "\u53D6\u308A\u6D88\u3057\u6E08\u307F"
703
+ recalled: "\u53D6\u308A\u6D88\u3057\u6E08\u307F",
704
+ returned: "\u5DEE\u3057\u623B\u3057\u6E08\u307F"
476
705
  }
477
706
  },
478
707
  current_step: {
@@ -514,7 +743,11 @@ var init_ja_JP_objects_generated = __esm({
514
743
  },
515
744
  _views: {
516
745
  my_pending: {
517
- label: "\u81EA\u5206\u306E\u4FDD\u7559\u4E2D"
746
+ label: "\u81EA\u5206\u306E\u4FDD\u7559\u4E2D",
747
+ emptyState: {
748
+ title: "\u627F\u8A8D\u5F85\u3061\u306F\u3042\u308A\u307E\u305B\u3093",
749
+ 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"
750
+ }
518
751
  },
519
752
  submitted_by_me: {
520
753
  label: "\u81EA\u5206\u304C\u9001\u4FE1"
@@ -525,6 +758,92 @@ var init_ja_JP_objects_generated = __esm({
525
758
  all_requests: {
526
759
  label: "\u3059\u3079\u3066"
527
760
  }
761
+ },
762
+ _actions: {
763
+ approval_approve: {
764
+ label: "\u627F\u8A8D",
765
+ successMessage: "\u627F\u8A8D\u3057\u307E\u3057\u305F\u3002",
766
+ params: {
767
+ comment: {
768
+ label: "\u30B3\u30E1\u30F3\u30C8"
769
+ },
770
+ attachments: {
771
+ label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB"
772
+ }
773
+ }
774
+ },
775
+ approval_reject: {
776
+ label: "\u5374\u4E0B",
777
+ 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",
778
+ successMessage: "\u5374\u4E0B\u3057\u307E\u3057\u305F\u3002",
779
+ params: {
780
+ comment: {
781
+ label: "\u30B3\u30E1\u30F3\u30C8"
782
+ },
783
+ attachments: {
784
+ label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB"
785
+ }
786
+ }
787
+ },
788
+ approval_reassign: {
789
+ label: "\u518D\u5272\u308A\u5F53\u3066",
790
+ successMessage: "\u518D\u5272\u308A\u5F53\u3066\u3057\u307E\u3057\u305F\u3002",
791
+ params: {
792
+ to: {
793
+ label: "\u65B0\u3057\u3044\u627F\u8A8D\u8005",
794
+ helpText: "\u3053\u306E\u30B9\u30C6\u30C3\u30D7\u3092\u5F15\u304D\u7D99\u3050\u30E6\u30FC\u30B6\u30FC"
795
+ },
796
+ comment: {
797
+ label: "\u30B3\u30E1\u30F3\u30C8"
798
+ }
799
+ }
800
+ },
801
+ approval_send_back: {
802
+ label: "\u5DEE\u3057\u623B\u3057",
803
+ successMessage: "\u4FEE\u6B63\u306E\u305F\u3081\u5DEE\u3057\u623B\u3057\u307E\u3057\u305F\u3002",
804
+ params: {
805
+ comment: {
806
+ label: "\u7406\u7531"
807
+ }
808
+ }
809
+ },
810
+ approval_request_info: {
811
+ label: "\u8FFD\u52A0\u60C5\u5831\u3092\u4F9D\u983C",
812
+ successMessage: "\u8FFD\u52A0\u60C5\u5831\u3092\u4F9D\u983C\u3057\u307E\u3057\u305F\u3002",
813
+ params: {
814
+ comment: {
815
+ label: "\u4F55\u304C\u5FC5\u8981\u3067\u3059\u304B\uFF1F"
816
+ }
817
+ }
818
+ },
819
+ approval_remind: {
820
+ label: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC\u9001\u4FE1",
821
+ successMessage: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC\u3092\u9001\u4FE1\u3057\u307E\u3057\u305F\u3002",
822
+ params: {
823
+ comment: {
824
+ label: "\u30E1\u30E2"
825
+ }
826
+ }
827
+ },
828
+ approval_recall: {
829
+ label: "\u53D6\u308A\u4E0B\u3052",
830
+ 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",
831
+ successMessage: "\u53D6\u308A\u4E0B\u3052\u307E\u3057\u305F\u3002",
832
+ params: {
833
+ comment: {
834
+ label: "\u30B3\u30E1\u30F3\u30C8"
835
+ }
836
+ }
837
+ },
838
+ approval_resubmit: {
839
+ label: "\u518D\u63D0\u51FA",
840
+ successMessage: "\u518D\u63D0\u51FA\u3057\u307E\u3057\u305F\u3002",
841
+ params: {
842
+ comment: {
843
+ label: "\u4F55\u3092\u5909\u66F4\u3057\u307E\u3057\u305F\u304B\uFF1F"
844
+ }
845
+ }
846
+ }
528
847
  }
529
848
  },
530
849
  sys_approval_action: {
@@ -556,7 +875,14 @@ var init_ja_JP_objects_generated = __esm({
556
875
  approve: "\u627F\u8A8D",
557
876
  reject: "\u5374\u4E0B",
558
877
  recall: "\u53D6\u6D88",
559
- escalate: "\u30A8\u30B9\u30AB\u30EC\u30FC\u30B7\u30E7\u30F3"
878
+ escalate: "\u30A8\u30B9\u30AB\u30EC\u30FC\u30B7\u30E7\u30F3",
879
+ reassign: "\u518D\u5272\u308A\u5F53\u3066",
880
+ remind: "\u30EA\u30DE\u30A4\u30F3\u30C0\u30FC",
881
+ request_info: "\u8FFD\u52A0\u60C5\u5831\u4F9D\u983C",
882
+ comment: "\u30B3\u30E1\u30F3\u30C8",
883
+ revise: "\u5DEE\u3057\u623B\u3057",
884
+ resubmit: "\u518D\u63D0\u51FA",
885
+ ooo_substitute: "\u4E0D\u5728\u6642\u4EE3\u7406"
560
886
  }
561
887
  },
562
888
  actor_id: {
@@ -565,13 +891,29 @@ var init_ja_JP_objects_generated = __esm({
565
891
  comment: {
566
892
  label: "\u30B3\u30E1\u30F3\u30C8"
567
893
  },
894
+ reassign_from: {
895
+ label: "\u5F15\u304D\u7D99\u304E\u5143",
896
+ help: "\u627F\u8A8D\u5F85\u3061\u30B9\u30ED\u30C3\u30C8\u3092\u5F15\u304D\u6E21\u3057\u305F\u30E6\u30FC\u30B6\u30FC\uFF08\u5F15\u304D\u7D99\u304E\u64CD\u4F5C\u306E\u307F\uFF09"
897
+ },
898
+ reassign_to: {
899
+ label: "\u5F15\u304D\u7D99\u304E\u5148",
900
+ help: "\u627F\u8A8D\u5F85\u3061\u30B9\u30ED\u30C3\u30C8\u3092\u53D7\u3051\u53D6\u3063\u305F\u30E6\u30FC\u30B6\u30FC\uFF08\u5F15\u304D\u7D99\u304E\u64CD\u4F5C\u306E\u307F\uFF09"
901
+ },
902
+ attachments: {
903
+ label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB",
904
+ 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"
905
+ },
568
906
  created_at: {
569
907
  label: "\u4F5C\u6210\u65E5\u6642"
570
908
  }
571
909
  },
572
910
  _views: {
573
911
  recent: {
574
- label: "\u6700\u8FD1"
912
+ label: "\u6700\u8FD1",
913
+ emptyState: {
914
+ title: "\u627F\u8A8D\u64CD\u4F5C\u306F\u307E\u3060\u3042\u308A\u307E\u305B\u3093",
915
+ message: "\u627F\u8A8D\u304C\u9032\u3080\u3068\u64CD\u4F5C\u306F\u81EA\u52D5\u7684\u306B\u8A18\u9332\u3055\u308C\u307E\u3059\u3002"
916
+ }
575
917
  },
576
918
  by_actor: {
577
919
  label: "\u64CD\u4F5C\u8005\u5225"
@@ -675,7 +1017,8 @@ var init_es_ES_objects_generated = __esm({
675
1017
  pending: "Pendiente",
676
1018
  approved: "Aprobada",
677
1019
  rejected: "Rechazada",
678
- recalled: "Retirada"
1020
+ recalled: "Retirada",
1021
+ returned: "Devuelta para revisi\xF3n"
679
1022
  }
680
1023
  },
681
1024
  current_step: {
@@ -717,7 +1060,11 @@ var init_es_ES_objects_generated = __esm({
717
1060
  },
718
1061
  _views: {
719
1062
  my_pending: {
720
- label: "Mis pendientes"
1063
+ label: "Mis pendientes",
1064
+ emptyState: {
1065
+ title: "Sin aprobaciones pendientes",
1066
+ message: "Est\xE1s al d\xEDa: nada espera tu aprobaci\xF3n."
1067
+ }
721
1068
  },
722
1069
  submitted_by_me: {
723
1070
  label: "Enviadas por m\xED"
@@ -728,6 +1075,92 @@ var init_es_ES_objects_generated = __esm({
728
1075
  all_requests: {
729
1076
  label: "Todas"
730
1077
  }
1078
+ },
1079
+ _actions: {
1080
+ approval_approve: {
1081
+ label: "Aprobar",
1082
+ successMessage: "Aprobada.",
1083
+ params: {
1084
+ comment: {
1085
+ label: "Comentario"
1086
+ },
1087
+ attachments: {
1088
+ label: "Adjuntos"
1089
+ }
1090
+ }
1091
+ },
1092
+ approval_reject: {
1093
+ label: "Rechazar",
1094
+ confirmText: "\xBFRechazar esta solicitud? Un rechazo es definitivo para todos los aprobadores.",
1095
+ successMessage: "Rechazada.",
1096
+ params: {
1097
+ comment: {
1098
+ label: "Comentario"
1099
+ },
1100
+ attachments: {
1101
+ label: "Adjuntos"
1102
+ }
1103
+ }
1104
+ },
1105
+ approval_reassign: {
1106
+ label: "Reasignar",
1107
+ successMessage: "Reasignada.",
1108
+ params: {
1109
+ to: {
1110
+ label: "Nuevo aprobador",
1111
+ helpText: "Usuario al que se entrega este paso"
1112
+ },
1113
+ comment: {
1114
+ label: "Comentario"
1115
+ }
1116
+ }
1117
+ },
1118
+ approval_send_back: {
1119
+ label: "Devolver",
1120
+ successMessage: "Devuelta para revisi\xF3n.",
1121
+ params: {
1122
+ comment: {
1123
+ label: "Motivo"
1124
+ }
1125
+ }
1126
+ },
1127
+ approval_request_info: {
1128
+ label: "Pedir informaci\xF3n",
1129
+ successMessage: "Informaci\xF3n solicitada.",
1130
+ params: {
1131
+ comment: {
1132
+ label: "\xBFQu\xE9 necesitas?"
1133
+ }
1134
+ }
1135
+ },
1136
+ approval_remind: {
1137
+ label: "Enviar recordatorio",
1138
+ successMessage: "Recordatorio enviado.",
1139
+ params: {
1140
+ comment: {
1141
+ label: "Nota"
1142
+ }
1143
+ }
1144
+ },
1145
+ approval_recall: {
1146
+ label: "Retirar",
1147
+ confirmText: "\xBFRetirar esta solicitud? Los aprobadores ya no podr\xE1n actuar sobre ella y el registro se desbloquear\xE1.",
1148
+ successMessage: "Retirada.",
1149
+ params: {
1150
+ comment: {
1151
+ label: "Comentario"
1152
+ }
1153
+ }
1154
+ },
1155
+ approval_resubmit: {
1156
+ label: "Reenviar",
1157
+ successMessage: "Reenviada.",
1158
+ params: {
1159
+ comment: {
1160
+ label: "\xBFQu\xE9 ha cambiado?"
1161
+ }
1162
+ }
1163
+ }
731
1164
  }
732
1165
  },
733
1166
  sys_approval_action: {
@@ -759,7 +1192,14 @@ var init_es_ES_objects_generated = __esm({
759
1192
  approve: "Aprobar",
760
1193
  reject: "Rechazar",
761
1194
  recall: "Retirar",
762
- escalate: "Escalar"
1195
+ escalate: "Escalar",
1196
+ reassign: "Reasignaci\xF3n",
1197
+ remind: "Recordatorio",
1198
+ request_info: "Petici\xF3n de informaci\xF3n",
1199
+ comment: "Comentario",
1200
+ revise: "Devoluci\xF3n",
1201
+ resubmit: "Reenv\xEDo",
1202
+ ooo_substitute: "Sustituci\xF3n por ausencia"
763
1203
  }
764
1204
  },
765
1205
  actor_id: {
@@ -768,13 +1208,29 @@ var init_es_ES_objects_generated = __esm({
768
1208
  comment: {
769
1209
  label: "Comentario"
770
1210
  },
1211
+ reassign_from: {
1212
+ label: "Reasignado de",
1213
+ help: "Usuario cuyo turno de aprobaci\xF3n pendiente fue traspasado (solo acciones de reasignaci\xF3n)"
1214
+ },
1215
+ reassign_to: {
1216
+ label: "Reasignado a",
1217
+ help: "Usuario que recibi\xF3 el turno de aprobaci\xF3n pendiente (solo acciones de reasignaci\xF3n)"
1218
+ },
1219
+ attachments: {
1220
+ label: "Adjuntos",
1221
+ help: "Archivos que respaldan esta acci\xF3n, p. ej. un contrato firmado o pruebas (#3266)."
1222
+ },
771
1223
  created_at: {
772
1224
  label: "Creado el"
773
1225
  }
774
1226
  },
775
1227
  _views: {
776
1228
  recent: {
777
- label: "Recientes"
1229
+ label: "Recientes",
1230
+ emptyState: {
1231
+ title: "A\xFAn no hay acciones de aprobaci\xF3n",
1232
+ message: "Las acciones se registran autom\xE1ticamente a medida que avanza la aprobaci\xF3n."
1233
+ }
778
1234
  },
779
1235
  by_actor: {
780
1236
  label: "Por actor"
@@ -874,6 +1330,7 @@ module.exports = __toCommonJS(index_exports);
874
1330
 
875
1331
  // src/sys-approval-request.object.ts
876
1332
  var import_data = require("@objectstack/spec/data");
1333
+ var import_contracts = require("@objectstack/spec/contracts");
877
1334
  var SysApprovalRequest = import_data.ObjectSchema.create({
878
1335
  name: "sys_approval_request",
879
1336
  label: "Approval Request",
@@ -972,9 +1429,10 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
972
1429
  group: "Target"
973
1430
  }),
974
1431
  status: import_data.Field.select(
975
- // Keep in sync with `ApprovalStatus` (spec/contracts). `returned` =
976
- // sent back for revision (ADR-0044) terminal for this round.
977
- ["pending", "approved", "rejected", "recalled", "returned"],
1432
+ // Spread from the contract, not re-typed (#3786). `APPROVAL_STATUSES` is
1433
+ // where the list and the reason for each entry live; `ApprovalStatus` is
1434
+ // derived from it, so this column and the contract cannot disagree.
1435
+ [...import_contracts.APPROVAL_STATUSES],
978
1436
  {
979
1437
  label: "Status",
980
1438
  required: true,
@@ -1072,13 +1530,22 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1072
1530
  // per-viewer block (#3310): approver actions on `record.viewer.can_act`
1073
1531
  // (the caller is a current pending approver — same check the service
1074
1532
  // 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.
1533
+ // submitter actions on `record.viewer.is_submitter`. The core decision levers
1534
+ // (approve/reject/reassign) additionally OR in `record.viewer.can_override`
1535
+ // (#3424) so a platform/tenant admin can rescue a request routed to an
1536
+ // unstaffed position — otherwise undecidable, locking the record forever — by
1537
+ // approving, rejecting, or reassigning it to a real approver. `viewer` is
1538
+ // attached by getRequest/listRequests; where it is absent the predicate fails
1539
+ // closed.
1077
1540
  actions: [
1078
1541
  {
1079
1542
  name: "approval_approve",
1080
1543
  label: "Approve",
1081
1544
  icon: "check-circle",
1545
+ // Primary decision — the console renders this filled/highlighted so it
1546
+ // stands out from the secondary levers in the drawer's action bar,
1547
+ // matching the mobile card hierarchy (objectui#2762 P1-5).
1548
+ variant: "primary",
1082
1549
  type: "api",
1083
1550
  method: "POST",
1084
1551
  target: "/api/v1/approvals/requests/{id}/approve",
@@ -1089,7 +1556,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1089
1556
  // string[]`; the decision route persists them on `sys_approval_action`.
1090
1557
  { name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
1091
1558
  ],
1092
- visible: "record.viewer.can_act",
1559
+ visible: "record.viewer.can_act || record.viewer.can_override",
1093
1560
  locations: ["record_section", "list_item"],
1094
1561
  successMessage: "Approved.",
1095
1562
  refreshAfter: true
@@ -1098,6 +1565,9 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1098
1565
  name: "approval_reject",
1099
1566
  label: "Reject",
1100
1567
  icon: "x-circle",
1568
+ // Destructive decision — rendered in the console's danger styling so it
1569
+ // reads as the irreversible action it is (objectui#2762 P1-5).
1570
+ variant: "danger",
1101
1571
  type: "api",
1102
1572
  method: "POST",
1103
1573
  target: "/api/v1/approvals/requests/{id}/reject",
@@ -1105,7 +1575,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1105
1575
  { name: "comment", label: "Comment", type: "textarea", required: false },
1106
1576
  { name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
1107
1577
  ],
1108
- visible: "record.viewer.can_act",
1578
+ visible: "record.viewer.can_act || record.viewer.can_override",
1109
1579
  confirmText: "Reject this request? A rejection is final for every approver.",
1110
1580
  locations: ["record_section", "list_item"],
1111
1581
  successMessage: "Rejected.",
@@ -1127,7 +1597,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1127
1597
  { field: "submitter_id", name: "to", label: "New approver", required: true, helpText: "User to hand this step to" },
1128
1598
  { name: "comment", label: "Comment", type: "textarea", required: false }
1129
1599
  ],
1130
- visible: "record.viewer.can_act",
1600
+ visible: "record.viewer.can_act || record.viewer.can_override",
1131
1601
  locations: ["record_section"],
1132
1602
  successMessage: "Reassigned.",
1133
1603
  refreshAfter: true
@@ -1231,6 +1701,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1231
1701
 
1232
1702
  // src/sys-approval-action.object.ts
1233
1703
  var import_data2 = require("@objectstack/spec/data");
1704
+ var import_contracts2 = require("@objectstack/spec/contracts");
1234
1705
  var SysApprovalAction = import_data2.ObjectSchema.create({
1235
1706
  name: "sys_approval_action",
1236
1707
  label: "Approval Action",
@@ -1244,6 +1715,14 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
1244
1715
  // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
1245
1716
  titleFormat: "{action} \xB7 {step_name}",
1246
1717
  highlightFields: ["request_id", "step_name", "action", "actor_id", "created_at"],
1718
+ // ADR-0104 D3 wave 2. `attachments` is a media field, so the files it holds
1719
+ // are OWNED by this row — and the storage service would otherwise authorize
1720
+ // their download by testing whether the caller can READ this row. It cannot:
1721
+ // this table is deliberately closed to ordinary approver positions, so that
1722
+ // test denies the very approver the attachment was filed for. The approvals
1723
+ // service already owns the rule for seeing a decision (visibility of the
1724
+ // parent request, exactly as `listActions` applies it), so it answers.
1725
+ fileAccessDelegate: "approvals",
1247
1726
  listViews: {
1248
1727
  recent: {
1249
1728
  type: "grid",
@@ -1301,12 +1780,11 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
1301
1780
  group: "Target"
1302
1781
  }),
1303
1782
  action: import_data2.Field.select(
1304
- // Keep in sync with `ApprovalActionKind` (spec/contracts). reassign /
1305
- // remind / request_info / comment are thread interactions they never
1306
- // move the flow. revise / resubmit (ADR-0044) DO move it: send back for
1307
- // revision and the later resubmission. ooo_substitute (#1322 M1) is a
1308
- // system-recorded reroute of an out-of-office approver — no flow movement.
1309
- ["submit", "approve", "reject", "recall", "escalate", "reassign", "remind", "request_info", "comment", "revise", "resubmit", "ooo_substitute"],
1783
+ // Spread from the contract, not re-typed (#3786). `APPROVAL_ACTION_KINDS`
1784
+ // is where the list and the per-kind notes live (which kinds move the flow
1785
+ // and which are thread-only); `ApprovalActionKind` is derived from it, so
1786
+ // this column and the contract cannot disagree.
1787
+ [...import_contracts2.APPROVAL_ACTION_KINDS],
1310
1788
  {
1311
1789
  label: "Action",
1312
1790
  required: true,
@@ -1319,6 +1797,23 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
1319
1797
  group: "Action"
1320
1798
  }),
1321
1799
  comment: import_data2.Field.textarea({ label: "Comment", required: false, group: "Action" }),
1800
+ // Structured hand-off parties for `action: 'reassign'` (#4365). Before
1801
+ // these existed the pair lived only inside a default free-text comment
1802
+ // ("<from_id> → <to_id>"), which no client could parse or render readably.
1803
+ // `comment` is pure user input again; timelines render "from A to B" from
1804
+ // these fields.
1805
+ reassign_from: import_data2.Field.lookup("sys_user", {
1806
+ label: "Reassigned From",
1807
+ required: false,
1808
+ group: "Action",
1809
+ description: "User whose pending-approver slot was handed over (reassign actions only)"
1810
+ }),
1811
+ reassign_to: import_data2.Field.lookup("sys_user", {
1812
+ label: "Reassigned To",
1813
+ required: false,
1814
+ group: "Action",
1815
+ description: "User who received the pending-approver slot (reassign actions only)"
1816
+ }),
1322
1817
  attachments: import_data2.Field.file({
1323
1818
  label: "Attachments",
1324
1819
  required: false,
@@ -1495,23 +1990,167 @@ var SysApprovalDelegation = import_data4.ObjectSchema.create({
1495
1990
  trackHistory: true,
1496
1991
  searchable: true,
1497
1992
  apiEnabled: true,
1498
- apiMethods: ["get", "list", "create", "update", "delete"],
1499
- trash: true,
1500
- mru: false
1993
+ // `bulk` = the batch shape of the verbs above; the gate is `bulk ∧ child`
1994
+ // (#3391 P1), so omitting it 405s /batch and the *Many routes (#3026).
1995
+ apiMethods: ["get", "list", "create", "update", "delete", "bulk"]
1501
1996
  }
1502
1997
  });
1503
1998
 
1504
1999
  // src/approval-service.ts
1505
2000
  var import_node_crypto = require("crypto");
1506
2001
  var import_automation = require("@objectstack/spec/automation");
2002
+ var import_formula = require("@objectstack/formula");
2003
+ var import_types = require("@objectstack/types");
2004
+ var import_identity = require("@objectstack/spec/identity");
2005
+ var import_contracts3 = require("@objectstack/spec/contracts");
2006
+ var import_data5 = require("@objectstack/spec/data");
1507
2007
  var import_core = require("@objectstack/core");
2008
+
2009
+ // src/approver-org-scope.ts
2010
+ var MAX_ORG_DEPTH = 32;
2011
+ var SYSTEM_CTX = { isSystem: true };
2012
+ function fail(message) {
2013
+ throw new Error(`VALIDATION_FAILED: ${message}`);
2014
+ }
2015
+ async function findOrg(engine, where) {
2016
+ try {
2017
+ const rows = await engine.find("sys_organization", {
2018
+ where,
2019
+ fields: ["id", "slug", "parent_organization_id"],
2020
+ limit: 1,
2021
+ context: SYSTEM_CTX
2022
+ });
2023
+ return Array.isArray(rows) && rows[0] ? rows[0] : null;
2024
+ } catch {
2025
+ return null;
2026
+ }
2027
+ }
2028
+ async function ancestorChain(engine, orgId) {
2029
+ const chain = [];
2030
+ const seen = /* @__PURE__ */ new Set();
2031
+ let cursor = orgId;
2032
+ for (let depth = 0; cursor && depth < MAX_ORG_DEPTH; depth++) {
2033
+ if (seen.has(cursor)) break;
2034
+ seen.add(cursor);
2035
+ chain.push(cursor);
2036
+ const row = await findOrg(engine, { id: cursor });
2037
+ const parent = row?.parent_organization_id;
2038
+ cursor = parent ? String(parent) : null;
2039
+ }
2040
+ return chain;
2041
+ }
2042
+ async function resolveApproverDirectoryOrg(deps, declaration, requestOrgId, approverType, isOrgScopedType) {
2043
+ const declared = typeof declaration === "string" ? declaration.trim() : "";
2044
+ if (!declared) return requestOrgId;
2045
+ if (!isOrgScopedType) {
2046
+ fail(
2047
+ `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)`
2048
+ );
2049
+ }
2050
+ const posture = deps.posture?.();
2051
+ if (posture && posture !== "group") {
2052
+ fail(
2053
+ `cross-organization approver targeting ('organization: ${declared}') requires the 'group' tenancy posture; this deployment resolves '${posture}' (ADR-0105 D9)`
2054
+ );
2055
+ }
2056
+ const requestOrg = requestOrgId ? String(requestOrgId) : "";
2057
+ if (!requestOrg) {
2058
+ fail(
2059
+ `'organization: ${declared}' cannot be resolved for a request that carries no organization \u2014 cross-organization targeting needs an organization to resolve from`
2060
+ );
2061
+ }
2062
+ const chain = await ancestorChain(deps.engine, requestOrg);
2063
+ if (declared === "$root") {
2064
+ const root = chain[chain.length - 1];
2065
+ if (root === requestOrg && chain.length === 1) {
2066
+ fail(
2067
+ `'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`
2068
+ );
2069
+ }
2070
+ return root;
2071
+ }
2072
+ if (declared === "$parent") {
2073
+ const parent = chain[1];
2074
+ if (!parent) {
2075
+ fail(
2076
+ `'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')`
2077
+ );
2078
+ }
2079
+ return parent;
2080
+ }
2081
+ const target = await findOrg(deps.engine, { slug: declared });
2082
+ if (!target?.id) {
2083
+ fail(
2084
+ `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)`
2085
+ );
2086
+ }
2087
+ const targetId = String(target.id);
2088
+ if (targetId === requestOrg) return targetId;
2089
+ const targetChain = await ancestorChain(deps.engine, targetId);
2090
+ const targetRoot = targetChain[targetChain.length - 1];
2091
+ const requestRoot = chain[chain.length - 1];
2092
+ if (!targetRoot || !requestRoot || targetRoot !== requestRoot) {
2093
+ fail(
2094
+ `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)`
2095
+ );
2096
+ }
2097
+ return targetId;
2098
+ }
2099
+ async function filterApproversWhoCanRead(deps, userIds, requestOrgId, context) {
2100
+ const requestOrg = requestOrgId ? String(requestOrgId) : "";
2101
+ if (!requestOrg || userIds.length === 0) return userIds;
2102
+ let members = [];
2103
+ try {
2104
+ members = await deps.engine.find("sys_member", {
2105
+ where: { organization_id: requestOrg, user_id: { $in: userIds } },
2106
+ fields: ["user_id"],
2107
+ limit: 1e4,
2108
+ context: SYSTEM_CTX
2109
+ });
2110
+ } catch {
2111
+ return userIds;
2112
+ }
2113
+ const canRead = new Set(
2114
+ (members ?? []).map((m) => String(m?.user_id ?? "")).filter(Boolean)
2115
+ );
2116
+ const dropped = userIds.filter((u) => !canRead.has(u));
2117
+ if (dropped.length === 0) return userIds;
2118
+ deps.logger?.warn?.(
2119
+ `[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.`,
2120
+ { dropped, requestOrganizationId: requestOrg, directoryOrganizationId: context.directoryOrgId }
2121
+ );
2122
+ return userIds.filter((u) => canRead.has(u));
2123
+ }
2124
+
2125
+ // src/approval-service.ts
1508
2126
  var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
1509
2127
  var ESCALATION_JOB_NAME = "approvals-sla-escalation";
1510
2128
  var ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1e3;
1511
2129
  var SLA_ACTOR_ID = "system:sla";
2130
+ var DEAD_RUN_ACTOR_ID = "system:dead-run";
2131
+ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
2132
+ "completed",
2133
+ "failed",
2134
+ "cancelled",
2135
+ "timed_out"
2136
+ ]);
1512
2137
  var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
1513
- var SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
2138
+ var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
2139
+ function actingUserId(context) {
2140
+ const userId = context?.userId;
2141
+ return typeof userId === "string" && userId ? userId : null;
2142
+ }
1514
2143
  var OOO_MAX_CHAIN = 8;
2144
+ var GRAPH_APPROVER_TYPES = /* @__PURE__ */ new Set([
2145
+ "team",
2146
+ "department",
2147
+ "business_unit",
2148
+ "bu",
2149
+ "position",
2150
+ "org_membership_level",
2151
+ "manager"
2152
+ ]);
2153
+ var APPROVER_EXPRESSION_ROOTS = /* @__PURE__ */ new Set(["current", "trigger", "vars"]);
1515
2154
  function uid(prefix) {
1516
2155
  const g = globalThis;
1517
2156
  if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
@@ -1533,6 +2172,12 @@ function csvSplit(raw) {
1533
2172
  if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
1534
2173
  return String(raw).split(",").map((s) => s.trim()).filter(Boolean);
1535
2174
  }
2175
+ function isBlankDecisionOutput(value) {
2176
+ if (value === void 0 || value === null) return true;
2177
+ if (typeof value === "string") return value.trim() === "";
2178
+ if (Array.isArray(value)) return value.filter((v) => v !== null && v !== void 0 && String(v).trim() !== "").length === 0;
2179
+ return false;
2180
+ }
1536
2181
  function prettifyMachineName(raw) {
1537
2182
  if (!raw) return void 0;
1538
2183
  const base = String(raw).replace(/^flow:/, "").trim();
@@ -1565,7 +2210,26 @@ function rowFromRequest(row) {
1565
2210
  step_label: cfg?.__nodeLabel ?? prettifyMachineName(row.current_step),
1566
2211
  sla_due_at: slaDueAt(row.created_at, cfg),
1567
2212
  // ADR-0044 revision round (rides the config snapshot; absent ⇒ round 1).
1568
- round: typeof cfg?.__round === "number" ? cfg.__round : void 0
2213
+ round: typeof cfg?.__round === "number" ? cfg.__round : void 0,
2214
+ // objectui#2902: the node's record-lock policy. The lock is enforced
2215
+ // server-side in `lifecycle-hooks.ts` off THIS SAME snapshot with the
2216
+ // same `!== false` default, so the flag a client renders and the rule the
2217
+ // server applies can never drift. Without it a console can only see
2218
+ // "a pending request exists" and has to assume the record is locked —
2219
+ // which mislabels every `lockRecord: false` node as locked and hides an
2220
+ // edit the server would have accepted.
2221
+ lock_record: cfg?.lockRecord !== false,
2222
+ // #3447 P2: the node's author-declared decision outputs, surfaced so a
2223
+ // decision UI can render input fields for them and POST `outputs` on
2224
+ // approve/reject. Per-request (each node declares its own), which is why
2225
+ // this rides the row instead of the static action params. Two shapes for
2226
+ // version skew: `decision_outputs` stays the bare KEY list an older
2227
+ // console renders as text inputs; `decision_output_defs` carries the
2228
+ // normalized typed declarations a picker-aware console prefers.
2229
+ ...(() => {
2230
+ const defs = (0, import_automation.normalizeDecisionOutputs)(cfg?.decisionOutputs);
2231
+ return defs.length ? { decision_outputs: defs.map((d) => d.key), decision_output_defs: defs } : {};
2232
+ })()
1569
2233
  };
1570
2234
  }
1571
2235
  function slaDueAt(createdAt, cfg) {
@@ -1575,7 +2239,29 @@ function slaDueAt(createdAt, cfg) {
1575
2239
  if (Number.isNaN(t)) return void 0;
1576
2240
  return new Date(t + hours * 36e5).toISOString();
1577
2241
  }
2242
+ function normalizeActionAttachment(entry) {
2243
+ if (entry == null) return void 0;
2244
+ if (typeof entry === "string") {
2245
+ const id = entry.trim();
2246
+ if (!id) return void 0;
2247
+ return (0, import_data5.isFileIdToken)(id) ? { id } : { id, url: id };
2248
+ }
2249
+ if (typeof entry === "object") {
2250
+ const id = entry.id ?? entry.file_id;
2251
+ if (id == null || String(id) === "") return void 0;
2252
+ const mimeType = entry.mimeType ?? entry.mime_type;
2253
+ return {
2254
+ id: String(id),
2255
+ name: typeof entry.name === "string" ? entry.name : void 0,
2256
+ url: typeof entry.url === "string" ? entry.url : void 0,
2257
+ mimeType: typeof mimeType === "string" ? mimeType : void 0,
2258
+ size: typeof entry.size === "number" ? entry.size : void 0
2259
+ };
2260
+ }
2261
+ return void 0;
2262
+ }
1578
2263
  function rowFromAction(row) {
2264
+ const attachments = Array.isArray(row.attachments) ? row.attachments.map(normalizeActionAttachment).filter((a) => !!a) : [];
1579
2265
  return {
1580
2266
  id: String(row.id),
1581
2267
  request_id: String(row.request_id),
@@ -1584,10 +2270,12 @@ function rowFromAction(row) {
1584
2270
  action: row.action,
1585
2271
  actor_id: row.actor_id ?? void 0,
1586
2272
  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,
2273
+ // Structured reassign hand-off parties (#4365).
2274
+ reassign_from: row.reassign_from ?? void 0,
2275
+ reassign_to: row.reassign_to ?? void 0,
2276
+ // Decision attachments (#3266): rich descriptors carrying the display name +
2277
+ // download URL, so consumers label/open them without reading `sys_file`.
2278
+ attachments: attachments.length ? attachments : void 0,
1591
2279
  created_at: row.created_at ?? void 0
1592
2280
  };
1593
2281
  }
@@ -1599,6 +2287,33 @@ var _ApprovalService = class _ApprovalService {
1599
2287
  this.automation = opts.automation;
1600
2288
  this.messaging = opts.messaging;
1601
2289
  this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
2290
+ this.tenancyPosture = opts.tenancyPosture;
2291
+ }
2292
+ /** Attach (or replace) the ADR-0105 D9 posture provider. */
2293
+ attachTenancyPosture(provider) {
2294
+ this.tenancyPosture = provider;
2295
+ }
2296
+ /** Deps bundle for the ADR-0105 D9 org-scope helpers. */
2297
+ get orgScopeDeps() {
2298
+ return {
2299
+ engine: this.engine,
2300
+ posture: this.tenancyPosture,
2301
+ logger: this.logger
2302
+ };
2303
+ }
2304
+ /**
2305
+ * [ADR-0105 D9] Which organization's directory resolves ONE approver spec.
2306
+ * Absent declaration ⇒ the request's own organization (unchanged, no reads).
2307
+ */
2308
+ async directoryOrgFor(a, requestOrgId) {
2309
+ const rawType = String(a?.type ?? "");
2310
+ return resolveApproverDirectoryOrg(
2311
+ this.orgScopeDeps,
2312
+ a?.organization,
2313
+ requestOrgId,
2314
+ rawType,
2315
+ (0, import_automation.approverTypeIsOrgScoped)(rawType)
2316
+ );
1602
2317
  }
1603
2318
  /** Attach (or replace) the automation surface used to resume flow runs. */
1604
2319
  attachAutomation(automation) {
@@ -1633,13 +2348,108 @@ var _ApprovalService = class _ApprovalService {
1633
2348
  const rows = await this.engine.find("sys_approval_request", {
1634
2349
  where: { id: requestId },
1635
2350
  limit: 1,
1636
- context: SYSTEM_CTX
2351
+ context: SYSTEM_CTX2
1637
2352
  });
1638
2353
  const raw = Array.isArray(rows) ? rows[0] : null;
1639
2354
  if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
1640
2355
  if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
1641
2356
  return raw;
1642
2357
  }
2358
+ /**
2359
+ * Privileged-override gate (#3424). A stuck approval — one routed to a
2360
+ * position/team with no holders (so its `pending_approvers` is only an
2361
+ * unresolvable `type:value` literal) or to approvers who have all since left —
2362
+ * is otherwise undecidable: no concrete user is in the slate, so every normal
2363
+ * `decide` / `reassign` / `recall` is `FORBIDDEN` and (with `lockRecord`) the
2364
+ * record stays locked forever with no in-product recovery. A platform or
2365
+ * tenant admin — the same posture the engine's superuser bypass already
2366
+ * trusts — may always act on a PENDING request to release it: approve, reject,
2367
+ * reassign it to a real approver, or recall it.
2368
+ *
2369
+ * A platform admin crosses the tenant wall (matching the unscoped
2370
+ * `admin_full_access` evidence); a tenant admin may override only within their
2371
+ * own org (or an org-less request). A system context always passes. Signals are
2372
+ * read defensively off the resolved exec context (`permissions` / `positions` /
2373
+ * the derived `posture`, ADR-0095) so any transport that resolves through the
2374
+ * shared authz resolver lights this up without extra wiring.
2375
+ */
2376
+ isOverrideActor(context, requestOrg) {
2377
+ if (!context) return false;
2378
+ if (context.isSystem) return true;
2379
+ const perms = Array.isArray(context.permissions) ? context.permissions : [];
2380
+ const positions = Array.isArray(context.positions) ? context.positions : [];
2381
+ const posture = context.posture;
2382
+ const isPlatformAdmin = posture === "PLATFORM_ADMIN" || perms.includes(import_identity.ADMIN_FULL_ACCESS) || positions.includes(import_identity.BUILTIN_IDENTITY_PLATFORM_ADMIN);
2383
+ if (isPlatformAdmin) return true;
2384
+ 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);
2385
+ if (!isTenantAdmin) return false;
2386
+ const actorTenant = context.tenantId ?? context.organizationId ?? null;
2387
+ return requestOrg == null || actorTenant != null && String(requestOrg) === String(actorTenant);
2388
+ }
2389
+ /**
2390
+ * Pin the acting identity to the AUTHENTICATED CALLER (#3800).
2391
+ *
2392
+ * Every public entrypoint accepts an `actorId`, and the REST routes fill it
2393
+ * from `body.actorId ?? body.actor_id ?? context.userId` — so before this
2394
+ * gate the body won. The authorization checks downstream all read that value
2395
+ * (`pending_approvers.includes(input.actorId)`, `submitter_id === actorId`),
2396
+ * which made the body-supplied string not merely the audit label but the key
2397
+ * that opens the door: any authenticated user could name a pending approver
2398
+ * and have that approver's decision recorded and the owning flow resumed.
2399
+ * #3783 drew this line for the data-write identity ({@link actingUserId});
2400
+ * this closes the authorization half.
2401
+ *
2402
+ * A caller may still name an identity OTHER than their bare user id, because
2403
+ * a slot legitimately can be keyed by one: `resolveApproverSpec` stores the
2404
+ * `type:value` literal when a graph lookup yields nothing, and an author may
2405
+ * write an email as a `user` approver. So the rule is not "actorId must equal
2406
+ * userId" — it is **"actorId must be an identity the SERVER can prove belongs
2407
+ * to the caller"**. Anything else is `FORBIDDEN`.
2408
+ *
2409
+ * A system context is exempt and keeps its explicit actor: the SLA sweep
2410
+ * passes the reserved {@link SLA_ACTOR_ID} sentinel, and the ADR-0043 action
2411
+ * link passes the approver its single-use token is cryptographically bound to
2412
+ * (having also put them on the context). Those are the only two callers that
2413
+ * hold a trustworthy actor with no session behind them.
2414
+ *
2415
+ * A caller with NO identity at all cannot act. Belt-and-suspenders: the REST
2416
+ * anonymous-deny now denies every anonymous request (#3963), but this service
2417
+ * must not rely on a caller upstream — an anonymous actor could otherwise
2418
+ * decide approvals outright by naming one.
2419
+ */
2420
+ async resolveActor(actorId, context) {
2421
+ if (context?.isSystem) {
2422
+ if (!actorId) throw new Error("VALIDATION_FAILED: actorId is required");
2423
+ return actorId;
2424
+ }
2425
+ const uid2 = actingUserId(context);
2426
+ if (!uid2) {
2427
+ throw new Error("FORBIDDEN: an approval action requires an authenticated caller");
2428
+ }
2429
+ if (!actorId || String(actorId) === uid2) return uid2;
2430
+ const named = String(actorId);
2431
+ for (const position of context.positions ?? []) {
2432
+ if (named === `position:${position}` || named === `role:${position}`) return named;
2433
+ }
2434
+ if (named.includes("@") && await this.callerHasEmail(uid2, named)) return named;
2435
+ throw new Error(
2436
+ `FORBIDDEN: cannot act as '${named}' \u2014 an approval action is recorded against the authenticated caller`
2437
+ );
2438
+ }
2439
+ /** Does `userId`'s own account carry `email`? (Slots keyed by email, #3800.) */
2440
+ async callerHasEmail(userId, email) {
2441
+ try {
2442
+ const rows = await this.engine.find("sys_user", {
2443
+ where: { id: userId },
2444
+ limit: 1,
2445
+ context: SYSTEM_CTX2
2446
+ });
2447
+ const row = Array.isArray(rows) ? rows[0] : null;
2448
+ return !!row?.email && String(row.email).toLowerCase() === email.toLowerCase();
2449
+ } catch {
2450
+ return false;
2451
+ }
2452
+ }
1643
2453
  /**
1644
2454
  * Expand the approvers on an Approval node into user IDs by querying the
1645
2455
  * graph tables for `team:` / `department:` / `position:` /
@@ -1674,7 +2484,7 @@ var _ApprovalService = class _ApprovalService {
1674
2484
  * `opts.substitutions` collector to record the hops for audit + notification.
1675
2485
  */
1676
2486
  async expandApprovers(step, record, organizationId, opts) {
1677
- var _a;
2487
+ var _a, _b, _c;
1678
2488
  if (!step || !Array.isArray(step.approvers)) return [];
1679
2489
  const now = opts?.now ?? this.clock.now().getTime();
1680
2490
  const out = [];
@@ -1682,12 +2492,34 @@ var _ApprovalService = class _ApprovalService {
1682
2492
  for (let idx = 0; idx < specs.length; idx++) {
1683
2493
  const a = specs[idx];
1684
2494
  if (!a) continue;
1685
- const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
1686
2495
  const groupKey = a.group != null && String(a.group) !== "" ? String(a.group) : `#${idx}`;
2496
+ if ((0, import_automation.canonicalApproverType)(String(a.type)) === "expression") {
2497
+ const resolved = await this.resolveExpressionApprovers(
2498
+ a,
2499
+ record,
2500
+ organizationId,
2501
+ now,
2502
+ opts?.substitutions,
2503
+ opts?.exprCtx
2504
+ );
2505
+ if (opts?.resolvedFrom) opts.resolvedFrom[`expression#${idx}`] = resolved.raw;
2506
+ for (const entry of resolved.slots) {
2507
+ if (!entry.id) continue;
2508
+ out.push(entry.id);
2509
+ if (opts?.groups) {
2510
+ ((_a = opts.groups)[_b = entry.id] ?? (_a[_b] = [])).push(entry.subGroup ? `${groupKey}:${entry.subGroup}` : groupKey);
2511
+ }
2512
+ }
2513
+ continue;
2514
+ }
2515
+ if (opts?.resolvedFrom && (0, import_automation.canonicalApproverType)(String(a.type)) === "field" && a.value != null) {
2516
+ opts.resolvedFrom[`field:${a.value}`] = record?.[a.value] ?? null;
2517
+ }
2518
+ const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
1687
2519
  for (const u of ids) {
1688
2520
  if (!u) continue;
1689
2521
  out.push(u);
1690
- if (opts?.groups) ((_a = opts.groups)[u] ?? (_a[u] = [])).push(groupKey);
2522
+ if (opts?.groups) ((_c = opts.groups)[u] ?? (_c[u] = [])).push(groupKey);
1691
2523
  }
1692
2524
  }
1693
2525
  return out.filter(Boolean);
@@ -1708,24 +2540,35 @@ var _ApprovalService = class _ApprovalService {
1708
2540
  { deprecated: a.type, canonical: type }
1709
2541
  );
1710
2542
  }
2543
+ const directoryOrg = await this.directoryOrgFor(a, organizationId);
2544
+ const crossOrg = directoryOrg !== organizationId;
1711
2545
  if (type === "user") {
1712
2546
  return this.applyOooDelegation(String(a.value), now, organizationId, substitutions);
1713
2547
  }
1714
2548
  if (type === "field" && record) {
1715
- return this.applyOooDelegation(String(record[a.value] ?? ""), now, organizationId, substitutions);
2549
+ const out = [];
2550
+ for (const id of csvSplit(record[a.value])) {
2551
+ out.push(...await this.applyOooDelegation(id, now, organizationId, substitutions));
2552
+ }
2553
+ return out;
1716
2554
  }
2555
+ const bounded = async (users) => crossOrg ? filterApproversWhoCanRead(this.orgScopeDeps, users, organizationId, {
2556
+ approverType: type,
2557
+ value: a.value != null ? String(a.value) : void 0,
2558
+ directoryOrgId: directoryOrg
2559
+ }) : users;
1717
2560
  try {
1718
2561
  if (type === "team") {
1719
2562
  const users = await this.expandTeamUsers(String(a.value));
1720
2563
  if (users.length) return users;
1721
2564
  } else if (type === "department" || type === "business_unit" || type === "bu") {
1722
- const users = await this.expandBusinessUnitUsers(String(a.value), organizationId);
2565
+ const users = await bounded(await this.expandBusinessUnitUsers(String(a.value), directoryOrg));
1723
2566
  if (users.length) return users;
1724
2567
  } else if (type === "position") {
1725
- const users = await this.expandPositionUsers(String(a.value), organizationId);
2568
+ const users = await bounded(await this.expandPositionUsers(String(a.value), directoryOrg));
1726
2569
  if (users.length) return users;
1727
2570
  } else if (type === "org_membership_level") {
1728
- const users = await this.expandMembershipTierUsers(String(a.value), organizationId);
2571
+ const users = await bounded(await this.expandMembershipTierUsers(String(a.value), directoryOrg));
1729
2572
  if (users.length) return users;
1730
2573
  } else if (type === "manager" && record) {
1731
2574
  const subject = record[a.value] ?? record.owner_id;
@@ -1736,33 +2579,171 @@ var _ApprovalService = class _ApprovalService {
1736
2579
  }
1737
2580
  } catch {
1738
2581
  }
2582
+ if (type === "queue") {
2583
+ this.logger?.warn?.(
2584
+ `[approvals] approver type 'queue' is not implemented \u2014 the slot resolves to nobody (#3508)`,
2585
+ { value: a.value }
2586
+ );
2587
+ } else if (GRAPH_APPROVER_TYPES.has(type)) {
2588
+ this.logger?.warn?.(
2589
+ `[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)`,
2590
+ { type, value: a.value, organizationId: organizationId ?? null }
2591
+ );
2592
+ }
1739
2593
  return [`${a.type}:${a.value}`];
1740
2594
  }
2595
+ /**
2596
+ * Resolve an `expression` approver (#3447 P2): evaluate its CEL source at
2597
+ * node entry against the three explicit roots — `current` (live record),
2598
+ * `trigger` (submit snapshot), `vars` (flow variables) — then expand the
2599
+ * result into people per `resolveAs`.
2600
+ *
2601
+ * Every failure here THROWS (config/parse errors as `VALIDATION_FAILED`,
2602
+ * evaluation faults as `EXPRESSION_FAILED`) so the approval node fails
2603
+ * loudly instead of opening a request routed to nobody — an approver
2604
+ * expression that cannot run is a routing bug, never "condition not met".
2605
+ * Error messages carry the correct spelling because their primary reader is
2606
+ * the AI author fixing the flow on the next validate pass.
2607
+ *
2608
+ * Returns `slots` (approver id + optional per_group sub-key) and `raw` (the
2609
+ * expression's own values, pre-expansion) for the `__resolvedFrom` audit.
2610
+ */
2611
+ async resolveExpressionApprovers(a, liveRecord, organizationId, now, substitutions, exprCtx) {
2612
+ const source = String(a.value ?? "").trim();
2613
+ if (!source) {
2614
+ throw new Error("VALIDATION_FAILED: expression approver has an empty expression");
2615
+ }
2616
+ const parsed = (0, import_formula.collectCelRootIdentifiers)(source);
2617
+ if (!parsed.ok) {
2618
+ throw new Error(`VALIDATION_FAILED: expression approver does not parse: ${parsed.error} \u2014 source: \`${source}\``);
2619
+ }
2620
+ const illegal = parsed.roots.filter((r) => !APPROVER_EXPRESSION_ROOTS.has(r));
2621
+ if (illegal.length) {
2622
+ 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)?`;
2623
+ throw new Error(
2624
+ `VALIDATION_FAILED: expression approver references \`${illegal.join("`, `")}\` \u2014 only \`current.*\`, \`trigger.*\` and \`vars.*\` are available; ${hint}. Source: \`${source}\``
2625
+ );
2626
+ }
2627
+ const result = import_formula.ExpressionEngine.evaluate(
2628
+ { dialect: "cel", source },
2629
+ { extra: { current: liveRecord ?? {}, trigger: exprCtx?.trigger ?? {}, vars: exprCtx?.vars ?? {} } }
2630
+ );
2631
+ if (!result.ok) {
2632
+ throw new Error(
2633
+ `EXPRESSION_FAILED: expression approver failed to evaluate (${result.error.kind}): ${result.error.message} \u2014 source: \`${source}\``
2634
+ );
2635
+ }
2636
+ const value = result.value;
2637
+ let raw;
2638
+ if (value == null || value === "") {
2639
+ raw = [];
2640
+ } else if (typeof value === "string") {
2641
+ raw = csvSplit(value);
2642
+ } else if (Array.isArray(value)) {
2643
+ const bad = value.find((v) => v != null && typeof v !== "string" && typeof v !== "number");
2644
+ if (bad !== void 0) {
2645
+ throw new Error(
2646
+ `EXPRESSION_FAILED: expression approver must yield ids (string / CSV / string array), got an array containing ${typeof bad} \u2014 source: \`${source}\``
2647
+ );
2648
+ }
2649
+ raw = value.map((v) => String(v ?? "").trim()).filter(Boolean);
2650
+ } else {
2651
+ throw new Error(
2652
+ `EXPRESSION_FAILED: expression approver must yield ids (string / CSV / string array), got ${typeof value} \u2014 source: \`${source}\``
2653
+ );
2654
+ }
2655
+ const resolveAs = String(a.resolveAs ?? "user");
2656
+ if (resolveAs === "user") {
2657
+ const slots2 = [];
2658
+ for (const id of raw) {
2659
+ for (const routed of await this.applyOooDelegation(id, now, organizationId, substitutions)) {
2660
+ slots2.push({ id: routed });
2661
+ }
2662
+ }
2663
+ return { slots: slots2, raw };
2664
+ }
2665
+ const directoryOrg = await this.directoryOrgFor(a, organizationId);
2666
+ const crossOrg = directoryOrg !== organizationId;
2667
+ const slots = [];
2668
+ for (const key of raw) {
2669
+ let users = [];
2670
+ try {
2671
+ if (resolveAs === "department") users = await this.expandBusinessUnitUsers(key, directoryOrg);
2672
+ else if (resolveAs === "position") users = await this.expandPositionUsers(key, directoryOrg);
2673
+ else if (resolveAs === "team") users = await this.expandTeamUsers(key);
2674
+ else {
2675
+ throw new Error(
2676
+ `VALIDATION_FAILED: expression approver has unknown resolveAs '${resolveAs}' \u2014 use 'user', 'department', 'position', or 'team'`
2677
+ );
2678
+ }
2679
+ } catch (err) {
2680
+ if (String(err?.message ?? "").startsWith("VALIDATION_FAILED")) throw err;
2681
+ users = [];
2682
+ }
2683
+ if (crossOrg && users.length) {
2684
+ users = await filterApproversWhoCanRead(this.orgScopeDeps, users, organizationId, {
2685
+ approverType: resolveAs,
2686
+ value: key,
2687
+ directoryOrgId: directoryOrg
2688
+ });
2689
+ }
2690
+ if (!users.length) {
2691
+ slots.push({ id: `${resolveAs}:${key}`, subGroup: key });
2692
+ continue;
2693
+ }
2694
+ for (const u of users) slots.push({ id: u, subGroup: key });
2695
+ }
2696
+ return { slots, raw };
2697
+ }
1741
2698
  /** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
1742
2699
  async expandTeamUsers(teamId) {
1743
2700
  if (!teamId) return [];
1744
2701
  let rows = [];
1745
2702
  try {
1746
2703
  rows = await this.engine.find("sys_team_member", {
1747
- filter: { team_id: teamId },
2704
+ where: { team_id: teamId },
1748
2705
  fields: ["user_id"],
1749
2706
  limit: 1e4,
1750
- context: SYSTEM_CTX
2707
+ context: SYSTEM_CTX2
1751
2708
  });
1752
2709
  } catch {
1753
2710
  rows = [];
1754
2711
  }
1755
2712
  return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
1756
2713
  }
2714
+ /**
2715
+ * Tenant scope for a `sys_business_unit` read that may legitimately be
2716
+ * env-wide (#3807).
2717
+ *
2718
+ * `organization_id = null` on a platform object means "owned by no
2719
+ * organization" — a row written by a seed, the file layer, or bootstrap,
2720
+ * i.e. before (or outside) any org exists. A strict
2721
+ * `organization_id = <request org>` equality made every such row invisible:
2722
+ * the seed check below found nothing, the whole expansion returned `[]`, and
2723
+ * the approver fell back to the dead `department:<id>` literal that routes to
2724
+ * nobody. That is not an edge case — an app's org tree is normally seeded
2725
+ * (a seed cannot know the org id the runtime mints at boot) while the
2726
+ * approval request always carries one, so EVERY department approver a
2727
+ * designer could pick resolved to nobody.
2728
+ *
2729
+ * Widen to "this org ∪ env-wide", the same predicate `sys_metadata`'s
2730
+ * pending-draft listing settled on for the identical reason. Another org's
2731
+ * unit still fails the match, so the wall between two organizations is
2732
+ * unchanged — only rows belonging to no org at all become visible.
2733
+ */
2734
+ businessUnitOrgScope(filter, organizationId) {
2735
+ if (!organizationId) return filter;
2736
+ return { ...filter, $or: [{ organization_id: organizationId }, { organization_id: null }] };
2737
+ }
1757
2738
  /** Recursive department — walks `sys_business_unit.parent_business_unit_id`. */
1758
2739
  async expandBusinessUnitUsers(businessUnitId, organizationId) {
1759
2740
  if (!businessUnitId) return [];
1760
2741
  try {
1761
2742
  const seed = await this.engine.find("sys_business_unit", {
1762
- filter: organizationId ? { id: businessUnitId, organization_id: organizationId } : { id: businessUnitId },
2743
+ where: this.businessUnitOrgScope({ id: businessUnitId }, organizationId),
1763
2744
  fields: ["id", "active"],
1764
2745
  limit: 1,
1765
- context: SYSTEM_CTX
2746
+ context: SYSTEM_CTX2
1766
2747
  });
1767
2748
  const seedRow = Array.isArray(seed) ? seed[0] : null;
1768
2749
  if (!seedRow || seedRow.active === false) return [];
@@ -1775,9 +2756,11 @@ var _ApprovalService = class _ApprovalService {
1775
2756
  const parent = queue.shift();
1776
2757
  let kids = [];
1777
2758
  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 });
2759
+ const filter = this.businessUnitOrgScope(
2760
+ { parent_business_unit_id: parent, active: { $ne: false } },
2761
+ organizationId
2762
+ );
2763
+ kids = await this.engine.find("sys_business_unit", { filter, fields: ["id"], limit: 1e3, context: SYSTEM_CTX2 });
1781
2764
  } catch {
1782
2765
  kids = [];
1783
2766
  }
@@ -1792,10 +2775,10 @@ var _ApprovalService = class _ApprovalService {
1792
2775
  let rows = [];
1793
2776
  try {
1794
2777
  rows = await this.engine.find("sys_business_unit_member", {
1795
- filter: { business_unit_id: { $in: Array.from(seen) } },
2778
+ where: { business_unit_id: { $in: Array.from(seen) } },
1796
2779
  fields: ["user_id"],
1797
2780
  limit: 1e4,
1798
- context: SYSTEM_CTX
2781
+ context: SYSTEM_CTX2
1799
2782
  });
1800
2783
  } catch {
1801
2784
  rows = [];
@@ -1820,7 +2803,7 @@ var _ApprovalService = class _ApprovalService {
1820
2803
  filter,
1821
2804
  fields: ["user_id"],
1822
2805
  limit: 1e4,
1823
- context: SYSTEM_CTX
2806
+ context: SYSTEM_CTX2
1824
2807
  });
1825
2808
  for (const r of rows ?? []) {
1826
2809
  const uid2 = String(r.user_id ?? "");
@@ -1843,7 +2826,7 @@ var _ApprovalService = class _ApprovalService {
1843
2826
  if (organizationId) filter.organization_id = organizationId;
1844
2827
  let rows = [];
1845
2828
  try {
1846
- rows = await this.engine.find("sys_member", { filter, fields: ["user_id"], limit: 1e4, context: SYSTEM_CTX });
2829
+ rows = await this.engine.find("sys_member", { filter, fields: ["user_id"], limit: 1e4, context: SYSTEM_CTX2 });
1847
2830
  } catch {
1848
2831
  rows = [];
1849
2832
  }
@@ -1852,10 +2835,10 @@ var _ApprovalService = class _ApprovalService {
1852
2835
  async lookupManager(userId) {
1853
2836
  try {
1854
2837
  const rows = await this.engine.find("sys_user", {
1855
- filter: { id: userId },
2838
+ where: { id: userId },
1856
2839
  fields: ["id", "manager_id"],
1857
2840
  limit: 1,
1858
- context: SYSTEM_CTX
2841
+ context: SYSTEM_CTX2
1859
2842
  });
1860
2843
  const row = Array.isArray(rows) ? rows[0] : null;
1861
2844
  return row?.manager_id ? String(row.manager_id) : null;
@@ -1902,10 +2885,10 @@ var _ApprovalService = class _ApprovalService {
1902
2885
  let rows = [];
1903
2886
  try {
1904
2887
  rows = await this.engine.find("sys_approval_delegation", {
1905
- filter: { delegator_id: delegatorId },
2888
+ where: { delegator_id: delegatorId },
1906
2889
  fields: ["id", "delegator_id", "delegate_id", "valid_from", "valid_until", "reason", "organization_id"],
1907
2890
  limit: 50,
1908
- context: SYSTEM_CTX
2891
+ context: SYSTEM_CTX2
1909
2892
  });
1910
2893
  } catch {
1911
2894
  return null;
@@ -1919,14 +2902,86 @@ var _ApprovalService = class _ApprovalService {
1919
2902
  });
1920
2903
  return active[0];
1921
2904
  }
1922
- /** Mirror a request status onto a business-object field, if configured. */
1923
- async mirrorStatusField(object, recordId, field, status) {
2905
+ /**
2906
+ * Mirror a request status onto a business-object field, if configured.
2907
+ *
2908
+ * **Elevated, but not anonymous (#3783).** The write stays `isSystem`: the
2909
+ * record is normally LOCKED while its approval is live and the submitter
2910
+ * cannot edit it, so only a platform write can land the status — that is what
2911
+ * the lock hook's system exemption (`lifecycle-hooks.ts`) is for. What it must
2912
+ * NOT do is throw away *who* caused the transition. Every status below is
2913
+ * something a specific human just did — a submitter submitting or recalling,
2914
+ * an approver deciding or sending back — and this write is what fires the
2915
+ * target object's record-change flows. With no `userId` on it those cascades
2916
+ * inherit no trigger user, and since #3760 a `runAs:'user'` run with no trigger
2917
+ * user has its data ops REFUSED — so "when the invoice is approved, do X", the
2918
+ * most natural approvals automation there is, had to declare `runAs:'system'`
2919
+ * and take blanket elevation for a case where a perfectly good scoped identity
2920
+ * existed. Re-attaching the actor lets those cascades run as the deciding user
2921
+ * with RLS enforced. Same shape the approval node already uses when it calls
2922
+ * into this service (`approval-node.ts`).
2923
+ *
2924
+ * `actorId` is `null` for the genuinely machine-driven transitions (the SLA
2925
+ * escalation's auto-decision, the dead-run sweep). There is no human to name
2926
+ * there, and naming a sentinel would put a non-user in `updated_by` and in
2927
+ * every downstream flow's identity. Those cascades stay user-less — a flow
2928
+ * that wants to react to them still has to declare `runAs:'system'`, which is
2929
+ * the honest answer rather than an oversight.
2930
+ *
2931
+ * Deliberately carries `userId` ONLY, not the request's org. On an
2932
+ * ExecutionContext `tenantId` is a driver-scoping knob, not attribution
2933
+ * (`buildDriverOptions` turns it into a tenant predicate on the update), so
2934
+ * passing it would newly org-scope this write and silently no-op the mirror on
2935
+ * a record whose org differs from the request's — while buying nothing: the
2936
+ * automation engine back-fills the run's `tenantId` from the resolved user's
2937
+ * own grants.
2938
+ */
2939
+ async mirrorStatusField(object, recordId, field, status, actorId) {
1924
2940
  try {
1925
- await this.engine.update(object, { id: recordId, [field]: status }, { context: SYSTEM_CTX });
2941
+ const context = actorId ? { ...SYSTEM_CTX2, userId: actorId } : SYSTEM_CTX2;
2942
+ await this.engine.update(object, { id: recordId, [field]: status }, { context });
1926
2943
  } catch (err) {
1927
2944
  this.logger?.warn?.(`[approvals] mirrorStatusField failed: ${err?.message ?? err}`);
1928
2945
  }
1929
2946
  }
2947
+ /**
2948
+ * Re-read a business record's CURRENT state by id so approver resolution binds
2949
+ * to live data at node entry, not the trigger snapshot the flow froze into
2950
+ * `$record` at submit time (#3447). A `field` / `manager` approver names *who*
2951
+ * decides, and an earlier node — or the approver of an earlier step — may have
2952
+ * written that routing field after submit (e.g. a lead reviewer picking which
2953
+ * departments co-review). Graph approvers (team / position / …) already query
2954
+ * live; this brings the in-record types into line.
2955
+ *
2956
+ * Read under system identity: approver routing is a platform concern and the
2957
+ * record is the flow's own subject, so the submitter's RLS/FLS must not narrow
2958
+ * it. Degrades to `fallback` (the snapshot) when the record can't be re-read —
2959
+ * hard-deleted between submit and node entry, or an object whose backend can't
2960
+ * serve a point read — warning rather than throwing so a transient miss can't
2961
+ * wedge an approval. That "warn but proceed" stance matches the
2962
+ * no-concrete-approver guard (#3424) and the "record is gone" enrichment path
2963
+ * that already falls back to the payload snapshot.
2964
+ */
2965
+ async loadLiveRecord(object, recordId, fallback) {
2966
+ try {
2967
+ const rows = await this.engine.find(object, {
2968
+ where: { id: recordId },
2969
+ limit: 1,
2970
+ context: SYSTEM_CTX2
2971
+ });
2972
+ const live = Array.isArray(rows) ? rows[0] : rows;
2973
+ if (live) return live;
2974
+ this.logger?.warn?.(
2975
+ `[approvals] live record ${object}/${recordId} not found at node entry \u2014 resolving approvers against the trigger snapshot (#3447 fallback).`,
2976
+ { object, recordId }
2977
+ );
2978
+ } catch (err) {
2979
+ this.logger?.warn?.(
2980
+ `[approvals] live record re-read failed for ${object}/${recordId}: ${err?.message ?? err} \u2014 resolving approvers against the trigger snapshot (#3447 fallback).`
2981
+ );
2982
+ }
2983
+ return fallback ?? {};
2984
+ }
1930
2985
  // ── ADR-0019: Approval-as-flow-node ──────────────────────────
1931
2986
  //
1932
2987
  // A flow's Approval node opens a request via `openNodeRequest` (carrying its
@@ -1938,6 +2993,10 @@ var _ApprovalService = class _ApprovalService {
1938
2993
  * Open a pending approval request on behalf of a flow's Approval node. The
1939
2994
  * node config (approvers / behavior / status field) is snapshotted on the row
1940
2995
  * so a decision can be made without any process to resolve against.
2996
+ *
2997
+ * #3447 P2: may instead return an {@link ApprovalNodeAutoOutcome} — no
2998
+ * request opened — when the slate resolves empty and the node's
2999
+ * `onEmptyApprovers` policy is `auto_approve`.
1941
3000
  */
1942
3001
  async openNodeRequest(input, context) {
1943
3002
  if (!input.object) throw new Error("VALIDATION_FAILED: object is required");
@@ -1946,7 +3005,7 @@ var _ApprovalService = class _ApprovalService {
1946
3005
  const existing = await this.engine.find("sys_approval_request", {
1947
3006
  where: { object_name: input.object, record_id: input.recordId, status: "pending" },
1948
3007
  limit: 1,
1949
- context: SYSTEM_CTX
3008
+ context: SYSTEM_CTX2
1950
3009
  });
1951
3010
  if (Array.isArray(existing) && existing[0]) {
1952
3011
  throw new Error(`DUPLICATE_REQUEST: a pending approval already exists for ${input.object}/${input.recordId}`);
@@ -1955,26 +3014,56 @@ var _ApprovalService = class _ApprovalService {
1955
3014
  const nowDate = this.clock.now();
1956
3015
  const substitutions = [];
1957
3016
  const groups = {};
3017
+ const liveRecord = await this.loadLiveRecord(input.object, input.recordId, input.record);
3018
+ const resolvedFrom = {};
1958
3019
  const approvers = await this.expandApprovers(
1959
3020
  { approvers: input.config.approvers },
1960
- input.record,
3021
+ liveRecord,
1961
3022
  ctxOrg,
1962
- { now: nowDate.getTime(), substitutions, groups }
3023
+ {
3024
+ now: nowDate.getTime(),
3025
+ substitutions,
3026
+ groups,
3027
+ exprCtx: { trigger: input.record ?? null, vars: input.variables ?? null },
3028
+ resolvedFrom
3029
+ }
1963
3030
  );
3031
+ if (!approvers.some((a) => a && !a.includes(":"))) {
3032
+ const emptyPolicy = input.config.onEmptyApprovers ?? "admin_rescue";
3033
+ if (emptyPolicy === "fail") {
3034
+ throw new Error(
3035
+ `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.`
3036
+ );
3037
+ }
3038
+ if (emptyPolicy === "auto_approve") {
3039
+ this.logger?.warn?.(
3040
+ `[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).`,
3041
+ { object: input.object, recordId: input.recordId, node: input.nodeId, resolved: approvers }
3042
+ );
3043
+ return { autoApproved: true, reason: "empty_approvers" };
3044
+ }
3045
+ this.logger?.warn?.(
3046
+ `[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.`,
3047
+ { object: input.object, recordId: input.recordId, node: input.nodeId, resolved: approvers }
3048
+ );
3049
+ }
1964
3050
  const now = nowDate.toISOString();
1965
3051
  const id = uid("areq");
1966
3052
  const processName = `flow:${input.flowName ?? input.nodeId}`;
1967
3053
  const configSnapshot = { ...input.config };
1968
3054
  if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
1969
3055
  if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
1970
- if (input.config.behavior === "quorum" || input.config.behavior === "per_group") {
3056
+ if (input.config.behavior && input.config.behavior !== "first_response") {
1971
3057
  configSnapshot.__approverGroups = groups;
1972
3058
  }
3059
+ if (Object.keys(resolvedFrom).length) {
3060
+ configSnapshot.__resolvedFrom = resolvedFrom;
3061
+ }
1973
3062
  try {
1974
3063
  const prior = await this.engine.find("sys_approval_request", {
1975
3064
  where: { flow_run_id: input.runId, flow_node_id: input.nodeId },
1976
3065
  limit: 500,
1977
- context: SYSTEM_CTX
3066
+ context: SYSTEM_CTX2
1978
3067
  });
1979
3068
  const n = Array.isArray(prior) ? prior.length : 0;
1980
3069
  if (n > 0) configSnapshot.__round = n + 1;
@@ -1998,7 +3087,7 @@ var _ApprovalService = class _ApprovalService {
1998
3087
  created_at: now,
1999
3088
  updated_at: now
2000
3089
  };
2001
- await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX });
3090
+ await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX2 });
2002
3091
  await this.syncApproverIndex(id, approvers, ctxOrg, now);
2003
3092
  await this.engine.insert("sys_approval_action", {
2004
3093
  id: uid("aact"),
@@ -2010,7 +3099,7 @@ var _ApprovalService = class _ApprovalService {
2010
3099
  actor_id: input.submitterId ?? context.userId ?? null,
2011
3100
  comment: null,
2012
3101
  created_at: now
2013
- }, { context: SYSTEM_CTX });
3102
+ }, { context: SYSTEM_CTX2 });
2014
3103
  for (const sub of substitutions) {
2015
3104
  await this.engine.insert("sys_approval_action", {
2016
3105
  id: uid("aact"),
@@ -2022,7 +3111,7 @@ var _ApprovalService = class _ApprovalService {
2022
3111
  actor_id: null,
2023
3112
  comment: `${sub.from} \u2192 ${sub.to}${sub.reason ? ` \u2014 ${sub.reason}` : ""}`,
2024
3113
  created_at: now
2025
- }, { context: SYSTEM_CTX });
3114
+ }, { context: SYSTEM_CTX2 });
2026
3115
  await this.notify({
2027
3116
  topic: "approval.ooo_substituted",
2028
3117
  audience: [sub.to],
@@ -2047,7 +3136,13 @@ var _ApprovalService = class _ApprovalService {
2047
3136
  });
2048
3137
  }
2049
3138
  if (input.config.approvalStatusField) {
2050
- await this.mirrorStatusField(input.object, input.recordId, input.config.approvalStatusField, "pending");
3139
+ await this.mirrorStatusField(
3140
+ input.object,
3141
+ input.recordId,
3142
+ input.config.approvalStatusField,
3143
+ "pending",
3144
+ row.submitter_id ?? null
3145
+ );
2051
3146
  }
2052
3147
  return rowFromRequest(row);
2053
3148
  }
@@ -2091,27 +3186,61 @@ var _ApprovalService = class _ApprovalService {
2091
3186
  */
2092
3187
  async decideNode(requestId, input, context) {
2093
3188
  if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
2094
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3189
+ const actorId = await this.resolveActor(input?.actorId, context);
2095
3190
  if (input.decision !== "approve" && input.decision !== "reject") {
2096
3191
  throw new Error("VALIDATION_FAILED: decision must be approve|reject");
2097
3192
  }
2098
3193
  const rawRows = await this.engine.find("sys_approval_request", {
2099
3194
  where: { id: requestId },
2100
3195
  limit: 1,
2101
- context: SYSTEM_CTX
3196
+ context: SYSTEM_CTX2
2102
3197
  });
2103
3198
  const raw = Array.isArray(rawRows) ? rawRows[0] : null;
2104
3199
  if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
2105
3200
  if (raw.status !== "pending") throw new Error(`INVALID_STATE: request is ${raw.status}`);
2106
3201
  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`);
3202
+ const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
3203
+ const isSlotHolder = pendingApprovers.includes(actorId);
3204
+ if (!isSlotHolder && !isOverride) {
3205
+ throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
2109
3206
  }
2110
3207
  const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
2111
3208
  const org = raw.organization_id ?? null;
2112
3209
  const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
2113
3210
  const runId = raw.flow_run_id ?? null;
2114
3211
  const now = this.clock.now().toISOString();
3212
+ const outputKeys = input.outputs ? Object.keys(input.outputs) : [];
3213
+ let acceptedOutputs;
3214
+ const declaredDefs = (0, import_automation.normalizeDecisionOutputs)(config.decisionOutputs);
3215
+ if (outputKeys.length) {
3216
+ const declared = declaredDefs.map((d) => d.key);
3217
+ if (!declared.length) {
3218
+ throw new Error(
3219
+ `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.`
3220
+ );
3221
+ }
3222
+ const reserved = outputKeys.filter((k) => k === "decision" || k === "requestId");
3223
+ if (reserved.length) {
3224
+ throw new Error(
3225
+ `VALIDATION_FAILED: decision output key(s) \`${reserved.join("`, `")}\` are reserved by the resume envelope \u2014 pick different names.`
3226
+ );
3227
+ }
3228
+ const undeclared = outputKeys.filter((k) => !declared.includes(k));
3229
+ if (undeclared.length) {
3230
+ throw new Error(
3231
+ `VALIDATION_FAILED: decision output key(s) \`${undeclared.join("`, `")}\` are not declared on this node \u2014 declared keys: ${declared.map((k) => `'${k}'`).join(", ") || "(none)"}.`
3232
+ );
3233
+ }
3234
+ acceptedOutputs = { ...input.outputs };
3235
+ }
3236
+ if (input.decision === "approve") {
3237
+ const missing = declaredDefs.filter((d) => d.required === true && isBlankDecisionOutput(input.outputs?.[d.key])).map((d) => d.key);
3238
+ if (missing.length) {
3239
+ throw new Error(
3240
+ `VALIDATION_FAILED: decision output(s) \`${missing.join("`, `")}\` are required to approve this request \u2014 open the approval and fill them in before approving.`
3241
+ );
3242
+ }
3243
+ }
2115
3244
  await this.engine.insert("sys_approval_action", {
2116
3245
  id: uid("aact"),
2117
3246
  request_id: requestId,
@@ -2119,23 +3248,23 @@ var _ApprovalService = class _ApprovalService {
2119
3248
  step_name: nodeId,
2120
3249
  step_index: 0,
2121
3250
  action: input.decision,
2122
- actor_id: input.actorId,
3251
+ actor_id: actorId,
2123
3252
  comment: input.comment ?? null,
2124
3253
  attachments: input.attachments?.length ? input.attachments : null,
2125
3254
  created_at: now
2126
- }, { context: SYSTEM_CTX });
3255
+ }, { context: SYSTEM_CTX2 });
2127
3256
  const behavior = config.behavior ?? "first_response";
2128
- if (input.decision === "approve" && behavior !== "first_response") {
3257
+ if (input.decision === "approve" && behavior !== "first_response" && isSlotHolder) {
2129
3258
  const acts = await this.engine.find("sys_approval_action", {
2130
3259
  where: { request_id: requestId, step_index: 0, action: "approve" },
2131
3260
  limit: 1e3,
2132
- context: SYSTEM_CTX
3261
+ context: SYSTEM_CTX2
2133
3262
  });
2134
3263
  const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
2135
3264
  const snapshotGroups = config.__approverGroups;
2136
3265
  let original;
2137
3266
  let groupMap;
2138
- if (snapshotGroups && (behavior === "quorum" || behavior === "per_group")) {
3267
+ if (snapshotGroups) {
2139
3268
  groupMap = snapshotGroups;
2140
3269
  original = Object.keys(snapshotGroups);
2141
3270
  } else {
@@ -2151,27 +3280,65 @@ var _ApprovalService = class _ApprovalService {
2151
3280
  await this.engine.update("sys_approval_request", {
2152
3281
  id: requestId,
2153
3282
  pending_approvers: stillPending.join(","),
2154
- updated_at: now
2155
- }, { context: SYSTEM_CTX });
3283
+ updated_at: now,
3284
+ // #3447 P2: a mid-tally approval may carry outputs too (unanimous /
3285
+ // per_group co-sign, each approver contributing their declared keys)
3286
+ // — accumulate them on the snapshot so the FINALIZING decision hands
3287
+ // the merged set to the flow.
3288
+ ...acceptedOutputs ? {
3289
+ node_config_json: JSON.stringify({
3290
+ ...config,
3291
+ __decisionOutputs: { ...config.__decisionOutputs ?? {}, ...acceptedOutputs }
3292
+ })
3293
+ } : {}
3294
+ }, { context: SYSTEM_CTX2 });
2156
3295
  await this.syncApproverIndex(requestId, stillPending, org, now);
2157
- const fresh2 = await this.getRequest(requestId, context);
3296
+ const fresh2 = await this.readBackRequest(requestId, context);
2158
3297
  return { request: fresh2, runId, nodeId, finalized: false, decision: input.decision };
2159
3298
  }
2160
3299
  }
2161
3300
  const finalStatus = input.decision === "approve" ? "approved" : "rejected";
3301
+ const mergedOutputs = acceptedOutputs || config.__decisionOutputs ? { ...config.__decisionOutputs ?? {}, ...acceptedOutputs ?? {} } : void 0;
2162
3302
  await this.engine.update("sys_approval_request", {
2163
3303
  id: requestId,
2164
3304
  status: finalStatus,
2165
3305
  pending_approvers: null,
2166
3306
  completed_at: now,
2167
- updated_at: now
2168
- }, { context: SYSTEM_CTX });
3307
+ updated_at: now,
3308
+ ...mergedOutputs ? {
3309
+ node_config_json: JSON.stringify({ ...config, __decisionOutputs: mergedOutputs })
3310
+ } : {}
3311
+ }, { context: SYSTEM_CTX2 });
2169
3312
  await this.syncApproverIndex(requestId, [], org, now);
2170
3313
  if (config.approvalStatusField) {
2171
- await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, finalStatus);
3314
+ await this.mirrorStatusField(
3315
+ raw.object_name,
3316
+ raw.record_id,
3317
+ config.approvalStatusField,
3318
+ finalStatus,
3319
+ actingUserId(context)
3320
+ );
2172
3321
  }
2173
- const fresh = await this.getRequest(requestId, context);
2174
- return { request: fresh, runId, nodeId, finalized: true, decision: input.decision };
3322
+ const fresh = await this.readBackRequest(requestId, context);
3323
+ return { request: fresh, runId, nodeId, finalized: true, decision: input.decision, outputs: mergedOutputs };
3324
+ }
3325
+ /**
3326
+ * Continue the owning flow run after an outcome this service has already
3327
+ * authorized and written down (#3801).
3328
+ *
3329
+ * The `approval` node declares `resumeAuthority: 'service'`, so the engine
3330
+ * refuses any resume of an approval suspension that does not carry
3331
+ * {@link RESUME_AUTHORITY_SERVICE}. Every approvals-side resume goes through
3332
+ * here so the marker is stamped in ONE place — a new outcome path cannot
3333
+ * quietly ship a resume that the gate then rejects at runtime, and nothing
3334
+ * in this file hands the marker to a caller-supplied signal.
3335
+ *
3336
+ * Callers still guard on `typeof this.automation?.resume === 'function'`
3337
+ * (approvals runs fine with no automation attached) and keep their own
3338
+ * try/catch, because what a failed resume means differs per path.
3339
+ */
3340
+ async serviceResume(runId, signal) {
3341
+ await this.automation.resume(runId, { ...signal, [import_contracts3.RESUME_AUTHORITY_SERVICE]: true });
2175
3342
  }
2176
3343
  /**
2177
3344
  * Public contract entrypoint (ADR-0019). Records a decision on a node-driven
@@ -2184,9 +3351,14 @@ var _ApprovalService = class _ApprovalService {
2184
3351
  if (result.finalized && result.runId && typeof this.automation?.resume === "function") {
2185
3352
  const branchLabel = result.decision === "approve" ? import_automation.APPROVAL_BRANCH_LABELS.approve : import_automation.APPROVAL_BRANCH_LABELS.reject;
2186
3353
  try {
2187
- await this.automation.resume(result.runId, {
3354
+ await this.serviceResume(result.runId, {
2188
3355
  branchLabel,
2189
- output: { decision: result.decision, requestId }
3356
+ // #3447 P2: accepted decision outputs ride the resume envelope and
3357
+ // land as `<nodeId>.<key>` flow variables — a later approval node's
3358
+ // `expression` approver reads them as `vars.<nodeId>.<key>`.
3359
+ // Reserved keys are spread LAST so no output can shadow them (the
3360
+ // whitelist already rejects them; this is defense in depth).
3361
+ output: { ...result.outputs ?? {}, decision: result.decision, requestId }
2190
3362
  });
2191
3363
  resumed = true;
2192
3364
  } catch (err) {
@@ -2220,11 +3392,11 @@ var _ApprovalService = class _ApprovalService {
2220
3392
  */
2221
3393
  async recall(requestId, input, context) {
2222
3394
  if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
2223
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3395
+ const actorId = await this.resolveActor(input?.actorId, context);
2224
3396
  const rawRows = await this.engine.find("sys_approval_request", {
2225
3397
  where: { id: requestId },
2226
3398
  limit: 1,
2227
- context: SYSTEM_CTX
3399
+ context: SYSTEM_CTX2
2228
3400
  });
2229
3401
  const raw = Array.isArray(rawRows) ? rawRows[0] : null;
2230
3402
  if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
@@ -2232,7 +3404,7 @@ var _ApprovalService = class _ApprovalService {
2232
3404
  if (raw.status !== "pending" && !inReviseWindow) {
2233
3405
  throw new Error(`INVALID_STATE: request is ${raw.status}`);
2234
3406
  }
2235
- if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
3407
+ if (!this.isOverrideActor(context, raw.organization_id ?? null) && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
2236
3408
  throw new Error(`FORBIDDEN: only the submitter may recall this request`);
2237
3409
  }
2238
3410
  if (inReviseWindow) await this.assertLatestForRun(raw);
@@ -2248,20 +3420,26 @@ var _ApprovalService = class _ApprovalService {
2248
3420
  step_name: nodeId,
2249
3421
  step_index: 0,
2250
3422
  action: "recall",
2251
- actor_id: input.actorId,
3423
+ actor_id: actorId,
2252
3424
  comment: input.comment ?? null,
2253
3425
  created_at: now
2254
- }, { context: SYSTEM_CTX });
3426
+ }, { context: SYSTEM_CTX2 });
2255
3427
  await this.engine.update("sys_approval_request", {
2256
3428
  id: requestId,
2257
3429
  status: "recalled",
2258
3430
  pending_approvers: null,
2259
3431
  completed_at: now,
2260
3432
  updated_at: now
2261
- }, { context: SYSTEM_CTX });
3433
+ }, { context: SYSTEM_CTX2 });
2262
3434
  await this.syncApproverIndex(requestId, [], org, now);
2263
3435
  if (config.approvalStatusField) {
2264
- await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, "recalled");
3436
+ await this.mirrorStatusField(
3437
+ raw.object_name,
3438
+ raw.record_id,
3439
+ config.approvalStatusField,
3440
+ "recalled",
3441
+ actingUserId(context)
3442
+ );
2265
3443
  }
2266
3444
  let resumed = false;
2267
3445
  if (inReviseWindow) {
@@ -2278,7 +3456,7 @@ var _ApprovalService = class _ApprovalService {
2278
3456
  }
2279
3457
  } else if (runId && typeof this.automation?.resume === "function") {
2280
3458
  try {
2281
- await this.automation.resume(runId, {
3459
+ await this.serviceResume(runId, {
2282
3460
  branchLabel: import_automation.APPROVAL_BRANCH_LABELS.reject,
2283
3461
  output: { decision: "recall", requestId }
2284
3462
  });
@@ -2291,7 +3469,7 @@ var _ApprovalService = class _ApprovalService {
2291
3469
  });
2292
3470
  }
2293
3471
  }
2294
- const fresh = await this.getRequest(requestId, context);
3472
+ const fresh = await this.readBackRequest(requestId, context);
2295
3473
  return { request: fresh, runId, resumed };
2296
3474
  }
2297
3475
  // ── Send back for revision / resubmit (ADR-0044) ─────────────
@@ -2309,11 +3487,11 @@ var _ApprovalService = class _ApprovalService {
2309
3487
  * `output.autoRejected = true`) so instances cannot orbit forever.
2310
3488
  */
2311
3489
  async sendBack(requestId, input, context) {
2312
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3490
+ const actorId = await this.resolveActor(input?.actorId, context);
2313
3491
  const raw = await this.loadPendingRow(requestId);
2314
3492
  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`);
3493
+ if (!context.isSystem && !pending.includes(actorId)) {
3494
+ throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
2317
3495
  }
2318
3496
  const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
2319
3497
  const org = raw.organization_id ?? null;
@@ -2327,7 +3505,7 @@ var _ApprovalService = class _ApprovalService {
2327
3505
  const siblings = await this.engine.find("sys_approval_request", {
2328
3506
  where: { flow_run_id: runId, flow_node_id: nodeId, status: "returned" },
2329
3507
  limit: 500,
2330
- context: SYSTEM_CTX
3508
+ context: SYSTEM_CTX2
2331
3509
  });
2332
3510
  priorSendBacks = Array.isArray(siblings) ? siblings.length : 0;
2333
3511
  }
@@ -2338,10 +3516,10 @@ var _ApprovalService = class _ApprovalService {
2338
3516
  step_name: nodeId,
2339
3517
  step_index: 0,
2340
3518
  action: "revise",
2341
- actor_id: input.actorId,
3519
+ actor_id: actorId,
2342
3520
  comment: input.comment ?? null,
2343
3521
  created_at: now
2344
- }, { context: SYSTEM_CTX });
3522
+ }, { context: SYSTEM_CTX2 });
2345
3523
  if (priorSendBacks >= maxRevisions) {
2346
3524
  await this.engine.insert("sys_approval_action", {
2347
3525
  id: uid("aact"),
@@ -2350,25 +3528,31 @@ var _ApprovalService = class _ApprovalService {
2350
3528
  step_name: nodeId,
2351
3529
  step_index: 0,
2352
3530
  action: "reject",
2353
- actor_id: input.actorId,
3531
+ actor_id: actorId,
2354
3532
  comment: `Auto-rejected: revision limit (${maxRevisions}) exceeded`,
2355
3533
  created_at: now
2356
- }, { context: SYSTEM_CTX });
3534
+ }, { context: SYSTEM_CTX2 });
2357
3535
  await this.engine.update("sys_approval_request", {
2358
3536
  id: requestId,
2359
3537
  status: "rejected",
2360
3538
  pending_approvers: null,
2361
3539
  completed_at: now,
2362
3540
  updated_at: now
2363
- }, { context: SYSTEM_CTX });
3541
+ }, { context: SYSTEM_CTX2 });
2364
3542
  await this.syncApproverIndex(requestId, [], org, now);
2365
3543
  if (config.approvalStatusField) {
2366
- await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, "rejected");
3544
+ await this.mirrorStatusField(
3545
+ raw.object_name,
3546
+ raw.record_id,
3547
+ config.approvalStatusField,
3548
+ "rejected",
3549
+ actingUserId(context)
3550
+ );
2367
3551
  }
2368
3552
  let resumed2 = false;
2369
3553
  if (runId && typeof this.automation?.resume === "function") {
2370
3554
  try {
2371
- await this.automation.resume(runId, {
3555
+ await this.serviceResume(runId, {
2372
3556
  branchLabel: import_automation.APPROVAL_BRANCH_LABELS.reject,
2373
3557
  output: { decision: "reject", autoRejected: true, requestId }
2374
3558
  });
@@ -2385,7 +3569,7 @@ var _ApprovalService = class _ApprovalService {
2385
3569
  await this.notify({
2386
3570
  topic: "approval.returned",
2387
3571
  audience: [String(raw.submitter_id)],
2388
- actorId: input.actorId,
3572
+ actorId,
2389
3573
  source: { object: "sys_approval_request", id: requestId },
2390
3574
  payload: {
2391
3575
  title: "Approval auto-rejected",
@@ -2394,7 +3578,7 @@ var _ApprovalService = class _ApprovalService {
2394
3578
  }
2395
3579
  });
2396
3580
  }
2397
- const fresh2 = await this.getRequest(requestId, context);
3581
+ const fresh2 = await this.readBackRequest(requestId, context);
2398
3582
  return { request: fresh2, runId, resumed: resumed2, autoRejected: true };
2399
3583
  }
2400
3584
  await this.engine.update("sys_approval_request", {
@@ -2403,15 +3587,21 @@ var _ApprovalService = class _ApprovalService {
2403
3587
  pending_approvers: null,
2404
3588
  completed_at: now,
2405
3589
  updated_at: now
2406
- }, { context: SYSTEM_CTX });
3590
+ }, { context: SYSTEM_CTX2 });
2407
3591
  await this.syncApproverIndex(requestId, [], org, now);
2408
3592
  if (config.approvalStatusField) {
2409
- await this.mirrorStatusField(raw.object_name, raw.record_id, config.approvalStatusField, "returned");
3593
+ await this.mirrorStatusField(
3594
+ raw.object_name,
3595
+ raw.record_id,
3596
+ config.approvalStatusField,
3597
+ "returned",
3598
+ actingUserId(context)
3599
+ );
2410
3600
  }
2411
3601
  let resumed = false;
2412
3602
  if (runId && typeof this.automation?.resume === "function") {
2413
3603
  try {
2414
- await this.automation.resume(runId, {
3604
+ await this.serviceResume(runId, {
2415
3605
  branchLabel: import_automation.APPROVAL_BRANCH_LABELS.revise,
2416
3606
  output: { decision: "revise", requestId }
2417
3607
  });
@@ -2428,7 +3618,7 @@ var _ApprovalService = class _ApprovalService {
2428
3618
  await this.notify({
2429
3619
  topic: "approval.returned",
2430
3620
  audience: [String(raw.submitter_id)],
2431
- actorId: input.actorId,
3621
+ actorId,
2432
3622
  source: { object: "sys_approval_request", id: requestId },
2433
3623
  payload: {
2434
3624
  title: "Sent back for revision",
@@ -2437,7 +3627,7 @@ var _ApprovalService = class _ApprovalService {
2437
3627
  }
2438
3628
  });
2439
3629
  }
2440
- const fresh = await this.getRequest(requestId, context);
3630
+ const fresh = await this.readBackRequest(requestId, context);
2441
3631
  return { request: fresh, runId, resumed };
2442
3632
  }
2443
3633
  /**
@@ -2448,25 +3638,25 @@ var _ApprovalService = class _ApprovalService {
2448
3638
  * round-N+1 request — fresh approver slate, record re-locks.
2449
3639
  */
2450
3640
  async resubmit(requestId, input, context) {
2451
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3641
+ const actorId = await this.resolveActor(input?.actorId, context);
2452
3642
  const rawRows = await this.engine.find("sys_approval_request", {
2453
3643
  where: { id: requestId },
2454
3644
  limit: 1,
2455
- context: SYSTEM_CTX
3645
+ context: SYSTEM_CTX2
2456
3646
  });
2457
3647
  const raw = Array.isArray(rawRows) ? rawRows[0] : null;
2458
3648
  if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
2459
3649
  if (raw.status !== "returned") {
2460
3650
  throw new Error(`INVALID_STATE: request is ${raw.status} (resubmit applies to returned requests)`);
2461
3651
  }
2462
- if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
3652
+ if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
2463
3653
  throw new Error("FORBIDDEN: only the submitter may resubmit");
2464
3654
  }
2465
3655
  await this.assertLatestForRun(raw);
2466
3656
  const colliding = await this.engine.find("sys_approval_request", {
2467
3657
  where: { object_name: raw.object_name, record_id: raw.record_id, status: "pending" },
2468
3658
  limit: 1,
2469
- context: SYSTEM_CTX
3659
+ context: SYSTEM_CTX2
2470
3660
  });
2471
3661
  if (Array.isArray(colliding) && colliding[0]) {
2472
3662
  throw new Error(
@@ -2484,14 +3674,14 @@ var _ApprovalService = class _ApprovalService {
2484
3674
  step_name: nodeId,
2485
3675
  step_index: 0,
2486
3676
  action: "resubmit",
2487
- actor_id: input.actorId,
3677
+ actor_id: actorId,
2488
3678
  comment: input.comment ?? null,
2489
3679
  created_at: now
2490
- }, { context: SYSTEM_CTX });
3680
+ }, { context: SYSTEM_CTX2 });
2491
3681
  let resumed = false;
2492
3682
  if (runId && typeof this.automation?.resume === "function") {
2493
3683
  try {
2494
- await this.automation.resume(runId, {
3684
+ await this.serviceResume(runId, {
2495
3685
  branchLabel: import_automation.APPROVAL_BRANCH_LABELS.resubmit,
2496
3686
  output: { resubmitted: true, requestId }
2497
3687
  });
@@ -2504,7 +3694,7 @@ var _ApprovalService = class _ApprovalService {
2504
3694
  });
2505
3695
  }
2506
3696
  }
2507
- const fresh = await this.getRequest(requestId, context);
3697
+ const fresh = await this.readBackRequest(requestId, context);
2508
3698
  return { request: fresh, runId, resumed };
2509
3699
  }
2510
3700
  /**
@@ -2539,7 +3729,7 @@ var _ApprovalService = class _ApprovalService {
2539
3729
  where: { flow_run_id: runId },
2540
3730
  orderBy: [{ field: "created_at", order: "desc" }],
2541
3731
  limit: 1,
2542
- context: SYSTEM_CTX
3732
+ context: SYSTEM_CTX2
2543
3733
  });
2544
3734
  const latest = Array.isArray(rows) ? rows[0] : null;
2545
3735
  if (latest && String(latest.id) !== String(raw.id)) {
@@ -2550,25 +3740,33 @@ var _ApprovalService = class _ApprovalService {
2550
3740
  /**
2551
3741
  * Hand a pending-approver slot to someone else. `from` defaults to the
2552
3742
  * actor itself; the actor must hold the slot being handed over (or be a
2553
- * system caller). Audits `reassign` and notifies the new approver.
3743
+ * system caller). A privileged admin (#3424) may reassign a request whose
3744
+ * slate holds no real user — an unstaffed-position literal — by handing the
3745
+ * whole request to a real approver, rescuing it from the locked dead-end.
3746
+ * Audits `reassign` and notifies the new approver.
2554
3747
  */
2555
3748
  async reassign(requestId, input, context) {
2556
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3749
+ const actorId = await this.resolveActor(input?.actorId, context);
2557
3750
  const to = String(input?.to ?? "").trim();
2558
3751
  if (!to) throw new Error("VALIDATION_FAILED: `to` (new approver) is required");
2559
3752
  const raw = await this.loadPendingRow(requestId);
2560
3753
  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
3754
  if (pending.includes(to)) {
2569
3755
  throw new Error(`VALIDATION_FAILED: '${to}' is already a pending approver`);
2570
3756
  }
2571
- const next = pending.map((a) => a === from ? to : a);
3757
+ const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
3758
+ const from = String(input.from ?? actorId).trim();
3759
+ let next;
3760
+ if (pending.includes(from)) {
3761
+ if (!context.isSystem && !isOverride && actorId !== from && !pending.includes(actorId)) {
3762
+ throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
3763
+ }
3764
+ next = pending.map((a) => a === from ? to : a);
3765
+ } else if (isOverride) {
3766
+ next = [to];
3767
+ } else {
3768
+ throw new Error(`FORBIDDEN: '${from}' is not a pending approver on this request`);
3769
+ }
2572
3770
  const now = this.clock.now().toISOString();
2573
3771
  await this.engine.insert("sys_approval_action", {
2574
3772
  id: uid("aact"),
@@ -2577,10 +3775,15 @@ var _ApprovalService = class _ApprovalService {
2577
3775
  step_name: raw.flow_node_id ?? raw.current_step ?? null,
2578
3776
  step_index: 0,
2579
3777
  action: "reassign",
2580
- actor_id: input.actorId,
2581
- comment: input.comment ?? `${from} \u2192 ${to}`,
3778
+ // The hand-off parties are STRUCTURED fields (#4365) — the old default
3779
+ // comment (`"<from> <to>"`) baked raw user ids into user-facing text.
3780
+ // `comment` is pure user input: absent unless the actor wrote one.
3781
+ actor_id: actorId,
3782
+ reassign_from: from,
3783
+ reassign_to: to,
3784
+ comment: input.comment ?? null,
2582
3785
  created_at: now
2583
- }, { context: SYSTEM_CTX });
3786
+ }, { context: SYSTEM_CTX2 });
2584
3787
  let configPatch = {};
2585
3788
  try {
2586
3789
  const cfg = parseJson(raw.node_config_json, null);
@@ -2597,12 +3800,12 @@ var _ApprovalService = class _ApprovalService {
2597
3800
  pending_approvers: next.join(","),
2598
3801
  updated_at: now,
2599
3802
  ...configPatch
2600
- }, { context: SYSTEM_CTX });
3803
+ }, { context: SYSTEM_CTX2 });
2601
3804
  await this.syncApproverIndex(requestId, next, raw.organization_id ?? null, now);
2602
3805
  await this.notify({
2603
3806
  topic: "approval.reassigned",
2604
3807
  audience: [to],
2605
- actorId: input.actorId,
3808
+ actorId,
2606
3809
  source: { object: "sys_approval_request", id: requestId },
2607
3810
  dedupKey: `approval-reassign-${requestId}-${to}`,
2608
3811
  payload: {
@@ -2611,7 +3814,7 @@ var _ApprovalService = class _ApprovalService {
2611
3814
  actionUrl: "/system/approvals"
2612
3815
  }
2613
3816
  });
2614
- const fresh = await this.getRequest(requestId, context);
3817
+ const fresh = await this.readBackRequest(requestId, context);
2615
3818
  return { request: fresh };
2616
3819
  }
2617
3820
  /**
@@ -2619,16 +3822,16 @@ var _ApprovalService = class _ApprovalService {
2619
3822
  * reminder per {@link REMIND_COOLDOWN_MS} per request.
2620
3823
  */
2621
3824
  async remind(requestId, input, context) {
2622
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3825
+ const actorId = await this.resolveActor(input?.actorId, context);
2623
3826
  const raw = await this.loadPendingRow(requestId);
2624
- if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(input.actorId)) {
3827
+ if (!context.isSystem && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
2625
3828
  throw new Error("FORBIDDEN: only the submitter may send reminders");
2626
3829
  }
2627
3830
  const acts = await this.engine.find("sys_approval_action", {
2628
3831
  where: { request_id: requestId, action: "remind" },
2629
3832
  orderBy: [{ field: "created_at", order: "desc" }],
2630
3833
  limit: 1,
2631
- context: SYSTEM_CTX
3834
+ context: SYSTEM_CTX2
2632
3835
  });
2633
3836
  const last = Array.isArray(acts) ? acts[0] : null;
2634
3837
  const now = this.clock.now();
@@ -2644,10 +3847,10 @@ var _ApprovalService = class _ApprovalService {
2644
3847
  step_name: raw.flow_node_id ?? raw.current_step ?? null,
2645
3848
  step_index: 0,
2646
3849
  action: "remind",
2647
- actor_id: input.actorId,
3850
+ actor_id: actorId,
2648
3851
  comment: input.comment ?? null,
2649
3852
  created_at: nowIso
2650
- }, { context: SYSTEM_CTX });
3853
+ }, { context: SYSTEM_CTX2 });
2651
3854
  let notified = 0;
2652
3855
  const concrete = pending.filter((a) => a && !a.includes(":"));
2653
3856
  const literals = pending.filter((a) => a && a.includes(":"));
@@ -2657,7 +3860,7 @@ var _ApprovalService = class _ApprovalService {
2657
3860
  notified += await this.notify({
2658
3861
  topic: "approval.reminder",
2659
3862
  audience: [approver],
2660
- actorId: input.actorId,
3863
+ actorId,
2661
3864
  source: { object: "sys_approval_request", id: requestId },
2662
3865
  dedupKey: `approval-remind-${requestId}-${nowIso}-${approver}`,
2663
3866
  payload: {
@@ -2682,7 +3885,7 @@ var _ApprovalService = class _ApprovalService {
2682
3885
  notified += await this.notify({
2683
3886
  topic: "approval.reminder",
2684
3887
  audience: literals,
2685
- actorId: input.actorId,
3888
+ actorId,
2686
3889
  source: { object: "sys_approval_request", id: requestId },
2687
3890
  dedupKey: `approval-remind-${requestId}-${nowIso}`,
2688
3891
  payload: {
@@ -2692,7 +3895,7 @@ var _ApprovalService = class _ApprovalService {
2692
3895
  }
2693
3896
  });
2694
3897
  }
2695
- const fresh = await this.getRequest(requestId, context);
3898
+ const fresh = await this.readBackRequest(requestId, context);
2696
3899
  return { request: fresh, notified };
2697
3900
  }
2698
3901
  // ── Actionable links (ADR-0043) ──────────────────────────────
@@ -2727,7 +3930,7 @@ var _ApprovalService = class _ApprovalService {
2727
3930
  expires_at: expires,
2728
3931
  consumed_at: null,
2729
3932
  created_at: now.toISOString()
2730
- }, { context: SYSTEM_CTX });
3933
+ }, { context: SYSTEM_CTX2 });
2731
3934
  out[action] = rawToken;
2732
3935
  }
2733
3936
  return out;
@@ -2740,7 +3943,7 @@ var _ApprovalService = class _ApprovalService {
2740
3943
  const rows = await this.engine.find("sys_approval_token", {
2741
3944
  where: { token_hash: hash },
2742
3945
  limit: 1,
2743
- context: SYSTEM_CTX
3946
+ context: SYSTEM_CTX2
2744
3947
  });
2745
3948
  const token = Array.isArray(rows) ? rows[0] : null;
2746
3949
  if (!token) return { ok: false, reason: "invalid" };
@@ -2748,7 +3951,7 @@ var _ApprovalService = class _ApprovalService {
2748
3951
  if (Date.parse(token.expires_at) < this.clock.now().getTime()) {
2749
3952
  return { ok: false, reason: "expired" };
2750
3953
  }
2751
- const request = await this.getRequest(token.request_id, SYSTEM_CTX);
3954
+ const request = await this.getRequest(token.request_id, SYSTEM_CTX2);
2752
3955
  if (!request || request.status !== "pending") {
2753
3956
  return { ok: false, reason: "not_pending", request: request ?? void 0 };
2754
3957
  }
@@ -2773,12 +3976,19 @@ var _ApprovalService = class _ApprovalService {
2773
3976
  await this.engine.update("sys_approval_token", {
2774
3977
  id: res.token.id,
2775
3978
  consumed_at: this.clock.now().toISOString()
2776
- }, { context: SYSTEM_CTX });
3979
+ }, { context: SYSTEM_CTX2 });
2777
3980
  const out = await this.decide(res.token.request_id, {
2778
3981
  decision: res.token.action,
2779
3982
  actorId: res.token.approver_id,
2780
3983
  comment: "Via action link"
2781
- }, SYSTEM_CTX);
3984
+ // The token IS the authentication (#3783): it is single-use, hashed at
3985
+ // rest and bound to one approver, who `resolveActionToken` has just
3986
+ // re-checked still holds a pending slot. So this decision has a real
3987
+ // acting user even though no session carried it — name them on the
3988
+ // context, so the status mirror and every flow it cascades into are
3989
+ // attributed exactly like a decision made through the UI. Elevation is
3990
+ // unchanged: `isSystem` still stands in for the missing session.
3991
+ }, { ...SYSTEM_CTX2, userId: res.token.approver_id });
2782
3992
  return { ok: true, action: res.token.action, request: out.request, approverId: res.token.approver_id };
2783
3993
  }
2784
3994
  /**
@@ -2786,12 +3996,12 @@ var _ApprovalService = class _ApprovalService {
2786
3996
  * pending — a thread interaction, not a flow decision.
2787
3997
  */
2788
3998
  async requestInfo(requestId, input, context) {
2789
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
3999
+ const actorId = await this.resolveActor(input?.actorId, context);
2790
4000
  if (!input?.comment?.trim()) throw new Error("VALIDATION_FAILED: comment is required");
2791
4001
  const raw = await this.loadPendingRow(requestId);
2792
4002
  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`);
4003
+ if (!context.isSystem && !pending.includes(actorId)) {
4004
+ throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
2795
4005
  }
2796
4006
  const now = this.clock.now().toISOString();
2797
4007
  await this.engine.insert("sys_approval_action", {
@@ -2801,15 +4011,15 @@ var _ApprovalService = class _ApprovalService {
2801
4011
  step_name: raw.flow_node_id ?? raw.current_step ?? null,
2802
4012
  step_index: 0,
2803
4013
  action: "request_info",
2804
- actor_id: input.actorId,
4014
+ actor_id: actorId,
2805
4015
  comment: input.comment.trim(),
2806
4016
  created_at: now
2807
- }, { context: SYSTEM_CTX });
4017
+ }, { context: SYSTEM_CTX2 });
2808
4018
  if (raw.submitter_id) {
2809
4019
  await this.notify({
2810
4020
  topic: "approval.request_info",
2811
4021
  audience: [String(raw.submitter_id)],
2812
- actorId: input.actorId,
4022
+ actorId,
2813
4023
  source: { object: "sys_approval_request", id: requestId },
2814
4024
  payload: {
2815
4025
  title: "More information requested",
@@ -2818,18 +4028,18 @@ var _ApprovalService = class _ApprovalService {
2818
4028
  }
2819
4029
  });
2820
4030
  }
2821
- const fresh = await this.getRequest(requestId, context);
4031
+ const fresh = await this.readBackRequest(requestId, context);
2822
4032
  return { request: fresh };
2823
4033
  }
2824
4034
  /** Free-form reply on the thread (submitter or any pending approver). */
2825
4035
  async comment(requestId, input, context) {
2826
- if (!input?.actorId) throw new Error("VALIDATION_FAILED: actorId is required");
4036
+ const actorId = await this.resolveActor(input?.actorId, context);
2827
4037
  if (!input?.comment?.trim()) throw new Error("VALIDATION_FAILED: comment is required");
2828
4038
  const raw = await this.loadPendingRow(requestId);
2829
4039
  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`);
4040
+ const isSubmitter = raw.submitter_id && String(raw.submitter_id) === String(actorId);
4041
+ if (!context.isSystem && !isSubmitter && !pending.includes(actorId)) {
4042
+ throw new Error(`FORBIDDEN: actor '${actorId}' is not on this request`);
2833
4043
  }
2834
4044
  const now = this.clock.now().toISOString();
2835
4045
  await this.engine.insert("sys_approval_action", {
@@ -2839,16 +4049,16 @@ var _ApprovalService = class _ApprovalService {
2839
4049
  step_name: raw.flow_node_id ?? raw.current_step ?? null,
2840
4050
  step_index: 0,
2841
4051
  action: "comment",
2842
- actor_id: input.actorId,
4052
+ actor_id: actorId,
2843
4053
  comment: input.comment.trim(),
2844
4054
  attachments: input.attachments?.length ? input.attachments : null,
2845
4055
  created_at: now
2846
- }, { context: SYSTEM_CTX });
4056
+ }, { context: SYSTEM_CTX2 });
2847
4057
  const audience = isSubmitter ? pending : [String(raw.submitter_id ?? "")].filter(Boolean);
2848
4058
  await this.notify({
2849
4059
  topic: "approval.comment",
2850
4060
  audience,
2851
- actorId: input.actorId,
4061
+ actorId,
2852
4062
  source: { object: "sys_approval_request", id: requestId },
2853
4063
  payload: {
2854
4064
  title: "New comment on an approval",
@@ -2856,7 +4066,7 @@ var _ApprovalService = class _ApprovalService {
2856
4066
  actionUrl: "/system/approvals"
2857
4067
  }
2858
4068
  });
2859
- const fresh = await this.getRequest(requestId, context);
4069
+ const fresh = await this.readBackRequest(requestId, context);
2860
4070
  return { request: fresh };
2861
4071
  }
2862
4072
  // ── SLA escalation (ADR-0042) ─────────────────────────────────
@@ -2873,7 +4083,7 @@ var _ApprovalService = class _ApprovalService {
2873
4083
  rows = await this.engine.find("sys_approval_request", {
2874
4084
  where: { status: "pending" },
2875
4085
  limit: 500,
2876
- context: SYSTEM_CTX
4086
+ context: SYSTEM_CTX2
2877
4087
  }) ?? [];
2878
4088
  } catch (err) {
2879
4089
  this.logger?.warn?.("[approvals] escalation scan failed to list requests", {
@@ -2892,7 +4102,7 @@ var _ApprovalService = class _ApprovalService {
2892
4102
  const prior = await this.engine.find("sys_approval_action", {
2893
4103
  where: { request_id: raw.id, action: "escalate" },
2894
4104
  limit: 1,
2895
- context: SYSTEM_CTX
4105
+ context: SYSTEM_CTX2
2896
4106
  });
2897
4107
  if (Array.isArray(prior) && prior[0]) continue;
2898
4108
  await this.escalateRequest(raw, esc2);
@@ -2909,6 +4119,130 @@ var _ApprovalService = class _ApprovalService {
2909
4119
  }
2910
4120
  return { scanned: rows.length, escalated };
2911
4121
  }
4122
+ // ── Dead-run release (#3456) ──────────────────────────────────
4123
+ /**
4124
+ * One dead-run sweep: a pending request whose owning flow run has reached a
4125
+ * TERMINAL state can never be decided — nothing is left to resume — so the
4126
+ * request is finalised as `recalled` and, with `lockRecord`, the record it was
4127
+ * holding is released.
4128
+ *
4129
+ * This is the recovery half of #3456. The prevention half is the record lock's
4130
+ * owning-run exemption (`lifecycle-hooks.ts`), which stops a run from killing
4131
+ * itself on its own lock in the first place; this sweep cleans up the runs that
4132
+ * still die — for any reason, including a process crash, which no in-band
4133
+ * handler can catch because the process that would have run it is gone.
4134
+ *
4135
+ * **Fail-safe by construction.** It acts only on an explicit terminal status
4136
+ * from a closed set. Every other answer — `paused` (the normal state of a run
4137
+ * waiting on its approval), `running`, an unrecognised status, `null` (unknown
4138
+ * run, evicted log, no durable store), a `getRun` that throws, or no automation
4139
+ * engine at all — is read as "still alive" and left strictly alone. The failure
4140
+ * mode is therefore "a dead run's lock survives until an admin recalls it"
4141
+ * (today's behaviour, #3424), never "a live approval is destroyed".
4142
+ *
4143
+ * `recalled` is the finalisation because it is the platform's existing terminal
4144
+ * state for *a live request that ended without a decision*; the audit row names
4145
+ * the real cause and {@link DEAD_RUN_ACTOR_ID} the real actor, so a dead-run
4146
+ * release is never mistaken for a submitter's withdrawal.
4147
+ */
4148
+ async releaseDeadRunRequests() {
4149
+ if (typeof this.automation?.getRun !== "function") return { scanned: 0, released: 0 };
4150
+ let rows = [];
4151
+ try {
4152
+ rows = await this.engine.find("sys_approval_request", {
4153
+ where: { status: "pending" },
4154
+ limit: 500,
4155
+ context: SYSTEM_CTX2
4156
+ }) ?? [];
4157
+ } catch (err) {
4158
+ this.logger?.warn?.("[approvals] dead-run sweep failed to list requests", {
4159
+ error: err?.message ?? String(err)
4160
+ });
4161
+ return { scanned: 0, released: 0 };
4162
+ }
4163
+ let released = 0;
4164
+ for (const raw of rows) {
4165
+ try {
4166
+ const runId = raw?.flow_run_id ? String(raw.flow_run_id) : "";
4167
+ if (!runId) continue;
4168
+ let status;
4169
+ try {
4170
+ const run = await this.automation.getRun(runId);
4171
+ status = typeof run?.status === "string" ? run.status : void 0;
4172
+ } catch (err) {
4173
+ this.logger?.warn?.("[approvals] dead-run sweep could not read run status", {
4174
+ request: raw?.id,
4175
+ run: runId,
4176
+ error: err?.message ?? String(err)
4177
+ });
4178
+ continue;
4179
+ }
4180
+ if (!status || !TERMINAL_RUN_STATUSES.has(status)) continue;
4181
+ await this.abandonForDeadRun(raw, runId, status);
4182
+ released++;
4183
+ } catch (err) {
4184
+ this.logger?.warn?.("[approvals] dead-run release failed for request", {
4185
+ request: raw?.id,
4186
+ error: err?.message ?? String(err)
4187
+ });
4188
+ }
4189
+ }
4190
+ if (released > 0) {
4191
+ this.logger?.info?.("[approvals] dead-run sweep", { scanned: rows.length, released });
4192
+ }
4193
+ return { scanned: rows.length, released };
4194
+ }
4195
+ /**
4196
+ * Finalise one pending request whose owning run is terminal. Mirrors the
4197
+ * shape of {@link recall} — audit row first (so a crash mid-release leaves a
4198
+ * trace of the intent), then the status transition, approver-index sync and
4199
+ * the optional status-field mirror. No resume/cancel of the run: it is already
4200
+ * terminal, which is precisely why we are here.
4201
+ */
4202
+ async abandonForDeadRun(raw, runId, runStatus) {
4203
+ const org = raw.organization_id ?? null;
4204
+ const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
4205
+ const now = this.clock.now().toISOString();
4206
+ await this.engine.insert("sys_approval_action", {
4207
+ id: uid("aact"),
4208
+ request_id: raw.id,
4209
+ organization_id: org,
4210
+ step_name: nodeId,
4211
+ step_index: 0,
4212
+ action: "recall",
4213
+ actor_id: DEAD_RUN_ACTOR_ID,
4214
+ comment: `owning flow run ${runId} is ${runStatus} \u2014 request abandoned and record lock released`,
4215
+ created_at: now
4216
+ }, { context: SYSTEM_CTX2 });
4217
+ await this.engine.update("sys_approval_request", {
4218
+ id: raw.id,
4219
+ status: "recalled",
4220
+ pending_approvers: null,
4221
+ completed_at: now,
4222
+ updated_at: now
4223
+ }, { context: SYSTEM_CTX2 });
4224
+ await this.syncApproverIndex(raw.id, [], org, now);
4225
+ const config = parseJson(
4226
+ raw.node_config_json,
4227
+ { approvers: [], behavior: "first_response" }
4228
+ );
4229
+ if (config.approvalStatusField) {
4230
+ await this.mirrorStatusField(
4231
+ raw.object_name,
4232
+ raw.record_id,
4233
+ config.approvalStatusField,
4234
+ "recalled",
4235
+ null
4236
+ );
4237
+ }
4238
+ this.logger?.warn?.("[approvals] released a record held by a dead approval run", {
4239
+ request: raw.id,
4240
+ run: runId,
4241
+ runStatus,
4242
+ object: raw.object_name,
4243
+ record: raw.record_id
4244
+ });
4245
+ }
2912
4246
  /** Execute the configured escalation action for one overdue request. */
2913
4247
  async escalateRequest(raw, esc2) {
2914
4248
  const action = esc2.action ?? "notify";
@@ -2934,13 +4268,13 @@ var _ApprovalService = class _ApprovalService {
2934
4268
  actor_id: SLA_ACTOR_ID,
2935
4269
  comment: `${action}${escalateTo ? ` \u2192 ${escalateTo}` : ""}`,
2936
4270
  created_at: now
2937
- }, { context: SYSTEM_CTX });
4271
+ }, { context: SYSTEM_CTX2 });
2938
4272
  if (action === "reassign" && escalatees.length) {
2939
4273
  await this.engine.update("sys_approval_request", {
2940
4274
  id: raw.id,
2941
4275
  pending_approvers: escalatees.join(","),
2942
4276
  updated_at: now
2943
- }, { context: SYSTEM_CTX });
4277
+ }, { context: SYSTEM_CTX2 });
2944
4278
  await this.syncApproverIndex(raw.id, escalatees, raw.organization_id ?? null, now);
2945
4279
  await this.notify({
2946
4280
  topic: "approval.escalated",
@@ -2958,7 +4292,7 @@ var _ApprovalService = class _ApprovalService {
2958
4292
  decision: action === "auto_approve" ? "approve" : "reject",
2959
4293
  actorId: SLA_ACTOR_ID,
2960
4294
  comment: "SLA escalation"
2961
- }, SYSTEM_CTX);
4295
+ }, SYSTEM_CTX2);
2962
4296
  } else {
2963
4297
  await this.notify({
2964
4298
  topic: "approval.sla_breached",
@@ -3026,7 +4360,7 @@ var _ApprovalService = class _ApprovalService {
3026
4360
  where: { id: { $in: targets } },
3027
4361
  fields: ["id", "name", "email"],
3028
4362
  limit: targets.length,
3029
- context: SYSTEM_CTX
4363
+ context: SYSTEM_CTX2
3030
4364
  });
3031
4365
  for (const u of users ?? []) {
3032
4366
  if (u?.id && (u.name || u.email)) names.set(String(u.id), String(u.name ?? u.email));
@@ -3040,7 +4374,7 @@ var _ApprovalService = class _ApprovalService {
3040
4374
  where: { email: { $in: unresolvedEmails } },
3041
4375
  fields: ["email", "name"],
3042
4376
  limit: unresolvedEmails.length,
3043
- context: SYSTEM_CTX
4377
+ context: SYSTEM_CTX2
3044
4378
  });
3045
4379
  for (const u of users ?? []) {
3046
4380
  if (u?.email && u.name) names.set(String(u.email), String(u.name));
@@ -3066,11 +4400,32 @@ var _ApprovalService = class _ApprovalService {
3066
4400
  return [];
3067
4401
  }
3068
4402
  }
4403
+ /**
4404
+ * Field key → display label for an object's schema. Lets the inbox summary
4405
+ * show a human field name ("考核状态") instead of a title-cased machine key
4406
+ * ("Assessment Status"). For a single-locale project the schema label already
4407
+ * IS the localized string; symmetric with `resolveDisplayField`/lookup
4408
+ * resolution that power `payload_display`.
4409
+ */
4410
+ resolveFieldLabels(object) {
4411
+ try {
4412
+ const schema = this.engine.getSchema?.(object);
4413
+ const fields = schema?.fields ?? {};
4414
+ const out = {};
4415
+ for (const [key, f] of Object.entries(fields)) {
4416
+ if (f?.label) out[key] = String(f.label);
4417
+ }
4418
+ return out;
4419
+ } catch {
4420
+ return {};
4421
+ }
4422
+ }
3069
4423
  /**
3070
4424
  * Attach inbox display fields to rows so clients never render a raw
3071
4425
  * 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).
4426
+ * `pending_approver_names` (user-id approvers), `payload_display`
4427
+ * (lookup foreign keys in the snapshot → referenced record titles), and
4428
+ * `payload_labels` (snapshot field keys → the target object's field labels).
3074
4429
  * Batched: one query per distinct object (target + referenced) plus one
3075
4430
  * `sys_user` lookup. Best-effort — a deleted record falls back to the
3076
4431
  * payload snapshot, and any failure leaves the field unset rather than
@@ -3102,7 +4457,7 @@ var _ApprovalService = class _ApprovalService {
3102
4457
  const recs = await this.engine.find(object, {
3103
4458
  where: { id: { $in: ids } },
3104
4459
  limit: ids.length,
3105
- context: SYSTEM_CTX
4460
+ context: SYSTEM_CTX2
3106
4461
  });
3107
4462
  for (const rec of recs ?? []) {
3108
4463
  const title = _ApprovalService.pickTitle(rec, displayField);
@@ -3112,9 +4467,12 @@ var _ApprovalService = class _ApprovalService {
3112
4467
  }
3113
4468
  }
3114
4469
  const lookupFieldsByObject = /* @__PURE__ */ new Map();
4470
+ const fieldLabelsByObject = /* @__PURE__ */ new Map();
3115
4471
  for (const object of byObject.keys()) {
3116
4472
  const lookups = this.resolveLookupFields(object);
3117
4473
  if (lookups.length) lookupFieldsByObject.set(object, lookups);
4474
+ const labels = this.resolveFieldLabels(object);
4475
+ if (Object.keys(labels).length) fieldLabelsByObject.set(object, labels);
3118
4476
  }
3119
4477
  const refIds = /* @__PURE__ */ new Map();
3120
4478
  for (const r of rows) {
@@ -3140,7 +4498,7 @@ var _ApprovalService = class _ApprovalService {
3140
4498
  const recs = await this.engine.find(object, {
3141
4499
  where: { id: { $in: ids } },
3142
4500
  limit: ids.length,
3143
- context: SYSTEM_CTX
4501
+ context: SYSTEM_CTX2
3144
4502
  });
3145
4503
  for (const rec of recs ?? []) {
3146
4504
  const title = _ApprovalService.pickTitle(rec, displayField);
@@ -3181,6 +4539,15 @@ var _ApprovalService = class _ApprovalService {
3181
4539
  }
3182
4540
  if (Object.keys(display).length) r.payload_display = display;
3183
4541
  }
4542
+ const fieldLabels = fieldLabelsByObject.get(r.object_name);
4543
+ if (fieldLabels && r.payload && typeof r.payload === "object") {
4544
+ const labels = {};
4545
+ for (const key of Object.keys(r.payload)) {
4546
+ const l = fieldLabels[key];
4547
+ if (l) labels[key] = l;
4548
+ }
4549
+ if (Object.keys(labels).length) r.payload_labels = labels;
4550
+ }
3184
4551
  }
3185
4552
  }
3186
4553
  // ── Pending-approver index (issue #1745) ─────────────────────
@@ -3196,12 +4563,12 @@ var _ApprovalService = class _ApprovalService {
3196
4563
  const existing = await this.engine.find("sys_approval_approver", {
3197
4564
  where: { request_id: requestId },
3198
4565
  limit: 500,
3199
- context: SYSTEM_CTX
4566
+ context: SYSTEM_CTX2
3200
4567
  });
3201
4568
  const rows = Array.isArray(existing) ? existing : [];
3202
4569
  for (const row of rows) {
3203
4570
  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 });
4571
+ else await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX2 });
3205
4572
  }
3206
4573
  for (const approver of desired) {
3207
4574
  await this.engine.insert("sys_approval_approver", {
@@ -3210,7 +4577,7 @@ var _ApprovalService = class _ApprovalService {
3210
4577
  approver,
3211
4578
  organization_id: org,
3212
4579
  created_at: now
3213
- }, { context: SYSTEM_CTX });
4580
+ }, { context: SYSTEM_CTX2 });
3214
4581
  }
3215
4582
  }
3216
4583
  /**
@@ -3222,35 +4589,28 @@ var _ApprovalService = class _ApprovalService {
3222
4589
  async rebuildApproverIndex() {
3223
4590
  const desired = /* @__PURE__ */ new Map();
3224
4591
  const PAGE = 500;
3225
- for (let offset = 0; ; offset += PAGE) {
3226
- const batch = await this.engine.find("sys_approval_request", {
3227
- where: { status: "pending" },
4592
+ const requests = (0, import_types.keysetWalk)(
4593
+ (q) => this.engine.find("sys_approval_request", {
4594
+ ...q,
3228
4595
  fields: ["id", "pending_approvers", "organization_id"],
3229
- limit: PAGE,
3230
- offset,
3231
- context: SYSTEM_CTX
3232
- });
3233
- const rows = Array.isArray(batch) ? batch : [];
4596
+ context: SYSTEM_CTX2
4597
+ }),
4598
+ { where: { status: "pending" }, pageSize: PAGE }
4599
+ );
4600
+ for await (const rows of requests.pages()) {
3234
4601
  for (const r of rows) {
3235
4602
  desired.set(String(r.id), {
3236
4603
  approvers: new Set(csvSplit(r.pending_approvers)),
3237
4604
  org: r.organization_id ?? null
3238
4605
  });
3239
4606
  }
3240
- if (rows.length < PAGE) break;
3241
4607
  }
3242
4608
  const indexRows = [];
3243
- for (let offset = 0; ; offset += PAGE) {
3244
- const batch = await this.engine.find("sys_approval_approver", {
3245
- orderBy: [{ field: "created_at", order: "asc" }],
3246
- limit: PAGE,
3247
- offset,
3248
- context: SYSTEM_CTX
3249
- });
3250
- const rows = Array.isArray(batch) ? batch : [];
3251
- indexRows.push(...rows);
3252
- if (rows.length < PAGE) break;
3253
- }
4609
+ const index = (0, import_types.keysetWalk)(
4610
+ (q) => this.engine.find("sys_approval_approver", { ...q, context: SYSTEM_CTX2 }),
4611
+ { pageSize: PAGE }
4612
+ );
4613
+ for await (const rows of index.pages()) indexRows.push(...rows);
3254
4614
  let inserted = 0;
3255
4615
  let deleted = 0;
3256
4616
  const seen = /* @__PURE__ */ new Map();
@@ -3259,7 +4619,7 @@ var _ApprovalService = class _ApprovalService {
3259
4619
  const want = desired.get(reqId);
3260
4620
  const have = seen.get(reqId) ?? seen.set(reqId, /* @__PURE__ */ new Set()).get(reqId);
3261
4621
  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 });
4622
+ await this.engine.delete("sys_approval_approver", { where: { id: row.id }, context: SYSTEM_CTX2 });
3263
4623
  deleted++;
3264
4624
  continue;
3265
4625
  }
@@ -3276,7 +4636,7 @@ var _ApprovalService = class _ApprovalService {
3276
4636
  approver,
3277
4637
  organization_id: want.org,
3278
4638
  created_at: now
3279
- }, { context: SYSTEM_CTX });
4639
+ }, { context: SYSTEM_CTX2 });
3280
4640
  inserted++;
3281
4641
  }
3282
4642
  }
@@ -3326,7 +4686,7 @@ var _ApprovalService = class _ApprovalService {
3326
4686
  where,
3327
4687
  fields: ["request_id"],
3328
4688
  limit: _ApprovalService.APPROVER_INDEX_CAP,
3329
- context: SYSTEM_CTX
4689
+ context: SYSTEM_CTX2
3330
4690
  });
3331
4691
  const list = Array.isArray(rows) ? rows : [];
3332
4692
  if (list.length >= _ApprovalService.APPROVER_INDEX_CAP) {
@@ -3337,6 +4697,88 @@ var _ApprovalService = class _ApprovalService {
3337
4697
  }
3338
4698
  return [...new Set(list.map((r) => String(r.request_id)))];
3339
4699
  }
4700
+ /**
4701
+ * The request ids this caller is a PARTICIPANT of, or `null` for a caller
4702
+ * who may see everything in scope (#3590).
4703
+ *
4704
+ * These reads deliberately run with `SYSTEM_CTX` to bypass RLS — the
4705
+ * approver-visibility rule spans several identity forms that RLS cannot model
4706
+ * cleanly, which is why it has to be expressed here. Until now only the
4707
+ * TENANT half of that rule was applied, so any authenticated user could read
4708
+ * any request in their tenant (and, once attachments derived their access
4709
+ * from the request, its files too). This adds the participant half.
4710
+ *
4711
+ * A participant is the submitter, a current approver, or someone who has
4712
+ * already acted on the request (a past approver whose slot has moved on, a
4713
+ * commenter). Admins with override authority keep the unrestricted view the
4714
+ * "all requests" console surface depends on.
4715
+ *
4716
+ * Keying on the concrete user id is sufficient rather than an approximation:
4717
+ * position/team/manager/field approvers are resolved to concrete user ids at
4718
+ * open time, and the `type:value` literal is only the fallback for a spec
4719
+ * that resolved to NOBODY — a slot no one can act on either way (`can_act`
4720
+ * is a plain membership test over the resolved ids). So this cannot hide a
4721
+ * request from someone who could actually act on it.
4722
+ */
4723
+ async visibleRequestIds(context, tenantOrg) {
4724
+ if (this.isOverrideActor(context, tenantOrg)) return null;
4725
+ const uid2 = context?.userId != null ? String(context.userId) : "";
4726
+ if (!uid2) return /* @__PURE__ */ new Set();
4727
+ const ids = /* @__PURE__ */ new Set();
4728
+ const cap = _ApprovalService.APPROVER_INDEX_CAP;
4729
+ const add = (rows, key) => {
4730
+ const list = Array.isArray(rows) ? rows : [];
4731
+ for (const r of list) if (r?.[key] != null) ids.add(String(r[key]));
4732
+ if (list.length >= cap) {
4733
+ this.logger?.warn?.(
4734
+ "[approvals] participant-visibility probe hit its window \u2014 some requests may be hidden from a legitimate participant",
4735
+ { cap, key }
4736
+ );
4737
+ }
4738
+ };
4739
+ try {
4740
+ for (const id of await this.approverRequestIds([uid2], tenantOrg) ?? []) ids.add(id);
4741
+ const orgWhere = tenantOrg ? { organization_id: tenantOrg } : {};
4742
+ add(
4743
+ await this.engine.find("sys_approval_request", {
4744
+ where: { submitter_id: uid2, ...orgWhere },
4745
+ fields: ["id"],
4746
+ limit: cap,
4747
+ context: SYSTEM_CTX2
4748
+ }),
4749
+ "id"
4750
+ );
4751
+ add(
4752
+ await this.engine.find("sys_approval_action", {
4753
+ where: { actor_id: uid2 },
4754
+ fields: ["request_id"],
4755
+ limit: cap,
4756
+ context: SYSTEM_CTX2
4757
+ }),
4758
+ "request_id"
4759
+ );
4760
+ } catch (err) {
4761
+ this.logger?.warn?.("[approvals] participant-visibility probe failed", {
4762
+ error: err instanceof Error ? err.message : String(err)
4763
+ });
4764
+ }
4765
+ return ids;
4766
+ }
4767
+ /** Intersect an existing `where.id` constraint with the participant set. */
4768
+ applyVisibility(where, visible) {
4769
+ if (!visible) return true;
4770
+ if (visible.size === 0) return false;
4771
+ let allowed = [...visible];
4772
+ const current = where.id;
4773
+ if (typeof current === "string") allowed = allowed.filter((x) => x === current);
4774
+ else if (current && typeof current === "object" && Array.isArray(current.$in)) {
4775
+ const set = new Set(current.$in.map((v) => String(v)));
4776
+ allowed = allowed.filter((x) => set.has(x));
4777
+ }
4778
+ if (allowed.length === 0) return false;
4779
+ where.id = allowed.length === 1 ? allowed[0] : { $in: allowed };
4780
+ return true;
4781
+ }
3340
4782
  async listRequests(filter, context) {
3341
4783
  const { where, tenantOrg } = this.buildRequestWhere(filter, context);
3342
4784
  const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
@@ -3345,10 +4787,11 @@ var _ApprovalService = class _ApprovalService {
3345
4787
  if (ids.length === 0) return [];
3346
4788
  where.id = ids.length === 1 ? ids[0] : { $in: ids };
3347
4789
  }
4790
+ if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return [];
3348
4791
  const findOpts = {
3349
4792
  where,
3350
4793
  orderBy: [{ field: "created_at", order: "desc" }],
3351
- context: SYSTEM_CTX
4794
+ context: SYSTEM_CTX2
3352
4795
  };
3353
4796
  if (filter?.limit != null || filter?.offset != null) {
3354
4797
  findOpts.limit = Math.min(Math.max(filter?.limit ?? 50, 1), 200);
@@ -3370,10 +4813,11 @@ var _ApprovalService = class _ApprovalService {
3370
4813
  if (ids.length === 0) return 0;
3371
4814
  where.id = ids.length === 1 ? ids[0] : { $in: ids };
3372
4815
  }
4816
+ if (!this.applyVisibility(where, await this.visibleRequestIds(context, tenantOrg))) return 0;
3373
4817
  const countFn = this.engine.count;
3374
4818
  if (typeof countFn === "function") {
3375
4819
  try {
3376
- const n = await countFn.call(this.engine, "sys_approval_request", { where, context: SYSTEM_CTX });
4820
+ const n = await countFn.call(this.engine, "sys_approval_request", { where, context: SYSTEM_CTX2 });
3377
4821
  if (typeof n === "number") return n;
3378
4822
  } catch {
3379
4823
  }
@@ -3382,11 +4826,28 @@ var _ApprovalService = class _ApprovalService {
3382
4826
  where,
3383
4827
  fields: ["id"],
3384
4828
  limit: ids ? Math.max(500, ids.length) : 500,
3385
- context: SYSTEM_CTX
4829
+ context: SYSTEM_CTX2
3386
4830
  });
3387
4831
  return Array.isArray(rows) ? rows.length : 0;
3388
4832
  }
4833
+ /**
4834
+ * Read the request a write path just changed, to echo back as its result.
4835
+ *
4836
+ * NOT participant-gated (#3590), deliberately: the operation authorized
4837
+ * itself by its own rule before writing, so re-asking "may you see this?"
4838
+ * for the echo answers a question that has already been settled — and would
4839
+ * answer it WRONG for a caller context that carries no `userId` (a
4840
+ * flow-driven resume, a service-to-service call), turning a successful write
4841
+ * into a `null` result. Gating belongs on the read API, not on an
4842
+ * operation's own return value.
4843
+ */
4844
+ async readBackRequest(requestId, context) {
4845
+ return this.loadRequest(requestId, context, false);
4846
+ }
3389
4847
  async getRequest(requestId, context) {
4848
+ return this.loadRequest(requestId, context, true);
4849
+ }
4850
+ async loadRequest(requestId, context, enforceVisibility) {
3390
4851
  if (!requestId) return null;
3391
4852
  const where = { id: requestId };
3392
4853
  const tenantOrg = context?.organizationId ?? context?.tenantId;
@@ -3394,9 +4855,13 @@ var _ApprovalService = class _ApprovalService {
3394
4855
  const rows = await this.engine.find("sys_approval_request", {
3395
4856
  where,
3396
4857
  limit: 1,
3397
- context: SYSTEM_CTX
4858
+ context: SYSTEM_CTX2
3398
4859
  });
3399
4860
  if (!Array.isArray(rows) || !rows[0]) return null;
4861
+ if (enforceVisibility) {
4862
+ const visible = await this.visibleRequestIds(context, tenantOrg ?? null);
4863
+ if (visible && !visible.has(String(rows[0].id))) return null;
4864
+ }
3400
4865
  const row = rowFromRequest(rows[0]);
3401
4866
  await this.enrichRows([row]);
3402
4867
  await this.attachFlowSteps(row);
@@ -3423,7 +4888,7 @@ var _ApprovalService = class _ApprovalService {
3423
4888
  const acts = await this.engine.find("sys_approval_action", {
3424
4889
  where: { request_id: row.id, step_index: 0, action: "approve" },
3425
4890
  limit: 1e3,
3426
- context: SYSTEM_CTX
4891
+ context: SYSTEM_CTX2
3427
4892
  });
3428
4893
  const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
3429
4894
  const snapshot = cfg?.__approverGroups;
@@ -3444,6 +4909,14 @@ var _ApprovalService = class _ApprovalService {
3444
4909
  });
3445
4910
  progress.got = progress.groups.filter((g) => g.satisfied).length;
3446
4911
  progress.need = progress.groups.length;
4912
+ const pendingGroups = {};
4913
+ for (const a of row.pending_approvers ?? []) {
4914
+ const named = (snapshot[a] ?? []).filter((g) => !/^#\d+$/.test(g));
4915
+ if (named.length) pendingGroups[a] = named;
4916
+ }
4917
+ if (Object.keys(pendingGroups).length) {
4918
+ row.pending_approver_groups = pendingGroups;
4919
+ }
3447
4920
  }
3448
4921
  row.decision_progress = progress;
3449
4922
  } catch {
@@ -3455,8 +4928,13 @@ var _ApprovalService = class _ApprovalService {
3455
4928
  * caller's user id is in the resolved `pending_approvers` while the request is
3456
4929
  * still `pending` (position/team/manager approvers are already resolved to
3457
4930
  * 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.
4931
+ * `is_submitter` is a straight owner check. `can_override` (#3424) is true for
4932
+ * a platform/tenant admin on a PENDING request the recovery path for an
4933
+ * approval routed to an unstaffed position or to approvers who have all left;
4934
+ * clients OR it into the decision actions' `visible` gate so an admin can act
4935
+ * even when they hold no slot. System/tokenless contexts get a both-false
4936
+ * `can_act`/`is_submitter` block (system gets `can_override` too — it may act
4937
+ * on anything). Cheap + synchronous — safe on list reads.
3460
4938
  */
3461
4939
  attachViewers(rows, context) {
3462
4940
  const uid2 = context?.userId != null ? String(context.userId) : null;
@@ -3464,7 +4942,8 @@ var _ApprovalService = class _ApprovalService {
3464
4942
  const pending = row.pending_approvers ?? [];
3465
4943
  row.viewer = {
3466
4944
  can_act: row.status === "pending" && !!uid2 && pending.includes(uid2),
3467
- is_submitter: !!uid2 && row.submitter_id != null && String(row.submitter_id) === uid2
4945
+ is_submitter: !!uid2 && row.submitter_id != null && String(row.submitter_id) === uid2,
4946
+ can_override: row.status === "pending" && this.isOverrideActor(context, row.organization_id ?? null)
3468
4947
  };
3469
4948
  }
3470
4949
  }
@@ -3511,26 +4990,60 @@ var _ApprovalService = class _ApprovalService {
3511
4990
  where: { request_id: requestId },
3512
4991
  limit: 500,
3513
4992
  orderBy: [{ field: "created_at", order: "asc" }],
3514
- context: SYSTEM_CTX
4993
+ context: SYSTEM_CTX2
3515
4994
  });
3516
4995
  const actions = Array.isArray(rows) ? rows.map(rowFromAction) : [];
3517
4996
  const names = await this.resolveUserNames(
3518
- actions.map((a) => a.actor_id).filter((id) => id && !id.includes(":"))
4997
+ actions.flatMap((a) => [a.actor_id, a.reassign_from, a.reassign_to]).filter((id) => id && !id.includes(":"))
3519
4998
  );
3520
4999
  for (const a of actions) {
3521
5000
  const n = a.actor_id ? names.get(String(a.actor_id)) : void 0;
3522
5001
  if (n) a.actor_name = n;
5002
+ const fromName = a.reassign_from ? names.get(String(a.reassign_from)) : void 0;
5003
+ if (fromName) a.reassign_from_name = fromName;
5004
+ const toName = a.reassign_to ? names.get(String(a.reassign_to)) : void 0;
5005
+ if (toName) a.reassign_to_name = toName;
3523
5006
  }
3524
5007
  return actions;
3525
5008
  }
5009
+ /**
5010
+ * `IFileAccessDelegate` — may this caller download a decision attachment?
5011
+ * (ADR-0104 D3 wave 2; declared by `sys_approval_action.fileAccessDelegate`.)
5012
+ *
5013
+ * A file referenced by `sys_approval_action.attachments` is owned by that
5014
+ * audit row, so the storage service would otherwise authorize the download by
5015
+ * testing whether the caller can READ the row. It cannot: `sys_approval_action`
5016
+ * is deliberately closed to ordinary approver positions, so that test denies
5017
+ * the very approver the attachment was filed for.
5018
+ *
5019
+ * The rule that actually governs seeing a decision is the one `listActions`
5020
+ * applies — can the caller see the PARENT REQUEST? — so this reuses it
5021
+ * exactly, rather than inventing a second, looser rule for the bytes. Fails
5022
+ * closed on any error.
5023
+ */
5024
+ async authorizeFileRead(actionId, context) {
5025
+ if (!actionId) return false;
5026
+ try {
5027
+ const rows = await this.engine.find("sys_approval_action", {
5028
+ where: { id: actionId },
5029
+ limit: 1,
5030
+ context: SYSTEM_CTX2
5031
+ });
5032
+ const requestId = (Array.isArray(rows) ? rows[0] : void 0)?.request_id;
5033
+ if (!requestId) return false;
5034
+ return !!await this.getRequest(String(requestId), context);
5035
+ } catch {
5036
+ return false;
5037
+ }
5038
+ }
3526
5039
  };
3527
5040
  /** Window the approver-index probe — pending queues live far below this. */
3528
5041
  _ApprovalService.APPROVER_INDEX_CAP = 1e4;
3529
5042
  var ApprovalService = _ApprovalService;
3530
5043
 
3531
5044
  // src/sys-approval-token.object.ts
3532
- var import_data5 = require("@objectstack/spec/data");
3533
- var SysApprovalToken = import_data5.ObjectSchema.create({
5045
+ var import_data6 = require("@objectstack/spec/data");
5046
+ var SysApprovalToken = import_data6.ObjectSchema.create({
3534
5047
  name: "sys_approval_token",
3535
5048
  label: "Approval Action Token",
3536
5049
  pluralLabel: "Approval Action Tokens",
@@ -3542,13 +5055,13 @@ var SysApprovalToken = import_data5.ObjectSchema.create({
3542
5055
  nameField: "id",
3543
5056
  // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
3544
5057
  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", {
5058
+ id: import_data6.Field.text({ label: "Token ID", required: true, readonly: true, group: "System" }),
5059
+ organization_id: import_data6.Field.lookup("sys_organization", {
3547
5060
  label: "Organization",
3548
5061
  required: false,
3549
5062
  group: "System"
3550
5063
  }),
3551
- token_hash: import_data5.Field.text({
5064
+ token_hash: import_data6.Field.text({
3552
5065
  label: "Token Hash",
3553
5066
  required: true,
3554
5067
  maxLength: 100,
@@ -3556,20 +5069,20 @@ var SysApprovalToken = import_data5.ObjectSchema.create({
3556
5069
  description: "SHA-256 hex of the raw token \u2014 the raw value is never stored",
3557
5070
  group: "Token"
3558
5071
  }),
3559
- request_id: import_data5.Field.text({
5072
+ request_id: import_data6.Field.text({
3560
5073
  label: "Request",
3561
5074
  required: true,
3562
5075
  maxLength: 100,
3563
5076
  readonly: true,
3564
5077
  group: "Token"
3565
5078
  }),
3566
- action: import_data5.Field.select(["approve", "reject"], {
5079
+ action: import_data6.Field.select(["approve", "reject"], {
3567
5080
  label: "Action",
3568
5081
  required: true,
3569
5082
  readonly: true,
3570
5083
  group: "Token"
3571
5084
  }),
3572
- approver_id: import_data5.Field.text({
5085
+ approver_id: import_data6.Field.text({
3573
5086
  label: "Approver",
3574
5087
  required: true,
3575
5088
  maxLength: 200,
@@ -3577,18 +5090,18 @@ var SysApprovalToken = import_data5.ObjectSchema.create({
3577
5090
  description: "Identity the token is bound to; the decision is audited as this approver",
3578
5091
  group: "Token"
3579
5092
  }),
3580
- expires_at: import_data5.Field.datetime({
5093
+ expires_at: import_data6.Field.datetime({
3581
5094
  label: "Expires At",
3582
5095
  required: true,
3583
5096
  readonly: true,
3584
5097
  group: "Lifecycle"
3585
5098
  }),
3586
- consumed_at: import_data5.Field.datetime({
5099
+ consumed_at: import_data6.Field.datetime({
3587
5100
  label: "Consumed At",
3588
5101
  required: false,
3589
5102
  group: "Lifecycle"
3590
5103
  }),
3591
- created_at: import_data5.Field.datetime({
5104
+ created_at: import_data6.Field.datetime({
3592
5105
  label: "Created At",
3593
5106
  required: true,
3594
5107
  defaultValue: "NOW()",
@@ -3717,6 +5230,8 @@ function bindApprovalLockHook(engine, logger) {
3717
5230
  if (Array.isArray(roles) && roles.includes("admin")) return;
3718
5231
  const pending = await pendingRequestFor(engine, object, id);
3719
5232
  if (!pending) return;
5233
+ const writerRun = ctx?.provenance?.flowRunId;
5234
+ if (writerRun && pending.flow_run_id && String(writerRun) === String(pending.flow_run_id)) return;
3720
5235
  const config = parseJson2(pending.node_config_json, {});
3721
5236
  if (config?.lockRecord === false) return;
3722
5237
  const mirror = config?.approvalStatusField;
@@ -3768,7 +5283,22 @@ function unbindAllHooks(engine) {
3768
5283
 
3769
5284
  // src/approval-node.ts
3770
5285
  var import_automation2 = require("@objectstack/spec/automation");
3771
- var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
5286
+ var SYSTEM_CTX3 = { isSystem: true, positions: [], permissions: [] };
5287
+ function nestVariables(variables) {
5288
+ const vars = {};
5289
+ for (const [key, value] of variables) {
5290
+ const segs = key.split(".");
5291
+ let cursor = vars;
5292
+ for (let i = 0; i < segs.length - 1; i++) {
5293
+ if (typeof cursor[segs[i]] !== "object" || cursor[segs[i]] === null) {
5294
+ cursor[segs[i]] = {};
5295
+ }
5296
+ cursor = cursor[segs[i]];
5297
+ }
5298
+ cursor[segs[segs.length - 1]] = value;
5299
+ }
5300
+ return vars;
5301
+ }
3772
5302
  function registerApprovalNode(automation, service, logger) {
3773
5303
  automation.registerNodeExecutor({
3774
5304
  type: import_automation2.APPROVAL_NODE_TYPE,
@@ -3784,6 +5314,15 @@ function registerApprovalNode(automation, service, logger) {
3784
5314
  // Human decision: the run suspends here awaiting an external reply.
3785
5315
  supportsPause: true,
3786
5316
  isAsync: true,
5317
+ // #3801: this pause is NOT resumable through the generic run-resume
5318
+ // route. Continuing an approval is a side effect of a DECISION, and the
5319
+ // decision is the thing that must be authorized (the approver slate),
5320
+ // recorded (`sys_approval_action`) and mirrored (the status field) —
5321
+ // all of which lives in `ApprovalService.decide`. The engine now refuses
5322
+ // any resume of an approval suspension that does not carry the service's
5323
+ // in-process marker, so "decide via the approvals API, never a raw engine
5324
+ // resume" is enforced rather than merely documented.
5325
+ resumeAuthority: "service",
3787
5326
  // Publish the node's config contract (ADR-0018 §configSchema) so the
3788
5327
  // Studio flow designer renders the Approval property form from the engine
3789
5328
  // rather than a hardcoded client form — the engine owns the shape.
@@ -3817,13 +5356,28 @@ function registerApprovalNode(automation, service, logger) {
3817
5356
  nodeLabel: typeof node.label === "string" ? node.label : void 0,
3818
5357
  submitterId: context?.userId ?? null,
3819
5358
  record,
3820
- organizationId: context?.organizationId ?? context?.tenantId ?? null
5359
+ organizationId: context?.organizationId ?? context?.tenantId ?? null,
5360
+ // #3447 P2: flow variables (nested, as CEL conditions see them) — the
5361
+ // `vars.*` root for `expression` approvers; `record` above doubles as
5362
+ // their `trigger.*` snapshot root.
5363
+ variables: nestVariables(variables)
3821
5364
  }, {
3822
- ...SYSTEM_CTX2,
5365
+ ...SYSTEM_CTX3,
3823
5366
  userId: context?.userId,
3824
5367
  organizationId: context?.organizationId,
3825
5368
  tenantId: context?.tenantId
3826
5369
  });
5370
+ if ("autoApproved" in request) {
5371
+ logger?.info?.("[approvals] approval node auto-approved (empty approver slate)", {
5372
+ node: node.id,
5373
+ run: String(runId)
5374
+ });
5375
+ return {
5376
+ success: true,
5377
+ branchLabel: "approve",
5378
+ output: { decision: "approve", autoApproved: true }
5379
+ };
5380
+ }
3827
5381
  logger?.info?.("[approvals] approval node suspended run", {
3828
5382
  node: node.id,
3829
5383
  request: request.id,
@@ -3909,7 +5463,21 @@ var ApprovalsServicePlugin = class {
3909
5463
  this.service = new ApprovalService({
3910
5464
  engine,
3911
5465
  logger: ctx.logger,
3912
- publicBaseUrl: this.options.publicBaseUrl
5466
+ publicBaseUrl: this.options.publicBaseUrl,
5467
+ // [ADR-0105 D9] Cross-organization approver targeting is a `group`-posture
5468
+ // capability. Read LAZILY (not captured at start) because the tenancy
5469
+ // service resolves its posture during its own start, which may not have
5470
+ // run yet; an unresolvable posture reads as "unknown" and the guard
5471
+ // stands down rather than refusing a legitimate flow on a minimal stack.
5472
+ tenancyPosture: () => {
5473
+ try {
5474
+ const tenancy = ctx.getService("tenancy");
5475
+ const posture = tenancy?.posture;
5476
+ return typeof posture === "string" && posture ? posture : void 0;
5477
+ } catch {
5478
+ return void 0;
5479
+ }
5480
+ }
3913
5481
  });
3914
5482
  if (!this.options.disableAutoHooks) {
3915
5483
  try {
@@ -3935,12 +5503,23 @@ var ApprovalsServicePlugin = class {
3935
5503
  if (!jobs || typeof jobs.schedule !== "function" || !this.service) return;
3936
5504
  const svc = this.service;
3937
5505
  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
- });
5506
+ const sweep = async () => {
5507
+ const results = await Promise.allSettled([
5508
+ svc.runEscalations(),
5509
+ svc.releaseDeadRunRequests()
5510
+ ]);
5511
+ for (const r of results) {
5512
+ if (r.status === "rejected") {
5513
+ ctx.logger.warn?.("[approvals] periodic sweep leg failed", {
5514
+ error: r.reason?.message ?? String(r.reason)
5515
+ });
5516
+ }
5517
+ }
5518
+ };
5519
+ await jobs.schedule(ESCALATION_JOB_NAME, { type: "interval", intervalMs }, sweep);
3941
5520
  this.escalationJobScheduled = true;
3942
- void svc.runEscalations().catch((err) => {
3943
- ctx.logger.warn?.("[approvals] boot escalation sweep failed", { error: err?.message });
5521
+ void sweep().catch((err) => {
5522
+ ctx.logger.warn?.("[approvals] boot sweep failed", { error: err?.message });
3944
5523
  });
3945
5524
  ctx.logger.info("ApprovalsServicePlugin: SLA escalation scan scheduled", { intervalMs });
3946
5525
  } catch {