@objectstack/plugin-approvals 17.1.0 → 17.3.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.
1421
- //
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.
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.
1428
1555
  //
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.
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.
1436
1566
  //
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
@@ -2235,11 +2463,44 @@ async function filterApproversWhoCanRead(deps, userIds, requestOrgId, context) {
2235
2463
  return userIds.filter((u) => canRead.has(u));
2236
2464
  }
2237
2465
 
2466
+ // src/payload-redaction.ts
2467
+ function redactSnapshot(payload, readable) {
2468
+ if (readable === void 0) return { payload, redactedKeys: [] };
2469
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
2470
+ return { payload, redactedKeys: [] };
2471
+ }
2472
+ const allowed = new Set(readable.map((f) => String(f)));
2473
+ const source = payload;
2474
+ const redactedKeys = [];
2475
+ const kept = {};
2476
+ for (const key of Object.keys(source)) {
2477
+ if (allowed.has(key)) kept[key] = source[key];
2478
+ else redactedKeys.push(key);
2479
+ }
2480
+ if (redactedKeys.length === 0) return { payload, redactedKeys: [] };
2481
+ return { payload: kept, redactedKeys: redactedKeys.sort() };
2482
+ }
2483
+ async function resolveReadableSnapshotFields(security, objectName, context, logger) {
2484
+ if (!security || typeof security.getReadableFields !== "function") return void 0;
2485
+ const object = String(objectName ?? "").trim();
2486
+ if (!object) return void 0;
2487
+ try {
2488
+ return await security.getReadableFields(object, context);
2489
+ } catch (err) {
2490
+ logger?.warn?.("[approvals] payload redaction could not resolve readable fields \u2014 serving the snapshot unredacted", {
2491
+ object,
2492
+ error: err?.message ?? String(err)
2493
+ });
2494
+ return void 0;
2495
+ }
2496
+ }
2497
+
2238
2498
  // src/approval-service.ts
2239
2499
  var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
2240
2500
  var ESCALATION_JOB_NAME = "approvals-sla-escalation";
2241
2501
  var ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1e3;
2242
2502
  var SLA_ACTOR_ID = "system:sla";
2503
+ var ESCALATION_ENABLED_FLIP_CUTOFF_MS = Date.parse("2026-08-28T00:00:00Z");
2243
2504
  var DEAD_RUN_ACTOR_ID = "system:dead-run";
2244
2505
  var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
2245
2506
  "completed",
@@ -2248,13 +2509,43 @@ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
2248
2509
  "timed_out"
2249
2510
  ]);
2250
2511
  var STRANDABLE_REQUEST_STATUSES = ["approved", "rejected", "returned"];
2512
+ function classifyStrandedRunState(run) {
2513
+ if (!run) return "missing";
2514
+ switch (run.status) {
2515
+ // The resume consumed the pause and a downstream node threw. Reported.
2516
+ case "failed":
2517
+ return "failed";
2518
+ // ── The negatives, each for its own reason ──────────────────────────────
2519
+ // The decision advanced the flow and the flow finished. Healthy.
2520
+ case "completed":
2521
+ return void 0;
2522
+ // Deliberately terminated by an operator (`cancelRun`, ADR-0044). The run
2523
+ // stopping is the intended outcome, exactly as `recalled` is on the request
2524
+ // side — reporting it would bury the real findings under expected ones.
2525
+ case "cancelled":
2526
+ return void 0;
2527
+ // The history's last row says `paused` while the suspension store says no
2528
+ // live pause. That is AMBIGUOUS, and the ambiguity is not resolvable from
2529
+ // one scan: a resume in flight right now has consumed the suspension and
2530
+ // not yet written its terminal row, and reads exactly like a process that
2531
+ // died in the same window. Condemning it would name every concurrently
2532
+ // resuming approval — so this stays SKIPPED, the conservative arm this
2533
+ // whole method is built on.
2534
+ case "paused":
2535
+ return void 0;
2536
+ default:
2537
+ return void 0;
2538
+ }
2539
+ }
2251
2540
  var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
2252
2541
  var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
2542
+ var RECORD_DELETE_CANCEL_LIMIT = 200;
2253
2543
  function actingUserId(context) {
2254
2544
  const userId = context?.userId;
2255
2545
  return typeof userId === "string" && userId ? userId : null;
2256
2546
  }
2257
2547
  var OOO_MAX_CHAIN = 8;
2548
+ var MEMBER_SCREEN_READ_LIMIT = 5e4;
2258
2549
  var GRAPH_APPROVER_TYPES = /* @__PURE__ */ new Set([
2259
2550
  "team",
2260
2551
  "department",
@@ -2309,6 +2600,10 @@ function rowFromRequest(row) {
2309
2600
  submitter_id: row.submitter_id ?? void 0,
2310
2601
  submitter_comment: row.submitter_comment ?? void 0,
2311
2602
  status: row.status ?? "pending",
2603
+ // [#13568] Why a cancelled row was cancelled. Surfaced on every read for
2604
+ // the same reason `status` is: a client that can see the terminal state and
2605
+ // not the machine-readable cause is back to guessing from prose.
2606
+ cancel_reason: row.cancel_reason ?? void 0,
2312
2607
  current_step: row.current_step ?? void 0,
2313
2608
  current_step_index: row.current_step_index ?? void 0,
2314
2609
  pending_approvers: csvSplit(row.pending_approvers),
@@ -2401,12 +2696,15 @@ function rowFromAction(row) {
2401
2696
  var _ApprovalService = class _ApprovalService {
2402
2697
  constructor(opts) {
2403
2698
  this.engine = opts.engine;
2699
+ this.recordOrgResolver = (0, import_metadata_core.createRecordOrganizationResolver)(opts.engine);
2404
2700
  this.clock = opts.clock ?? { now: () => /* @__PURE__ */ new Date() };
2405
2701
  this.logger = opts.logger;
2406
2702
  this.automation = opts.automation;
2407
2703
  this.messaging = opts.messaging;
2408
2704
  this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
2409
2705
  this.tenancyPosture = opts.tenancyPosture;
2706
+ this.fieldVisibility = opts.fieldVisibility;
2707
+ this.messageTranslator = opts.messageTranslator;
2410
2708
  this.recordReaderVisibleObjects = new Set(
2411
2709
  (Array.isArray(opts.recordReaderVisibleObjects) ? opts.recordReaderVisibleObjects : []).map((n) => String(n ?? "").trim()).filter(Boolean)
2412
2710
  );
@@ -2415,6 +2713,57 @@ var _ApprovalService = class _ApprovalService {
2415
2713
  attachTenancyPosture(provider) {
2416
2714
  this.tenancyPosture = provider;
2417
2715
  }
2716
+ /**
2717
+ * [#10749] Attach (or replace) the field-visibility authority the payload
2718
+ * redaction seam reads. Late-bound: plugin load order does not guarantee the
2719
+ * security service exists when this one is constructed.
2720
+ */
2721
+ attachFieldVisibility(source) {
2722
+ this.fieldVisibility = source;
2723
+ }
2724
+ /**
2725
+ * [#10749] Redact each row's payload snapshot down to the fields the READING
2726
+ * caller may see on that row's subject object.
2727
+ *
2728
+ * Runs BEFORE {@link ApprovalService.enrichRows}, and that ordering is
2729
+ * load-bearing rather than incidental: `enrichRows` derives `payload_display`
2730
+ * (lookup foreign keys inside the snapshot resolved to referenced record
2731
+ * titles) and `payload_labels` (a label per snapshot key) by WALKING THE
2732
+ * SNAPSHOT'S OWN KEYS. Redact first and both derived maps are clean for free;
2733
+ * redact after and a restricted field's name, its authored label and the
2734
+ * title of the record it points at all still ship — the value would be gone
2735
+ * and the disclosure would not.
2736
+ *
2737
+ * Rows are grouped by subject object so one `getReadableFields` call covers a
2738
+ * whole page of same-object requests.
2739
+ */
2740
+ async redactPayloads(rows, context) {
2741
+ const withPayload = rows.filter((r) => r?.payload != null);
2742
+ if (withPayload.length === 0) return;
2743
+ const byObject = /* @__PURE__ */ new Map();
2744
+ for (const r of withPayload) {
2745
+ const key = String(r.object_name ?? "");
2746
+ let list = byObject.get(key);
2747
+ if (!list) {
2748
+ list = [];
2749
+ byObject.set(key, list);
2750
+ }
2751
+ list.push(r);
2752
+ }
2753
+ for (const [object, group] of byObject) {
2754
+ const readable = await resolveReadableSnapshotFields(
2755
+ this.fieldVisibility,
2756
+ object,
2757
+ context,
2758
+ this.logger
2759
+ );
2760
+ if (readable === void 0) continue;
2761
+ for (const r of group) {
2762
+ const { payload } = redactSnapshot(r.payload, readable);
2763
+ r.payload = payload;
2764
+ }
2765
+ }
2766
+ }
2418
2767
  /** Deps bundle for the ADR-0105 D9 org-scope helpers. */
2419
2768
  get orgScopeDeps() {
2420
2769
  return {
@@ -2508,6 +2857,41 @@ var _ApprovalService = class _ApprovalService {
2508
2857
  const actorTenant = context.tenantId ?? context.organizationId ?? null;
2509
2858
  return requestOrg == null || actorTenant != null && String(requestOrg) === String(actorTenant);
2510
2859
  }
2860
+ /**
2861
+ * [#11993] The END USER's half of an approval refusal.
2862
+ *
2863
+ * `handleApprovalError` in `@objectstack/rest` maps this service's
2864
+ * `CODE: message` throws onto the wire by testing the prefix for the status
2865
+ * and then STRIPPING it (a leading run of `[A-Z_]` plus a colon and any
2866
+ * following whitespace), shipping what remains as the body's
2867
+ * human-readable `error` — which Console splices under its own localized
2868
+ * label ("撤回审批失败: …"). A hardcoded English reason therefore reaches an
2869
+ * operator in a fully Chinese deployment as a Chinese prefix glued onto an
2870
+ * English sentence they cannot act on.
2871
+ *
2872
+ * Rendered through the SHARED Operation Message Catalog
2873
+ * (`@objectstack/spec/system`), not a second mechanism: same
2874
+ * `errors.<key>` override address, same resolution ladder (deployment
2875
+ * override -> locale catalog -> `en` -> the key), same guarantee that a
2876
+ * misbehaving i18n service cannot turn a 403 into a 500. The catalog's
2877
+ * header names this seat explicitly; `plugin-security`'s
2878
+ * `userFacingDenialMessage` is the sibling consumer this mirrors.
2879
+ *
2880
+ * The CODE PREFIX stays on the message. It is not user copy — it is how the
2881
+ * REST layer derives the status and the ADR-0112 wire code, and it is
2882
+ * stripped before the sentence reaches a body. The developer's half moves
2883
+ * to the log, where the ids it names are legible to an operator and to
2884
+ * nobody else.
2885
+ */
2886
+ userFacingRefusal(messageKey, context) {
2887
+ let translate;
2888
+ try {
2889
+ translate = this.messageTranslator?.();
2890
+ } catch {
2891
+ translate = void 0;
2892
+ }
2893
+ return (0, import_system.renderOperationMessage)({ messageKey }, { locale: context?.locale, translate });
2894
+ }
2511
2895
  /**
2512
2896
  * Pin the acting identity to the AUTHENTICATED CALLER (#3800).
2513
2897
  *
@@ -2681,7 +3065,7 @@ var _ApprovalService = class _ApprovalService {
2681
3065
  }) : users;
2682
3066
  try {
2683
3067
  if (type === "team") {
2684
- const users = await this.expandTeamUsers(String(a.value));
3068
+ const users = await this.expandTeamUsers(String(a.value), organizationId);
2685
3069
  if (users.length) return users;
2686
3070
  } else if (type === "department" || type === "business_unit" || type === "bu") {
2687
3071
  const users = await bounded(await this.expandBusinessUnitUsers(String(a.value), directoryOrg));
@@ -2695,7 +3079,7 @@ var _ApprovalService = class _ApprovalService {
2695
3079
  } else if (type === "manager" && record) {
2696
3080
  const subject = record[a.value] ?? record.owner_id;
2697
3081
  if (subject) {
2698
- const mgr = await this.lookupManager(String(subject));
3082
+ const mgr = await this.lookupManager(String(subject), organizationId);
2699
3083
  if (mgr) return this.applyOooDelegation(mgr, now, organizationId, substitutions);
2700
3084
  }
2701
3085
  }
@@ -2792,7 +3176,7 @@ var _ApprovalService = class _ApprovalService {
2792
3176
  try {
2793
3177
  if (resolveAs === "department") users = await this.expandBusinessUnitUsers(key, directoryOrg);
2794
3178
  else if (resolveAs === "position") users = await this.expandPositionUsers(key, directoryOrg);
2795
- else if (resolveAs === "team") users = await this.expandTeamUsers(key);
3179
+ else if (resolveAs === "team") users = await this.expandTeamUsers(key, directoryOrg);
2796
3180
  else {
2797
3181
  throw new Error(
2798
3182
  `VALIDATION_FAILED: expression approver has unknown resolveAs '${resolveAs}' \u2014 use 'user', 'department', 'position', or 'team'`
@@ -2817,9 +3201,50 @@ var _ApprovalService = class _ApprovalService {
2817
3201
  }
2818
3202
  return { slots, raw };
2819
3203
  }
2820
- /** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
2821
- async expandTeamUsers(teamId) {
3204
+ /**
3205
+ * Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy).
3206
+ *
3207
+ * Takes an organization for the reason every sibling expansion does
3208
+ * ({@link expandBusinessUnitUsers}, {@link expandPositionUsers},
3209
+ * {@link expandMembershipTierUsers}): an approver expansion answers "who, in
3210
+ * THIS organization". Before #10230 this one did not ask, and it was the last
3211
+ * expansion that did not — a `team` approver naming ANOTHER organization's
3212
+ * team routed that organization's people an approval over a record they are
3213
+ * not a tenant of.
3214
+ *
3215
+ * TWO screens run here, and they assert different things (#10230, #10547):
3216
+ *
3217
+ * 1. the TEAM must not provably belong to another organization
3218
+ * ({@link teamIsProvablyOutsideOrg}) — `sys_team` carries
3219
+ * `organization_id` outright
3220
+ * (`packages/platform-objects/src/identity/sys-team.object.ts`), so a
3221
+ * team id transitively names exactly one organization and ONE row
3222
+ * answers the question;
3223
+ * 2. each expanded MEMBER must not provably hold membership only in other
3224
+ * organizations ({@link dropMembersProvablyOutsideOrg}) —
3225
+ * `sys_team_member` carries `team_id` and `user_id` and NO tenancy
3226
+ * column at all, so passing (1) says nothing whatever about the people
3227
+ * it lists.
3228
+ *
3229
+ * #10230 landed (1) alone and deferred (2) on purpose. What closed the
3230
+ * deferral is that (1) does not imply (2) even a little: a member removed
3231
+ * from the organization but left on the team, a team re-parented across
3232
+ * organizations (`/organization/update-team` accepts `organizationId` in its
3233
+ * partial body), or a `sys_team_member` row written by a seed rather than
3234
+ * through better-auth all produce a team that passes (1) carrying a user who
3235
+ * is provably a tenant of somewhere else. Measured on this tree, not read off
3236
+ * the schema — the probe is quoted in `team-member-org-screen.test.ts`.
3237
+ *
3238
+ * (2) is the SAME assertion as {@link managerIsProvablyOutsideOrg}, one hop
3239
+ * further out, and it is asserted the same way: `sys_user` carries no tenancy
3240
+ * fact, so `sys_member` rows are the only evidence that a person is placed
3241
+ * anywhere. Like that screen, this one grants no reads and applies no read
3242
+ * screen to any approver type that lacks one today, so it decides nothing
3243
+ * #7497 (does approver routing imply record read visibility?) asks.
3244
+ */
3245
+ async expandTeamUsers(teamId, organizationId) {
2822
3246
  if (!teamId) return [];
3247
+ if (await this.teamIsProvablyOutsideOrg(teamId, organizationId)) return [];
2823
3248
  let rows = [];
2824
3249
  try {
2825
3250
  rows = await this.engine.find("sys_team_member", {
@@ -2831,7 +3256,149 @@ var _ApprovalService = class _ApprovalService {
2831
3256
  } catch {
2832
3257
  rows = [];
2833
3258
  }
2834
- return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
3259
+ const users = Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
3260
+ return await this.dropMembersProvablyOutsideOrg(teamId, users, organizationId);
3261
+ }
3262
+ /**
3263
+ * Is `teamId` PROVABLY a team of a DIFFERENT organization? (#10230)
3264
+ *
3265
+ * "Provably" carries the same posture the sibling screen states at length in
3266
+ * {@link managerIsProvablyOutsideOrg}, for the same reasons:
3267
+ *
3268
+ * - the team row carries an `organization_id` and it is not the request's
3269
+ * ⇒ the tenancy fact is present and NEGATIVE ⇒ screen it out;
3270
+ * - the row carries no `organization_id`, does not exist, or the read failed
3271
+ * ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
3272
+ *
3273
+ * The `organization_id = null` limb is not timidity — it is the reading
3274
+ * {@link businessUnitOrgScope} settled on one screen below, for the identical
3275
+ * shape: null on a platform object means "owned by no organization", which is
3276
+ * what a seed writes because a seed cannot know the organization id the
3277
+ * runtime mints at boot. Treating null as "not mine" would delete every
3278
+ * seeded team approver at once — a larger behaviour change than the hole
3279
+ * being closed. Measured, and not hypothetically: this package's own
3280
+ * `team_ok` expansion fixture is exactly such a stack (it has
3281
+ * `sys_team_member` rows, a request carrying an organization, and no
3282
+ * `sys_team` row at all).
3283
+ *
3284
+ * Screening the TEAM before reading its members is also what keeps the cost
3285
+ * at one row: a team that fails the screen never fans out.
3286
+ */
3287
+ async teamIsProvablyOutsideOrg(teamId, organizationId) {
3288
+ const requestOrg = organizationId ? String(organizationId) : "";
3289
+ if (!requestOrg) return false;
3290
+ let rows = [];
3291
+ try {
3292
+ rows = await this.engine.find("sys_team", {
3293
+ where: { id: teamId },
3294
+ fields: ["id", "organization_id"],
3295
+ limit: 1,
3296
+ context: SYSTEM_CTX2
3297
+ });
3298
+ } catch {
3299
+ return false;
3300
+ }
3301
+ const row = Array.isArray(rows) ? rows[0] : null;
3302
+ const teamOrg = row?.organization_id ? String(row.organization_id) : "";
3303
+ if (!teamOrg) return false;
3304
+ if (teamOrg === requestOrg) return false;
3305
+ this.logger?.warn?.(
3306
+ `[approvals] #10230: team '${teamId}' was dropped from the approver slate \u2014 'sys_team.organization_id' is '${teamOrg}', not the request's organization '${requestOrg}', so routing this approval to its members would put approval authority over the record outside its tenant. Point the approver at a team in this organization, or route this step with an approver type that names someone in it.`,
3307
+ { teamId, teamOrganizationId: teamOrg, requestOrganizationId: requestOrg }
3308
+ );
3309
+ return true;
3310
+ }
3311
+ /**
3312
+ * Drop the expanded team members who are PROVABLY tenants of other
3313
+ * organizations and not of `organizationId`. (#10547)
3314
+ *
3315
+ * Returns the survivors, in the order they were expanded.
3316
+ *
3317
+ * Posture — identical to {@link managerIsProvablyOutsideOrg} and
3318
+ * {@link teamIsProvablyOutsideOrg}, deliberately, because it is the same
3319
+ * assertion about the same table:
3320
+ *
3321
+ * - membership rows exist for this user, none in `organizationId`
3322
+ * ⇒ the tenancy fact is present and NEGATIVE ⇒ drop him;
3323
+ * - no membership rows at all for him, the read failed, or the request
3324
+ * carries no organization
3325
+ * ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
3326
+ *
3327
+ * The absent limb is load-bearing rather than timid, and #3807 is the recorded
3328
+ * cost of getting it wrong: a stack that stamps an organization on requests
3329
+ * but never materializes `sys_member` rows would otherwise lose EVERY team
3330
+ * approver at once. This package's own `team_ok` expansion fixture and
3331
+ * #10230's T2/T3 fixtures are exactly such stacks — they carry team rows and
3332
+ * a request organization and no `sys_member` table at all — so the absent
3333
+ * limb is exercised by neighbours on every run of this suite.
3334
+ *
3335
+ * ONE read for the whole slate, never one per person: the expansion is capped
3336
+ * at 10000 members and a per-user query would turn a single team approver
3337
+ * into 10000 round trips.
3338
+ *
3339
+ * ⚠️ A TRUNCATED read fails open, and that is the subtle half. This read is
3340
+ * the only evidence that a member IS a tenant here, so a result cut off at
3341
+ * the limit could be missing the very row that keeps a legitimate approver on
3342
+ * the slate — screening him out on missing evidence, which inverts the
3343
+ * posture into fail-CLOSED precisely where it must not. When the read comes
3344
+ * back at the cap it is treated as no evidence at all.
3345
+ */
3346
+ async dropMembersProvablyOutsideOrg(teamId, userIds, organizationId) {
3347
+ const requestOrg = organizationId ? String(organizationId) : "";
3348
+ if (!requestOrg || !userIds.length) return userIds;
3349
+ let rows = [];
3350
+ try {
3351
+ rows = await this.engine.find("sys_member", {
3352
+ where: { user_id: { $in: userIds } },
3353
+ fields: ["user_id", "organization_id"],
3354
+ limit: MEMBER_SCREEN_READ_LIMIT,
3355
+ context: SYSTEM_CTX2
3356
+ });
3357
+ } catch {
3358
+ return userIds;
3359
+ }
3360
+ if ((rows?.length ?? 0) >= MEMBER_SCREEN_READ_LIMIT) {
3361
+ this.logger?.warn?.(
3362
+ `[approvals] #10547: the membership screen for team '${teamId}' read ${rows.length} 'sys_member' rows, at or above its ${MEMBER_SCREEN_READ_LIMIT}-row cap, so the result may be truncated. Routing is left unchanged rather than risk dropping a member whose proof of membership fell outside the read.`,
3363
+ { teamId, requestOrganizationId: requestOrg, rowsRead: rows.length }
3364
+ );
3365
+ return userIds;
3366
+ }
3367
+ const orgsByUser = /* @__PURE__ */ new Map();
3368
+ for (const r of rows ?? []) {
3369
+ const uid2 = String(r?.user_id ?? "");
3370
+ const org = String(r?.organization_id ?? "");
3371
+ if (!uid2 || !org) continue;
3372
+ const seen = orgsByUser.get(uid2);
3373
+ if (seen) seen.push(org);
3374
+ else orgsByUser.set(uid2, [org]);
3375
+ }
3376
+ const kept = [];
3377
+ const dropped = [];
3378
+ for (const uid2 of userIds) {
3379
+ const orgs = orgsByUser.get(uid2);
3380
+ if (!orgs?.length) {
3381
+ kept.push(uid2);
3382
+ continue;
3383
+ }
3384
+ if (orgs.includes(requestOrg)) {
3385
+ kept.push(uid2);
3386
+ continue;
3387
+ }
3388
+ dropped.push({ userId: uid2, organizationIds: orgs });
3389
+ }
3390
+ if (dropped.length) {
3391
+ this.logger?.warn?.(
3392
+ `[approvals] #10547: ${dropped.length} member(s) of team '${teamId}' were dropped from the approver slate \u2014 ${dropped.map((d) => `'${d.userId}'`).join(", ")} hold membership in other organization(s), none of them the request's organization '${requestOrg}', so routing this approval to them would put approval authority over the record outside its tenant. The TEAM itself belongs to this organization; its 'sys_team_member' rows carry no organization of their own. Remove them from the team, grant them a membership in this organization, or route this step with an approver type that names someone in it.`,
3393
+ {
3394
+ teamId,
3395
+ requestOrganizationId: requestOrg,
3396
+ droppedUserIds: dropped.map((d) => d.userId),
3397
+ droppedMemberOrganizationIds: dropped.map((d) => d.organizationIds)
3398
+ }
3399
+ );
3400
+ }
3401
+ return kept;
2835
3402
  }
2836
3403
  /**
2837
3404
  * Tenant scope for a `sys_business_unit` read that may legitimately be
@@ -3000,7 +3567,36 @@ var _ApprovalService = class _ApprovalService {
3000
3567
  }
3001
3568
  return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
3002
3569
  }
3003
- async lookupManager(userId) {
3570
+ /**
3571
+ * `sys_user.manager_id`, screened to the request's organization (#10153).
3572
+ *
3573
+ * Takes an organization argument for the same reason its siblings do
3574
+ * ({@link expandPositionUsers}, {@link expandMembershipTierUsers}): an
3575
+ * approver expansion answers "who, in THIS organization". Before #10153 this
3576
+ * one did not ask, and it was the only expansion that did not — a
3577
+ * `manager_id` pointing at a person in another organization routed that
3578
+ * person an approval over a record they are not a tenant of.
3579
+ *
3580
+ * ⚠️ The screen reads `sys_member`, which LOOKS like the D2 read-visibility
3581
+ * filter next to it ({@link filterApproversWhoCanRead}). It is not, and this
3582
+ * comment exists so the next reader does not conclude that #7497 (does
3583
+ * approver routing imply record read visibility?) was settled here. It was
3584
+ * not. Two facts make this the SIBLING treatment rather than a
3585
+ * read-visibility ruling:
3586
+ *
3587
+ * 1. Two of the three org-scoped expansions already screen on exactly this
3588
+ * column — `expandMembershipTierUsers` filters `sys_member.organization_id`
3589
+ * outright, and it is also the second limb of `expandPositionUsers`. So
3590
+ * `sys_member.organization_id` is already this file's answer to "which
3591
+ * organization is this person in", independent of what they may read.
3592
+ * 2. `sys_user` carries no `organization_id` at all. It is a GLOBAL identity
3593
+ * table, so a membership row is the only tenancy fact that exists for a
3594
+ * user — there is no other read this screen could have been written with.
3595
+ *
3596
+ * This change grants no reads and applies no read screen to any type that
3597
+ * lacks one today, so it decides nothing #7497 asks.
3598
+ */
3599
+ async lookupManager(userId, organizationId) {
3004
3600
  try {
3005
3601
  const rows = await this.engine.find("sys_user", {
3006
3602
  where: { id: userId },
@@ -3009,11 +3605,64 @@ var _ApprovalService = class _ApprovalService {
3009
3605
  context: SYSTEM_CTX2
3010
3606
  });
3011
3607
  const row = Array.isArray(rows) ? rows[0] : null;
3012
- return row?.manager_id ? String(row.manager_id) : null;
3608
+ const managerId = row?.manager_id ? String(row.manager_id) : null;
3609
+ if (!managerId) return null;
3610
+ if (await this.managerIsProvablyOutsideOrg(managerId, organizationId)) return null;
3611
+ return managerId;
3013
3612
  } catch {
3014
3613
  return null;
3015
3614
  }
3016
3615
  }
3616
+ /**
3617
+ * Is `managerId` PROVABLY a member of other organizations and not of
3618
+ * `organizationId`? (#10153)
3619
+ *
3620
+ * "Provably" is the whole shape of this screen, and it is deliberate rather
3621
+ * than a weaker version of "must prove membership":
3622
+ *
3623
+ * - membership rows exist for this user, none in the request's org
3624
+ * ⇒ the tenancy fact is present and NEGATIVE ⇒ screen him out;
3625
+ * - no membership rows at all, or the read failed
3626
+ * ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
3627
+ *
3628
+ * The fail-open half is not timidity, it is this file's ruled posture on
3629
+ * addressing paths, stated twice already: {@link filterApproversWhoCanRead}
3630
+ * refuses to empty a live slate on an infrastructure hiccup, and
3631
+ * {@link expandPositionUsers} carries "a step routing to nobody is worse than
3632
+ * one routing to a lapsed holder". It is also load-bearing in practice — a
3633
+ * stack that stamps an organization on its requests but does not materialize
3634
+ * `sys_member` rows would otherwise lose every manager approver at once,
3635
+ * which is a bigger behaviour change than the hole being closed. Measured:
3636
+ * this repo's own `type:manager` out-of-office fixture is such a stack.
3637
+ *
3638
+ * Screening the MANAGER only, before OOO delegation, is deliberate too: the
3639
+ * delegate arrives from `sys_approval_delegation`, whose rows already carry
3640
+ * (and are already filtered by) an `organization_id` in
3641
+ * {@link lookupActiveDelegation}. This card is about `sys_user.manager_id`.
3642
+ */
3643
+ async managerIsProvablyOutsideOrg(managerId, organizationId) {
3644
+ const requestOrg = organizationId ? String(organizationId) : "";
3645
+ if (!requestOrg) return false;
3646
+ let rows = [];
3647
+ try {
3648
+ rows = await this.engine.find("sys_member", {
3649
+ where: { user_id: managerId },
3650
+ fields: ["user_id", "organization_id"],
3651
+ limit: 1e3,
3652
+ context: SYSTEM_CTX2
3653
+ });
3654
+ } catch {
3655
+ return false;
3656
+ }
3657
+ const orgs = (rows ?? []).map((r) => String(r?.organization_id ?? "")).filter(Boolean);
3658
+ if (!orgs.length) return false;
3659
+ if (orgs.includes(requestOrg)) return false;
3660
+ this.logger?.warn?.(
3661
+ `[approvals] #10153: manager '${managerId}' was dropped from the approver slate \u2014 'sys_user.manager_id' points across an organization boundary. He holds membership in ${orgs.length} organization(s), none of them the request's organization '${requestOrg}', so routing this approval to him would put approval authority over the record outside its tenant. Fix the 'manager_id' link, grant him a membership in this organization, or route this step with an approver type that names someone in it.`,
3662
+ { managerId, requestOrganizationId: requestOrg, managerOrganizationIds: orgs }
3663
+ );
3664
+ return true;
3665
+ }
3017
3666
  /**
3018
3667
  * Out-of-office auto-skip (#1322 M1). Given an individually-routed approver
3019
3668
  * id, follow any active `sys_approval_delegation` chain and return the id the
@@ -3183,11 +3832,13 @@ var _ApprovalService = class _ApprovalService {
3183
3832
  const substitutions = [];
3184
3833
  const groups = {};
3185
3834
  const liveRecord = await this.loadLiveRecord(input.object, input.recordId, input.record);
3835
+ const subjectOrg = this.recordOrgResolver.organizationOf(input.object, liveRecord, input.record);
3836
+ const requestOrg = subjectOrg ?? ctxOrg;
3186
3837
  const resolvedFrom = {};
3187
3838
  const approvers = await this.expandApprovers(
3188
3839
  { approvers: input.config.approvers },
3189
3840
  liveRecord,
3190
- ctxOrg,
3841
+ requestOrg,
3191
3842
  {
3192
3843
  now: nowDate.getTime(),
3193
3844
  substitutions,
@@ -3251,16 +3902,16 @@ var _ApprovalService = class _ApprovalService {
3251
3902
  flow_run_id: input.runId,
3252
3903
  flow_node_id: input.nodeId,
3253
3904
  node_config_json: JSON.stringify(configSnapshot),
3254
- organization_id: ctxOrg,
3905
+ organization_id: requestOrg,
3255
3906
  created_at: now,
3256
3907
  updated_at: now
3257
3908
  };
3258
3909
  await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX2 });
3259
- await this.syncApproverIndex(id, approvers, ctxOrg, now);
3910
+ await this.syncApproverIndex(id, approvers, requestOrg, now);
3260
3911
  await this.engine.insert("sys_approval_action", {
3261
3912
  id: uid("aact"),
3262
3913
  request_id: id,
3263
- organization_id: ctxOrg,
3914
+ organization_id: requestOrg,
3264
3915
  step_name: input.nodeId,
3265
3916
  step_index: 0,
3266
3917
  action: "submit",
@@ -3272,7 +3923,7 @@ var _ApprovalService = class _ApprovalService {
3272
3923
  await this.engine.insert("sys_approval_action", {
3273
3924
  id: uid("aact"),
3274
3925
  request_id: id,
3275
- organization_id: ctxOrg,
3926
+ organization_id: requestOrg,
3276
3927
  step_name: input.nodeId,
3277
3928
  step_index: 0,
3278
3929
  action: "ooo_substitute",
@@ -3714,6 +4365,13 @@ var _ApprovalService = class _ApprovalService {
3714
4365
  * is then paused at the revise-window node (no reject edge), so it is
3715
4366
  * terminally cancelled via {@link ApprovalResumeSurface.cancelRun} rather
3716
4367
  * than resumed.
4368
+ *
4369
+ * The #3424 privileged override reaches a PENDING request only (#12775,
4370
+ * maintainer ruling 2026-09-02). On `returned` an override actor is refused
4371
+ * exactly as any other non-submitter: the gate is spelled as `attachViewers`
4372
+ * computes `viewer.can_override`, so the gate, the
4373
+ * {@link ApprovalService.isOverrideActor} doc block and the viewer flag agree
4374
+ * at one point.
3717
4375
  */
3718
4376
  async recall(requestId, input, context) {
3719
4377
  if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
@@ -3729,8 +4387,18 @@ var _ApprovalService = class _ApprovalService {
3729
4387
  if (raw.status !== "pending" && !inReviseWindow) {
3730
4388
  throw new Error(`INVALID_STATE: request is ${raw.status}`);
3731
4389
  }
3732
- if (!this.isOverrideActor(context, raw.organization_id ?? null) && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
3733
- throw new Error(`FORBIDDEN: only the submitter may recall this request`);
4390
+ const overrideAdmits = raw.status === "pending" && this.isOverrideActor(context, raw.organization_id ?? null);
4391
+ if (!overrideAdmits && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
4392
+ 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)`;
4393
+ this.logger?.warn?.(developerMessage, {
4394
+ request: requestId,
4395
+ actor: actorId,
4396
+ submitter: String(raw.submitter_id),
4397
+ status: raw.status
4398
+ });
4399
+ throw new Error(
4400
+ `FORBIDDEN: ${this.userFacingRefusal("approval_recall_not_submitter", context)}`
4401
+ );
3734
4402
  }
3735
4403
  if (inReviseWindow) await this.assertLatestForRun(raw);
3736
4404
  const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
@@ -3806,6 +4474,127 @@ var _ApprovalService = class _ApprovalService {
3806
4474
  const fresh = await this.readBackRequest(requestId, context);
3807
4475
  return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
3808
4476
  }
4477
+ // ── Record-delete lifecycle linkage (#13568) ─────────────────
4478
+ /**
4479
+ * Void every `pending` request about a record that has just been DELETED
4480
+ * (#13568, maintainer ruling 2026-08-31 「同意」).
4481
+ *
4482
+ * ## What the ruling asked for, and what each half costs
4483
+ *
4484
+ * status → `cancelled`, a machine-readable `cancel_reason`, the row KEPT
4485
+ * for audit, and the request out of the pending count and the inbox's
4486
+ * default view.
4487
+ *
4488
+ * The last clause needs both writes below, not just the status one: the
4489
+ * inbox's "My Pending" tab pages the request table by `status`, but the
4490
+ * approver filter resolves through the normalized `sys_approval_approver`
4491
+ * index (#1745), which is keyed on nothing but `request_id`. Leaving the
4492
+ * index rows behind would keep the request in `approverRequestIds`' answer
4493
+ * for every approver forever — so {@link syncApproverIndex} clears them, the
4494
+ * same call every other exit from `pending` makes.
4495
+ *
4496
+ * ## Three things this deliberately does NOT do
4497
+ *
4498
+ * 1. **No status mirror-back.** Every other terminal transition calls
4499
+ * {@link mirrorStatusField} to write the outcome onto the subject record's
4500
+ * `approvalStatusField`. Here the subject record is precisely what no
4501
+ * longer exists, so that write is guaranteed to fail — it is the very
4502
+ * `update_record(...) failed: Record ... not found` this card's own
4503
+ * forensics recorded on the reject door. Skipped by construction, not by a
4504
+ * swallowed error.
4505
+ *
4506
+ * 2. **No flow resume, and no run cancel.** A cancellation is a status write
4507
+ * plus a reason; it is not a decision, so there is no branch to resume
4508
+ * down and no downstream node that should run. The suspended run this
4509
+ * request gated is reported (below) and left alone — what becomes of an
4510
+ * approval run whose request was voided is a lifecycle question that
4511
+ * belongs to the automation service, not to a delete hook, and answering
4512
+ * it here by guessing would be exactly the kind of consumer-side
4513
+ * accommodation PD #12 refuses.
4514
+ *
4515
+ * 3. **No refusal of the delete.** The delete has already landed by the time
4516
+ * this runs, and the "forbid delete while an approval is pending"
4517
+ * direction was VETOED in the same ruling (`lockRecord` already blocks the
4518
+ * edit; blocking the delete too locks an author onto a record they cannot
4519
+ * fix). So nothing here throws — a failure degrades to a warning and the
4520
+ * stale row, which is the pre-existing state, never to a failed delete.
4521
+ *
4522
+ * Terminal rows are untouched: the `where` below names `status: 'pending'`,
4523
+ * so an `approved` / `rejected` / `recalled` / `returned` request about the
4524
+ * same record keeps its recorded outcome. That is the ruling's second half —
4525
+ * history is kept; only the presentation of its dead record reference is
4526
+ * someone else's card.
4527
+ *
4528
+ * @returns the request ids cancelled and the suspended run ids left behind.
4529
+ */
4530
+ async cancelForDeletedRecord(objectName, recordId) {
4531
+ const empty = { cancelled: [], suspendedRuns: [] };
4532
+ const object = String(objectName ?? "").trim();
4533
+ const record = String(recordId ?? "").trim();
4534
+ if (!object || !record) return empty;
4535
+ let pending;
4536
+ try {
4537
+ const rows = await this.engine.find("sys_approval_request", {
4538
+ where: { object_name: object, record_id: record, status: "pending" },
4539
+ limit: RECORD_DELETE_CANCEL_LIMIT,
4540
+ context: SYSTEM_CTX2
4541
+ });
4542
+ pending = Array.isArray(rows) ? rows : [];
4543
+ } catch (err) {
4544
+ this.logger?.warn?.(
4545
+ "[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",
4546
+ { object, record, error: err?.message ?? String(err) }
4547
+ );
4548
+ return empty;
4549
+ }
4550
+ if (pending.length === 0) return empty;
4551
+ const now = this.clock.now().toISOString();
4552
+ const cancelled = [];
4553
+ const suspendedRuns = [];
4554
+ for (const raw of pending) {
4555
+ const requestId = String(raw?.id ?? "");
4556
+ if (!requestId) continue;
4557
+ const org = raw?.organization_id ?? null;
4558
+ const nodeId = raw?.flow_node_id ?? raw?.current_step ?? null;
4559
+ try {
4560
+ await this.engine.insert("sys_approval_action", {
4561
+ id: uid("aact"),
4562
+ request_id: requestId,
4563
+ organization_id: org,
4564
+ step_name: nodeId,
4565
+ step_index: 0,
4566
+ action: "cancel",
4567
+ actor_id: null,
4568
+ comment: `Auto-cancelled: the ${object} record '${record}' this request is about was deleted`,
4569
+ created_at: now
4570
+ }, { context: SYSTEM_CTX2 });
4571
+ await this.engine.update("sys_approval_request", {
4572
+ id: requestId,
4573
+ status: "cancelled",
4574
+ cancel_reason: "record_deleted",
4575
+ pending_approvers: null,
4576
+ completed_at: now,
4577
+ updated_at: now
4578
+ }, { context: SYSTEM_CTX2 });
4579
+ await this.syncApproverIndex(requestId, [], org, now);
4580
+ cancelled.push(requestId);
4581
+ const runId = raw?.flow_run_id ? String(raw.flow_run_id) : "";
4582
+ if (runId) suspendedRuns.push(runId);
4583
+ } catch (err) {
4584
+ this.logger?.warn?.(
4585
+ "[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",
4586
+ { object, record, request: requestId, error: err?.message ?? String(err) }
4587
+ );
4588
+ }
4589
+ }
4590
+ if (suspendedRuns.length) {
4591
+ this.logger?.warn?.(
4592
+ "[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",
4593
+ { object, record, requests: cancelled, runs: suspendedRuns }
4594
+ );
4595
+ }
4596
+ return { cancelled, suspendedRuns };
4597
+ }
3809
4598
  // ── Send back for revision / resubmit (ADR-0044) ─────────────
3810
4599
  /**
3811
4600
  * ADR-0044 send back for revision. Finalises the pending request as
@@ -4436,7 +5225,10 @@ var _ApprovalService = class _ApprovalService {
4436
5225
  * `escalation.timeoutHours` and whose deadline has passed is escalated
4437
5226
  * **at most once, ever** — the `escalate` audit row is the idempotency
4438
5227
  * marker, written before any mutation (audit-first, like reassign). One
4439
- * bad row never stops the sweep.
5228
+ * bad row never stops the sweep. A block that explicitly declares
5229
+ * `enabled: false` is skipped (#12278 — the declared switch is enforced;
5230
+ * pre-flip snapshots ride the legacy window on
5231
+ * {@link ESCALATION_ENABLED_FLIP_CUTOFF_MS}).
4440
5232
  */
4441
5233
  async runEscalations() {
4442
5234
  let rows = [];
@@ -4458,6 +5250,7 @@ var _ApprovalService = class _ApprovalService {
4458
5250
  const cfg = parseJson(raw.node_config_json, void 0);
4459
5251
  const esc2 = cfg?.escalation;
4460
5252
  if (!esc2 || typeof esc2.timeoutHours !== "number" || esc2.timeoutHours <= 0) continue;
5253
+ if (esc2.enabled === false && Date.parse(raw.created_at ?? "") >= ESCALATION_ENABLED_FLIP_CUTOFF_MS) continue;
4461
5254
  const due = slaDueAt(raw.created_at, cfg);
4462
5255
  if (!due || Date.parse(due) > this.clock.now().getTime()) continue;
4463
5256
  const prior = await this.engine.find("sys_approval_action", {
@@ -4538,9 +5331,35 @@ var _ApprovalService = class _ApprovalService {
4538
5331
  * live pause exists. It THROWS when the store cannot be read, and that
4539
5332
  * case is SKIPPED, never counted as dead: an unreadable store means
4540
5333
  * "unknown", and a storage outage must not be published as a lost run.
4541
- * - `getRun(runId) == null` — no terminal history row either (the `run_`
4542
- * prefixed rows in `sys_automation_run`). A run that merely finished is
4543
- * not stranded; a request whose run neither waits nor ever completed is.
5334
+ * - `classifyStrandedRunState` over `getRun(runId)` — the run's own
5335
+ * history row (the `run_` prefixed rows in `sys_automation_run`). A run
5336
+ * that merely finished is not stranded; a request whose run neither waits
5337
+ * nor completed is.
5338
+ *
5339
+ * **The second oracle was widened (#13909), and this is the whole point of
5340
+ * that card's first slice.** It used to be `if (terminal) continue` — the
5341
+ * existence of ANY history row ended the check, on the reading "the run ran to
5342
+ * a terminal state, it is not dangling". That is true of a run that COMPLETED
5343
+ * and false of one that FAILED: the engine consumes a suspension *before*
5344
+ * running the downstream nodes (`AutomationEngine.resumeInternal` calls
5345
+ * `forgetSuspendedRun(run, 'resumed')` and only then `traverseNext`), so a
5346
+ * downstream node that merely threw threw with the pause already gone, and the
5347
+ * catch arm wrote a terminal `failed` row. The decision is durable, the
5348
+ * continuation stopped half-way, `resume` answers `RUN_NOT_FOUND` and
5349
+ * `cancelRun` is a no-op — and the terminal row this oracle used to read as
5350
+ * health is written BY the very failure that stranded it. So this inspection
5351
+ * reported `0` for the one shape an operator most needs to see.
5352
+ *
5353
+ * ⚠️ The widening does NOT reverse the conservatism: `completed`, `cancelled`
5354
+ * and `paused` are each still skipped, for reasons named one at a time in
5355
+ * `classifyStrandedRunState`, and an unrecognised status is skipped too.
5356
+ * What the widening buys is that a `failed` run is now reported with
5357
+ * `runState: 'failed'` instead of counted as healthy.
5358
+ *
5359
+ * ⚠️ **What this can and cannot size.** It makes the condition *visible* in a
5360
+ * deployment; it is not itself a census, and it says nothing about this
5361
+ * repository. How many runs are already in this state can only be answered
5362
+ * against a real deployment's tables — see the card.
4544
5363
  *
4545
5364
  * **Reports; never rewrites.** No status is changed and no run is cancelled.
4546
5365
  * The decision genuinely happened — a human approved or rejected — and
@@ -4598,7 +5417,8 @@ var _ApprovalService = class _ApprovalService {
4598
5417
  });
4599
5418
  continue;
4600
5419
  }
4601
- if (terminal) continue;
5420
+ const runState = classifyStrandedRunState(terminal);
5421
+ if (!runState) continue;
4602
5422
  const config = parseJson(
4603
5423
  raw.node_config_json,
4604
5424
  { approvers: [], behavior: "first_response" }
@@ -4621,6 +5441,7 @@ var _ApprovalService = class _ApprovalService {
4621
5441
  requestId: String(raw.id),
4622
5442
  status: raw.status,
4623
5443
  runId,
5444
+ runState,
4624
5445
  flowName: typeof raw.process_name === "string" ? raw.process_name.replace(/^flow:/, "") : void 0,
4625
5446
  nodeId: raw.flow_node_id ?? raw.current_step ?? void 0,
4626
5447
  objectName: raw.object_name,
@@ -4632,11 +5453,13 @@ var _ApprovalService = class _ApprovalService {
4632
5453
  });
4633
5454
  }
4634
5455
  if (stranded.length || undetermined) {
4635
- this.logger?.warn?.("[approvals] stranded terminal requests (decision recorded, flow run gone)", {
5456
+ this.logger?.warn?.("[approvals] stranded terminal requests (decision recorded, flow run unrecoverable)", {
4636
5457
  scanned: rows.length,
4637
5458
  stranded: stranded.length,
4638
5459
  undetermined,
4639
- requests: stranded.map((s) => `${s.requestId}@${s.nodeId ?? "?"} \u2192 run ${s.runId}`)
5460
+ runMissing: stranded.filter((s) => s.runState === "missing").length,
5461
+ runFailed: stranded.filter((s) => s.runState === "failed").length,
5462
+ requests: stranded.map((s) => `${s.requestId}@${s.nodeId ?? "?"} \u2192 run ${s.runId} (${s.runState})`)
4640
5463
  });
4641
5464
  }
4642
5465
  return { scanned: rows.length, stranded, undetermined };
@@ -5139,8 +5962,70 @@ var _ApprovalService = class _ApprovalService {
5139
5962
  return { requests: desired.size, inserted, deleted };
5140
5963
  }
5141
5964
  // ── Read API ─────────────────────────────────────────────────
5965
+ /**
5966
+ * [#11040] May the free-text pushdown carry an arm on the SNAPSHOT column,
5967
+ * for THIS caller over THIS query's scope?
5968
+ *
5969
+ * `payload_json` is the one searched column whose contents the serve path
5970
+ * masks per reader (`redactPayloads`, #10749). A predicate over it is
5971
+ * evaluated by the driver against the column AT REST — unmasked, before
5972
+ * anything is served — so for a caller whose view of the snapshot is masked,
5973
+ * row membership answers questions about contents that caller may not read.
5974
+ * The other four arms are columns of `sys_approval_request` itself, which
5975
+ * every caller who can see the row reads whole; they are untouched.
5976
+ *
5977
+ * ## The invariant this method exists to hold
5978
+ *
5979
+ * "This caller's view is masked" is read from **the same authority and the
5980
+ * same per-caller call as the serve path** — `resolveReadableSnapshotFields`,
5981
+ * asked as the CALLER (never `SYSTEM_CTX`). A second, independently derived
5982
+ * notion of "redacted" — comparing the readable set against the object's
5983
+ * schema, say — would be a fresh source of drift, and drift between the
5984
+ * serve rule and the filter rule IS the defect this closes, reconstituted one
5985
+ * layer down. So the only "not masked" answer accepted here is the one serve
5986
+ * itself acts on: `undefined`, the seam's documented do-not-narrow branch.
5987
+ * When the seam holds a concrete list the mask is IN FORCE, whether or not it
5988
+ * happens to remove a key from any particular row — a row-dependent question
5989
+ * no predicate can answer before rows exist.
5990
+ *
5991
+ * ## The two predicate-time cases
5992
+ *
5993
+ * Redaction is decided per ROW (each row names its own subject object, hence
5994
+ * its own readable set), but a filter is built before any row exists:
5995
+ *
5996
+ * - **authority absent** — `resolveReadableSnapshotFields` answers
5997
+ * `undefined` for EVERY object, so serve hands over every snapshot whole.
5998
+ * Keeping the arm leaks nothing serve does not already hand over, and this
5999
+ * is the shape every deployment that has not wired the security plugin
6000
+ * gets: search is byte-for-byte unchanged. Checked first, and that order
6001
+ * is load-bearing — see the object-scope note below.
6002
+ * - **authority wired** — the readable set is per object, so the arm is
6003
+ * admissible only for a scope of exactly one KNOWN object. With
6004
+ * `filter.object` present that object is known at predicate time and the
6005
+ * seam is asked about it directly. Absent, the query spans every object
6006
+ * and there is nothing sound to ask, so the arm is dropped.
6007
+ *
6008
+ * Dropping an arm is strictly NARROWING: it never refuses a query and never
6009
+ * widens what comes back, so the fail-closed direction is cheap here and is
6010
+ * taken rather than reaching for a per-object predicate machine to avoid it.
6011
+ * Refusing the query outright would be the louder behaviour change, and is
6012
+ * deliberately not what this does.
6013
+ */
6014
+ async freeTextMayMatchSnapshot(objectInScope, context) {
6015
+ const authority = this.fieldVisibility;
6016
+ if (!authority || typeof authority.getReadableFields !== "function") return true;
6017
+ const object = String(objectInScope ?? "").trim();
6018
+ if (!object) return false;
6019
+ const readable = await resolveReadableSnapshotFields(
6020
+ authority,
6021
+ object,
6022
+ context,
6023
+ this.logger
6024
+ );
6025
+ return readable === void 0;
6026
+ }
5142
6027
  /** Filter type accepted by {@link listRequests} / {@link countRequests}. */
5143
- buildRequestWhere(filter, context) {
6028
+ async buildRequestWhere(filter, context) {
5144
6029
  const f = {};
5145
6030
  if (filter?.object) f.object_name = filter.object;
5146
6031
  if (filter?.recordId) f.record_id = filter.recordId;
@@ -5149,13 +6034,16 @@ var _ApprovalService = class _ApprovalService {
5149
6034
  if (tenantOrg) f.organization_id = tenantOrg;
5150
6035
  const q = filter?.q?.trim();
5151
6036
  if (q) {
5152
- f.$or = [
6037
+ const arms = [
5153
6038
  { process_name: { $contains: q } },
5154
6039
  { object_name: { $contains: q } },
5155
6040
  { record_id: { $contains: q } },
5156
- { submitter_id: { $contains: q } },
5157
- { payload_json: { $contains: q } }
6041
+ { submitter_id: { $contains: q } }
5158
6042
  ];
6043
+ if (await this.freeTextMayMatchSnapshot(filter?.object, context)) {
6044
+ arms.push({ payload_json: { $contains: q } });
6045
+ }
6046
+ f.$or = arms;
5159
6047
  }
5160
6048
  if (Array.isArray(filter?.status)) {
5161
6049
  const statuses = filter.status.filter(Boolean);
@@ -5358,7 +6246,7 @@ var _ApprovalService = class _ApprovalService {
5358
6246
  return true;
5359
6247
  }
5360
6248
  async listRequests(filter, context) {
5361
- const { where, tenantOrg } = this.buildRequestWhere(filter, context);
6249
+ const { where, tenantOrg } = await this.buildRequestWhere(filter, context);
5362
6250
  const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
5363
6251
  const ids = await this.approverRequestIds(approverTargets, tenantOrg);
5364
6252
  if (ids) {
@@ -5382,12 +6270,13 @@ var _ApprovalService = class _ApprovalService {
5382
6270
  }
5383
6271
  const rows = await this.engine.find("sys_approval_request", findOpts);
5384
6272
  const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
6273
+ await this.redactPayloads(list, context);
5385
6274
  await this.enrichRows(list);
5386
6275
  this.attachViewers(list, context);
5387
6276
  return list;
5388
6277
  }
5389
6278
  async countRequests(filter, context) {
5390
- const { where, tenantOrg } = this.buildRequestWhere(filter, context);
6279
+ const { where, tenantOrg } = await this.buildRequestWhere(filter, context);
5391
6280
  const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
5392
6281
  const ids = await this.approverRequestIds(approverTargets, tenantOrg);
5393
6282
  if (ids) {
@@ -5424,9 +6313,28 @@ var _ApprovalService = class _ApprovalService {
5424
6313
  * flow-driven resume, a service-to-service call), turning a successful write
5425
6314
  * into a `null` result. Gating belongs on the read API, not on an
5426
6315
  * operation's own return value.
6316
+ *
6317
+ * [#12769] The read still narrows by the CALLER's organization
6318
+ * (`loadRequest` — a deliberate tenancy wall, untouched here), and an
6319
+ * org-less request row is invisible inside an org-scoped caller's narrowing.
6320
+ * Org-less rows arise by construction on every schedule / time-relative /
6321
+ * api trigger run (#10131; #9132 pinned that behaviour rather than repairing
6322
+ * it), so this is a live state, not an edge case: the write IS recorded and
6323
+ * the echo cannot be built. It used to escape as `null` behind the callers'
6324
+ * `fresh!` non-null assertions — a well-formed 200 whose declared-non-null
6325
+ * `request` was `null` on the wire. Now it refuses loudly instead, which
6326
+ * keeps every declared result type true: a result that cannot be built is
6327
+ * never returned. Callers must NOT catch this to fall back to `null` —
6328
+ * that would re-open the type-lie one level up.
5427
6329
  */
5428
6330
  async readBackRequest(requestId, context) {
5429
- return this.loadRequest(requestId, context, false);
6331
+ const fresh = await this.loadRequest(requestId, context, false);
6332
+ if (!fresh) {
6333
+ throw new Error(
6334
+ `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.`
6335
+ );
6336
+ }
6337
+ return fresh;
5430
6338
  }
5431
6339
  async getRequest(requestId, context) {
5432
6340
  return this.loadRequest(requestId, context, true);
@@ -5450,6 +6358,7 @@ var _ApprovalService = class _ApprovalService {
5450
6358
  if (visible && !visible.has(String(rows[0].id))) return null;
5451
6359
  }
5452
6360
  const row = rowFromRequest(rows[0]);
6361
+ await this.redactPayloads([row], context);
5453
6362
  await this.enrichRows([row]);
5454
6363
  await this.attachFlowSteps(row);
5455
6364
  await this.attachDecisionProgress(row, rows[0]);
@@ -5917,6 +6826,41 @@ function bindApprovalLockHook(engine, logger) {
5917
6826
  }, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
5918
6827
  logger?.info?.("[approvals] record-lock hook bound");
5919
6828
  }
6829
+ var APPROVALS_OWN_OBJECTS = [
6830
+ "sys_approval_request",
6831
+ "sys_approval_action",
6832
+ "sys_approval_approver",
6833
+ "sys_approval_token",
6834
+ "sys_approval_delegation"
6835
+ ];
6836
+ function bindRecordDeleteCancelHook(engine, service, logger) {
6837
+ engine.registerHook("afterDelete", async (ctx) => {
6838
+ const object = ctx?.object ?? ctx?.objectName;
6839
+ if (!object) return;
6840
+ const raw = ctx?.previous?.id ?? ctx?.input?.id;
6841
+ const recordId = raw == null ? "" : String(raw);
6842
+ if (!recordId) {
6843
+ logger?.warn?.(
6844
+ "[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",
6845
+ { object }
6846
+ );
6847
+ return;
6848
+ }
6849
+ try {
6850
+ await service.cancelForDeletedRecord(String(object), recordId);
6851
+ } catch (err) {
6852
+ logger?.warn?.(
6853
+ "[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",
6854
+ { object, record: recordId, error: err?.message ?? String(err) }
6855
+ );
6856
+ }
6857
+ }, {
6858
+ excludeObjects: [...APPROVALS_OWN_OBJECTS],
6859
+ packageId: APPROVALS_HOOK_PACKAGE,
6860
+ priority: 50
6861
+ });
6862
+ logger?.info?.("[approvals] record-delete cancel hook bound");
6863
+ }
5920
6864
  var DELEGATION_OBJECT = "sys_approval_delegation";
5921
6865
  function bindDelegationWriteGuard(engine, logger) {
5922
6866
  const makeGuard = (isInsert) => async (ctx) => {
@@ -5953,6 +6897,52 @@ function unbindAllHooks(engine) {
5953
6897
  return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
5954
6898
  }
5955
6899
 
6900
+ // src/payload-redaction-middleware.ts
6901
+ var APPROVAL_REQUEST_OBJECT = "sys_approval_request";
6902
+ function parseSnapshot(raw) {
6903
+ if (typeof raw !== "string" || raw.trim() === "") return { ok: false, value: void 0 };
6904
+ try {
6905
+ return { ok: true, value: JSON.parse(raw) };
6906
+ } catch {
6907
+ return { ok: false, value: void 0 };
6908
+ }
6909
+ }
6910
+ async function redactRowsInPlace(rows, security, context, logger) {
6911
+ const list = Array.isArray(rows) ? rows : rows ? [rows] : [];
6912
+ if (list.length === 0) return;
6913
+ const cache = /* @__PURE__ */ new Map();
6914
+ for (const row of list) {
6915
+ if (!row || typeof row !== "object") continue;
6916
+ const raw = row.payload_json;
6917
+ const parsed = parseSnapshot(raw);
6918
+ if (!parsed.ok) continue;
6919
+ const object = String(row.object_name ?? "").trim();
6920
+ if (!object) continue;
6921
+ if (!cache.has(object)) {
6922
+ cache.set(object, await resolveReadableSnapshotFields(security, object, context, logger));
6923
+ }
6924
+ const readable = cache.get(object);
6925
+ if (readable === void 0) continue;
6926
+ const { payload, redactedKeys } = redactSnapshot(parsed.value, readable);
6927
+ if (redactedKeys.length === 0) continue;
6928
+ row.payload_json = JSON.stringify(payload);
6929
+ }
6930
+ }
6931
+ function bindSnapshotRedactionMiddleware(engine, getSecurity, logger) {
6932
+ engine.registerMiddleware(async (opCtx, next) => {
6933
+ await next();
6934
+ if (opCtx?.operation !== "find" && opCtx?.operation !== "findOne") return;
6935
+ if (opCtx?.context?.isSystem) return;
6936
+ try {
6937
+ await redactRowsInPlace(opCtx.result, getSecurity(), opCtx.context, logger);
6938
+ } catch (err) {
6939
+ logger?.warn?.("[approvals] snapshot redaction middleware failed", {
6940
+ error: err?.message ?? String(err)
6941
+ });
6942
+ }
6943
+ }, { object: APPROVAL_REQUEST_OBJECT });
6944
+ }
6945
+
5956
6946
  // src/approval-node.ts
5957
6947
  var import_automation3 = require("@objectstack/spec/automation");
5958
6948
 
@@ -6214,13 +7204,46 @@ var ApprovalsServicePlugin = class {
6214
7204
  } catch {
6215
7205
  return void 0;
6216
7206
  }
7207
+ },
7208
+ // [#11993] Deployment override lookup for user-facing refusal copy.
7209
+ // Resolved LAZILY for the reason the two providers above are: the i18n
7210
+ // service is contributed by another plugin (ADR-0029 D8) and may start
7211
+ // after this one. Without it the service still renders the built-in
7212
+ // catalog in the caller's locale; with it, a deployment's own
7213
+ // `translation` for `errors.approval_recall_not_submitter` wins — the
7214
+ // override address the catalog documents, made real for this emitter.
7215
+ messageTranslator: () => {
7216
+ try {
7217
+ const i18n = ctx.getService("i18n");
7218
+ const t = i18n?.t;
7219
+ if (typeof t !== "function") return void 0;
7220
+ return (key, locale, params) => t.call(i18n, key, locale, params);
7221
+ } catch {
7222
+ return void 0;
7223
+ }
7224
+ }
7225
+ });
7226
+ const fieldVisibility = () => {
7227
+ try {
7228
+ const sec = ctx.getService("security");
7229
+ return sec && typeof sec.getReadableFields === "function" ? sec : void 0;
7230
+ } catch {
7231
+ return void 0;
7232
+ }
7233
+ };
7234
+ this.service.attachFieldVisibility({
7235
+ getReadableFields: (object, context) => {
7236
+ const sec = fieldVisibility();
7237
+ return sec ? sec.getReadableFields(object, context) : Promise.resolve(void 0);
6217
7238
  }
6218
7239
  });
6219
7240
  if (!this.options.disableAutoHooks) {
6220
7241
  try {
6221
7242
  unbindAllHooks(engine);
6222
7243
  bindApprovalLockHook(engine, ctx.logger);
7244
+ bindRecordDeleteCancelHook(engine, this.service, ctx.logger);
6223
7245
  bindDelegationWriteGuard(engine, ctx.logger);
7246
+ bindSnapshotRedactionMiddleware(engine, fieldVisibility, ctx.logger);
6224
7247
  } catch (err) {
6225
7248
  ctx.logger.warn?.("[approvals] failed to bind approval hooks", { error: err?.message });
6226
7249
  }
@@ -6260,6 +7283,7 @@ var ApprovalsServicePlugin = class {
6260
7283
  }
6261
7284
  };
6262
7285
  await jobs.schedule(ESCALATION_JOB_NAME, { type: "interval", intervalMs }, sweep);
7286
+ this.jobService = jobs;
6263
7287
  this.escalationJobScheduled = true;
6264
7288
  void sweep().catch((err) => {
6265
7289
  ctx.logger.warn?.("[approvals] boot sweep failed", { error: err?.message });
@@ -6346,14 +7370,32 @@ var ApprovalsServicePlugin = class {
6346
7370
  );
6347
7371
  }
6348
7372
  }
6349
- async stop(ctx) {
7373
+ /**
7374
+ * The kernel's teardown hook (`Plugin.destroy?()`, core `types.ts`) — the
7375
+ * ONLY teardown entry point `ObjectKernel.performShutdown()` and
7376
+ * `LiteKernel.destroy()` invoke.
7377
+ *
7378
+ * [#10371] IT USED TO BE `stop()`, WHICH NOTHING CALLED. `Plugin` declares
7379
+ * `init()`, `start?()` and `destroy?()` and no `stop()`, so the kernel walked
7380
+ * past this plugin at shutdown: the SLA escalation job stayed scheduled and
7381
+ * this plugin's ObjectQL hooks stayed bound to an engine the kernel had
7382
+ * finished with. `start()` IS on the interface, so the pair read as symmetric
7383
+ * in review — that asymmetry is what let the same shape survive in six
7384
+ * packages at once.
7385
+ *
7386
+ * This member owns no timer of its own (the escalation clock belongs to
7387
+ * `service-job`), so it never cost a merge-queue eviction the way the
7388
+ * `plugin-reports` / `service-messaging` members did (#9371). The class is
7389
+ * the same one either way: a teardown the kernel does not reach.
7390
+ */
7391
+ async destroy() {
6350
7392
  if (this.escalationJobScheduled) {
6351
7393
  try {
6352
- const jobs = ctx.getService("job");
6353
- await jobs?.cancel?.(ESCALATION_JOB_NAME);
7394
+ await this.jobService?.cancel?.(ESCALATION_JOB_NAME);
6354
7395
  } catch {
6355
7396
  }
6356
7397
  this.escalationJobScheduled = false;
7398
+ this.jobService = void 0;
6357
7399
  }
6358
7400
  if (this.engine) {
6359
7401
  try {
@@ -6362,6 +7404,17 @@ var ApprovalsServicePlugin = class {
6362
7404
  }
6363
7405
  }
6364
7406
  }
7407
+ /**
7408
+ * Retained alias for {@link destroy}. Kept because it is public API of an
7409
+ * exported class, and removing it would break an embedder who learned to call
7410
+ * it directly precisely BECAUSE the kernel never did. The parameter is now
7411
+ * optional and ignored: `destroy()` takes no context, so teardown uses the
7412
+ * job service captured when the escalation clock was wired. Prefer kernel
7413
+ * shutdown; direct callers keep working unchanged.
7414
+ */
7415
+ async stop(_ctx) {
7416
+ await this.destroy();
7417
+ }
6365
7418
  };
6366
7419
  // Annotate the CommonJS export names for ESM import in node:
6367
7420
  0 && (module.exports = {