@objectstack/plugin-approvals 17.2.0 → 17.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -41,7 +41,7 @@ var init_en_objects_generated = __esm({
41
41
  },
42
42
  organization_id: {
43
43
  label: "Organization",
44
- help: "Tenant that owns this approval request (propagated from submitter context)"
44
+ help: "Organization of the record this request is about (falls back to the acting context when the record has none)"
45
45
  },
46
46
  process_name: {
47
47
  label: "Source",
@@ -67,7 +67,15 @@ var init_en_objects_generated = __esm({
67
67
  approved: "Approved",
68
68
  rejected: "Rejected",
69
69
  recalled: "Recalled",
70
- returned: "Returned"
70
+ returned: "Returned",
71
+ cancelled: "Cancelled"
72
+ }
73
+ },
74
+ cancel_reason: {
75
+ label: "Cancellation Reason",
76
+ help: "Why the platform voided this request (set only when the status is cancelled)",
77
+ options: {
78
+ record_deleted: "Related record deleted"
71
79
  }
72
80
  },
73
81
  current_step: {
@@ -248,7 +256,8 @@ var init_en_objects_generated = __esm({
248
256
  comment: "Comment",
249
257
  revise: "Revise",
250
258
  resubmit: "Resubmit",
251
- ooo_substitute: "Out-of-Office Substitution"
259
+ ooo_substitute: "Out-of-Office Substitution",
260
+ cancel: "Cancel"
252
261
  }
253
262
  },
254
263
  actor_id: {
@@ -362,7 +371,7 @@ var init_zh_CN_objects_generated = __esm({
362
371
  },
363
372
  organization_id: {
364
373
  label: "\u7EC4\u7EC7",
365
- help: "\u62E5\u6709\u8BE5\u5BA1\u6279\u8BF7\u6C42\u7684\u79DF\u6237\uFF08\u4ECE\u63D0\u4EA4\u65B9\u4E0A\u4E0B\u6587\u4F20\u64AD\uFF09"
374
+ help: "\u8BE5\u8BF7\u6C42\u6240\u6D89\u8BB0\u5F55\u6240\u5C5E\u7684\u7EC4\u7EC7\uFF08\u8BB0\u5F55\u65E0\u7EC4\u7EC7\u65F6\u56DE\u9000\u5230\u64CD\u4F5C\u4E0A\u4E0B\u6587\uFF09"
366
375
  },
367
376
  process_name: {
368
377
  label: "\u6765\u6E90",
@@ -388,7 +397,15 @@ var init_zh_CN_objects_generated = __esm({
388
397
  approved: "\u5DF2\u6279\u51C6",
389
398
  rejected: "\u5DF2\u62D2\u7EDD",
390
399
  recalled: "\u5DF2\u64A4\u56DE",
391
- returned: "\u5DF2\u9000\u56DE\u4FEE\u6539"
400
+ returned: "\u5DF2\u9000\u56DE\u4FEE\u6539",
401
+ cancelled: "\u5DF2\u4F5C\u5E9F"
402
+ }
403
+ },
404
+ cancel_reason: {
405
+ label: "\u4F5C\u5E9F\u539F\u56E0",
406
+ help: "\u5E73\u53F0\u4F5C\u5E9F\u8BE5\u5BA1\u6279\u5355\u7684\u539F\u56E0(\u4EC5\u5728\u72B6\u6001\u4E3A\u300C\u5DF2\u4F5C\u5E9F\u300D\u65F6\u5199\u5165)",
407
+ options: {
408
+ record_deleted: "\u5173\u8054\u8BB0\u5F55\u5DF2\u5220\u9664"
392
409
  }
393
410
  },
394
411
  current_step: {
@@ -569,7 +586,8 @@ var init_zh_CN_objects_generated = __esm({
569
586
  comment: "\u8BC4\u8BBA",
570
587
  revise: "\u9000\u56DE\u4FEE\u6539",
571
588
  resubmit: "\u91CD\u65B0\u63D0\u4EA4",
572
- ooo_substitute: "\u4E0D\u5728\u5C97\u6539\u6D3E"
589
+ ooo_substitute: "\u4E0D\u5728\u5C97\u6539\u6D3E",
590
+ cancel: "\u4F5C\u5E9F"
573
591
  }
574
592
  },
575
593
  actor_id: {
@@ -683,7 +701,7 @@ var init_ja_JP_objects_generated = __esm({
683
701
  },
684
702
  organization_id: {
685
703
  label: "\u7D44\u7E54",
686
- help: "\u3053\u306E\u627F\u8A8D\u30EA\u30AF\u30A8\u30B9\u30C8\u3092\u6240\u6709\u3059\u308B\u30C6\u30CA\u30F3\u30C8\uFF08\u9001\u4FE1\u8005\u30B3\u30F3\u30C6\u30AD\u30B9\u30C8\u304B\u3089\u4F1D\u64AD\uFF09"
704
+ help: "\u3053\u306E\u30EA\u30AF\u30A8\u30B9\u30C8\u306E\u5BFE\u8C61\u30EC\u30B3\u30FC\u30C9\u304C\u5C5E\u3059\u308B\u7D44\u7E54\uFF08\u30EC\u30B3\u30FC\u30C9\u306B\u7D44\u7E54\u304C\u306A\u3044\u5834\u5408\u306F\u64CD\u4F5C\u30B3\u30F3\u30C6\u30AD\u30B9\u30C8\u306B\u30D5\u30A9\u30FC\u30EB\u30D0\u30C3\u30AF\uFF09"
687
705
  },
688
706
  process_name: {
689
707
  label: "\u30BD\u30FC\u30B9",
@@ -709,7 +727,15 @@ var init_ja_JP_objects_generated = __esm({
709
727
  approved: "\u627F\u8A8D\u6E08\u307F",
710
728
  rejected: "\u5374\u4E0B\u6E08\u307F",
711
729
  recalled: "\u53D6\u308A\u6D88\u3057\u6E08\u307F",
712
- returned: "\u5DEE\u3057\u623B\u3057\u6E08\u307F"
730
+ returned: "\u5DEE\u3057\u623B\u3057\u6E08\u307F",
731
+ cancelled: "\u7121\u52B9\u5316\u6E08\u307F"
732
+ }
733
+ },
734
+ cancel_reason: {
735
+ label: "\u7121\u52B9\u5316\u7406\u7531",
736
+ help: "\u30D7\u30E9\u30C3\u30C8\u30D5\u30A9\u30FC\u30E0\u304C\u3053\u306E\u7533\u8ACB\u3092\u7121\u52B9\u5316\u3057\u305F\u7406\u7531(\u30B9\u30C6\u30FC\u30BF\u30B9\u304C\u7121\u52B9\u5316\u6E08\u307F\u306E\u5834\u5408\u306E\u307F\u8A2D\u5B9A)",
737
+ options: {
738
+ record_deleted: "\u95A2\u9023\u30EC\u30B3\u30FC\u30C9\u304C\u524A\u9664\u3055\u308C\u307E\u3057\u305F"
713
739
  }
714
740
  },
715
741
  current_step: {
@@ -890,7 +916,8 @@ var init_ja_JP_objects_generated = __esm({
890
916
  comment: "\u30B3\u30E1\u30F3\u30C8",
891
917
  revise: "\u5DEE\u3057\u623B\u3057",
892
918
  resubmit: "\u518D\u63D0\u51FA",
893
- ooo_substitute: "\u4E0D\u5728\u6642\u4EE3\u7406"
919
+ ooo_substitute: "\u4E0D\u5728\u6642\u4EE3\u7406",
920
+ cancel: "\u7121\u52B9\u5316"
894
921
  }
895
922
  },
896
923
  actor_id: {
@@ -1004,7 +1031,7 @@ var init_es_ES_objects_generated = __esm({
1004
1031
  },
1005
1032
  organization_id: {
1006
1033
  label: "Organizaci\xF3n",
1007
- help: "Tenant que posee esta solicitud de aprobaci\xF3n (propagado desde el contexto del solicitante)."
1034
+ help: "Organizaci\xF3n del registro al que se refiere esta solicitud (recurre al contexto del actor cuando el registro no tiene ninguna)."
1008
1035
  },
1009
1036
  process_name: {
1010
1037
  label: "Origen",
@@ -1030,7 +1057,15 @@ var init_es_ES_objects_generated = __esm({
1030
1057
  approved: "Aprobada",
1031
1058
  rejected: "Rechazada",
1032
1059
  recalled: "Retirada",
1033
- returned: "Devuelta para revisi\xF3n"
1060
+ returned: "Devuelta para revisi\xF3n",
1061
+ cancelled: "Anulada"
1062
+ }
1063
+ },
1064
+ cancel_reason: {
1065
+ label: "Motivo de anulaci\xF3n",
1066
+ help: "Por qu\xE9 la plataforma anul\xF3 esta solicitud (solo se establece cuando el estado es Anulada)",
1067
+ options: {
1068
+ record_deleted: "Registro relacionado eliminado"
1034
1069
  }
1035
1070
  },
1036
1071
  current_step: {
@@ -1211,7 +1246,8 @@ var init_es_ES_objects_generated = __esm({
1211
1246
  comment: "Comentario",
1212
1247
  revise: "Devoluci\xF3n",
1213
1248
  resubmit: "Reenv\xEDo",
1214
- ooo_substitute: "Sustituci\xF3n por ausencia"
1249
+ ooo_substitute: "Sustituci\xF3n por ausencia",
1250
+ cancel: "Anulaci\xF3n"
1215
1251
  }
1216
1252
  },
1217
1253
  actor_id: {
@@ -1309,24 +1345,120 @@ var init_es_ES_objects_generated = __esm({
1309
1345
  }
1310
1346
  });
1311
1347
 
1348
+ // src/translations/zh-CN.source-hashes.generated.ts
1349
+ var zhCNGeneratedSourceHashes;
1350
+ var init_zh_CN_source_hashes_generated = __esm({
1351
+ "src/translations/zh-CN.source-hashes.generated.ts"() {
1352
+ "use strict";
1353
+ zhCNGeneratedSourceHashes = {
1354
+ "objects.sys_approval_request.fields.flow_node_id.help": "154aa23b4eee4cae",
1355
+ "objects.sys_approval_request.fields.flow_node_id.label": "052ad568aa41227c",
1356
+ "objects.sys_approval_request.fields.flow_run_id.help": "35c92818f5e11090",
1357
+ "objects.sys_approval_request.fields.flow_run_id.label": "d0124e323da966f9",
1358
+ "objects.sys_approval_request.fields.node_config_json.help": "7ee00ef9b3ca7e80",
1359
+ "objects.sys_approval_request.fields.node_config_json.label": "e64eb4bf7d7ea033"
1360
+ };
1361
+ }
1362
+ });
1363
+
1364
+ // src/translations/ja-JP.source-hashes.generated.ts
1365
+ var jaJPGeneratedSourceHashes;
1366
+ var init_ja_JP_source_hashes_generated = __esm({
1367
+ "src/translations/ja-JP.source-hashes.generated.ts"() {
1368
+ "use strict";
1369
+ jaJPGeneratedSourceHashes = {
1370
+ "objects.sys_approval_delegation._views.active.emptyState.message": "1095d61c017b6db2",
1371
+ "objects.sys_approval_delegation._views.active.emptyState.title": "799d28e51d88963e",
1372
+ "objects.sys_approval_delegation._views.active.label": "eee107cf4e466779",
1373
+ "objects.sys_approval_delegation.description": "432b0435aa15f6b9",
1374
+ "objects.sys_approval_delegation.fields.created_at.label": "1f02d416befb595b",
1375
+ "objects.sys_approval_delegation.fields.delegate_id.help": "9cfc641f7b0b04f9",
1376
+ "objects.sys_approval_delegation.fields.delegate_id.label": "afd6d8733dc5bc14",
1377
+ "objects.sys_approval_delegation.fields.delegator_id.help": "c4686c5c9f24e0be",
1378
+ "objects.sys_approval_delegation.fields.delegator_id.label": "f76b1f95f2fdabff",
1379
+ "objects.sys_approval_delegation.fields.id.label": "3383564051b4b76d",
1380
+ "objects.sys_approval_delegation.fields.organization_id.help": "f02982e88229d9ca",
1381
+ "objects.sys_approval_delegation.fields.organization_id.label": "3e55836156e1c1de",
1382
+ "objects.sys_approval_delegation.fields.reason.help": "712516f1461dff2b",
1383
+ "objects.sys_approval_delegation.fields.reason.label": "ac399f03d7f2e64d",
1384
+ "objects.sys_approval_delegation.fields.updated_at.label": "aba63dc2a9c79b8d",
1385
+ "objects.sys_approval_delegation.fields.valid_from.help": "01386010c454bfbd",
1386
+ "objects.sys_approval_delegation.fields.valid_from.label": "02eaf201e57c9eda",
1387
+ "objects.sys_approval_delegation.fields.valid_until.help": "a48ea34e65933e9d",
1388
+ "objects.sys_approval_delegation.fields.valid_until.label": "a68db5bd4fc72a13",
1389
+ "objects.sys_approval_delegation.label": "4389c4fe5f5c9746",
1390
+ "objects.sys_approval_delegation.pluralLabel": "f07fec3ffd74e6db",
1391
+ "objects.sys_approval_request.fields.flow_node_id.help": "154aa23b4eee4cae",
1392
+ "objects.sys_approval_request.fields.flow_node_id.label": "052ad568aa41227c",
1393
+ "objects.sys_approval_request.fields.flow_run_id.help": "35c92818f5e11090",
1394
+ "objects.sys_approval_request.fields.flow_run_id.label": "d0124e323da966f9",
1395
+ "objects.sys_approval_request.fields.node_config_json.help": "7ee00ef9b3ca7e80",
1396
+ "objects.sys_approval_request.fields.node_config_json.label": "e64eb4bf7d7ea033"
1397
+ };
1398
+ }
1399
+ });
1400
+
1401
+ // src/translations/es-ES.source-hashes.generated.ts
1402
+ var esESGeneratedSourceHashes;
1403
+ var init_es_ES_source_hashes_generated = __esm({
1404
+ "src/translations/es-ES.source-hashes.generated.ts"() {
1405
+ "use strict";
1406
+ esESGeneratedSourceHashes = {
1407
+ "objects.sys_approval_action.fields.actor_id.label": "b155813f8a7f06e3",
1408
+ "objects.sys_approval_delegation._views.active.emptyState.message": "1095d61c017b6db2",
1409
+ "objects.sys_approval_delegation._views.active.emptyState.title": "799d28e51d88963e",
1410
+ "objects.sys_approval_delegation._views.active.label": "eee107cf4e466779",
1411
+ "objects.sys_approval_delegation.description": "432b0435aa15f6b9",
1412
+ "objects.sys_approval_delegation.fields.created_at.label": "1f02d416befb595b",
1413
+ "objects.sys_approval_delegation.fields.delegate_id.help": "9cfc641f7b0b04f9",
1414
+ "objects.sys_approval_delegation.fields.delegate_id.label": "afd6d8733dc5bc14",
1415
+ "objects.sys_approval_delegation.fields.delegator_id.help": "c4686c5c9f24e0be",
1416
+ "objects.sys_approval_delegation.fields.delegator_id.label": "f76b1f95f2fdabff",
1417
+ "objects.sys_approval_delegation.fields.id.label": "3383564051b4b76d",
1418
+ "objects.sys_approval_delegation.fields.organization_id.help": "f02982e88229d9ca",
1419
+ "objects.sys_approval_delegation.fields.organization_id.label": "3e55836156e1c1de",
1420
+ "objects.sys_approval_delegation.fields.reason.help": "712516f1461dff2b",
1421
+ "objects.sys_approval_delegation.fields.reason.label": "ac399f03d7f2e64d",
1422
+ "objects.sys_approval_delegation.fields.updated_at.label": "aba63dc2a9c79b8d",
1423
+ "objects.sys_approval_delegation.fields.valid_from.help": "01386010c454bfbd",
1424
+ "objects.sys_approval_delegation.fields.valid_from.label": "02eaf201e57c9eda",
1425
+ "objects.sys_approval_delegation.fields.valid_until.help": "a48ea34e65933e9d",
1426
+ "objects.sys_approval_delegation.fields.valid_until.label": "a68db5bd4fc72a13",
1427
+ "objects.sys_approval_delegation.label": "4389c4fe5f5c9746",
1428
+ "objects.sys_approval_delegation.pluralLabel": "f07fec3ffd74e6db",
1429
+ "objects.sys_approval_request.fields.flow_node_id.help": "154aa23b4eee4cae",
1430
+ "objects.sys_approval_request.fields.flow_node_id.label": "052ad568aa41227c",
1431
+ "objects.sys_approval_request.fields.flow_run_id.help": "35c92818f5e11090",
1432
+ "objects.sys_approval_request.fields.flow_run_id.label": "d0124e323da966f9",
1433
+ "objects.sys_approval_request.fields.node_config_json.help": "7ee00ef9b3ca7e80",
1434
+ "objects.sys_approval_request.fields.node_config_json.label": "e64eb4bf7d7ea033"
1435
+ };
1436
+ }
1437
+ });
1438
+
1312
1439
  // src/translations/index.ts
1313
1440
  var translations_exports = {};
1314
1441
  __export(translations_exports, {
1315
1442
  ApprovalsTranslations: () => ApprovalsTranslations
1316
1443
  });
1317
- var ApprovalsTranslations;
1444
+ var import_apps, enSource, ApprovalsTranslations;
1318
1445
  var init_translations = __esm({
1319
1446
  "src/translations/index.ts"() {
1320
1447
  "use strict";
1448
+ import_apps = require("@objectstack/platform-objects/apps");
1321
1449
  init_en_objects_generated();
1322
1450
  init_zh_CN_objects_generated();
1323
1451
  init_ja_JP_objects_generated();
1324
1452
  init_es_ES_objects_generated();
1453
+ init_zh_CN_source_hashes_generated();
1454
+ init_ja_JP_source_hashes_generated();
1455
+ init_es_ES_source_hashes_generated();
1456
+ enSource = { objects: enObjects };
1325
1457
  ApprovalsTranslations = {
1326
- en: { objects: enObjects },
1327
- "zh-CN": { objects: zhCNObjects },
1328
- "ja-JP": { objects: jaJPObjects },
1329
- "es-ES": { objects: esESObjects }
1458
+ en: enSource,
1459
+ "zh-CN": (0, import_apps.withSourceFallback)({ objects: zhCNObjects }, enSource, void 0, zhCNGeneratedSourceHashes),
1460
+ "ja-JP": (0, import_apps.withSourceFallback)({ objects: jaJPObjects }, enSource, void 0, jaJPGeneratedSourceHashes),
1461
+ "es-ES": (0, import_apps.withSourceFallback)({ objects: esESObjects }, enSource, void 0, esESGeneratedSourceHashes)
1330
1462
  };
1331
1463
  }
1332
1464
  });
@@ -1395,7 +1527,10 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1395
1527
  label: "Completed",
1396
1528
  data: { provider: "object", object: "sys_approval_request" },
1397
1529
  columns: ["process_name", "object_name", "record_id", "status", "submitter_id", "completed_at"],
1398
- filter: [{ field: "status", operator: "in", value: ["approved", "rejected", "recalled"] }],
1530
+ // Every terminal state, `cancelled` included (#13568) a platform-voided
1531
+ // request is kept as audit evidence, and a terminal state absent from the
1532
+ // only curated terminal view is evidence nobody can find.
1533
+ filter: [{ field: "status", operator: "in", value: ["approved", "rejected", "recalled", "cancelled"] }],
1399
1534
  sort: [{ field: "completed_at", order: "desc" }],
1400
1535
  pagination: { pageSize: 25 }
1401
1536
  },
@@ -1411,46 +1546,40 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1411
1546
  },
1412
1547
  fields: {
1413
1548
  id: import_data.Field.text({ label: "Request ID", required: true, readonly: true, group: "System" }),
1414
- // ⚠️ MEASURED DEFECT, cloud#1395 read this before trusting the column.
1415
- //
1416
- // An approval request DOES belong to an organization: it is read through the
1417
- // organization wall by the approvals inbox, and on a shared-database
1418
- // deployment a row carrying no organization is not filtered BY that wall —
1419
- // it is either invisible to everyone or visible to everyone, decided by
1420
- // whatever filter each surface happens to apply rather than by the data.
1549
+ // [#10101, the cloud#1395 Option A ruling] The SUBJECT record's
1550
+ // organization, with the acting context as fallback — resolved by the
1551
+ // SHARED platform-row resolver (`resolveRecordOrganizationField`,
1552
+ // `@objectstack/metadata-core`) in `openNodeRequest`, the row's only
1553
+ // writer. The same `requestOrg` stamps `sys_approval_action` and the
1554
+ // `sys_approval_approver` index, so all three move together.
1421
1555
  //
1422
- // The value is resolved from the ACTING CONTEXT only (`openNodeRequest`'s
1423
- // `ctxOrg`), so it is NULL whenever the flow that opened the request ran
1424
- // without one — every schedule / time-relative / api triggered run, none of
1425
- // which sets a tenant. On a walled single-database HotCRM SaaS boot this
1426
- // measured 27 of 27 rows org-less, each naming an `object_name` /
1427
- // `record_id` owned by a specific customer.
1556
+ // Why subject-first: an approval request is read through the organization
1557
+ // wall by the approvals inbox, and the acting context is NULL on every
1558
+ // schedule / time-relative / api triggered run (none carries a tenant, by
1559
+ // construction). Stamped from the actor alone the pre-#10101 behaviour,
1560
+ // measured on cloud#1395 as 27 of 27 rows org-less on a walled HotCRM SaaS
1561
+ // boot such a request LOCKED the record it was about while being
1562
+ // invisible in every inbox, its owner's included. Subject-first is also
1563
+ // what `sys_audit_log`'s writer already did (#8707 honouring #8287's
1564
+ // ruling), so an approval row and an audit row about the same record now
1565
+ // land behind the same wall instead of two.
1428
1566
  //
1429
- // Do NOT read that as "platform tables do not carry an organization".
1430
- // `sys_audit_log` (1669 rows) was correctly attributed on the SAME boot,
1431
- // because its writer takes the organization from the RECORD the row is
1432
- // about, with the session only as fallback (plugin-audit
1433
- // `resolveRecordOrganizationField`, #8707 honouring #8287's ruling). Two
1434
- // writers read the actor; a third reads the subject. That disagreement is
1435
- // the defect.
1436
- //
1437
- // Which of the two a side-table row should follow is an open contract
1438
- // question on cloud#1395 — the audit resolver is scope-pinned to audit
1439
- // stamping by the #8778 ruling, so this writer needs its own. The same
1440
- // `ctxOrg` also stamps `sys_approval_action` and `sys_approval_approver`,
1441
- // so all three move together.
1567
+ // The `sys_api_key` divergence is deliberate and preserved: its
1568
+ // `tenancy.organizationField: 'active_organization_id'` (stamp-only,
1569
+ // #8778) wins limb 0 of the shared resolver, while the credential table
1570
+ // itself stays unwalled (`tenancy.enabled: false`) who a row is ABOUT
1571
+ // and what an object is WALLED by remain different questions.
1442
1572
  organization_id: import_data.Field.lookup("sys_organization", {
1443
1573
  label: "Organization",
1444
1574
  required: false,
1445
1575
  group: "System",
1446
- // String unchanged on purpose: it is extracted into the generated i18n
1447
- // bundles (`translations/*.objects.generated.ts`, as `help`), so rewording
1448
- // it is a translation-regeneration change and not a comment. The
1449
- // correction it needs it claims a propagation that measurably does not
1450
- // happen, and says "Tenant" where ADR-0120 §Terminology requires
1451
- // "organization" — rides the cloud#1395 write-side fix, which rewrites the
1452
- // sentence and regenerates the four locales in one pass.
1453
- description: "Tenant that owns this approval request (propagated from submitter context)"
1576
+ // Reworded with the #10101 write-side fix (was "Tenant that owns this
1577
+ // approval request (propagated from submitter context)" it claimed a
1578
+ // propagation that measurably did not happen, and said "Tenant" where
1579
+ // ADR-0120 §Terminology requires "organization"). Extracted into the
1580
+ // generated i18n bundles as `help`; the four locales regenerate in the
1581
+ // same pass.
1582
+ description: "Organization of the record this request is about (falls back to the acting context when the record has none)"
1454
1583
  }),
1455
1584
  process_name: import_data.Field.text({
1456
1585
  label: "Source",
@@ -1469,7 +1598,38 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1469
1598
  label: "Record ID",
1470
1599
  required: true,
1471
1600
  maxLength: 100,
1472
- group: "Target"
1601
+ // [#11386] The id half of this object's pointer pair (ADR-0052 §5),
1602
+ // adopting the #11339 carrier. VERIFIED for THIS object: the pair is not
1603
+ // decoration but the key the approval machinery QUERIES ON —
1604
+ // `approval-service.ts` finds a record's pending request with
1605
+ // `where: { object_name, record_id, status: 'pending' }`, and
1606
+ // `lifecycle-hooks.ts` holds the record LOCK on the same pair
1607
+ // (single-record and `$in` batch forms). `submit()` writes it from
1608
+ // `input.object` / `input.recordId`, so a stored value is always a
1609
+ // record id of the object the sibling names.
1610
+ //
1611
+ // Consequence of declaring, sharper here than elsewhere: a seeded
1612
+ // request whose `record_id` stayed a verbatim natural key locked
1613
+ // NOTHING and appeared under no record — it looked like a pending
1614
+ // approval while being invisible to both queries that give the row its
1615
+ // meaning. That is now a loud seed-time refusal. Both halves are
1616
+ // `required: true`, so the un-addressable case (id half authored, type
1617
+ // half empty) is already unreachable on this object.
1618
+ group: "Target",
1619
+ //
1620
+ // ⚠️ ORDERING CONSTRAINT — this id half is `required: true`, and that
1621
+ // makes it ORDER-DEPENDENT in seeds even though a pointer pair
1622
+ // contributes no static ordering edge (#11674, measured against the real
1623
+ // engine in `packages/objectql/src/engine-seed-required-deferral.test.ts`):
1624
+ // the seed loader defers an unresolvable reference by DELETING the column
1625
+ // from the pass-1 insert, required-validation rejects that row, and pass 2
1626
+ // is then left with no row to back-fill. So the pass-2 healing that makes
1627
+ // an OPTIONAL id half order-independent (`sys_audit_log`) does not reach
1628
+ // this one. ⇒ SEED THE TARGET DATASET FIRST. The failure if you do not is
1629
+ // loud in three places — a write error naming this column, a
1630
+ // dropped-deferral error, and `success: false` — and since #11674 the
1631
+ // loader also WARNS at load time, before the engine rejects the row.
1632
+ referenceVia: "object_name"
1473
1633
  }),
1474
1634
  submitter_id: import_data.Field.lookup("sys_user", {
1475
1635
  label: "Submitter",
@@ -1497,6 +1657,29 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1497
1657
  group: "State"
1498
1658
  }
1499
1659
  ),
1660
+ // [#13568] Why a `cancelled` request was cancelled — the machine-readable
1661
+ // half of the maintainer's 2026-08-31 ruling ("状态转 cancelled + 机器可读
1662
+ // 原因"). Derived from `APPROVAL_CANCEL_REASONS`, never re-typed, on the
1663
+ // same #3786 rule the `status` column above follows.
1664
+ //
1665
+ // On the ROW rather than on the `sys_approval_action` audit entry, because
1666
+ // the readers are the inbox and the tombstone presentation, which page the
1667
+ // request table: making them join the append-only action log to learn why a
1668
+ // row they already hold is cancelled buys nothing and would make the reason
1669
+ // unavailable to a plain list view. The action row still records the event
1670
+ // (`action: 'cancel'`); this column records the STATE.
1671
+ //
1672
+ // Null on every non-cancelled row, and on rows written before the column
1673
+ // existed — "not recorded", never "cancelled for no reason".
1674
+ cancel_reason: import_data.Field.select(
1675
+ import_contracts.APPROVAL_CANCEL_REASONS.map((value) => ({ value, label: import_contracts.APPROVAL_CANCEL_REASON_LABELS[value] })),
1676
+ {
1677
+ label: "Cancellation Reason",
1678
+ required: false,
1679
+ description: "Why the platform voided this request (set only when the status is cancelled)",
1680
+ group: "State"
1681
+ }
1682
+ ),
1500
1683
  current_step: import_data.Field.text({
1501
1684
  label: "Current Step",
1502
1685
  required: false,
@@ -1516,6 +1699,22 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1516
1699
  description: "Comma-separated user ids who can act on the current step",
1517
1700
  group: "State"
1518
1701
  }),
1702
+ // The module docstring above used to justify this column with "used by
1703
+ // notifications so they can render before the record is locked or
1704
+ // changed". That consumer does not exist, and the claim was cited as the
1705
+ // reason the column holds a FULL row before anyone checked it. Measured
1706
+ // against every `this.notify(...)` call site in `approval-service.ts` —
1707
+ // all 12 of them: each passes `{ title, message, actionUrl }` (two also
1708
+ // `actions`), built from `object_name` / `record_id` and the caller's
1709
+ // comment. None reads this column or the parsed `payload`. The real
1710
+ // readers are the serve path (`rowFromRequest` -> `payload`, redacted per
1711
+ // reader), the decide-time approver re-resolution and the org backfill,
1712
+ // both under SYSTEM_CTX, and the free-text predicate.
1713
+ //
1714
+ // The `description` below is deliberately UNCHANGED: it is accurate, and
1715
+ // it is extracted into the four generated i18n bundles as `help`. The
1716
+ // false sentence lived only in the JSDoc above, which is not extracted —
1717
+ // so this correction moves no translation leaf.
1519
1718
  payload_json: import_data.Field.textarea({
1520
1719
  label: "Snapshot",
1521
1720
  required: false,
@@ -1586,11 +1785,14 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1586
1785
  // per-viewer block (#3310): approver actions on `record.viewer.can_act`
1587
1786
  // (the caller is a current pending approver — same check the service
1588
1787
  // authorizes a decision with, so position/team approvers resolve correctly),
1589
- // submitter actions on `record.viewer.is_submitter`. The core decision levers
1590
- // (approve/reject/reassign) additionally OR in `record.viewer.can_override`
1591
- // (#3424) so a platform/tenant admin can rescue a request routed to an
1788
+ // submitter actions on `record.viewer.is_submitter`. The four levers the
1789
+ // #3424 override covers (approve/reject/reassign, and recall since #12716)
1790
+ // additionally OR in `record.viewer.can_override`
1791
+ // so a platform/tenant admin can rescue a request routed to an
1592
1792
  // unstaffed position — otherwise undecidable, locking the record forever — by
1593
- // approving, rejecting, or reassigning it to a real approver. `viewer` is
1793
+ // approving, rejecting, reassigning it to a real approver, or recalling it
1794
+ // (the lever that releases the record without recording a decision nobody
1795
+ // made). `viewer` is
1594
1796
  // attached by getRequest/listRequests; where it is absent the predicate fails
1595
1797
  // closed.
1596
1798
  //
@@ -1726,8 +1928,13 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1726
1928
  // Remind / recall (pending) and resubmit / recall (returned). These are the
1727
1929
  // submitter's own levers, so `visible` gates on `record.viewer.is_submitter`
1728
1930
  // (server-computed on the current viewer). The service re-checks ownership;
1729
- // the predicate keeps a non-submitter from ever seeing a button they cannot
1730
- // use.
1931
+ // the predicate keeps a plain non-submitter from ever seeing a button they
1932
+ // cannot use.
1933
+ //
1934
+ // `recall` is the one exception, and it is not a widening: it ALSO ORs in
1935
+ // the #3424 admin override (#12716), because an override admin is a caller
1936
+ // `ApprovalService.recall` already authorises. Remind and resubmit keep no
1937
+ // override arm.
1731
1938
  {
1732
1939
  name: "approval_remind",
1733
1940
  label: "Send reminder",
@@ -1758,7 +1965,26 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1758
1965
  ],
1759
1966
  // Recall applies while the request is live for the submitter — pending
1760
1967
  // (withdraw) or returned (abandon the revision instead of resubmitting).
1761
- visible: 'has(record.status) && (record.status == "pending" || record.status == "returned") && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true',
1968
+ //
1969
+ // The second arm is the #3424 admin override, spelled byte-identically to
1970
+ // the three core decision levers above (#12716). `ApprovalService.recall`
1971
+ // has admitted the override caller since #3424 — `isOverrideActor`'s own
1972
+ // doc block names recall as one of the four levers — so until this arm
1973
+ // landed, recall was the one authorised capability with no button: an
1974
+ // admin could approve or reject their way out of a stuck request (writing
1975
+ // a decision that did not happen) or reassign it, but could not withdraw.
1976
+ //
1977
+ // The override arm carries no status test of its own, on purpose, because
1978
+ // it does not need one and the siblings do not have one either: the flag
1979
+ // is already status-scoped where it is COMPUTED. `attachViewers` in
1980
+ // `approval-service.ts` sets
1981
+ // `can_override: row.status === 'pending' && isOverrideActor(...)` —
1982
+ // ANDed — so `record.viewer.can_override` can never be true off `pending`,
1983
+ // and this arm is pending-only in effect however CEL groups the
1984
+ // expression. Pinned in both directions in
1985
+ // `action-predicate-sparse-face.test.ts`, with the flag's own scoping
1986
+ // pinned against the real service in `approval-revise.test.ts`.
1987
+ visible: 'has(record.status) && (record.status == "pending" || record.status == "returned") && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true || has(record.viewer) && has(record.viewer.can_override) && record.viewer.can_override == true',
1762
1988
  locations: ["record_section"],
1763
1989
  successMessage: "Recalled.",
1764
1990
  refreshAfter: true
@@ -2113,10 +2339,12 @@ var SysApprovalDelegation = import_data4.ObjectSchema.create({
2113
2339
  var import_node_crypto = require("crypto");
2114
2340
  var import_automation = require("@objectstack/spec/automation");
2115
2341
  var import_formula = require("@objectstack/formula");
2342
+ var import_metadata_core = require("@objectstack/metadata-core");
2116
2343
  var import_types = require("@objectstack/types");
2117
2344
  var import_identity = require("@objectstack/spec/identity");
2118
2345
  var import_contracts3 = require("@objectstack/spec/contracts");
2119
2346
  var import_data5 = require("@objectstack/spec/data");
2347
+ var import_system = require("@objectstack/spec/system");
2120
2348
  var import_core = require("@objectstack/core");
2121
2349
 
2122
2350
  // src/approver-org-scope.ts
@@ -2272,6 +2500,7 @@ var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
2272
2500
  var ESCALATION_JOB_NAME = "approvals-sla-escalation";
2273
2501
  var ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1e3;
2274
2502
  var SLA_ACTOR_ID = "system:sla";
2503
+ var ESCALATION_ENABLED_FLIP_CUTOFF_MS = Date.parse("2026-08-28T00:00:00Z");
2275
2504
  var DEAD_RUN_ACTOR_ID = "system:dead-run";
2276
2505
  var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
2277
2506
  "completed",
@@ -2280,8 +2509,71 @@ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
2280
2509
  "timed_out"
2281
2510
  ]);
2282
2511
  var STRANDABLE_REQUEST_STATUSES = ["approved", "rejected", "returned"];
2512
+ var STRANDED_CONTINUATION_KEY = "__strandedContinuation";
2513
+ var CONTINUATIONS_A_STATUS_CAN_ISSUE = {
2514
+ approved: ["approve"],
2515
+ rejected: ["reject"],
2516
+ returned: ["revise", "resubmit"],
2517
+ recalled: ["recall"]
2518
+ };
2519
+ function classifyStrandedRunState(run) {
2520
+ if (!run) return "missing";
2521
+ switch (run.status) {
2522
+ // A terminal `failed` row. Reported — and, when the engine can be asked,
2523
+ // refined by the third oracle ({@link refineFailedRunState}) into which of
2524
+ // the three `failed` shapes it is. This arm alone cannot tell them apart:
2525
+ // the row reads identically for a resume that consumed the pause and
2526
+ // threw downstream (repairable) and for an ancestor `failAncestors`
2527
+ // cascade-failed (not). `'failed'` here means "reported, undifferentiated".
2528
+ case "failed":
2529
+ return "failed";
2530
+ // ── The negatives, each for its own reason ──────────────────────────────
2531
+ // The decision advanced the flow and the flow finished. Healthy.
2532
+ case "completed":
2533
+ return void 0;
2534
+ // Deliberately terminated by an operator (`cancelRun`, ADR-0044). The run
2535
+ // stopping is the intended outcome, exactly as `recalled` is on the request
2536
+ // side — reporting it would bury the real findings under expected ones.
2537
+ case "cancelled":
2538
+ return void 0;
2539
+ // The history's last row says `paused` while the suspension store says no
2540
+ // live pause. That is AMBIGUOUS, and the ambiguity is not resolvable from
2541
+ // one scan: a resume in flight right now has consumed the suspension and
2542
+ // not yet written its terminal row, and reads exactly like a process that
2543
+ // died in the same window. Condemning it would name every concurrently
2544
+ // resuming approval — so this stays SKIPPED, the conservative arm this
2545
+ // whole method is built on.
2546
+ case "paused":
2547
+ return void 0;
2548
+ default:
2549
+ return void 0;
2550
+ }
2551
+ }
2552
+ function refineFailedRunState(verdict) {
2553
+ if (verdict.repairable) return "repairable";
2554
+ switch (verdict.reason) {
2555
+ // The strand happened; the store could not keep the snapshot, and the
2556
+ // engine asked holds no hot copy. Its own class — see the type below.
2557
+ case "SNAPSHOT_DROPPED":
2558
+ return "snapshot_dropped";
2559
+ // Neither witness holds anything: cascade-failed, or never paused.
2560
+ case "NO_CONSUMED_SUSPENSION":
2561
+ return "unrepairable";
2562
+ // Re-armed between the two reads (an operator's restore landed while this
2563
+ // scan was running): the run is alive and resumable, which is what the
2564
+ // first oracle would have said a moment later. Not stranded.
2565
+ case "RUN_SUSPENDED":
2566
+ return void 0;
2567
+ // An answer this build does not know (an engine ahead of this plugin).
2568
+ // Fail-closed exactly as an absent member: reported, undifferentiated —
2569
+ // never condemned on a word this code cannot read.
2570
+ default:
2571
+ return "failed";
2572
+ }
2573
+ }
2283
2574
  var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
2284
2575
  var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
2576
+ var RECORD_DELETE_CANCEL_LIMIT = 200;
2285
2577
  function actingUserId(context) {
2286
2578
  const userId = context?.userId;
2287
2579
  return typeof userId === "string" && userId ? userId : null;
@@ -2342,6 +2634,10 @@ function rowFromRequest(row) {
2342
2634
  submitter_id: row.submitter_id ?? void 0,
2343
2635
  submitter_comment: row.submitter_comment ?? void 0,
2344
2636
  status: row.status ?? "pending",
2637
+ // [#13568] Why a cancelled row was cancelled. Surfaced on every read for
2638
+ // the same reason `status` is: a client that can see the terminal state and
2639
+ // not the machine-readable cause is back to guessing from prose.
2640
+ cancel_reason: row.cancel_reason ?? void 0,
2345
2641
  current_step: row.current_step ?? void 0,
2346
2642
  current_step_index: row.current_step_index ?? void 0,
2347
2643
  pending_approvers: csvSplit(row.pending_approvers),
@@ -2434,6 +2730,7 @@ function rowFromAction(row) {
2434
2730
  var _ApprovalService = class _ApprovalService {
2435
2731
  constructor(opts) {
2436
2732
  this.engine = opts.engine;
2733
+ this.recordOrgResolver = (0, import_metadata_core.createRecordOrganizationResolver)(opts.engine);
2437
2734
  this.clock = opts.clock ?? { now: () => /* @__PURE__ */ new Date() };
2438
2735
  this.logger = opts.logger;
2439
2736
  this.automation = opts.automation;
@@ -2441,6 +2738,7 @@ var _ApprovalService = class _ApprovalService {
2441
2738
  this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
2442
2739
  this.tenancyPosture = opts.tenancyPosture;
2443
2740
  this.fieldVisibility = opts.fieldVisibility;
2741
+ this.messageTranslator = opts.messageTranslator;
2444
2742
  this.recordReaderVisibleObjects = new Set(
2445
2743
  (Array.isArray(opts.recordReaderVisibleObjects) ? opts.recordReaderVisibleObjects : []).map((n) => String(n ?? "").trim()).filter(Boolean)
2446
2744
  );
@@ -2586,13 +2884,48 @@ var _ApprovalService = class _ApprovalService {
2586
2884
  const perms = Array.isArray(context.permissions) ? context.permissions : [];
2587
2885
  const positions = Array.isArray(context.positions) ? context.positions : [];
2588
2886
  const posture = context.posture;
2589
- const isPlatformAdmin = posture === "PLATFORM_ADMIN" || perms.includes(import_identity.ADMIN_FULL_ACCESS) || positions.includes(import_identity.BUILTIN_IDENTITY_PLATFORM_ADMIN);
2887
+ const isPlatformAdmin = posture === "PLATFORM_ADMIN" || perms.includes(import_identity.ADMIN_FULL_ACCESS);
2590
2888
  if (isPlatformAdmin) return true;
2591
2889
  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);
2592
2890
  if (!isTenantAdmin) return false;
2593
2891
  const actorTenant = context.tenantId ?? context.organizationId ?? null;
2594
2892
  return requestOrg == null || actorTenant != null && String(requestOrg) === String(actorTenant);
2595
2893
  }
2894
+ /**
2895
+ * [#11993] The END USER's half of an approval refusal.
2896
+ *
2897
+ * `handleApprovalError` in `@objectstack/rest` maps this service's
2898
+ * `CODE: message` throws onto the wire by testing the prefix for the status
2899
+ * and then STRIPPING it (a leading run of `[A-Z_]` plus a colon and any
2900
+ * following whitespace), shipping what remains as the body's
2901
+ * human-readable `error` — which Console splices under its own localized
2902
+ * label ("撤回审批失败: …"). A hardcoded English reason therefore reaches an
2903
+ * operator in a fully Chinese deployment as a Chinese prefix glued onto an
2904
+ * English sentence they cannot act on.
2905
+ *
2906
+ * Rendered through the SHARED Operation Message Catalog
2907
+ * (`@objectstack/spec/system`), not a second mechanism: same
2908
+ * `errors.<key>` override address, same resolution ladder (deployment
2909
+ * override -> locale catalog -> `en` -> the key), same guarantee that a
2910
+ * misbehaving i18n service cannot turn a 403 into a 500. The catalog's
2911
+ * header names this seat explicitly; `plugin-security`'s
2912
+ * `userFacingDenialMessage` is the sibling consumer this mirrors.
2913
+ *
2914
+ * The CODE PREFIX stays on the message. It is not user copy — it is how the
2915
+ * REST layer derives the status and the ADR-0112 wire code, and it is
2916
+ * stripped before the sentence reaches a body. The developer's half moves
2917
+ * to the log, where the ids it names are legible to an operator and to
2918
+ * nobody else.
2919
+ */
2920
+ userFacingRefusal(messageKey, context) {
2921
+ let translate;
2922
+ try {
2923
+ translate = this.messageTranslator?.();
2924
+ } catch {
2925
+ translate = void 0;
2926
+ }
2927
+ return (0, import_system.renderOperationMessage)({ messageKey }, { locale: context?.locale, translate });
2928
+ }
2596
2929
  /**
2597
2930
  * Pin the acting identity to the AUTHENTICATED CALLER (#3800).
2598
2931
  *
@@ -3125,7 +3458,56 @@ var _ApprovalService = class _ApprovalService {
3125
3458
  if (!organizationId) return filter;
3126
3459
  return { ...filter, $or: [{ organization_id: organizationId }, { organization_id: null }] };
3127
3460
  }
3128
- /** Recursive department — walks `sys_business_unit.parent_business_unit_id`. */
3461
+ /**
3462
+ * Tenant scope for the `sys_business_unit_member` read — a STRICT equality,
3463
+ * deliberately NOT {@link businessUnitOrgScope} (#14946).
3464
+ *
3465
+ * The two screens answer different questions. The UNIT is the anchor the
3466
+ * approver NAMES, and a seeded unit carries `organization_id = null` by
3467
+ * construction (a seed cannot know the id the runtime mints at boot), so
3468
+ * #3807 admits the null there on purpose. The MEMBER rows are the SET BEING
3469
+ * ROUTED TO — enumerated by the platform, never named by anyone — and a
3470
+ * seeded unit id exists identically in every tenant. Before this screen the
3471
+ * member read carried no organization predicate at all, under
3472
+ * {@link SYSTEM_CTX} which carries no tenant either, so tenant A's request
3473
+ * resolved the shared unit and then collected EVERY tenant's membership rows
3474
+ * hanging off it: approval authority over A's record, routed to B's users.
3475
+ *
3476
+ * Why the null arm is NOT copied here — measured on this tree:
3477
+ * - `sys_business_unit_member` declares no `organization_id`; the column
3478
+ * is injected (`applySystemFields`) and the tenancy census lists it in;
3479
+ * - REST / session writes fill it (`SqlDriver.injectTenantOnInsert`);
3480
+ * - seed replay does NOT (`seed-loader.ts` withholds its `fallbackOrgId`
3481
+ * from every `sys_` object), and elevated system-context writes do NOT
3482
+ * (`unclassified` in `PLATFORM_OBJECT_TENANCY`, tracked as #14570).
3483
+ * So a NULL on a member row means UNKNOWN tenancy, not "platform-global",
3484
+ * and unknown tenancy is not a member of this organization. This is the
3485
+ * ruling `plugin-sharing`'s `memberScope` already applies to the same rows
3486
+ * (#14547 / #14949), and the posture this file already takes for
3487
+ * `sys_team_member` and `sys_user_position`.
3488
+ *
3489
+ * The cost is declared, not hidden: an organization whose MEMBERSHIP rows
3490
+ * were seeded or system-written expands to nobody even on a unit it can
3491
+ * see. That is not silent — the graph-type fallback in `expandApprover`
3492
+ * warns `expanded to nobody` (#3807) and `onEmptyApprovers` governs the
3493
+ * request as for any unstaffed target — and the repair is to stamp the
3494
+ * membership rows, never to widen this screen. ⛔ Do not "unify" the two
3495
+ * screens: one method serving both re-opens whichever half it does not
3496
+ * implement.
3497
+ */
3498
+ businessUnitMemberScope(filter, organizationId) {
3499
+ if (!organizationId) return filter;
3500
+ return { ...filter, organization_id: organizationId };
3501
+ }
3502
+ /**
3503
+ * Recursive department — walks `sys_business_unit.parent_business_unit_id`.
3504
+ *
3505
+ * Two tenant screens, and they are different on purpose: the UNIT rows
3506
+ * (seed check and descent) go through the null-inclusive
3507
+ * {@link businessUnitOrgScope}; the MEMBER read goes through the strict
3508
+ * {@link businessUnitMemberScope}. `organizationId` is the DIRECTORY
3509
+ * organization the approver resolves in (ADR-0105 D9), for both.
3510
+ */
3129
3511
  async expandBusinessUnitUsers(businessUnitId, organizationId) {
3130
3512
  if (!businessUnitId) return [];
3131
3513
  try {
@@ -3165,7 +3547,11 @@ var _ApprovalService = class _ApprovalService {
3165
3547
  let rows = [];
3166
3548
  try {
3167
3549
  rows = await this.engine.find("sys_business_unit_member", {
3168
- where: { business_unit_id: { $in: Array.from(seen) } },
3550
+ // #14946: tenant-screened {@link businessUnitMemberScope} is STRICT
3551
+ // on purpose and is not {@link businessUnitOrgScope}. The units above
3552
+ // proved their tenancy (or are seeded); these rows have not, and the
3553
+ // shared seeded unit id is exactly where other tenants' rows sit.
3554
+ where: this.businessUnitMemberScope({ business_unit_id: { $in: Array.from(seen) } }, organizationId),
3169
3555
  fields: ["user_id"],
3170
3556
  limit: 1e4,
3171
3557
  context: SYSTEM_CTX2
@@ -3533,11 +3919,13 @@ var _ApprovalService = class _ApprovalService {
3533
3919
  const substitutions = [];
3534
3920
  const groups = {};
3535
3921
  const liveRecord = await this.loadLiveRecord(input.object, input.recordId, input.record);
3922
+ const subjectOrg = this.recordOrgResolver.organizationOf(input.object, liveRecord, input.record);
3923
+ const requestOrg = subjectOrg ?? ctxOrg;
3536
3924
  const resolvedFrom = {};
3537
3925
  const approvers = await this.expandApprovers(
3538
3926
  { approvers: input.config.approvers },
3539
3927
  liveRecord,
3540
- ctxOrg,
3928
+ requestOrg,
3541
3929
  {
3542
3930
  now: nowDate.getTime(),
3543
3931
  substitutions,
@@ -3601,16 +3989,16 @@ var _ApprovalService = class _ApprovalService {
3601
3989
  flow_run_id: input.runId,
3602
3990
  flow_node_id: input.nodeId,
3603
3991
  node_config_json: JSON.stringify(configSnapshot),
3604
- organization_id: ctxOrg,
3992
+ organization_id: requestOrg,
3605
3993
  created_at: now,
3606
3994
  updated_at: now
3607
3995
  };
3608
3996
  await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX2 });
3609
- await this.syncApproverIndex(id, approvers, ctxOrg, now);
3997
+ await this.syncApproverIndex(id, approvers, requestOrg, now);
3610
3998
  await this.engine.insert("sys_approval_action", {
3611
3999
  id: uid("aact"),
3612
4000
  request_id: id,
3613
- organization_id: ctxOrg,
4001
+ organization_id: requestOrg,
3614
4002
  step_name: input.nodeId,
3615
4003
  step_index: 0,
3616
4004
  action: "submit",
@@ -3622,7 +4010,7 @@ var _ApprovalService = class _ApprovalService {
3622
4010
  await this.engine.insert("sys_approval_action", {
3623
4011
  id: uid("aact"),
3624
4012
  request_id: id,
3625
- organization_id: ctxOrg,
4013
+ organization_id: requestOrg,
3626
4014
  step_name: input.nodeId,
3627
4015
  step_index: 0,
3628
4016
  action: "ooo_substitute",
@@ -3876,6 +4264,7 @@ var _ApprovalService = class _ApprovalService {
3876
4264
  `resume of run '${runId}' failed${reported.code ? ` [${reported.code}]` : ""}: ${reported.error ?? "unknown error"}`
3877
4265
  );
3878
4266
  err.resumeCode = reported.code;
4267
+ err.resumeStatus = reported.status;
3879
4268
  throw err;
3880
4269
  }
3881
4270
  }
@@ -3883,6 +4272,22 @@ var _ApprovalService = class _ApprovalService {
3883
4272
  static resumeCodeOf(err) {
3884
4273
  return err?.resumeCode;
3885
4274
  }
4275
+ /**
4276
+ * The engine's own run-state discriminator behind a {@link serviceResume}
4277
+ * rejection — `AutomationResult.status` — if the engine reported one
4278
+ * (#13807).
4279
+ *
4280
+ * Read as a SIBLING of {@link resumeCodeOf}, never as a substitute: the two
4281
+ * answer different questions and the stranded exit proves they are not
4282
+ * interchangeable. It reports `status: 'stranded'` and **no `code` at all**
4283
+ * (`service-automation` `engine.ts`, the resume catch arm), so a door that
4284
+ * reads only the code sees an unnamed failure and cannot tell a repairable
4285
+ * strand from a dead run — which is how the platform's own repairability
4286
+ * signal had a producer and zero consumers until this call site.
4287
+ */
4288
+ static resumeStatusOf(err) {
4289
+ return err?.resumeStatus;
4290
+ }
3886
4291
  /**
3887
4292
  * Refuse an operation whose whole point is to advance a flow run when that
3888
4293
  * run no longer exists — BEFORE anything is written down (#4420).
@@ -3976,10 +4381,33 @@ var _ApprovalService = class _ApprovalService {
3976
4381
  * which cannot throw without breaking every standalone deployment — it
3977
4382
  * reports through `resumeError` instead.
3978
4383
  *
4384
+ * ## The throw is truthful, not merely loud (#13807)
4385
+ *
4386
+ * Maintainer ruling 2026-09-04 (decision batch #37, option B): this door
4387
+ * KEEPS its status code — the effect landing while the run strands is still
4388
+ * a failure and must still be reported as one — and stops discarding what
4389
+ * the engine said. ⛔ Not "return 200", which the card forbids; ⛔ not
4390
+ * atomic, because rolling a real human decision back is excluded by the
4391
+ * #13937 shape-4 ruling, which binds this door's own writes too (a machine
4392
+ * that re-armed strandings by itself would re-run the node that threw,
4393
+ * forever, with nobody deciding it should).
4394
+ *
4395
+ * So the error carries {@link StrandedDecisionDetails} beside its prose:
4396
+ * `finalized` (the decision stands), `decision`, `runId`, and `repairable`
4397
+ * derived from the engine's `'stranded'` discriminator. Before this a caller
4398
+ * had a 500 and a sentence — and 500 alone reads as "the rejection did not
4399
+ * happen", which is the misreading that makes a caller retry or escalate
4400
+ * against a decision that IS durable.
4401
+ *
3979
4402
  * @param what - how the recorded outcome reads in the error, e.g.
3980
4403
  * `"the approve decision"`.
4404
+ * @param decision - the outcome label for the machine-readable envelope
4405
+ * (`'approve'` / `'reject'` / `'revise'` / `'resubmit'`). Passed
4406
+ * explicitly rather than parsed back out of `what` or the signal: the
4407
+ * prose is for humans and `output` is the flow's, and neither is a place
4408
+ * to keep a wire value.
3981
4409
  */
3982
- async resumeRecordedOutcome(runId, requestId, what, signal) {
4410
+ async resumeRecordedOutcome(runId, requestId, what, signal, decision) {
3983
4411
  const missing = this.missingRunCapability(runId, requestId, what, "resume");
3984
4412
  if (missing) return { resumed: false, resumeError: missing };
3985
4413
  try {
@@ -3995,14 +4423,27 @@ var _ApprovalService = class _ApprovalService {
3995
4423
  });
3996
4424
  return { resumed: false, resumeError: reason };
3997
4425
  }
4426
+ const status = _ApprovalService.resumeStatusOf(err);
4427
+ const repairable = status === "stranded";
3998
4428
  this.logger?.error?.("[approvals] resume failed \u2014 the run is stranded", {
3999
4429
  request: requestId,
4000
4430
  run: runId,
4001
4431
  outcome: what,
4002
- error: reason
4432
+ error: reason,
4433
+ status,
4434
+ repairable
4003
4435
  });
4004
- throw new Error(
4005
- `RESUME_FAILED: ${what} was recorded on request ${requestId}, but its flow run '${runId}' could not be resumed and is now stranded: ${reason}`
4436
+ if (repairable) {
4437
+ await this.journalStrandedContinuation(requestId, {
4438
+ branchLabel: signal.branchLabel,
4439
+ output: signal.output,
4440
+ decision,
4441
+ what
4442
+ });
4443
+ }
4444
+ throw (0, import_types.strandedDecisionFailure)(
4445
+ `RESUME_FAILED: ${what} was recorded on request ${requestId}, but its flow run '${runId}' could not be resumed and is now stranded: ${reason}`,
4446
+ { finalized: true, decision, runId, repairable }
4006
4447
  );
4007
4448
  }
4008
4449
  }
@@ -4038,7 +4479,8 @@ var _ApprovalService = class _ApprovalService {
4038
4479
  // Reserved keys are spread LAST so no output can shadow them (the
4039
4480
  // whitelist already rejects them; this is defense in depth).
4040
4481
  output: { ...result.outputs ?? {}, decision: result.decision, requestId }
4041
- }
4482
+ },
4483
+ result.decision
4042
4484
  );
4043
4485
  resumed = outcome.resumed;
4044
4486
  resumeError = outcome.resumeError;
@@ -4053,7 +4495,7 @@ var _ApprovalService = class _ApprovalService {
4053
4495
  };
4054
4496
  }
4055
4497
  /**
4056
- * Withdraw a pending request (submitter only). Finalises the row as
4498
+ * Withdraw an undecided request. Finalises the row as
4057
4499
  * `recalled`, releases the record lock (keyed on pending status), mirrors
4058
4500
  * the status field when configured, and resumes the owning flow run down
4059
4501
  * the `reject` branch with `output.decision = 'recall'` — leaving the run
@@ -4064,6 +4506,13 @@ var _ApprovalService = class _ApprovalService {
4064
4506
  * is then paused at the revise-window node (no reject edge), so it is
4065
4507
  * terminally cancelled via {@link ApprovalResumeSurface.cancelRun} rather
4066
4508
  * than resumed.
4509
+ *
4510
+ * The #3424 privileged override reaches a PENDING request only (#12775,
4511
+ * maintainer ruling 2026-09-02). On `returned` an override actor is refused
4512
+ * exactly as any other non-submitter: the gate is spelled as `attachViewers`
4513
+ * computes `viewer.can_override`, so the gate, the
4514
+ * {@link ApprovalService.isOverrideActor} doc block and the viewer flag agree
4515
+ * at one point.
4067
4516
  */
4068
4517
  async recall(requestId, input, context) {
4069
4518
  if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
@@ -4079,8 +4528,18 @@ var _ApprovalService = class _ApprovalService {
4079
4528
  if (raw.status !== "pending" && !inReviseWindow) {
4080
4529
  throw new Error(`INVALID_STATE: request is ${raw.status}`);
4081
4530
  }
4082
- if (!this.isOverrideActor(context, raw.organization_id ?? null) && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
4083
- throw new Error(`FORBIDDEN: only the submitter may recall this request`);
4531
+ const overrideAdmits = raw.status === "pending" && this.isOverrideActor(context, raw.organization_id ?? null);
4532
+ if (!overrideAdmits && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
4533
+ const developerMessage = `[approvals] recall refused: actor '${actorId}' is not the submitter of request '${requestId}' (submitter '${String(raw.submitter_id)}') and holds no #3424 override for a '${raw.status}' request (the override reaches pending requests only)`;
4534
+ this.logger?.warn?.(developerMessage, {
4535
+ request: requestId,
4536
+ actor: actorId,
4537
+ submitter: String(raw.submitter_id),
4538
+ status: raw.status
4539
+ });
4540
+ throw new Error(
4541
+ `FORBIDDEN: ${this.userFacingRefusal("approval_recall_not_submitter", context)}`
4542
+ );
4084
4543
  }
4085
4544
  if (inReviseWindow) await this.assertLatestForRun(raw);
4086
4545
  const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
@@ -4150,12 +4609,141 @@ var _ApprovalService = class _ApprovalService {
4150
4609
  run: runId,
4151
4610
  error: resumeError
4152
4611
  });
4612
+ if (_ApprovalService.resumeStatusOf(err) === "stranded") {
4613
+ await this.journalStrandedContinuation(requestId, {
4614
+ branchLabel: import_automation.APPROVAL_BRANCH_LABELS.reject,
4615
+ output: { decision: "recall", requestId },
4616
+ decision: "recall",
4617
+ what: "the recall"
4618
+ });
4619
+ }
4153
4620
  }
4154
4621
  }
4155
4622
  }
4156
4623
  const fresh = await this.readBackRequest(requestId, context);
4157
4624
  return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
4158
4625
  }
4626
+ // ── Record-delete lifecycle linkage (#13568) ─────────────────
4627
+ /**
4628
+ * Void every `pending` request about a record that has just been DELETED
4629
+ * (#13568, maintainer ruling 2026-08-31 「同意」).
4630
+ *
4631
+ * ## What the ruling asked for, and what each half costs
4632
+ *
4633
+ * status → `cancelled`, a machine-readable `cancel_reason`, the row KEPT
4634
+ * for audit, and the request out of the pending count and the inbox's
4635
+ * default view.
4636
+ *
4637
+ * The last clause needs both writes below, not just the status one: the
4638
+ * inbox's "My Pending" tab pages the request table by `status`, but the
4639
+ * approver filter resolves through the normalized `sys_approval_approver`
4640
+ * index (#1745), which is keyed on nothing but `request_id`. Leaving the
4641
+ * index rows behind would keep the request in `approverRequestIds`' answer
4642
+ * for every approver forever — so {@link syncApproverIndex} clears them, the
4643
+ * same call every other exit from `pending` makes.
4644
+ *
4645
+ * ## Three things this deliberately does NOT do
4646
+ *
4647
+ * 1. **No status mirror-back.** Every other terminal transition calls
4648
+ * {@link mirrorStatusField} to write the outcome onto the subject record's
4649
+ * `approvalStatusField`. Here the subject record is precisely what no
4650
+ * longer exists, so that write is guaranteed to fail — it is the very
4651
+ * `update_record(...) failed: Record ... not found` this card's own
4652
+ * forensics recorded on the reject door. Skipped by construction, not by a
4653
+ * swallowed error.
4654
+ *
4655
+ * 2. **No flow resume, and no run cancel.** A cancellation is a status write
4656
+ * plus a reason; it is not a decision, so there is no branch to resume
4657
+ * down and no downstream node that should run. The suspended run this
4658
+ * request gated is reported (below) and left alone — what becomes of an
4659
+ * approval run whose request was voided is a lifecycle question that
4660
+ * belongs to the automation service, not to a delete hook, and answering
4661
+ * it here by guessing would be exactly the kind of consumer-side
4662
+ * accommodation PD #12 refuses.
4663
+ *
4664
+ * 3. **No refusal of the delete.** The delete has already landed by the time
4665
+ * this runs, and the "forbid delete while an approval is pending"
4666
+ * direction was VETOED in the same ruling (`lockRecord` already blocks the
4667
+ * edit; blocking the delete too locks an author onto a record they cannot
4668
+ * fix). So nothing here throws — a failure degrades to a warning and the
4669
+ * stale row, which is the pre-existing state, never to a failed delete.
4670
+ *
4671
+ * Terminal rows are untouched: the `where` below names `status: 'pending'`,
4672
+ * so an `approved` / `rejected` / `recalled` / `returned` request about the
4673
+ * same record keeps its recorded outcome. That is the ruling's second half —
4674
+ * history is kept; only the presentation of its dead record reference is
4675
+ * someone else's card.
4676
+ *
4677
+ * @returns the request ids cancelled and the suspended run ids left behind.
4678
+ */
4679
+ async cancelForDeletedRecord(objectName, recordId) {
4680
+ const empty = { cancelled: [], suspendedRuns: [] };
4681
+ const object = String(objectName ?? "").trim();
4682
+ const record = String(recordId ?? "").trim();
4683
+ if (!object || !record) return empty;
4684
+ let pending;
4685
+ try {
4686
+ const rows = await this.engine.find("sys_approval_request", {
4687
+ where: { object_name: object, record_id: record, status: "pending" },
4688
+ limit: RECORD_DELETE_CANCEL_LIMIT,
4689
+ context: SYSTEM_CTX2
4690
+ });
4691
+ pending = Array.isArray(rows) ? rows : [];
4692
+ } catch (err) {
4693
+ this.logger?.warn?.(
4694
+ "[approvals] could not read the pending requests of a deleted record \u2014 they stay in the inbox until the record is deleted again or an operator finalises them",
4695
+ { object, record, error: err?.message ?? String(err) }
4696
+ );
4697
+ return empty;
4698
+ }
4699
+ if (pending.length === 0) return empty;
4700
+ const now = this.clock.now().toISOString();
4701
+ const cancelled = [];
4702
+ const suspendedRuns = [];
4703
+ for (const raw of pending) {
4704
+ const requestId = String(raw?.id ?? "");
4705
+ if (!requestId) continue;
4706
+ const org = raw?.organization_id ?? null;
4707
+ const nodeId = raw?.flow_node_id ?? raw?.current_step ?? null;
4708
+ try {
4709
+ await this.engine.insert("sys_approval_action", {
4710
+ id: uid("aact"),
4711
+ request_id: requestId,
4712
+ organization_id: org,
4713
+ step_name: nodeId,
4714
+ step_index: 0,
4715
+ action: "cancel",
4716
+ actor_id: null,
4717
+ comment: `Auto-cancelled: the ${object} record '${record}' this request is about was deleted`,
4718
+ created_at: now
4719
+ }, { context: SYSTEM_CTX2 });
4720
+ await this.engine.update("sys_approval_request", {
4721
+ id: requestId,
4722
+ status: "cancelled",
4723
+ cancel_reason: "record_deleted",
4724
+ pending_approvers: null,
4725
+ completed_at: now,
4726
+ updated_at: now
4727
+ }, { context: SYSTEM_CTX2 });
4728
+ await this.syncApproverIndex(requestId, [], org, now);
4729
+ cancelled.push(requestId);
4730
+ const runId = raw?.flow_run_id ? String(raw.flow_run_id) : "";
4731
+ if (runId) suspendedRuns.push(runId);
4732
+ } catch (err) {
4733
+ this.logger?.warn?.(
4734
+ "[approvals] could not auto-cancel a pending request whose record was deleted \u2014 it stays in the inbox pointing at a record that no longer exists",
4735
+ { object, record, request: requestId, error: err?.message ?? String(err) }
4736
+ );
4737
+ }
4738
+ }
4739
+ if (suspendedRuns.length) {
4740
+ this.logger?.warn?.(
4741
+ "[approvals] auto-cancelled approval request(s) for a deleted record \u2014 the automation run(s) they gated stay suspended and are not resumed by a cancellation",
4742
+ { object, record, requests: cancelled, runs: suspendedRuns }
4743
+ );
4744
+ }
4745
+ return { cancelled, suspendedRuns };
4746
+ }
4159
4747
  // ── Send back for revision / resubmit (ADR-0044) ─────────────
4160
4748
  /**
4161
4749
  * ADR-0044 send back for revision. Finalises the pending request as
@@ -4244,7 +4832,8 @@ var _ApprovalService = class _ApprovalService {
4244
4832
  {
4245
4833
  branchLabel: import_automation.APPROVAL_BRANCH_LABELS.reject,
4246
4834
  output: { decision: "reject", autoRejected: true, requestId }
4247
- }
4835
+ },
4836
+ "reject"
4248
4837
  );
4249
4838
  resumed2 = outcome.resumed;
4250
4839
  resumeError2 = outcome.resumeError;
@@ -4292,7 +4881,8 @@ var _ApprovalService = class _ApprovalService {
4292
4881
  {
4293
4882
  branchLabel: import_automation.APPROVAL_BRANCH_LABELS.revise,
4294
4883
  output: { decision: "revise", requestId }
4295
- }
4884
+ },
4885
+ "revise"
4296
4886
  );
4297
4887
  resumed = outcome.resumed;
4298
4888
  resumeError = outcome.resumeError;
@@ -4372,7 +4962,8 @@ var _ApprovalService = class _ApprovalService {
4372
4962
  {
4373
4963
  branchLabel: import_automation.APPROVAL_BRANCH_LABELS.resubmit,
4374
4964
  output: { resubmitted: true, requestId }
4375
- }
4965
+ },
4966
+ "resubmit"
4376
4967
  );
4377
4968
  resumed = outcome.resumed;
4378
4969
  resumeError = outcome.resumeError;
@@ -4786,7 +5377,10 @@ var _ApprovalService = class _ApprovalService {
4786
5377
  * `escalation.timeoutHours` and whose deadline has passed is escalated
4787
5378
  * **at most once, ever** — the `escalate` audit row is the idempotency
4788
5379
  * marker, written before any mutation (audit-first, like reassign). One
4789
- * bad row never stops the sweep.
5380
+ * bad row never stops the sweep. A block that explicitly declares
5381
+ * `enabled: false` is skipped (#12278 — the declared switch is enforced;
5382
+ * pre-flip snapshots ride the legacy window on
5383
+ * {@link ESCALATION_ENABLED_FLIP_CUTOFF_MS}).
4790
5384
  */
4791
5385
  async runEscalations() {
4792
5386
  let rows = [];
@@ -4808,6 +5402,7 @@ var _ApprovalService = class _ApprovalService {
4808
5402
  const cfg = parseJson(raw.node_config_json, void 0);
4809
5403
  const esc2 = cfg?.escalation;
4810
5404
  if (!esc2 || typeof esc2.timeoutHours !== "number" || esc2.timeoutHours <= 0) continue;
5405
+ if (esc2.enabled === false && Date.parse(raw.created_at ?? "") >= ESCALATION_ENABLED_FLIP_CUTOFF_MS) continue;
4811
5406
  const due = slaDueAt(raw.created_at, cfg);
4812
5407
  if (!due || Date.parse(due) > this.clock.now().getTime()) continue;
4813
5408
  const prior = await this.engine.find("sys_approval_action", {
@@ -4888,9 +5483,52 @@ var _ApprovalService = class _ApprovalService {
4888
5483
  * live pause exists. It THROWS when the store cannot be read, and that
4889
5484
  * case is SKIPPED, never counted as dead: an unreadable store means
4890
5485
  * "unknown", and a storage outage must not be published as a lost run.
4891
- * - `getRun(runId) == null` — no terminal history row either (the `run_`
4892
- * prefixed rows in `sys_automation_run`). A run that merely finished is
4893
- * not stranded; a request whose run neither waits nor ever completed is.
5486
+ * - `classifyStrandedRunState` over `getRun(runId)` — the run's own
5487
+ * history row (the `run_` prefixed rows in `sys_automation_run`). A run
5488
+ * that merely finished is not stranded; a request whose run neither waits
5489
+ * nor completed is.
5490
+ *
5491
+ * **The second oracle was widened (#13909), and this is the whole point of
5492
+ * that card's first slice.** It used to be `if (terminal) continue` — the
5493
+ * existence of ANY history row ended the check, on the reading "the run ran to
5494
+ * a terminal state, it is not dangling". That is true of a run that COMPLETED
5495
+ * and false of one that FAILED: the engine consumes a suspension *before*
5496
+ * running the downstream nodes (`AutomationEngine.resumeInternal` calls
5497
+ * `forgetSuspendedRun(run, 'resumed')` and only then `traverseNext`), so a
5498
+ * downstream node that merely threw threw with the pause already gone, and the
5499
+ * catch arm wrote a terminal `failed` row. The decision is durable, the
5500
+ * continuation stopped half-way, `resume` answers `RUN_NOT_FOUND` and
5501
+ * `cancelRun` is a no-op — and the terminal row this oracle used to read as
5502
+ * health is written BY the very failure that stranded it. So this inspection
5503
+ * reported `0` for the one shape an operator most needs to see.
5504
+ *
5505
+ * ⚠️ The widening does NOT reverse the conservatism: `completed`, `cancelled`
5506
+ * and `paused` are each still skipped, for reasons named one at a time in
5507
+ * `classifyStrandedRunState`, and an unrecognised status is skipped too.
5508
+ * What the widening buys is that a `failed` run is now reported instead of
5509
+ * counted as healthy.
5510
+ *
5511
+ * **A THIRD oracle tells the `failed` rows apart (#15358).** `status ===
5512
+ * 'failed'` over-reports in one specific direction: a cascade-failed run
5513
+ * — an ancestor `failAncestors` failed while it was parked at its `subflow`
5514
+ * node, whose pause `failSuspendedRun` consumed and journalled nothing — has
5515
+ * the same terminal row as the #13909 strand, and `restoreConsumedSuspension`
5516
+ * refuses it. The engine's discriminator (the consumed-suspension snapshot)
5517
+ * is deliberately NOT on the object `getRun` answers, so it is asked through
5518
+ * a dedicated read-only member, `inspectConsumedSuspension`, and only for
5519
+ * `failed` rows: the answer splits `'failed'` into `'repairable'`,
5520
+ * `'snapshot_dropped'` and `'unrepairable'` (see {@link StrandedRunState}).
5521
+ * A surface without that member leaves the row `'failed'` — reported,
5522
+ * undifferentiated — because absence of the discriminator is not evidence
5523
+ * of anything. So does a read that THREW or answered a malformed verdict
5524
+ * (#16709): by the time this oracle is asked the row is already known to be
5525
+ * stranded, so a failure to differentiate it is not a reason to drop it from
5526
+ * a report — it is counted `undetermined` as telemetry AND reported.
5527
+ *
5528
+ * ⚠️ **What this can and cannot size.** It makes the condition *visible* in a
5529
+ * deployment; it is not itself a census, and it says nothing about this
5530
+ * repository. How many runs are already in this state can only be answered
5531
+ * against a real deployment's tables — see the card.
4894
5532
  *
4895
5533
  * **Reports; never rewrites.** No status is changed and no run is cancelled.
4896
5534
  * The decision genuinely happened — a human approved or rejected — and
@@ -4948,7 +5586,27 @@ var _ApprovalService = class _ApprovalService {
4948
5586
  });
4949
5587
  continue;
4950
5588
  }
4951
- if (terminal) continue;
5589
+ let runState = classifyStrandedRunState(terminal);
5590
+ if (!runState) continue;
5591
+ if (runState === "failed" && typeof this.automation.inspectConsumedSuspension === "function") {
5592
+ let refined;
5593
+ let differentiated = true;
5594
+ try {
5595
+ refined = refineFailedRunState(await this.automation.inspectConsumedSuspension(runId));
5596
+ } catch (err) {
5597
+ differentiated = false;
5598
+ undetermined++;
5599
+ this.logger?.warn?.("[approvals] stranded-request scan could not read the consumed-suspension state", {
5600
+ request: raw?.id,
5601
+ run: runId,
5602
+ error: err?.message ?? String(err)
5603
+ });
5604
+ }
5605
+ if (differentiated) {
5606
+ if (!refined) continue;
5607
+ runState = refined;
5608
+ }
5609
+ }
4952
5610
  const config = parseJson(
4953
5611
  raw.node_config_json,
4954
5612
  { approvers: [], behavior: "first_response" }
@@ -4971,6 +5629,7 @@ var _ApprovalService = class _ApprovalService {
4971
5629
  requestId: String(raw.id),
4972
5630
  status: raw.status,
4973
5631
  runId,
5632
+ runState,
4974
5633
  flowName: typeof raw.process_name === "string" ? raw.process_name.replace(/^flow:/, "") : void 0,
4975
5634
  nodeId: raw.flow_node_id ?? raw.current_step ?? void 0,
4976
5635
  objectName: raw.object_name,
@@ -4982,15 +5641,395 @@ var _ApprovalService = class _ApprovalService {
4982
5641
  });
4983
5642
  }
4984
5643
  if (stranded.length || undetermined) {
4985
- this.logger?.warn?.("[approvals] stranded terminal requests (decision recorded, flow run gone)", {
5644
+ this.logger?.warn?.("[approvals] stranded terminal requests (decision recorded, flow run unrecoverable)", {
4986
5645
  scanned: rows.length,
4987
5646
  stranded: stranded.length,
4988
5647
  undetermined,
4989
- requests: stranded.map((s) => `${s.requestId}@${s.nodeId ?? "?"} \u2192 run ${s.runId}`)
5648
+ runMissing: stranded.filter((s) => s.runState === "missing").length,
5649
+ runFailed: stranded.filter((s) => s.runState === "failed").length,
5650
+ runRepairable: stranded.filter((s) => s.runState === "repairable").length,
5651
+ runSnapshotDropped: stranded.filter((s) => s.runState === "snapshot_dropped").length,
5652
+ runUnrepairable: stranded.filter((s) => s.runState === "unrepairable").length,
5653
+ requests: stranded.map((s) => `${s.requestId}@${s.nodeId ?? "?"} \u2192 run ${s.runId} (${s.runState})`)
4990
5654
  });
4991
5655
  }
4992
5656
  return { scanned: rows.length, stranded, undetermined };
4993
5657
  }
5658
+ /**
5659
+ * Stash the continuation a door just failed to deliver, so it can be issued
5660
+ * again after the pause is re-armed (#15389).
5661
+ *
5662
+ * `AutomationEngine.restoreConsumedSuspension` puts a stranded approval run
5663
+ * back on its pause and tells the operator to *re-issue the continuation* —
5664
+ * but for an `approval` node the only issuers are this service's doors, and
5665
+ * every one of them guards on a `pending` request that the stranding call
5666
+ * itself just made terminal. Re-opening the row is excluded (it would let a
5667
+ * decided request be decided again), so what is kept instead is the SIGNAL:
5668
+ * the exact `branchLabel` + `output` the failed resume carried.
5669
+ *
5670
+ * ⚠️ Best-effort by construction, and it must stay that way: the decision is
5671
+ * already durable and its caller is already owed a `RESUME_FAILED` throw. A
5672
+ * failure to write recovery bookkeeping must not replace that throw with a
5673
+ * storage error — {@link ApprovalService.continueRestoredRun} rebuilds the
5674
+ * signal from the row when the stash is absent, so this failing costs
5675
+ * fidelity on one shape, not the repair path.
5676
+ */
5677
+ async journalStrandedContinuation(requestId, signal) {
5678
+ try {
5679
+ const rows = await this.engine.find("sys_approval_request", {
5680
+ where: { id: requestId },
5681
+ limit: 1,
5682
+ context: SYSTEM_CTX2
5683
+ });
5684
+ const raw = Array.isArray(rows) ? rows[0] : null;
5685
+ if (!raw) return;
5686
+ const config = parseJson(raw.node_config_json, {});
5687
+ await this.engine.update("sys_approval_request", {
5688
+ id: requestId,
5689
+ node_config_json: JSON.stringify({ ...config, [STRANDED_CONTINUATION_KEY]: signal })
5690
+ }, { context: SYSTEM_CTX2 });
5691
+ } catch (err) {
5692
+ this.logger?.warn?.(
5693
+ "[approvals] could not journal the stranded continuation \u2014 the repair path falls back to rebuilding it from the row",
5694
+ { request: requestId, error: err?.message ?? String(err) }
5695
+ );
5696
+ }
5697
+ }
5698
+ /**
5699
+ * The continuation to re-issue for a request whose recorded outcome stranded
5700
+ * its run — the journalled one when there is one, otherwise rebuilt from the
5701
+ * row (#15389).
5702
+ *
5703
+ * ## Why a rebuild path exists at all
5704
+ *
5705
+ * The journal only covers runs stranded by a build that HAS it. The card is
5706
+ * explicitly about *"the runs already in this state"*, and one of those can
5707
+ * still be restored whenever the durable run-history row carried its
5708
+ * suspension snapshot — so a repair verb that only served future strands
5709
+ * would miss the population the card was filed for.
5710
+ *
5711
+ * ## Which statuses it rebuilds, which it discriminates, and which it refuses
5712
+ *
5713
+ * ⛔ A status is NOT the same thing as a continuation. Three of the four
5714
+ * terminal statuses have more than one writer or more than one issuer, so
5715
+ * "one status, one signal" is false and is not what this relies on. Each row
5716
+ * below states its own population and its own discriminator:
5717
+ *
5718
+ * | status | writers / issuers | rebuilt as | how it is decided |
5719
+ * |---|---|---|---|
5720
+ * | `approved` | 1 (`decide`; escalation auto-approve routes through it) | `approve` | unambiguous |
5721
+ * | `rejected` | 2 (`decide`; ADR-0044 revision-limit auto-reject) | `reject`, or REFUSED | a `revise` action row means the auto-reject arm is possible |
5722
+ * | `returned` | 1 writer, 2 issuers (`sendBack` → `revise`; a later `resubmit` → `resubmit`, writing no status) | `resubmit` or `revise` | a `resubmit` action row, whose sole writer is `resubmit` |
5723
+ * | `recalled` | 2 writers, 3 behaviours, 2 issuing NO continuation | REFUSED | nothing on the row distinguishes them |
5724
+ *
5725
+ * ⚠️ **Both refusals are deliberate and neither is best-effort.** The failure
5726
+ * mode of a wrong rebuild is a flow advanced down a branch nobody chose —
5727
+ * strictly worse than the dead end this verb exists to open. Where the signal
5728
+ * cannot be proved, this refuses and names what the operator can do instead;
5729
+ * the journal is what makes both shapes recoverable going forward.
5730
+ *
5731
+ * ⛔ `pending` and `cancelled` are refused outright: neither names a recorded
5732
+ * outcome to replay. A `pending` request's continuation is an ordinary
5733
+ * decision through the front door, which is exactly the guard this verb
5734
+ * exists to avoid weakening.
5735
+ */
5736
+ async resolveRecordedContinuation(raw, requestId) {
5737
+ const config = parseJson(raw.node_config_json, {});
5738
+ const status = String(raw.status ?? "");
5739
+ const stashed = config?.[STRANDED_CONTINUATION_KEY];
5740
+ if (stashed && typeof stashed === "object" && typeof stashed.decision === "string") {
5741
+ const issuable = CONTINUATIONS_A_STATUS_CAN_ISSUE[status];
5742
+ const journalled = String(stashed.decision);
5743
+ if (!issuable?.includes(journalled)) {
5744
+ throw new Error(
5745
+ `INVALID_STATE: request ${requestId} is '${status || "unknown"}' and its journalled continuation is the ${journalled}, which a '${status || "unknown"}' request cannot have issued \u2014 ${issuable ? `a '${status}' row is replayable only for ${issuable.map((d) => `'${d}'`).join(" or ")}` : `no continuation is replayable for '${status || "unknown"}'`}. The journal records what the last FAILED resume was carrying, so a later recall (or any other door that moved this row on) leaves a signal behind that the row's own status no longer stands behind, and replaying it would advance a step nobody is waiting on. Refusing to replay it: cancel the run with the engine's cancelRun('${raw.flow_run_id}') if the newer outcome should stand, or resume it by hand with the signal the flow expects.`
5746
+ );
5747
+ }
5748
+ return { signal: stashed, source: "journal" };
5749
+ }
5750
+ const outputs = { ...config?.__decisionOutputs ?? {} };
5751
+ if (status === "approved" || status === "rejected") {
5752
+ if (status === "rejected") {
5753
+ const priorRevise = await this.engine.find("sys_approval_action", {
5754
+ where: { request_id: requestId, action: "revise" },
5755
+ limit: 1,
5756
+ context: SYSTEM_CTX2
5757
+ });
5758
+ if (Array.isArray(priorRevise) && priorRevise.length) {
5759
+ throw new Error(
5760
+ `INVALID_STATE: request ${requestId} is 'rejected' and also carries a 'revise' action, so this service cannot tell a decided rejection from an ADR-0044 revision-limit auto-rejection \u2014 and the two resume the same edge with different flow output (\`autoRejected\`). Refusing to guess: replay it by hand with the signal the flow expects, or cancel the run.`
5761
+ );
5762
+ }
5763
+ }
5764
+ const decision = status === "approved" ? "approve" : "reject";
5765
+ return {
5766
+ source: "reconstructed",
5767
+ signal: {
5768
+ branchLabel: status === "approved" ? import_automation.APPROVAL_BRANCH_LABELS.approve : import_automation.APPROVAL_BRANCH_LABELS.reject,
5769
+ output: { ...outputs, decision, requestId },
5770
+ decision,
5771
+ what: `the ${decision} decision`
5772
+ }
5773
+ };
5774
+ }
5775
+ if (status === "returned") {
5776
+ const resubmitted = await this.engine.find("sys_approval_action", {
5777
+ where: { request_id: requestId, action: "resubmit" },
5778
+ limit: 1,
5779
+ context: SYSTEM_CTX2
5780
+ });
5781
+ if (Array.isArray(resubmitted) && resubmitted.length) {
5782
+ return {
5783
+ source: "reconstructed",
5784
+ signal: {
5785
+ branchLabel: import_automation.APPROVAL_BRANCH_LABELS.resubmit,
5786
+ output: { resubmitted: true, requestId },
5787
+ decision: "resubmit",
5788
+ what: "the resubmit"
5789
+ }
5790
+ };
5791
+ }
5792
+ return {
5793
+ source: "reconstructed",
5794
+ signal: {
5795
+ branchLabel: import_automation.APPROVAL_BRANCH_LABELS.revise,
5796
+ output: { decision: "revise", requestId },
5797
+ decision: "revise",
5798
+ what: "the send-back"
5799
+ }
5800
+ };
5801
+ }
5802
+ if (status === "recalled") {
5803
+ throw new Error(
5804
+ `INVALID_STATE: request ${requestId} is 'recalled' and carries no journalled continuation, so the signal cannot be rebuilt: a recall reaches this state three ways (resumed down 'reject', terminally cancelled inside a revision window, or swept as a dead run) and two of them issue no continuation at all \u2014 replaying the wrong one would re-open a request that was deliberately withdrawn. Refusing to guess: cancel the run with the engine's cancelRun('${raw.flow_run_id}') if the withdrawal should stand, or resume it by hand with the signal the flow expects.`
5805
+ );
5806
+ }
5807
+ throw new Error(
5808
+ `INVALID_STATE: request is ${status || "unknown"} \u2014 only a request whose recorded outcome already resumed its run can have that continuation re-issued (approved, rejected, returned, recalled)`
5809
+ );
5810
+ }
5811
+ /**
5812
+ * WHERE the pause a recorded continuation was refused on actually sits
5813
+ * (#15389) — the expected node guard 3 compares the run's parked node against.
5814
+ *
5815
+ * ⚠️ This is signal-aware, and that is the whole point of it. "This request's
5816
+ * own node" is the right answer for three of the four signals and the WRONG
5817
+ * answer for the fourth:
5818
+ *
5819
+ * | signal | issued from | why |
5820
+ * |---|---|---|
5821
+ * | `approve` / `reject` | the request's own approval node | the decision is taken at the pause it gates |
5822
+ * | `revise` (send-back) | the request's own approval node | send-back resumes that same pause down the `revise` edge |
5823
+ * | `recall` | the request's own approval node | recall-on-pending resumes that same pause down `reject` |
5824
+ * | `resubmit` | the **revise window** the request's `revise` edge leads to | by construction: a resubmit is only reachable AFTER a send-back moved the run there, and it resumes THAT pause down the `resubmit` back-edge |
5825
+ *
5826
+ * Measured before this existed: a `returned` row whose resubmit stranded was
5827
+ * refused by guard 3 on both the journal and the rebuild paths — the pause
5828
+ * re-armed at the revise window while the row's `flow_node_id` still read the
5829
+ * approval node — and the refusal told the operator the pause was not this
5830
+ * request's when it was exactly this request's. A refusal may ship; a refusal
5831
+ * that names a cause the code did not take may not.
5832
+ *
5833
+ * ⛔ It stays FAIL-CLOSED: the revise window is derived from the flow
5834
+ * definition the same way {@link ApprovalService.assertReviseEdge} derives it
5835
+ * — a `revise` out-edge of this request's node into a node the flow declares
5836
+ * as `{@link APPROVAL_REVISE_NODE_TYPE}`, which is the pause only this service
5837
+ * can continue. No engine, no flow, no such edge, or more than one candidate
5838
+ * ⇒ refuse. It needs no automation surface `assertReviseEdge` did not already
5839
+ * use (`getFlow`), and no engine change.
5840
+ *
5841
+ * ⚠️ It does not widen what guard 3 admits beyond that one signal: for every
5842
+ * other decision the answer is byte-identical to the row's own node.
5843
+ *
5844
+ * ⛔ It is NOT what keeps the recall-in-revise-window shape (row `recalled`,
5845
+ * run at the revise window) refused, and an earlier revision of this comment
5846
+ * claimed it was — on the reasoning that such a row's journalled signal is
5847
+ * `recall` rather than `resubmit`. That is false: a recall taken inside the
5848
+ * revise window calls `cancelRun` and journals NOTHING, so the journal on
5849
+ * such a row is whatever an EARLIER strand left there — a `resubmit`, most
5850
+ * often, since the resubmit is what the window exists to receive. Measured:
5851
+ * with the journal returned before the row's status was looked at, that
5852
+ * stale `resubmit` reached this method, was answered with the revise window,
5853
+ * matched the parked node, and opened a fresh `pending` round on a withdrawn
5854
+ * request. What refuses it is the journal/status compatibility check in
5855
+ * {@link ApprovalService.resolveRecordedContinuation} — see
5856
+ * {@link CONTINUATIONS_A_STATUS_CAN_ISSUE} — which runs BEFORE this method
5857
+ * and never hands it a signal the row's status cannot have issued.
5858
+ */
5859
+ async expectedPauseNode(raw, signal, requestId, runId) {
5860
+ const ownNode = raw.flow_node_id ?? raw.current_step ?? null;
5861
+ if (!ownNode) {
5862
+ throw new Error(
5863
+ `INVALID_STATE: request ${requestId} records no approval node, so the pause on run '${runId}' cannot be proved to be the one ${signal.what} was refused on \u2014 refusing rather than resuming a pause that may belong to another node`
5864
+ );
5865
+ }
5866
+ if (signal.decision !== "resubmit") {
5867
+ return { nodeId: ownNode, describe: `its own approval node '${ownNode}'` };
5868
+ }
5869
+ const processName = String(raw.process_name ?? "");
5870
+ const flowName = processName.startsWith("flow:") ? processName.slice("flow:".length) : "";
5871
+ if (!flowName || typeof this.automation?.getFlow !== "function") {
5872
+ throw new Error(
5873
+ `INVALID_STATE: ${signal.what} on request ${requestId} was issued from the revise window that approval node '${ownNode}' sends back to, and this service cannot read the owning flow definition to say which node that is \u2014 refusing, because continuing a pause it cannot identify advances a step nobody decided`
5874
+ );
5875
+ }
5876
+ const flow = await this.automation.getFlow(flowName);
5877
+ const nodeTypeById = new Map(
5878
+ (Array.isArray(flow?.nodes) ? flow.nodes : []).filter((n) => typeof n?.id === "string").map((n) => [n.id, typeof n.type === "string" ? n.type : ""])
5879
+ );
5880
+ const windows = Array.from(new Set(
5881
+ (Array.isArray(flow?.edges) ? flow.edges : []).filter((e) => e?.source === ownNode && e?.label === import_automation.APPROVAL_BRANCH_LABELS.revise).map((e) => typeof e?.target === "string" ? e.target : "").filter((t) => t && nodeTypeById.get(t) === import_automation.APPROVAL_REVISE_NODE_TYPE)
5882
+ ));
5883
+ if (windows.length !== 1) {
5884
+ throw new Error(
5885
+ `INVALID_STATE: ${signal.what} on request ${requestId} was issued from the revise window that approval node '${ownNode}' sends back to, and flow '${flowName}' declares ${windows.length === 0 ? "no such window" : `${windows.length} of them (${windows.join(", ")})`} \u2014 refusing, because a pause this service cannot identify must not be continued`
5886
+ );
5887
+ }
5888
+ return {
5889
+ nodeId: windows[0],
5890
+ describe: `the revise window '${windows[0]}' that its approval node '${ownNode}' sends back to`
5891
+ };
5892
+ }
5893
+ /**
5894
+ * Re-issue the continuation for a run an operator has re-armed with
5895
+ * `AutomationEngine.restoreConsumedSuspension` — the missing half of that
5896
+ * repair verb, for approvals (#15389).
5897
+ *
5898
+ * ## The dead end this exits
5899
+ *
5900
+ * A decision whose downstream node throws strands the run: the suspension is
5901
+ * consumed, the decision is durable, and the caller gets `RESUME_FAILED`
5902
+ * carrying `repairable: true`. `restoreConsumedSuspension` then genuinely
5903
+ * re-arms the pause — measured `restored: true`, `hasSuspendedRun` back to
5904
+ * `true` — and its own reason string tells the operator to *re-issue the
5905
+ * continuation*. For an `approval` node there was then nobody who could:
5906
+ *
5907
+ * - `decide` / `recall` / `sendBack` / `resubmit` all guard on a `pending`
5908
+ * request, and the row is terminal — written by the very call that
5909
+ * stranded the run;
5910
+ * - the generic `engine.resume` refuses, because the `approval` node
5911
+ * declares `resumeAuthority: 'service'` and the #3801 gate turns away any
5912
+ * resume that is not the tail of a decision this service authorized.
5913
+ *
5914
+ * So the only verb left was `cancelRun`, which discards the branch's
5915
+ * downstream work. Measured on the real engine and the real door: the
5916
+ * restored pause IS resumable, and a `resumeAuthority`-marked resume walks
5917
+ * the reject branch to completion. Nothing was missing in the engine — what
5918
+ * was missing was an ISSUER on this side. This is that issuer.
5919
+ *
5920
+ * ## What it deliberately does NOT do
5921
+ *
5922
+ * ⛔ It does not re-open, re-decide, or rewrite the request row: all four
5923
+ * `pending` guards stay exactly as they are, and no status, mirror field or
5924
+ * audit row is written. A person decided this once; this replays what they
5925
+ * decided onto the pause that was put back, and replays nothing else.
5926
+ * ⛔ It does not relax `resumeAuthority: 'service'` — the resume goes through
5927
+ * {@link ApprovalService.serviceResume} like every other, so the marker is
5928
+ * still stamped in exactly one place.
5929
+ * ⛔ It grants no capability that in-process code did not already have:
5930
+ * `RESUME_AUTHORITY_SERVICE` is importable by anything in the host, so the
5931
+ * raw form of this call was always available. What this adds is the GUARDED
5932
+ * form, and the guards are the substance of it — three, each with its own
5933
+ * reverse-control pin, because the raw marker is not a guard and an
5934
+ * unguarded repair verb advances flows nobody decided:
5935
+ *
5936
+ * 1. {@link assertLatestForRun} — this request is still the newest on its
5937
+ * run, so a superseded row cannot drive a later round or a later node;
5938
+ * 2. `hasSuspendedRun` — a pause exists at all (strict: an unreadable store
5939
+ * throws rather than reading as "not suspended");
5940
+ * 3. node identity — that pause is parked where THIS request's recorded
5941
+ * outcome was issued from: its own approval node for `approve`,
5942
+ * `reject`, `revise` and `recall`, and — for a `resubmit`, which is only
5943
+ * reachable from a revise window — the `approval_revise` node its own
5944
+ * `revise` edge leads to. {@link ApprovalService.expectedPauseNode}
5945
+ * derives it, fail-closed.
5946
+ *
5947
+ * Guard 3 is not redundant with guard 2: existence is not identity, and a
5948
+ * boolean cannot tell this request's re-armed pause from any other live
5949
+ * pause on the same run.
5950
+ *
5951
+ * ## Posture, and why it takes no `ExecutionContext`
5952
+ *
5953
+ * Deliberately shaped like the engine verb it completes: an in-process
5954
+ * operator repair, reachable from a host or a console script, with no REST
5955
+ * route and no entry in the spec `ApprovalService` contract — exactly as
5956
+ * `restoreConsumedSuspension` is a class method on `AutomationEngine` and
5957
+ * appears in no contract. It authorizes nothing new: the decision it replays
5958
+ * was authorized and recorded when it was made, and re-authorizing it here
5959
+ * against a present-day actor would be a different and wrong question (the
5960
+ * original approver may be long gone). `requestedBy` / `reason` ride the log
5961
+ * for the same reason they do on the restore.
5962
+ *
5963
+ * @returns what was replayed and whether the run moved — never a silent
5964
+ * `false`. A resume that fails again throws the same `RESUME_FAILED`
5965
+ * envelope the original decision did, `repairable` and all, so a second
5966
+ * restore-and-continue is possible.
5967
+ */
5968
+ async continueRestoredRun(requestId, options) {
5969
+ if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
5970
+ const rows = await this.engine.find("sys_approval_request", {
5971
+ where: { id: requestId },
5972
+ limit: 1,
5973
+ context: SYSTEM_CTX2
5974
+ });
5975
+ const raw = Array.isArray(rows) ? rows[0] : null;
5976
+ if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
5977
+ const runId = raw.flow_run_id ?? null;
5978
+ if (!runId) {
5979
+ throw new Error(
5980
+ `INVALID_STATE: request ${requestId} names no flow run \u2014 there is no continuation to re-issue`
5981
+ );
5982
+ }
5983
+ await this.assertLatestForRun(raw);
5984
+ const { signal, source } = await this.resolveRecordedContinuation(raw, requestId);
5985
+ if (typeof this.automation?.hasSuspendedRun === "function") {
5986
+ const parked = await this.automation.hasSuspendedRun(runId);
5987
+ if (!parked) {
5988
+ throw new Error(
5989
+ `INVALID_STATE: run '${runId}' behind request ${requestId} is not suspended, so there is no re-armed pause to continue \u2014 restore it first with the automation engine's restoreConsumedSuspension('${runId}'), which is what re-arms a consumed approval suspension`
5990
+ );
5991
+ }
5992
+ }
5993
+ const expected = await this.expectedPauseNode(raw, signal, requestId, runId);
5994
+ if (typeof this.automation?.listSuspendedRunsDurable !== "function") {
5995
+ throw new Error(
5996
+ `INVALID_STATE: this automation engine cannot report WHERE run '${runId}' is parked (no listSuspendedRunsDurable), so the pause cannot be proved to be the one ${signal.what} on request ${requestId} was refused on \u2014 refusing, because continuing the wrong pause advances a flow with no decision behind it`
5997
+ );
5998
+ }
5999
+ const parkedAt = (await this.automation.listSuspendedRunsDurable()).find((r) => String(r.runId) === String(runId))?.nodeId;
6000
+ if (parkedAt !== expected.nodeId) {
6001
+ throw new Error(
6002
+ `INVALID_STATE: run '${runId}' is parked at ${parkedAt ? `node '${parkedAt}'` : "no node this engine can see"}, but ${signal.what} on request ${requestId} was issued from ${expected.describe} \u2014 so the pause this verb was asked to continue is not the one that outcome was issued at, and continuing it would advance a step nobody decided`
6003
+ );
6004
+ }
6005
+ this.logger?.warn?.(
6006
+ "[approvals] re-issuing the continuation for a restored approval suspension",
6007
+ {
6008
+ request: requestId,
6009
+ run: runId,
6010
+ decision: signal.decision,
6011
+ branchLabel: signal.branchLabel,
6012
+ source,
6013
+ requestedBy: options?.requestedBy ?? "not recorded",
6014
+ reason: options?.reason ?? "not recorded"
6015
+ }
6016
+ );
6017
+ const outcome = await this.resumeRecordedOutcome(
6018
+ runId,
6019
+ requestId,
6020
+ signal.what,
6021
+ { branchLabel: signal.branchLabel, output: signal.output },
6022
+ signal.decision
6023
+ );
6024
+ return {
6025
+ resumed: outcome.resumed,
6026
+ runId,
6027
+ decision: signal.decision,
6028
+ branchLabel: signal.branchLabel,
6029
+ source,
6030
+ ...outcome.resumeError ? { resumeError: outcome.resumeError } : {}
6031
+ };
6032
+ }
4994
6033
  async releaseDeadRunRequests() {
4995
6034
  if (typeof this.automation?.getRun !== "function") return { scanned: 0, released: 0 };
4996
6035
  let rows = [];
@@ -5840,9 +6879,28 @@ var _ApprovalService = class _ApprovalService {
5840
6879
  * flow-driven resume, a service-to-service call), turning a successful write
5841
6880
  * into a `null` result. Gating belongs on the read API, not on an
5842
6881
  * operation's own return value.
6882
+ *
6883
+ * [#12769] The read still narrows by the CALLER's organization
6884
+ * (`loadRequest` — a deliberate tenancy wall, untouched here), and an
6885
+ * org-less request row is invisible inside an org-scoped caller's narrowing.
6886
+ * Org-less rows arise by construction on every schedule / time-relative /
6887
+ * api trigger run (#10131; #9132 pinned that behaviour rather than repairing
6888
+ * it), so this is a live state, not an edge case: the write IS recorded and
6889
+ * the echo cannot be built. It used to escape as `null` behind the callers'
6890
+ * `fresh!` non-null assertions — a well-formed 200 whose declared-non-null
6891
+ * `request` was `null` on the wire. Now it refuses loudly instead, which
6892
+ * keeps every declared result type true: a result that cannot be built is
6893
+ * never returned. Callers must NOT catch this to fall back to `null` —
6894
+ * that would re-open the type-lie one level up.
5843
6895
  */
5844
6896
  async readBackRequest(requestId, context) {
5845
- return this.loadRequest(requestId, context, false);
6897
+ const fresh = await this.loadRequest(requestId, context, false);
6898
+ if (!fresh) {
6899
+ throw new Error(
6900
+ `READ_BACK_FAILED: the write to approval request '${requestId}' was recorded, but the updated row is not visible inside the caller's organization scope, so the result envelope cannot be built. The write is NOT rolled back \u2014 read the request back with a system or matching-organization context.`
6901
+ );
6902
+ }
6903
+ return fresh;
5846
6904
  }
5847
6905
  async getRequest(requestId, context) {
5848
6906
  return this.loadRequest(requestId, context, true);
@@ -6334,6 +7392,41 @@ function bindApprovalLockHook(engine, logger) {
6334
7392
  }, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
6335
7393
  logger?.info?.("[approvals] record-lock hook bound");
6336
7394
  }
7395
+ var APPROVALS_OWN_OBJECTS = [
7396
+ "sys_approval_request",
7397
+ "sys_approval_action",
7398
+ "sys_approval_approver",
7399
+ "sys_approval_token",
7400
+ "sys_approval_delegation"
7401
+ ];
7402
+ function bindRecordDeleteCancelHook(engine, service, logger) {
7403
+ engine.registerHook("afterDelete", async (ctx) => {
7404
+ const object = ctx?.object ?? ctx?.objectName;
7405
+ if (!object) return;
7406
+ const raw = ctx?.previous?.id ?? ctx?.input?.id;
7407
+ const recordId = raw == null ? "" : String(raw);
7408
+ if (!recordId) {
7409
+ logger?.warn?.(
7410
+ "[approvals] a delete reached the approval-cancel hook with no record id \u2014 pending approvals for the deleted record (if any) stay in the inbox",
7411
+ { object }
7412
+ );
7413
+ return;
7414
+ }
7415
+ try {
7416
+ await service.cancelForDeletedRecord(String(object), recordId);
7417
+ } catch (err) {
7418
+ logger?.warn?.(
7419
+ "[approvals] the approval-cancel linkage failed for a deleted record \u2014 its pending approvals stay in the inbox pointing at a record that no longer exists",
7420
+ { object, record: recordId, error: err?.message ?? String(err) }
7421
+ );
7422
+ }
7423
+ }, {
7424
+ excludeObjects: [...APPROVALS_OWN_OBJECTS],
7425
+ packageId: APPROVALS_HOOK_PACKAGE,
7426
+ priority: 50
7427
+ });
7428
+ logger?.info?.("[approvals] record-delete cancel hook bound");
7429
+ }
6337
7430
  var DELEGATION_OBJECT = "sys_approval_delegation";
6338
7431
  function bindDelegationWriteGuard(engine, logger) {
6339
7432
  const makeGuard = (isInsert) => async (ctx) => {
@@ -6677,6 +7770,23 @@ var ApprovalsServicePlugin = class {
6677
7770
  } catch {
6678
7771
  return void 0;
6679
7772
  }
7773
+ },
7774
+ // [#11993] Deployment override lookup for user-facing refusal copy.
7775
+ // Resolved LAZILY for the reason the two providers above are: the i18n
7776
+ // service is contributed by another plugin (ADR-0029 D8) and may start
7777
+ // after this one. Without it the service still renders the built-in
7778
+ // catalog in the caller's locale; with it, a deployment's own
7779
+ // `translation` for `errors.approval_recall_not_submitter` wins — the
7780
+ // override address the catalog documents, made real for this emitter.
7781
+ messageTranslator: () => {
7782
+ try {
7783
+ const i18n = ctx.getService("i18n");
7784
+ const t = i18n?.t;
7785
+ if (typeof t !== "function") return void 0;
7786
+ return (key, locale, params) => t.call(i18n, key, locale, params);
7787
+ } catch {
7788
+ return void 0;
7789
+ }
6680
7790
  }
6681
7791
  });
6682
7792
  const fieldVisibility = () => {
@@ -6697,6 +7807,7 @@ var ApprovalsServicePlugin = class {
6697
7807
  try {
6698
7808
  unbindAllHooks(engine);
6699
7809
  bindApprovalLockHook(engine, ctx.logger);
7810
+ bindRecordDeleteCancelHook(engine, this.service, ctx.logger);
6700
7811
  bindDelegationWriteGuard(engine, ctx.logger);
6701
7812
  bindSnapshotRedactionMiddleware(engine, fieldVisibility, ctx.logger);
6702
7813
  } catch (err) {