@objectstack/plugin-approvals 15.1.1 → 16.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -162,6 +162,55 @@ var init_en_objects_generated = __esm({
162
162
  label: "All"
163
163
  }
164
164
  }
165
+ },
166
+ sys_approval_delegation: {
167
+ label: "Approval Delegation",
168
+ pluralLabel: "Approval Delegations",
169
+ description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
170
+ fields: {
171
+ id: {
172
+ label: "Delegation ID"
173
+ },
174
+ delegator_id: {
175
+ label: "Delegator",
176
+ help: "The user going out of office; their individually-routed approver slots are rerouted while active."
177
+ },
178
+ delegate_id: {
179
+ label: "Delegate",
180
+ help: "The backup who receives the delegator's approvals while this rule is active. Acts under their own identity."
181
+ },
182
+ valid_from: {
183
+ label: "Valid From",
184
+ help: "Rule is inactive before this instant. Null = active immediately. Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) \u2014 never by a background job."
185
+ },
186
+ valid_until: {
187
+ label: "Valid Until",
188
+ help: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
189
+ },
190
+ reason: {
191
+ label: "Reason",
192
+ help: 'Why the delegation exists (e.g. "Annual leave 5/26\u20135/30"). Recorded on the substitution audit row.'
193
+ },
194
+ organization_id: {
195
+ label: "Organization",
196
+ help: "Tenant that owns this rule; null = applies across tenants for this delegator."
197
+ },
198
+ created_at: {
199
+ label: "Created At"
200
+ },
201
+ updated_at: {
202
+ label: "Updated At"
203
+ }
204
+ },
205
+ _views: {
206
+ active: {
207
+ label: "Active",
208
+ emptyState: {
209
+ title: "No delegations",
210
+ message: "Declare an out-of-office delegation so approvals route to a backup while you are away."
211
+ }
212
+ }
213
+ }
165
214
  }
166
215
  };
167
216
  }
@@ -316,6 +365,55 @@ var init_zh_CN_objects_generated = __esm({
316
365
  label: "\u5168\u90E8"
317
366
  }
318
367
  }
368
+ },
369
+ sys_approval_delegation: {
370
+ label: "\u5BA1\u6279\u59D4\u6D3E",
371
+ pluralLabel: "\u5BA1\u6279\u59D4\u6D3E",
372
+ description: "\u81EA\u52A9\u4E0D\u5728\u5C97\u89C4\u5219\uFF1A\u5728\u65F6\u95F4\u7A97\u5185\u628A\u8BE5\u7528\u6237\u7684\u5BA1\u6279\u4EBA\u69FD\u4F4D\u6539\u6D3E\u7ED9\u5019\u8865\u4EBA\uFF08#1322 M1\uFF09\u3002",
373
+ fields: {
374
+ id: {
375
+ label: "\u59D4\u6D3E ID"
376
+ },
377
+ delegator_id: {
378
+ label: "\u59D4\u6D3E\u4EBA",
379
+ help: "\u5373\u5C06\u4E0D\u5728\u5C97\u7684\u7528\u6237\uFF1B\u89C4\u5219\u751F\u6548\u671F\u95F4\uFF0C\u8DEF\u7531\u5230\u5176\u4E2A\u4EBA\u7684\u5BA1\u6279\u4EBA\u69FD\u4F4D\u5C06\u88AB\u6539\u6D3E\u3002"
380
+ },
381
+ delegate_id: {
382
+ label: "\u5019\u8865\u4EBA",
383
+ help: "\u89C4\u5219\u751F\u6548\u671F\u95F4\u63A5\u6536\u59D4\u6D3E\u4EBA\u5BA1\u6279\u7684\u5019\u8865\u4EBA\u3002\u4EE5\u5176\u672C\u4EBA\u8EAB\u4EFD\u884C\u4F7F\u3002"
384
+ },
385
+ valid_from: {
386
+ label: "\u751F\u6548\u65F6\u95F4",
387
+ help: "\u6B64\u523B\u4E4B\u524D\u89C4\u5219\u4E0D\u751F\u6548\u3002\u7559\u7A7A = \u7ACB\u5373\u751F\u6548\u3002\u5728\u89E3\u6790\u65F6\u901A\u8FC7 isGrantActive \u5F3A\u5236\uFF08ADR-0091 D2 \u5224\u5B9A\u5F0F\uFF09\u2014\u2014\u7EDD\u4E0D\u4F9D\u8D56\u540E\u53F0\u4EFB\u52A1\u3002"
388
+ },
389
+ valid_until: {
390
+ label: "\u5931\u6548\u65F6\u95F4",
391
+ help: "\u6B64\u523B\uFF08\u542B\uFF09\u53CA\u4E4B\u540E\u89C4\u5219\u4E0D\u751F\u6548\uFF08\u534A\u5F00\u533A\u95F4 [from, until)\uFF0CUTC\uFF09\u3002\u7559\u7A7A = \u6C38\u4E0D\u8FC7\u671F\u3002"
392
+ },
393
+ reason: {
394
+ label: "\u539F\u56E0",
395
+ help: "\u59D4\u6D3E\u5B58\u5728\u7684\u539F\u56E0\uFF08\u4F8B\u5982\u201C\u5E74\u5047 5/26\u20135/30\u201D\uFF09\u3002\u4F1A\u8BB0\u5165\u6539\u6D3E\u5BA1\u8BA1\u884C\u3002"
396
+ },
397
+ organization_id: {
398
+ label: "\u7EC4\u7EC7",
399
+ help: "\u62E5\u6709\u6B64\u89C4\u5219\u7684\u79DF\u6237\uFF1B\u7559\u7A7A = \u5BF9\u8BE5\u59D4\u6D3E\u4EBA\u8DE8\u79DF\u6237\u751F\u6548\u3002"
400
+ },
401
+ created_at: {
402
+ label: "\u521B\u5EFA\u65F6\u95F4"
403
+ },
404
+ updated_at: {
405
+ label: "\u66F4\u65B0\u65F6\u95F4"
406
+ }
407
+ },
408
+ _views: {
409
+ active: {
410
+ label: "\u751F\u6548\u4E2D",
411
+ emptyState: {
412
+ title: "\u6682\u65E0\u59D4\u6D3E",
413
+ message: "\u58F0\u660E\u4E00\u6761\u4E0D\u5728\u5C97\u59D4\u6D3E\uFF0C\u4F11\u5047\u671F\u95F4\u5BA1\u6279\u81EA\u52A8\u6539\u6D3E\u7ED9\u5019\u8865\u4EBA\u3002"
414
+ }
415
+ }
416
+ }
319
417
  }
320
418
  };
321
419
  }
@@ -470,6 +568,55 @@ var init_ja_JP_objects_generated = __esm({
470
568
  label: "\u3059\u3079\u3066"
471
569
  }
472
570
  }
571
+ },
572
+ sys_approval_delegation: {
573
+ label: "Approval Delegation",
574
+ pluralLabel: "Approval Delegations",
575
+ description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
576
+ fields: {
577
+ id: {
578
+ label: "Delegation ID"
579
+ },
580
+ delegator_id: {
581
+ label: "Delegator",
582
+ help: "The user going out of office; their individually-routed approver slots are rerouted while active."
583
+ },
584
+ delegate_id: {
585
+ label: "Delegate",
586
+ help: "The backup who receives the delegator's approvals while this rule is active. Acts under their own identity."
587
+ },
588
+ valid_from: {
589
+ label: "Valid From",
590
+ help: "Rule is inactive before this instant. Null = active immediately. Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) \u2014 never by a background job."
591
+ },
592
+ valid_until: {
593
+ label: "Valid Until",
594
+ help: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
595
+ },
596
+ reason: {
597
+ label: "Reason",
598
+ help: 'Why the delegation exists (e.g. "Annual leave 5/26\u20135/30"). Recorded on the substitution audit row.'
599
+ },
600
+ organization_id: {
601
+ label: "Organization",
602
+ help: "Tenant that owns this rule; null = applies across tenants for this delegator."
603
+ },
604
+ created_at: {
605
+ label: "Created At"
606
+ },
607
+ updated_at: {
608
+ label: "Updated At"
609
+ }
610
+ },
611
+ _views: {
612
+ active: {
613
+ label: "Active",
614
+ emptyState: {
615
+ title: "No delegations",
616
+ message: "Declare an out-of-office delegation so approvals route to a backup while you are away."
617
+ }
618
+ }
619
+ }
473
620
  }
474
621
  };
475
622
  }
@@ -624,6 +771,55 @@ var init_es_ES_objects_generated = __esm({
624
771
  label: "Todas"
625
772
  }
626
773
  }
774
+ },
775
+ sys_approval_delegation: {
776
+ label: "Approval Delegation",
777
+ pluralLabel: "Approval Delegations",
778
+ description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
779
+ fields: {
780
+ id: {
781
+ label: "Delegation ID"
782
+ },
783
+ delegator_id: {
784
+ label: "Delegator",
785
+ help: "The user going out of office; their individually-routed approver slots are rerouted while active."
786
+ },
787
+ delegate_id: {
788
+ label: "Delegate",
789
+ help: "The backup who receives the delegator's approvals while this rule is active. Acts under their own identity."
790
+ },
791
+ valid_from: {
792
+ label: "Valid From",
793
+ help: "Rule is inactive before this instant. Null = active immediately. Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) \u2014 never by a background job."
794
+ },
795
+ valid_until: {
796
+ label: "Valid Until",
797
+ help: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
798
+ },
799
+ reason: {
800
+ label: "Reason",
801
+ help: 'Why the delegation exists (e.g. "Annual leave 5/26\u20135/30"). Recorded on the substitution audit row.'
802
+ },
803
+ organization_id: {
804
+ label: "Organization",
805
+ help: "Tenant that owns this rule; null = applies across tenants for this delegator."
806
+ },
807
+ created_at: {
808
+ label: "Created At"
809
+ },
810
+ updated_at: {
811
+ label: "Updated At"
812
+ }
813
+ },
814
+ _views: {
815
+ active: {
816
+ label: "Active",
817
+ emptyState: {
818
+ title: "No delegations",
819
+ message: "Declare an out-of-office delegation so approvals route to a backup while you are away."
820
+ }
821
+ }
822
+ }
627
823
  }
628
824
  };
629
825
  }
@@ -659,7 +855,7 @@ var SysApprovalRequest = ObjectSchema.create({
659
855
  pluralLabel: "Approval Requests",
660
856
  icon: "inbox",
661
857
  isSystem: true,
662
- managedBy: "system",
858
+ managedBy: "engine-owned",
663
859
  description: "Live approval instance tracked per submission",
664
860
  displayNameField: "id",
665
861
  nameField: "id",
@@ -840,7 +1036,172 @@ var SysApprovalRequest = ObjectSchema.create({
840
1036
  // human-readable CSV source of truth only.
841
1037
  { fields: ["status", "updated_at"] },
842
1038
  { fields: ["submitter_id", "status"] }
843
- ]
1039
+ ],
1040
+ // Server-declared decision actions (objectui#2678 P2-4). The console's
1041
+ // generic action runtime renders and executes these wherever this object is
1042
+ // surfaced — the approvals inbox included — so new decision capabilities
1043
+ // (and their params) ship as metadata, not as hand-written buttons. Each
1044
+ // targets the existing approvals REST route; `{id}` resolves from the row
1045
+ // and `actorId` defaults to the caller server-side. The service remains the
1046
+ // authority on who may act; `visible` gates on the server-computed
1047
+ // per-viewer block (#3310): approver actions on `record.viewer.can_act`
1048
+ // (the caller is a current pending approver — same check the service
1049
+ // authorizes a decision with, so position/team approvers resolve correctly),
1050
+ // submitter actions on `record.viewer.is_submitter`. `viewer` is attached by
1051
+ // getRequest/listRequests; where it is absent the predicate fails closed.
1052
+ actions: [
1053
+ {
1054
+ name: "approval_approve",
1055
+ label: "Approve",
1056
+ icon: "check-circle",
1057
+ type: "api",
1058
+ method: "POST",
1059
+ target: "/api/v1/approvals/requests/{id}/approve",
1060
+ params: [
1061
+ { name: "comment", label: "Comment", type: "textarea", required: false },
1062
+ // Decision attachments (#3266). The console renders `type:'file'` params
1063
+ // through the shared upload widget and POSTs the resolved `attachments:
1064
+ // string[]`; the decision route persists them on `sys_approval_action`.
1065
+ { name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
1066
+ ],
1067
+ visible: "record.viewer.can_act",
1068
+ locations: ["record_section", "list_item"],
1069
+ successMessage: "Approved.",
1070
+ refreshAfter: true
1071
+ },
1072
+ {
1073
+ name: "approval_reject",
1074
+ label: "Reject",
1075
+ icon: "x-circle",
1076
+ type: "api",
1077
+ method: "POST",
1078
+ target: "/api/v1/approvals/requests/{id}/reject",
1079
+ params: [
1080
+ { name: "comment", label: "Comment", type: "textarea", required: false },
1081
+ { name: "attachments", label: "Attachments", type: "file", multiple: true, required: false }
1082
+ ],
1083
+ visible: "record.viewer.can_act",
1084
+ confirmText: "Reject this request? A rejection is final for every approver.",
1085
+ locations: ["record_section", "list_item"],
1086
+ successMessage: "Rejected.",
1087
+ refreshAfter: true
1088
+ },
1089
+ {
1090
+ name: "approval_reassign",
1091
+ label: "Reassign",
1092
+ icon: "arrow-right-left",
1093
+ type: "api",
1094
+ method: "POST",
1095
+ target: "/api/v1/approvals/requests/{id}/reassign",
1096
+ params: [
1097
+ // Field-backed on `submitter_id` (the object's only `sys_user` lookup):
1098
+ // the console resolves its lookup config (`reference_to: sys_user`) so the
1099
+ // dialog renders a real user picker, while `name: 'to'` overrides the
1100
+ // request-body key to the `to` the reassign route expects. This is a
1101
+ // config-borrow, not a submitter pre-fill (`defaultFromRow` stays off).
1102
+ { field: "submitter_id", name: "to", label: "New approver", required: true, helpText: "User to hand this step to" },
1103
+ { name: "comment", label: "Comment", type: "textarea", required: false }
1104
+ ],
1105
+ visible: "record.viewer.can_act",
1106
+ locations: ["record_section"],
1107
+ successMessage: "Reassigned.",
1108
+ refreshAfter: true
1109
+ },
1110
+ // ── Approver secondary decisions ────────────────────────────────
1111
+ // Send back for revision / request more info (ADR-0044). Both are approver
1112
+ // actions, so `visible` gates on `record.viewer.can_act` (a current pending
1113
+ // approver) — same as approve/reject. The service stays the authority.
1114
+ {
1115
+ name: "approval_send_back",
1116
+ label: "Send back",
1117
+ icon: "corner-up-left",
1118
+ type: "api",
1119
+ method: "POST",
1120
+ target: "/api/v1/approvals/requests/{id}/revise",
1121
+ params: [
1122
+ { name: "comment", label: "Reason", type: "textarea", required: false }
1123
+ ],
1124
+ visible: "record.viewer.can_act",
1125
+ locations: ["record_section"],
1126
+ successMessage: "Sent back for revision.",
1127
+ refreshAfter: true
1128
+ },
1129
+ {
1130
+ name: "approval_request_info",
1131
+ label: "Request info",
1132
+ icon: "help-circle",
1133
+ type: "api",
1134
+ method: "POST",
1135
+ target: "/api/v1/approvals/requests/{id}/request-info",
1136
+ params: [
1137
+ { name: "comment", label: "What do you need?", type: "textarea", required: true }
1138
+ ],
1139
+ visible: "record.viewer.can_act",
1140
+ locations: ["record_section"],
1141
+ successMessage: "Information requested.",
1142
+ refreshAfter: true
1143
+ },
1144
+ // ── Submitter continuity actions ────────────────────────────────
1145
+ // Remind / recall (pending) and resubmit / recall (returned). These are the
1146
+ // submitter's own levers, so `visible` gates on `record.viewer.is_submitter`
1147
+ // (server-computed on the current viewer). The service re-checks ownership;
1148
+ // the predicate keeps a non-submitter from ever seeing a button they cannot
1149
+ // use.
1150
+ {
1151
+ name: "approval_remind",
1152
+ label: "Send reminder",
1153
+ icon: "bell-ring",
1154
+ type: "api",
1155
+ method: "POST",
1156
+ target: "/api/v1/approvals/requests/{id}/remind",
1157
+ params: [
1158
+ { name: "comment", label: "Note", type: "textarea", required: false }
1159
+ ],
1160
+ visible: 'record.status == "pending" && record.viewer.is_submitter',
1161
+ locations: ["record_section"],
1162
+ successMessage: "Reminder sent.",
1163
+ refreshAfter: true
1164
+ },
1165
+ {
1166
+ name: "approval_recall",
1167
+ label: "Recall",
1168
+ icon: "undo-2",
1169
+ type: "api",
1170
+ method: "POST",
1171
+ target: "/api/v1/approvals/requests/{id}/recall",
1172
+ params: [
1173
+ { name: "comment", label: "Comment", type: "textarea", required: false }
1174
+ ],
1175
+ // Recall applies while the request is live for the submitter — pending
1176
+ // (withdraw) or returned (abandon the revision instead of resubmitting).
1177
+ visible: '(record.status == "pending" || record.status == "returned") && record.viewer.is_submitter',
1178
+ confirmText: "Recall this request? Approvers can no longer act on it and the record is unlocked.",
1179
+ locations: ["record_section"],
1180
+ successMessage: "Recalled.",
1181
+ refreshAfter: true
1182
+ },
1183
+ {
1184
+ name: "approval_resubmit",
1185
+ label: "Resubmit",
1186
+ icon: "refresh-cw",
1187
+ type: "api",
1188
+ method: "POST",
1189
+ target: "/api/v1/approvals/requests/{id}/resubmit",
1190
+ params: [
1191
+ { name: "comment", label: "What changed?", type: "textarea", required: false }
1192
+ ],
1193
+ visible: 'record.status == "returned" && record.viewer.is_submitter',
1194
+ locations: ["record_section"],
1195
+ successMessage: "Resubmitted.",
1196
+ refreshAfter: true
1197
+ }
1198
+ ],
1199
+ enable: {
1200
+ // [ADR-0103] Engine-owned: the approval engine owns the request lifecycle
1201
+ // (SYSTEM_CTX); users act via domain actions (Submit/Approve/Recall), never
1202
+ // generic CRUD. Reads stay open.
1203
+ apiMethods: ["get", "list"]
1204
+ }
844
1205
  });
845
1206
 
846
1207
  // src/sys-approval-action.object.ts
@@ -918,8 +1279,9 @@ var SysApprovalAction = ObjectSchema2.create({
918
1279
  // Keep in sync with `ApprovalActionKind` (spec/contracts). reassign /
919
1280
  // remind / request_info / comment are thread interactions — they never
920
1281
  // move the flow. revise / resubmit (ADR-0044) DO move it: send back for
921
- // revision and the later resubmission.
922
- ["submit", "approve", "reject", "recall", "escalate", "reassign", "remind", "request_info", "comment", "revise", "resubmit"],
1282
+ // revision and the later resubmission. ooo_substitute (#1322 M1) is a
1283
+ // system-recorded reroute of an out-of-office approver no flow movement.
1284
+ ["submit", "approve", "reject", "recall", "escalate", "reassign", "remind", "request_info", "comment", "revise", "resubmit", "ooo_substitute"],
923
1285
  {
924
1286
  label: "Action",
925
1287
  required: true,
@@ -932,6 +1294,13 @@ var SysApprovalAction = ObjectSchema2.create({
932
1294
  group: "Action"
933
1295
  }),
934
1296
  comment: Field2.textarea({ label: "Comment", required: false, group: "Action" }),
1297
+ attachments: Field2.file({
1298
+ label: "Attachments",
1299
+ required: false,
1300
+ multiple: true,
1301
+ group: "Action",
1302
+ description: "Files supporting this action \u2014 e.g. a signed contract or evidence (#3266)."
1303
+ }),
935
1304
  created_at: Field2.datetime({
936
1305
  label: "Created At",
937
1306
  required: true,
@@ -943,7 +1312,12 @@ var SysApprovalAction = ObjectSchema2.create({
943
1312
  indexes: [
944
1313
  { fields: ["request_id", "created_at"] },
945
1314
  { fields: ["request_id", "step_index", "action"] }
946
- ]
1315
+ ],
1316
+ enable: {
1317
+ // [ADR-0103] Engine-owned append-only decision log: appended by the approval
1318
+ // engine (SYSTEM_CTX). Reads stay open.
1319
+ apiMethods: ["get", "list"]
1320
+ }
947
1321
  });
948
1322
 
949
1323
  // src/sys-approval-approver.object.ts
@@ -954,7 +1328,7 @@ var SysApprovalApprover = ObjectSchema3.create({
954
1328
  pluralLabel: "Approval Approvers",
955
1329
  icon: "users",
956
1330
  isSystem: true,
957
- managedBy: "system",
1331
+ managedBy: "engine-owned",
958
1332
  description: "Normalized pending-approver rows for indexed inbox queries",
959
1333
  displayNameField: "id",
960
1334
  nameField: "id",
@@ -994,20 +1368,128 @@ var SysApprovalApprover = ObjectSchema3.create({
994
1368
  { fields: ["approver", "organization_id"] },
995
1369
  // Sync path: rewrite all rows of one request on each approver-set change.
996
1370
  { fields: ["request_id"] }
997
- ]
1371
+ ],
1372
+ enable: {
1373
+ // [ADR-0103] Engine-owned: approver rows are rewritten by the approval
1374
+ // engine (SYSTEM_CTX) on each approver-set change, never via generic CRUD.
1375
+ apiMethods: ["get", "list"]
1376
+ }
1377
+ });
1378
+
1379
+ // src/sys-approval-delegation.object.ts
1380
+ import { ObjectSchema as ObjectSchema4, Field as Field4 } from "@objectstack/spec/data";
1381
+ var SysApprovalDelegation = ObjectSchema4.create({
1382
+ name: "sys_approval_delegation",
1383
+ label: "Approval Delegation",
1384
+ pluralLabel: "Approval Delegations",
1385
+ icon: "user-clock",
1386
+ isSystem: true,
1387
+ managedBy: "system",
1388
+ // [ADR-0103] Admin/user-writable DATA on a platform-defined schema: a user
1389
+ // authors their own out-of-office delegation. Affordance only (matches the
1390
+ // full-CRUD apiMethods below) — RLS/permission sets are the authz; opening it
1391
+ // keeps the system write guard from rejecting the self-service write.
1392
+ userActions: { create: true, edit: true, delete: true },
1393
+ description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
1394
+ titleFormat: "{delegator_id} \u2192 {delegate_id}",
1395
+ highlightFields: ["delegator_id", "delegate_id", "valid_from", "valid_until"],
1396
+ listViews: {
1397
+ active: {
1398
+ type: "grid",
1399
+ name: "active",
1400
+ label: "Active",
1401
+ data: { provider: "object", object: "sys_approval_delegation" },
1402
+ columns: ["delegator_id", "delegate_id", "valid_from", "valid_until", "reason"],
1403
+ sort: [{ field: "valid_until", order: "asc" }],
1404
+ pagination: { pageSize: 50 },
1405
+ emptyState: {
1406
+ title: "No delegations",
1407
+ message: "Declare an out-of-office delegation so approvals route to a backup while you are away."
1408
+ }
1409
+ }
1410
+ },
1411
+ fields: {
1412
+ id: Field4.text({ label: "Delegation ID", required: true, readonly: true, group: "System" }),
1413
+ delegator_id: Field4.lookup("sys_user", {
1414
+ label: "Delegator",
1415
+ required: true,
1416
+ group: "Delegation",
1417
+ description: "The user going out of office; their individually-routed approver slots are rerouted while active."
1418
+ }),
1419
+ delegate_id: Field4.lookup("sys_user", {
1420
+ label: "Delegate",
1421
+ required: true,
1422
+ group: "Delegation",
1423
+ description: "The backup who receives the delegator's approvals while this rule is active. Acts under their own identity."
1424
+ }),
1425
+ valid_from: Field4.datetime({
1426
+ label: "Valid From",
1427
+ required: false,
1428
+ group: "Delegation",
1429
+ description: "Rule is inactive before this instant. Null = active immediately. Enforced at resolution time via isGrantActive (ADR-0091 D2 predicate) \u2014 never by a background job."
1430
+ }),
1431
+ valid_until: Field4.datetime({
1432
+ label: "Valid Until",
1433
+ required: false,
1434
+ group: "Delegation",
1435
+ description: "Rule is inactive AT and AFTER this instant (half-open [from, until), UTC). Null = never expires."
1436
+ }),
1437
+ reason: Field4.text({
1438
+ label: "Reason",
1439
+ required: false,
1440
+ maxLength: 500,
1441
+ group: "Delegation",
1442
+ description: 'Why the delegation exists (e.g. "Annual leave 5/26\u20135/30"). Recorded on the substitution audit row.'
1443
+ }),
1444
+ organization_id: Field4.lookup("sys_organization", {
1445
+ label: "Organization",
1446
+ required: false,
1447
+ group: "System",
1448
+ description: "Tenant that owns this rule; null = applies across tenants for this delegator."
1449
+ }),
1450
+ created_at: Field4.datetime({
1451
+ label: "Created At",
1452
+ defaultValue: "NOW()",
1453
+ readonly: true,
1454
+ group: "System"
1455
+ }),
1456
+ updated_at: Field4.datetime({
1457
+ label: "Updated At",
1458
+ defaultValue: "NOW()",
1459
+ readonly: true,
1460
+ group: "System"
1461
+ })
1462
+ },
1463
+ indexes: [
1464
+ // Resolution-time lookup: "active delegations for this delegator".
1465
+ { fields: ["delegator_id", "organization_id"] },
1466
+ { fields: ["delegate_id"] },
1467
+ { fields: ["valid_until"] }
1468
+ ],
1469
+ enable: {
1470
+ trackHistory: true,
1471
+ searchable: true,
1472
+ apiEnabled: true,
1473
+ apiMethods: ["get", "list", "create", "update", "delete"],
1474
+ trash: true,
1475
+ mru: false
1476
+ }
998
1477
  });
999
1478
 
1000
1479
  // src/approval-service.ts
1001
1480
  import { createHash, randomBytes } from "crypto";
1002
1481
  import {
1003
- APPROVAL_BRANCH_LABELS
1482
+ APPROVAL_BRANCH_LABELS,
1483
+ canonicalApproverType
1004
1484
  } from "@objectstack/spec/automation";
1485
+ import { isGrantActive } from "@objectstack/core";
1005
1486
  var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
1006
1487
  var ESCALATION_JOB_NAME = "approvals-sla-escalation";
1007
1488
  var ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1e3;
1008
1489
  var SLA_ACTOR_ID = "system:sla";
1009
1490
  var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
1010
1491
  var SYSTEM_CTX = { isSystem: true, positions: [], permissions: [] };
1492
+ var OOO_MAX_CHAIN = 8;
1011
1493
  function uid(prefix) {
1012
1494
  const g = globalThis;
1013
1495
  if (g.crypto?.randomUUID) return `${prefix}_${g.crypto.randomUUID()}`;
@@ -1080,6 +1562,10 @@ function rowFromAction(row) {
1080
1562
  action: row.action,
1081
1563
  actor_id: row.actor_id ?? void 0,
1082
1564
  comment: row.comment ?? void 0,
1565
+ // Decision attachments (#3266). The column shipped in #3268 but this
1566
+ // contract mapping didn't — the raw engine row carried the fileIds while
1567
+ // every consumer of listActions saw none (caught by browser verification).
1568
+ attachments: Array.isArray(row.attachments) && row.attachments.length ? row.attachments.map(String) : void 0,
1083
1569
  created_at: row.created_at ?? void 0
1084
1570
  };
1085
1571
  }
@@ -1104,8 +1590,12 @@ var _ApprovalService = class _ApprovalService {
1104
1590
  async notify(input) {
1105
1591
  const audience = input.audience.filter((a) => a && !a.includes(":"));
1106
1592
  if (!this.messaging || !audience.length) return 0;
1593
+ let payload = input.payload;
1594
+ if (payload?.actionUrl === "/system/approvals" && input.source?.object === "sys_approval_request" && input.source.id) {
1595
+ payload = { ...payload, actionUrl: `/system/approvals?request=${encodeURIComponent(input.source.id)}` };
1596
+ }
1107
1597
  try {
1108
- await this.messaging.emit({ severity: "info", ...input, audience });
1598
+ await this.messaging.emit({ severity: "info", ...input, payload, audience });
1109
1599
  return audience.length;
1110
1600
  } catch (err) {
1111
1601
  this.logger?.warn?.("[approvals] notification failed", {
@@ -1130,10 +1620,10 @@ var _ApprovalService = class _ApprovalService {
1130
1620
  }
1131
1621
  /**
1132
1622
  * Expand the approvers on an Approval node into user IDs by querying the
1133
- * graph tables for `team:` / `department:` / `position:` / `role:` /
1134
- * `manager:` approver types. Falls back to a prefixed literal
1135
- * (`type:value`) when graph lookups produce nothing — so existing fixtures
1136
- * and flows that rely on substring matching keep working.
1623
+ * graph tables for `team:` / `department:` / `position:` /
1624
+ * `org_membership_level:` / `manager:` approver types. Falls back to a
1625
+ * prefixed literal (`type:value`) when graph lookups produce nothing — so
1626
+ * existing fixtures and flows that rely on substring matching keep working.
1137
1627
  *
1138
1628
  * **Graph semantics:**
1139
1629
  * - `team` → flat members of `sys_team` (better-auth; no BFS)
@@ -1141,66 +1631,90 @@ var _ApprovalService = class _ApprovalService {
1141
1631
  * → members of every descendant via `sys_business_unit_member`
1142
1632
  * - `position` → holders via `sys_user_position` ∪ `sys_member.role`
1143
1633
  * transition source (ADR-0090 D3 / ADR-0057 D4)
1144
- * - `role` → users with `sys_member.role = value` in tenant — the
1634
+ * - `org_membership_level`
1635
+ * → users with `sys_member.role = value` in tenant — the
1145
1636
  * better-auth MEMBERSHIP TIER (owner/admin/member), not a
1146
1637
  * position; author `position` for org positions
1147
1638
  * - `manager` → `sys_user.manager_id` of `record[value] ?? record.owner_id`
1148
1639
  * - `field` → literal user id stored in `record[value]`
1149
1640
  * - `user` → literal value
1641
+ *
1642
+ * `role` is accepted as the deprecated spelling of `org_membership_level`
1643
+ * (ADR-0090 D3) for one window: it resolves identically and logs a warning.
1644
+ *
1645
+ * **Out-of-office (#1322 M1):** individually-routed approvers — the ones that
1646
+ * resolve to a specific person (`user` / `field` / `manager`) — are passed
1647
+ * through {@link ApprovalService.applyOooDelegation}, which reroutes them onto
1648
+ * an active delegate when the resolved user has declared OOO. Group/graph
1649
+ * approvers (`team` / `department` / `position` / `org_membership_level`) are
1650
+ * left untouched: a group still has its other members, and position-routed
1651
+ * leave is already covered by ADR-0091 job delegation. Pass an `opts.now` /
1652
+ * `opts.substitutions` collector to record the hops for audit + notification.
1150
1653
  */
1151
- async expandApprovers(step, record, organizationId) {
1654
+ async expandApprovers(step, record, organizationId, opts) {
1655
+ var _a;
1152
1656
  if (!step || !Array.isArray(step.approvers)) return [];
1657
+ const now = opts?.now ?? this.clock.now().getTime();
1153
1658
  const out = [];
1154
- for (const a of step.approvers) {
1659
+ const specs = step.approvers;
1660
+ for (let idx = 0; idx < specs.length; idx++) {
1661
+ const a = specs[idx];
1155
1662
  if (!a) continue;
1156
- if (a.type === "user") {
1157
- out.push(String(a.value));
1158
- continue;
1663
+ const ids = await this.resolveApproverSpec(a, record, organizationId, now, opts?.substitutions);
1664
+ const groupKey = a.group != null && String(a.group) !== "" ? String(a.group) : `#${idx}`;
1665
+ for (const u of ids) {
1666
+ if (!u) continue;
1667
+ out.push(u);
1668
+ if (opts?.groups) ((_a = opts.groups)[u] ?? (_a[u] = [])).push(groupKey);
1159
1669
  }
1160
- if (a.type === "field" && record) {
1161
- out.push(String(record[a.value] ?? ""));
1162
- continue;
1163
- }
1164
- try {
1165
- if (a.type === "team") {
1166
- const users = await this.expandTeamUsers(String(a.value));
1167
- if (users.length) {
1168
- for (const u of users) out.push(u);
1169
- continue;
1170
- }
1171
- } else if (a.type === "department" || a.type === "business_unit" || a.type === "bu") {
1172
- const users = await this.expandBusinessUnitUsers(String(a.value), organizationId);
1173
- if (users.length) {
1174
- for (const u of users) out.push(u);
1175
- continue;
1176
- }
1177
- } else if (a.type === "position") {
1178
- const users = await this.expandPositionUsers(String(a.value), organizationId);
1179
- if (users.length) {
1180
- for (const u of users) out.push(u);
1181
- continue;
1182
- }
1183
- } else if (a.type === "role") {
1184
- const users = await this.expandRoleUsers(String(a.value), organizationId);
1185
- if (users.length) {
1186
- for (const u of users) out.push(u);
1187
- continue;
1188
- }
1189
- } else if (a.type === "manager" && record) {
1190
- const subject = record[a.value] ?? record.owner_id;
1191
- if (subject) {
1192
- const mgr = await this.lookupManager(String(subject));
1193
- if (mgr) {
1194
- out.push(mgr);
1195
- continue;
1196
- }
1197
- }
1670
+ }
1671
+ return out.filter(Boolean);
1672
+ }
1673
+ /**
1674
+ * Resolve ONE approver spec to concrete approver identities, applying OOO
1675
+ * substitution (#1322) to individually-routed types. Extracted from
1676
+ * {@link ApprovalService.expandApprovers} so the caller can tag each spec's
1677
+ * resolved ids with a group (#3266) without duplicating the resolution logic.
1678
+ * Returns the `type:value` literal as a single-element fallback when a graph
1679
+ * lookup yields nothing — same behaviour as before the extraction.
1680
+ */
1681
+ async resolveApproverSpec(a, record, organizationId, now, substitutions) {
1682
+ const type = canonicalApproverType(String(a.type));
1683
+ if (type !== a.type) {
1684
+ this.logger?.warn?.(
1685
+ `[approvals] approver type '${a.type}' is deprecated (ADR-0090 D3) \u2014 author '${type}' instead`,
1686
+ { deprecated: a.type, canonical: type }
1687
+ );
1688
+ }
1689
+ if (type === "user") {
1690
+ return this.applyOooDelegation(String(a.value), now, organizationId, substitutions);
1691
+ }
1692
+ if (type === "field" && record) {
1693
+ return this.applyOooDelegation(String(record[a.value] ?? ""), now, organizationId, substitutions);
1694
+ }
1695
+ try {
1696
+ if (type === "team") {
1697
+ const users = await this.expandTeamUsers(String(a.value));
1698
+ if (users.length) return users;
1699
+ } else if (type === "department" || type === "business_unit" || type === "bu") {
1700
+ const users = await this.expandBusinessUnitUsers(String(a.value), organizationId);
1701
+ if (users.length) return users;
1702
+ } else if (type === "position") {
1703
+ const users = await this.expandPositionUsers(String(a.value), organizationId);
1704
+ if (users.length) return users;
1705
+ } else if (type === "org_membership_level") {
1706
+ const users = await this.expandMembershipTierUsers(String(a.value), organizationId);
1707
+ if (users.length) return users;
1708
+ } else if (type === "manager" && record) {
1709
+ const subject = record[a.value] ?? record.owner_id;
1710
+ if (subject) {
1711
+ const mgr = await this.lookupManager(String(subject));
1712
+ if (mgr) return this.applyOooDelegation(mgr, now, organizationId, substitutions);
1198
1713
  }
1199
- } catch {
1200
1714
  }
1201
- out.push(`${a.type}:${a.value}`);
1715
+ } catch {
1202
1716
  }
1203
- return out.filter(Boolean);
1717
+ return [`${a.type}:${a.value}`];
1204
1718
  }
1205
1719
  /** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
1206
1720
  async expandTeamUsers(teamId) {
@@ -1292,13 +1806,18 @@ var _ApprovalService = class _ApprovalService {
1292
1806
  }
1293
1807
  } catch {
1294
1808
  }
1295
- for (const uid2 of await this.expandRoleUsers(positionName, organizationId)) users.add(uid2);
1809
+ for (const uid2 of await this.expandMembershipTierUsers(positionName, organizationId)) users.add(uid2);
1296
1810
  return Array.from(users);
1297
1811
  }
1298
- /** better-auth org-membership tier (`sys_member.role`) — NOT positions. */
1299
- async expandRoleUsers(roleName, organizationId) {
1300
- if (!roleName) return [];
1301
- const filter = { role: roleName };
1812
+ /**
1813
+ * better-auth org-membership tier (`sys_member.role`: owner/admin/member)
1814
+ * NOT positions. Named for the projection (`org_membership_level`, ADR-0057
1815
+ * D7 / ADR-0090 D3), not for better-auth's column: the column name is theirs
1816
+ * and stays, the platform-facing word does not.
1817
+ */
1818
+ async expandMembershipTierUsers(tier, organizationId) {
1819
+ if (!tier) return [];
1820
+ const filter = { role: tier };
1302
1821
  if (organizationId) filter.organization_id = organizationId;
1303
1822
  let rows = [];
1304
1823
  try {
@@ -1322,6 +1841,62 @@ var _ApprovalService = class _ApprovalService {
1322
1841
  return null;
1323
1842
  }
1324
1843
  }
1844
+ /**
1845
+ * Out-of-office auto-skip (#1322 M1). Given an individually-routed approver
1846
+ * id, follow any active `sys_approval_delegation` chain and return the id the
1847
+ * slot should actually go to — the delegate acts under their own identity, so
1848
+ * no impersonation is involved. Returns `[userId]` unchanged when there is no
1849
+ * active delegation. Each hop is appended to `collector` (when supplied) so
1850
+ * the caller can audit + notify (M4).
1851
+ *
1852
+ * The chain (A out → B, B out → C, …) is bounded by {@link OOO_MAX_CHAIN} and
1853
+ * stops on a self-reference or a cycle, so a mis-declared loop degrades to the
1854
+ * last reachable delegate rather than hanging.
1855
+ */
1856
+ async applyOooDelegation(userId, now, organizationId, collector) {
1857
+ const start = String(userId ?? "").trim();
1858
+ if (!start) return [];
1859
+ let current = start;
1860
+ const visited = /* @__PURE__ */ new Set([current]);
1861
+ for (let hop = 0; hop < OOO_MAX_CHAIN; hop++) {
1862
+ const del = await this.lookupActiveDelegation(current, now, organizationId);
1863
+ if (!del) break;
1864
+ const to = String(del.delegate_id ?? "").trim();
1865
+ if (!to || to === current || visited.has(to)) break;
1866
+ collector?.push({ from: current, to, reason: del.reason != null ? String(del.reason) : null });
1867
+ visited.add(to);
1868
+ current = to;
1869
+ }
1870
+ return [current];
1871
+ }
1872
+ /**
1873
+ * The active OOO delegation for a delegator at `now`, or null. Validity is the
1874
+ * shared `isGrantActive` half-open window (ADR-0091 D2), enforced here at
1875
+ * resolution time — never by a background job. When several rows are active,
1876
+ * the one expiring soonest wins (the most specific coverage window).
1877
+ */
1878
+ async lookupActiveDelegation(delegatorId, now, organizationId) {
1879
+ if (!delegatorId) return null;
1880
+ let rows = [];
1881
+ try {
1882
+ rows = await this.engine.find("sys_approval_delegation", {
1883
+ filter: { delegator_id: delegatorId },
1884
+ fields: ["id", "delegator_id", "delegate_id", "valid_from", "valid_until", "reason", "organization_id"],
1885
+ limit: 50,
1886
+ context: SYSTEM_CTX
1887
+ });
1888
+ } catch {
1889
+ return null;
1890
+ }
1891
+ const active = (rows ?? []).filter((r) => isGrantActive(r, now) && (organizationId == null || r.organization_id == null || String(r.organization_id) === String(organizationId)));
1892
+ if (!active.length) return null;
1893
+ active.sort((a, b) => {
1894
+ const au = a.valid_until ? Date.parse(String(a.valid_until)) : Number.POSITIVE_INFINITY;
1895
+ const bu = b.valid_until ? Date.parse(String(b.valid_until)) : Number.POSITIVE_INFINITY;
1896
+ return au - bu;
1897
+ });
1898
+ return active[0];
1899
+ }
1325
1900
  /** Mirror a request status onto a business-object field, if configured. */
1326
1901
  async mirrorStatusField(object, recordId, field, status) {
1327
1902
  try {
@@ -1355,13 +1930,24 @@ var _ApprovalService = class _ApprovalService {
1355
1930
  throw new Error(`DUPLICATE_REQUEST: a pending approval already exists for ${input.object}/${input.recordId}`);
1356
1931
  }
1357
1932
  const ctxOrg = context?.organizationId ?? context?.tenantId ?? input.organizationId ?? null;
1358
- const approvers = await this.expandApprovers({ approvers: input.config.approvers }, input.record, ctxOrg);
1359
- const now = this.clock.now().toISOString();
1933
+ const nowDate = this.clock.now();
1934
+ const substitutions = [];
1935
+ const groups = {};
1936
+ const approvers = await this.expandApprovers(
1937
+ { approvers: input.config.approvers },
1938
+ input.record,
1939
+ ctxOrg,
1940
+ { now: nowDate.getTime(), substitutions, groups }
1941
+ );
1942
+ const now = nowDate.toISOString();
1360
1943
  const id = uid("areq");
1361
1944
  const processName = `flow:${input.flowName ?? input.nodeId}`;
1362
1945
  const configSnapshot = { ...input.config };
1363
1946
  if (input.flowLabel) configSnapshot.__flowLabel = input.flowLabel;
1364
1947
  if (input.nodeLabel) configSnapshot.__nodeLabel = input.nodeLabel;
1948
+ if (input.config.behavior === "quorum" || input.config.behavior === "per_group") {
1949
+ configSnapshot.__approverGroups = groups;
1950
+ }
1365
1951
  try {
1366
1952
  const prior = await this.engine.find("sys_approval_request", {
1367
1953
  where: { flow_run_id: input.runId, flow_node_id: input.nodeId },
@@ -1403,17 +1989,83 @@ var _ApprovalService = class _ApprovalService {
1403
1989
  comment: null,
1404
1990
  created_at: now
1405
1991
  }, { context: SYSTEM_CTX });
1992
+ for (const sub of substitutions) {
1993
+ await this.engine.insert("sys_approval_action", {
1994
+ id: uid("aact"),
1995
+ request_id: id,
1996
+ organization_id: ctxOrg,
1997
+ step_name: input.nodeId,
1998
+ step_index: 0,
1999
+ action: "ooo_substitute",
2000
+ actor_id: null,
2001
+ comment: `${sub.from} \u2192 ${sub.to}${sub.reason ? ` \u2014 ${sub.reason}` : ""}`,
2002
+ created_at: now
2003
+ }, { context: SYSTEM_CTX });
2004
+ await this.notify({
2005
+ topic: "approval.ooo_substituted",
2006
+ audience: [sub.to],
2007
+ source: { object: "sys_approval_request", id },
2008
+ dedupKey: `approval-ooo-${id}-${sub.to}`,
2009
+ payload: {
2010
+ title: "Approval routed to you (out-of-office cover)",
2011
+ message: `You are covering an approval on ${input.object}/${input.recordId} while ${sub.from} is out of office.`,
2012
+ actionUrl: "/system/approvals"
2013
+ }
2014
+ });
2015
+ await this.notify({
2016
+ topic: "approval.ooo_skipped",
2017
+ audience: [sub.from],
2018
+ source: { object: "sys_approval_request", id },
2019
+ dedupKey: `approval-ooo-skip-${id}-${sub.from}`,
2020
+ payload: {
2021
+ title: "Approval routed to your delegate",
2022
+ message: `An approval on ${input.object}/${input.recordId} was routed to ${sub.to} while you are out of office.`,
2023
+ actionUrl: "/system/approvals"
2024
+ }
2025
+ });
2026
+ }
1406
2027
  if (input.config.approvalStatusField) {
1407
2028
  await this.mirrorStatusField(input.object, input.recordId, input.config.approvalStatusField, "pending");
1408
2029
  }
1409
2030
  return rowFromRequest(row);
1410
2031
  }
1411
2032
  /**
1412
- * Record a decision on a node-driven request. Honours the node's `unanimous`
1413
- * behavior (holds until every approver has approved). When the request
1414
- * finalizes, returns the suspended run id + node id so the caller (or
1415
- * {@link ApprovalService.decide}) can resume the flow down the matching
1416
- * branch.
2033
+ * True when the approve tally satisfies the node's `behavior` (#3266):
2034
+ * - `unanimous` every resolved approver approved.
2035
+ * - `quorum` at least `minApprovals` distinct approvals (default = all).
2036
+ * - `per_group` every group reached `minApprovals` approvals (default 1).
2037
+ * Thresholds are clamped to the resolvable count / group size, so a mis-set
2038
+ * value can never deadlock a request.
2039
+ */
2040
+ isApprovalSatisfied(behavior, config, original, groupMap, approved) {
2041
+ if (behavior === "unanimous") {
2042
+ return original.length > 0 && original.every((a) => approved.has(a));
2043
+ }
2044
+ if (behavior === "quorum") {
2045
+ const n = original.length || 1;
2046
+ const need = Math.min(Math.max(1, config.minApprovals ?? n), n);
2047
+ return approved.size >= need;
2048
+ }
2049
+ if (behavior === "per_group") {
2050
+ const perGroupNeed = Math.max(1, config.minApprovals ?? 1);
2051
+ const size = {};
2052
+ for (const gs of Object.values(groupMap)) for (const g of gs) size[g] = (size[g] ?? 0) + 1;
2053
+ const groups = Object.keys(size);
2054
+ if (!groups.length) return true;
2055
+ const got = {};
2056
+ for (const a of approved) for (const g of groupMap[a] ?? []) got[g] = (got[g] ?? 0) + 1;
2057
+ return groups.every((g) => (got[g] ?? 0) >= Math.min(perGroupNeed, size[g]));
2058
+ }
2059
+ return true;
2060
+ }
2061
+ /**
2062
+ * Record a decision on a node-driven request. Honours the node's `behavior`
2063
+ * (#3266): `first_response` finalizes on the first approval; `unanimous`,
2064
+ * `quorum`, and `per_group` hold the request open until their tally is met
2065
+ * (see {@link ApprovalService.isApprovalSatisfied}). A rejection always
2066
+ * finalizes the node (one veto). When the request finalizes, returns the
2067
+ * suspended run id + node id so the caller (or {@link ApprovalService.decide})
2068
+ * can resume the flow down the matching branch.
1417
2069
  */
1418
2070
  async decideNode(requestId, input, context) {
1419
2071
  if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
@@ -1447,22 +2099,33 @@ var _ApprovalService = class _ApprovalService {
1447
2099
  action: input.decision,
1448
2100
  actor_id: input.actorId,
1449
2101
  comment: input.comment ?? null,
2102
+ attachments: input.attachments?.length ? input.attachments : null,
1450
2103
  created_at: now
1451
2104
  }, { context: SYSTEM_CTX });
1452
- if (input.decision === "approve" && config.behavior === "unanimous") {
1453
- const original = await this.expandApprovers(
1454
- { approvers: config.approvers },
1455
- parseJson(raw.payload_json, void 0),
1456
- org
1457
- );
2105
+ const behavior = config.behavior ?? "first_response";
2106
+ if (input.decision === "approve" && behavior !== "first_response") {
1458
2107
  const acts = await this.engine.find("sys_approval_action", {
1459
2108
  where: { request_id: requestId, step_index: 0, action: "approve" },
1460
- limit: 500,
2109
+ limit: 1e3,
1461
2110
  context: SYSTEM_CTX
1462
2111
  });
1463
2112
  const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
1464
- const stillPending = original.filter((a) => !approved.has(a));
1465
- if (stillPending.length > 0) {
2113
+ const snapshotGroups = config.__approverGroups;
2114
+ let original;
2115
+ let groupMap;
2116
+ if (snapshotGroups && (behavior === "quorum" || behavior === "per_group")) {
2117
+ groupMap = snapshotGroups;
2118
+ original = Object.keys(snapshotGroups);
2119
+ } else {
2120
+ original = await this.expandApprovers(
2121
+ { approvers: config.approvers },
2122
+ parseJson(raw.payload_json, void 0),
2123
+ org
2124
+ );
2125
+ groupMap = {};
2126
+ }
2127
+ if (!this.isApprovalSatisfied(behavior, config, original, groupMap, approved)) {
2128
+ const stillPending = original.filter((a) => !approved.has(a));
1466
2129
  await this.engine.update("sys_approval_request", {
1467
2130
  id: requestId,
1468
2131
  pending_approvers: stillPending.join(","),
@@ -1896,10 +2559,22 @@ var _ApprovalService = class _ApprovalService {
1896
2559
  comment: input.comment ?? `${from} \u2192 ${to}`,
1897
2560
  created_at: now
1898
2561
  }, { context: SYSTEM_CTX });
2562
+ let configPatch = {};
2563
+ try {
2564
+ const cfg = parseJson(raw.node_config_json, null);
2565
+ const groups = cfg?.__approverGroups;
2566
+ if (groups && groups[from] && !groups[to]) {
2567
+ groups[to] = groups[from];
2568
+ delete groups[from];
2569
+ configPatch = { node_config_json: JSON.stringify(cfg) };
2570
+ }
2571
+ } catch {
2572
+ }
1899
2573
  await this.engine.update("sys_approval_request", {
1900
2574
  id: requestId,
1901
2575
  pending_approvers: next.join(","),
1902
- updated_at: now
2576
+ updated_at: now,
2577
+ ...configPatch
1903
2578
  }, { context: SYSTEM_CTX });
1904
2579
  await this.syncApproverIndex(requestId, next, raw.organization_id ?? null, now);
1905
2580
  await this.notify({
@@ -2144,6 +2819,7 @@ var _ApprovalService = class _ApprovalService {
2144
2819
  action: "comment",
2145
2820
  actor_id: input.actorId,
2146
2821
  comment: input.comment.trim(),
2822
+ attachments: input.attachments?.length ? input.attachments : null,
2147
2823
  created_at: now
2148
2824
  }, { context: SYSTEM_CTX });
2149
2825
  const audience = isSubmitter ? pending : [String(raw.submitter_id ?? "")].filter(Boolean);
@@ -2661,6 +3337,7 @@ var _ApprovalService = class _ApprovalService {
2661
3337
  const rows = await this.engine.find("sys_approval_request", findOpts);
2662
3338
  const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
2663
3339
  await this.enrichRows(list);
3340
+ this.attachViewers(list, context);
2664
3341
  return list;
2665
3342
  }
2666
3343
  async countRequests(filter, context) {
@@ -2701,8 +3378,74 @@ var _ApprovalService = class _ApprovalService {
2701
3378
  const row = rowFromRequest(rows[0]);
2702
3379
  await this.enrichRows([row]);
2703
3380
  await this.attachFlowSteps(row);
3381
+ await this.attachDecisionProgress(row, rows[0]);
3382
+ this.attachViewers([row], context);
2704
3383
  return row;
2705
3384
  }
3385
+ /**
3386
+ * Server-computed decision aggregation progress (#3266 / objectui#2678 P1.5).
3387
+ * Single-read enrichment only (like {@link ApprovalService.attachFlowSteps}):
3388
+ * for a PENDING request whose behavior aggregates multiple approvals
3389
+ * (`unanimous` / `quorum` / `per_group`), expose
3390
+ * `decision_progress: { behavior, got, need, groups? }` so any client renders
3391
+ * "2 of 3" or per-group ticks without re-deriving the engine's tally rules.
3392
+ * `first_response` requests carry no progress (one approval finalizes).
3393
+ * Display-only and best-effort — errors leave the row untouched.
3394
+ */
3395
+ async attachDecisionProgress(row, raw) {
3396
+ try {
3397
+ if (row.status !== "pending") return;
3398
+ const cfg = parseJson(raw.node_config_json, void 0);
3399
+ const behavior = cfg?.behavior ?? "first_response";
3400
+ if (behavior !== "unanimous" && behavior !== "quorum" && behavior !== "per_group") return;
3401
+ const acts = await this.engine.find("sys_approval_action", {
3402
+ where: { request_id: row.id, step_index: 0, action: "approve" },
3403
+ limit: 1e3,
3404
+ context: SYSTEM_CTX
3405
+ });
3406
+ const approved = new Set((acts ?? []).map((a) => String(a.actor_id ?? "")).filter(Boolean));
3407
+ const snapshot = cfg?.__approverGroups;
3408
+ const slate = snapshot ? Object.keys(snapshot) : [...approved, ...row.pending_approvers ?? []];
3409
+ const total = slate.length || 1;
3410
+ const progress = { behavior, got: approved.size, need: total };
3411
+ if (behavior === "quorum") {
3412
+ progress.need = Math.min(Math.max(1, cfg?.minApprovals ?? total), total);
3413
+ } else if (behavior === "per_group" && snapshot) {
3414
+ const perGroupNeed = Math.max(1, cfg?.minApprovals ?? 1);
3415
+ const size = {};
3416
+ for (const gs of Object.values(snapshot)) for (const g of gs) size[g] = (size[g] ?? 0) + 1;
3417
+ const got = {};
3418
+ for (const a of approved) for (const g of snapshot[a] ?? []) got[g] = (got[g] ?? 0) + 1;
3419
+ progress.groups = Object.keys(size).sort().map((g) => {
3420
+ const need = Math.min(perGroupNeed, size[g]);
3421
+ return { group: g, got: Math.min(got[g] ?? 0, need), need, satisfied: (got[g] ?? 0) >= need };
3422
+ });
3423
+ progress.got = progress.groups.filter((g) => g.satisfied).length;
3424
+ progress.need = progress.groups.length;
3425
+ }
3426
+ row.decision_progress = progress;
3427
+ } catch {
3428
+ }
3429
+ }
3430
+ /**
3431
+ * Attach the per-viewer capability block (#3310) from the caller's context.
3432
+ * `can_act` mirrors the exact authorization the decision methods enforce — the
3433
+ * caller's user id is in the resolved `pending_approvers` while the request is
3434
+ * still `pending` (position/team/manager approvers are already resolved to
3435
+ * concrete user ids at open time, so a plain membership test is faithful).
3436
+ * `is_submitter` is a straight owner check. System/tokenless contexts get a
3437
+ * both-false block. Cheap + synchronous — safe on list reads.
3438
+ */
3439
+ attachViewers(rows, context) {
3440
+ const uid2 = context?.userId != null ? String(context.userId) : null;
3441
+ for (const row of rows) {
3442
+ const pending = row.pending_approvers ?? [];
3443
+ row.viewer = {
3444
+ can_act: row.status === "pending" && !!uid2 && pending.includes(uid2),
3445
+ is_submitter: !!uid2 && row.submitter_id != null && String(row.submitter_id) === uid2
3446
+ };
3447
+ }
3448
+ }
2706
3449
  /**
2707
3450
  * Derive approval-step progress from the owning flow's graph (single-read
2708
3451
  * enrichment only — list reads skip it). Walks from the start node
@@ -2764,26 +3507,26 @@ _ApprovalService.APPROVER_INDEX_CAP = 1e4;
2764
3507
  var ApprovalService = _ApprovalService;
2765
3508
 
2766
3509
  // src/sys-approval-token.object.ts
2767
- import { ObjectSchema as ObjectSchema4, Field as Field4 } from "@objectstack/spec/data";
2768
- var SysApprovalToken = ObjectSchema4.create({
3510
+ import { ObjectSchema as ObjectSchema5, Field as Field5 } from "@objectstack/spec/data";
3511
+ var SysApprovalToken = ObjectSchema5.create({
2769
3512
  name: "sys_approval_token",
2770
3513
  label: "Approval Action Token",
2771
3514
  pluralLabel: "Approval Action Tokens",
2772
3515
  icon: "key",
2773
3516
  isSystem: true,
2774
- managedBy: "system",
3517
+ managedBy: "engine-owned",
2775
3518
  description: "Single-use tokens behind actionable approval links",
2776
3519
  displayNameField: "id",
2777
3520
  nameField: "id",
2778
3521
  // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
2779
3522
  fields: {
2780
- id: Field4.text({ label: "Token ID", required: true, readonly: true, group: "System" }),
2781
- organization_id: Field4.lookup("sys_organization", {
3523
+ id: Field5.text({ label: "Token ID", required: true, readonly: true, group: "System" }),
3524
+ organization_id: Field5.lookup("sys_organization", {
2782
3525
  label: "Organization",
2783
3526
  required: false,
2784
3527
  group: "System"
2785
3528
  }),
2786
- token_hash: Field4.text({
3529
+ token_hash: Field5.text({
2787
3530
  label: "Token Hash",
2788
3531
  required: true,
2789
3532
  maxLength: 100,
@@ -2791,20 +3534,20 @@ var SysApprovalToken = ObjectSchema4.create({
2791
3534
  description: "SHA-256 hex of the raw token \u2014 the raw value is never stored",
2792
3535
  group: "Token"
2793
3536
  }),
2794
- request_id: Field4.text({
3537
+ request_id: Field5.text({
2795
3538
  label: "Request",
2796
3539
  required: true,
2797
3540
  maxLength: 100,
2798
3541
  readonly: true,
2799
3542
  group: "Token"
2800
3543
  }),
2801
- action: Field4.select(["approve", "reject"], {
3544
+ action: Field5.select(["approve", "reject"], {
2802
3545
  label: "Action",
2803
3546
  required: true,
2804
3547
  readonly: true,
2805
3548
  group: "Token"
2806
3549
  }),
2807
- approver_id: Field4.text({
3550
+ approver_id: Field5.text({
2808
3551
  label: "Approver",
2809
3552
  required: true,
2810
3553
  maxLength: 200,
@@ -2812,18 +3555,18 @@ var SysApprovalToken = ObjectSchema4.create({
2812
3555
  description: "Identity the token is bound to; the decision is audited as this approver",
2813
3556
  group: "Token"
2814
3557
  }),
2815
- expires_at: Field4.datetime({
3558
+ expires_at: Field5.datetime({
2816
3559
  label: "Expires At",
2817
3560
  required: true,
2818
3561
  readonly: true,
2819
3562
  group: "Lifecycle"
2820
3563
  }),
2821
- consumed_at: Field4.datetime({
3564
+ consumed_at: Field5.datetime({
2822
3565
  label: "Consumed At",
2823
3566
  required: false,
2824
3567
  group: "Lifecycle"
2825
3568
  }),
2826
- created_at: Field4.datetime({
3569
+ created_at: Field5.datetime({
2827
3570
  label: "Created At",
2828
3571
  required: true,
2829
3572
  defaultValue: "NOW()",
@@ -2834,7 +3577,12 @@ var SysApprovalToken = ObjectSchema4.create({
2834
3577
  indexes: [
2835
3578
  { fields: ["token_hash"] },
2836
3579
  { fields: ["request_id"] }
2837
- ]
3580
+ ],
3581
+ enable: {
3582
+ // [ADR-0103] Engine-owned: one-time email-approval tokens are minted and
3583
+ // consumed by the approval engine (SYSTEM_CTX), never via the data API.
3584
+ apiMethods: ["get", "list"]
3585
+ }
2838
3586
  });
2839
3587
 
2840
3588
  // src/action-link-pages.ts
@@ -2958,6 +3706,40 @@ function bindApprovalLockHook(engine, logger) {
2958
3706
  }, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
2959
3707
  logger?.info?.("[approvals] record-lock hook bound");
2960
3708
  }
3709
+ var DELEGATION_OBJECT = "sys_approval_delegation";
3710
+ function bindDelegationWriteGuard(engine, logger) {
3711
+ const makeGuard = (isInsert) => async (ctx) => {
3712
+ const session = ctx?.session ?? {};
3713
+ if (session.isSystem) return;
3714
+ const roles = session.roles ?? [];
3715
+ if (Array.isArray(roles) && roles.includes("admin")) return;
3716
+ const userId = session.userId != null ? String(session.userId) : "";
3717
+ const data = ctx?.input?.data;
3718
+ const rows = Array.isArray(data) ? data : data && typeof data === "object" ? [data] : [];
3719
+ const deny = () => {
3720
+ const err = new Error(
3721
+ "FORBIDDEN: you may only manage out-of-office delegations where you are the delegator" + (userId ? ` ('${userId}')` : "")
3722
+ );
3723
+ err.code = "FORBIDDEN";
3724
+ err.statusCode = 403;
3725
+ throw err;
3726
+ };
3727
+ for (const row of rows) {
3728
+ if (!row || typeof row !== "object" || Array.isArray(row)) continue;
3729
+ const has = Object.prototype.hasOwnProperty.call(row, "delegator_id");
3730
+ const supplied = has ? String(row.delegator_id ?? "") : "";
3731
+ if (isInsert && (!has || supplied === "")) {
3732
+ if (!userId) deny();
3733
+ row.delegator_id = userId;
3734
+ continue;
3735
+ }
3736
+ if (has && supplied !== userId) deny();
3737
+ }
3738
+ };
3739
+ engine.registerHook("beforeInsert", makeGuard(true), { object: DELEGATION_OBJECT, packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
3740
+ engine.registerHook("beforeUpdate", makeGuard(false), { object: DELEGATION_OBJECT, packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
3741
+ logger?.info?.("[approvals] delegation write-guard bound");
3742
+ }
2961
3743
  function unbindAllHooks(engine) {
2962
3744
  return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
2963
3745
  }
@@ -3058,7 +3840,7 @@ var ApprovalsServicePlugin = class {
3058
3840
  scope: "system",
3059
3841
  defaultDatasource: "cloud",
3060
3842
  namespace: "sys",
3061
- objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken],
3843
+ objects: [SysApprovalRequest, SysApprovalAction, SysApprovalApprover, SysApprovalToken, SysApprovalDelegation],
3062
3844
  // ADR-0029 D7 — contribute the Approvals entries into the Setup app's
3063
3845
  // `group_approvals` slot. This plugin owns these objects (K2.b), so it
3064
3846
  // ships their menu too; when the plugin isn't installed the slot is empty.
@@ -3069,7 +3851,8 @@ var ApprovalsServicePlugin = class {
3069
3851
  priority: 100,
3070
3852
  items: [
3071
3853
  { id: "nav_approval_requests", type: "object", label: "Requests", objectName: "sys_approval_request", icon: "inbox", requiresObject: "sys_approval_request" },
3072
- { id: "nav_approval_actions", type: "object", label: "Action History", objectName: "sys_approval_action", icon: "history", requiresObject: "sys_approval_action" }
3854
+ { id: "nav_approval_actions", type: "object", label: "Action History", objectName: "sys_approval_action", icon: "history", requiresObject: "sys_approval_action" },
3855
+ { id: "nav_approval_delegations", type: "object", label: "Delegations (OOO)", objectName: "sys_approval_delegation", icon: "user-clock", requiresObject: "sys_approval_delegation" }
3073
3856
  ]
3074
3857
  }
3075
3858
  ]
@@ -3115,8 +3898,9 @@ var ApprovalsServicePlugin = class {
3115
3898
  try {
3116
3899
  unbindAllHooks(engine);
3117
3900
  bindApprovalLockHook(engine, ctx.logger);
3901
+ bindDelegationWriteGuard(engine, ctx.logger);
3118
3902
  } catch (err) {
3119
- ctx.logger.warn?.("[approvals] failed to bind record-lock hook", { error: err?.message });
3903
+ ctx.logger.warn?.("[approvals] failed to bind approval hooks", { error: err?.message });
3120
3904
  }
3121
3905
  }
3122
3906
  ctx.registerService("approvals", this.service);
@@ -3233,6 +4017,7 @@ export {
3233
4017
  ApprovalsServicePlugin,
3234
4018
  SysApprovalAction,
3235
4019
  SysApprovalApprover,
4020
+ SysApprovalDelegation,
3236
4021
  SysApprovalRequest,
3237
4022
  registerApprovalNode
3238
4023
  };