@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.mjs CHANGED
@@ -29,7 +29,7 @@ var init_en_objects_generated = __esm({
29
29
  },
30
30
  organization_id: {
31
31
  label: "Organization",
32
- help: "Tenant that owns this approval request (propagated from submitter context)"
32
+ help: "Organization of the record this request is about (falls back to the acting context when the record has none)"
33
33
  },
34
34
  process_name: {
35
35
  label: "Source",
@@ -55,7 +55,15 @@ var init_en_objects_generated = __esm({
55
55
  approved: "Approved",
56
56
  rejected: "Rejected",
57
57
  recalled: "Recalled",
58
- returned: "Returned"
58
+ returned: "Returned",
59
+ cancelled: "Cancelled"
60
+ }
61
+ },
62
+ cancel_reason: {
63
+ label: "Cancellation Reason",
64
+ help: "Why the platform voided this request (set only when the status is cancelled)",
65
+ options: {
66
+ record_deleted: "Related record deleted"
59
67
  }
60
68
  },
61
69
  current_step: {
@@ -236,7 +244,8 @@ var init_en_objects_generated = __esm({
236
244
  comment: "Comment",
237
245
  revise: "Revise",
238
246
  resubmit: "Resubmit",
239
- ooo_substitute: "Out-of-Office Substitution"
247
+ ooo_substitute: "Out-of-Office Substitution",
248
+ cancel: "Cancel"
240
249
  }
241
250
  },
242
251
  actor_id: {
@@ -350,7 +359,7 @@ var init_zh_CN_objects_generated = __esm({
350
359
  },
351
360
  organization_id: {
352
361
  label: "\u7EC4\u7EC7",
353
- help: "\u62E5\u6709\u8BE5\u5BA1\u6279\u8BF7\u6C42\u7684\u79DF\u6237\uFF08\u4ECE\u63D0\u4EA4\u65B9\u4E0A\u4E0B\u6587\u4F20\u64AD\uFF09"
362
+ 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"
354
363
  },
355
364
  process_name: {
356
365
  label: "\u6765\u6E90",
@@ -376,7 +385,15 @@ var init_zh_CN_objects_generated = __esm({
376
385
  approved: "\u5DF2\u6279\u51C6",
377
386
  rejected: "\u5DF2\u62D2\u7EDD",
378
387
  recalled: "\u5DF2\u64A4\u56DE",
379
- returned: "\u5DF2\u9000\u56DE\u4FEE\u6539"
388
+ returned: "\u5DF2\u9000\u56DE\u4FEE\u6539",
389
+ cancelled: "\u5DF2\u4F5C\u5E9F"
390
+ }
391
+ },
392
+ cancel_reason: {
393
+ label: "\u4F5C\u5E9F\u539F\u56E0",
394
+ 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)",
395
+ options: {
396
+ record_deleted: "\u5173\u8054\u8BB0\u5F55\u5DF2\u5220\u9664"
380
397
  }
381
398
  },
382
399
  current_step: {
@@ -557,7 +574,8 @@ var init_zh_CN_objects_generated = __esm({
557
574
  comment: "\u8BC4\u8BBA",
558
575
  revise: "\u9000\u56DE\u4FEE\u6539",
559
576
  resubmit: "\u91CD\u65B0\u63D0\u4EA4",
560
- ooo_substitute: "\u4E0D\u5728\u5C97\u6539\u6D3E"
577
+ ooo_substitute: "\u4E0D\u5728\u5C97\u6539\u6D3E",
578
+ cancel: "\u4F5C\u5E9F"
561
579
  }
562
580
  },
563
581
  actor_id: {
@@ -671,7 +689,7 @@ var init_ja_JP_objects_generated = __esm({
671
689
  },
672
690
  organization_id: {
673
691
  label: "\u7D44\u7E54",
674
- 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"
692
+ 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"
675
693
  },
676
694
  process_name: {
677
695
  label: "\u30BD\u30FC\u30B9",
@@ -697,7 +715,15 @@ var init_ja_JP_objects_generated = __esm({
697
715
  approved: "\u627F\u8A8D\u6E08\u307F",
698
716
  rejected: "\u5374\u4E0B\u6E08\u307F",
699
717
  recalled: "\u53D6\u308A\u6D88\u3057\u6E08\u307F",
700
- returned: "\u5DEE\u3057\u623B\u3057\u6E08\u307F"
718
+ returned: "\u5DEE\u3057\u623B\u3057\u6E08\u307F",
719
+ cancelled: "\u7121\u52B9\u5316\u6E08\u307F"
720
+ }
721
+ },
722
+ cancel_reason: {
723
+ label: "\u7121\u52B9\u5316\u7406\u7531",
724
+ 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)",
725
+ options: {
726
+ record_deleted: "\u95A2\u9023\u30EC\u30B3\u30FC\u30C9\u304C\u524A\u9664\u3055\u308C\u307E\u3057\u305F"
701
727
  }
702
728
  },
703
729
  current_step: {
@@ -878,7 +904,8 @@ var init_ja_JP_objects_generated = __esm({
878
904
  comment: "\u30B3\u30E1\u30F3\u30C8",
879
905
  revise: "\u5DEE\u3057\u623B\u3057",
880
906
  resubmit: "\u518D\u63D0\u51FA",
881
- ooo_substitute: "\u4E0D\u5728\u6642\u4EE3\u7406"
907
+ ooo_substitute: "\u4E0D\u5728\u6642\u4EE3\u7406",
908
+ cancel: "\u7121\u52B9\u5316"
882
909
  }
883
910
  },
884
911
  actor_id: {
@@ -992,7 +1019,7 @@ var init_es_ES_objects_generated = __esm({
992
1019
  },
993
1020
  organization_id: {
994
1021
  label: "Organizaci\xF3n",
995
- help: "Tenant que posee esta solicitud de aprobaci\xF3n (propagado desde el contexto del solicitante)."
1022
+ help: "Organizaci\xF3n del registro al que se refiere esta solicitud (recurre al contexto del actor cuando el registro no tiene ninguna)."
996
1023
  },
997
1024
  process_name: {
998
1025
  label: "Origen",
@@ -1018,7 +1045,15 @@ var init_es_ES_objects_generated = __esm({
1018
1045
  approved: "Aprobada",
1019
1046
  rejected: "Rechazada",
1020
1047
  recalled: "Retirada",
1021
- returned: "Devuelta para revisi\xF3n"
1048
+ returned: "Devuelta para revisi\xF3n",
1049
+ cancelled: "Anulada"
1050
+ }
1051
+ },
1052
+ cancel_reason: {
1053
+ label: "Motivo de anulaci\xF3n",
1054
+ help: "Por qu\xE9 la plataforma anul\xF3 esta solicitud (solo se establece cuando el estado es Anulada)",
1055
+ options: {
1056
+ record_deleted: "Registro relacionado eliminado"
1022
1057
  }
1023
1058
  },
1024
1059
  current_step: {
@@ -1199,7 +1234,8 @@ var init_es_ES_objects_generated = __esm({
1199
1234
  comment: "Comentario",
1200
1235
  revise: "Devoluci\xF3n",
1201
1236
  resubmit: "Reenv\xEDo",
1202
- ooo_substitute: "Sustituci\xF3n por ausencia"
1237
+ ooo_substitute: "Sustituci\xF3n por ausencia",
1238
+ cancel: "Anulaci\xF3n"
1203
1239
  }
1204
1240
  },
1205
1241
  actor_id: {
@@ -1297,12 +1333,104 @@ var init_es_ES_objects_generated = __esm({
1297
1333
  }
1298
1334
  });
1299
1335
 
1336
+ // src/translations/zh-CN.source-hashes.generated.ts
1337
+ var zhCNGeneratedSourceHashes;
1338
+ var init_zh_CN_source_hashes_generated = __esm({
1339
+ "src/translations/zh-CN.source-hashes.generated.ts"() {
1340
+ "use strict";
1341
+ zhCNGeneratedSourceHashes = {
1342
+ "objects.sys_approval_request.fields.flow_node_id.help": "154aa23b4eee4cae",
1343
+ "objects.sys_approval_request.fields.flow_node_id.label": "052ad568aa41227c",
1344
+ "objects.sys_approval_request.fields.flow_run_id.help": "35c92818f5e11090",
1345
+ "objects.sys_approval_request.fields.flow_run_id.label": "d0124e323da966f9",
1346
+ "objects.sys_approval_request.fields.node_config_json.help": "7ee00ef9b3ca7e80",
1347
+ "objects.sys_approval_request.fields.node_config_json.label": "e64eb4bf7d7ea033"
1348
+ };
1349
+ }
1350
+ });
1351
+
1352
+ // src/translations/ja-JP.source-hashes.generated.ts
1353
+ var jaJPGeneratedSourceHashes;
1354
+ var init_ja_JP_source_hashes_generated = __esm({
1355
+ "src/translations/ja-JP.source-hashes.generated.ts"() {
1356
+ "use strict";
1357
+ jaJPGeneratedSourceHashes = {
1358
+ "objects.sys_approval_delegation._views.active.emptyState.message": "1095d61c017b6db2",
1359
+ "objects.sys_approval_delegation._views.active.emptyState.title": "799d28e51d88963e",
1360
+ "objects.sys_approval_delegation._views.active.label": "eee107cf4e466779",
1361
+ "objects.sys_approval_delegation.description": "432b0435aa15f6b9",
1362
+ "objects.sys_approval_delegation.fields.created_at.label": "1f02d416befb595b",
1363
+ "objects.sys_approval_delegation.fields.delegate_id.help": "9cfc641f7b0b04f9",
1364
+ "objects.sys_approval_delegation.fields.delegate_id.label": "afd6d8733dc5bc14",
1365
+ "objects.sys_approval_delegation.fields.delegator_id.help": "c4686c5c9f24e0be",
1366
+ "objects.sys_approval_delegation.fields.delegator_id.label": "f76b1f95f2fdabff",
1367
+ "objects.sys_approval_delegation.fields.id.label": "3383564051b4b76d",
1368
+ "objects.sys_approval_delegation.fields.organization_id.help": "f02982e88229d9ca",
1369
+ "objects.sys_approval_delegation.fields.organization_id.label": "3e55836156e1c1de",
1370
+ "objects.sys_approval_delegation.fields.reason.help": "712516f1461dff2b",
1371
+ "objects.sys_approval_delegation.fields.reason.label": "ac399f03d7f2e64d",
1372
+ "objects.sys_approval_delegation.fields.updated_at.label": "aba63dc2a9c79b8d",
1373
+ "objects.sys_approval_delegation.fields.valid_from.help": "01386010c454bfbd",
1374
+ "objects.sys_approval_delegation.fields.valid_from.label": "02eaf201e57c9eda",
1375
+ "objects.sys_approval_delegation.fields.valid_until.help": "a48ea34e65933e9d",
1376
+ "objects.sys_approval_delegation.fields.valid_until.label": "a68db5bd4fc72a13",
1377
+ "objects.sys_approval_delegation.label": "4389c4fe5f5c9746",
1378
+ "objects.sys_approval_delegation.pluralLabel": "f07fec3ffd74e6db",
1379
+ "objects.sys_approval_request.fields.flow_node_id.help": "154aa23b4eee4cae",
1380
+ "objects.sys_approval_request.fields.flow_node_id.label": "052ad568aa41227c",
1381
+ "objects.sys_approval_request.fields.flow_run_id.help": "35c92818f5e11090",
1382
+ "objects.sys_approval_request.fields.flow_run_id.label": "d0124e323da966f9",
1383
+ "objects.sys_approval_request.fields.node_config_json.help": "7ee00ef9b3ca7e80",
1384
+ "objects.sys_approval_request.fields.node_config_json.label": "e64eb4bf7d7ea033"
1385
+ };
1386
+ }
1387
+ });
1388
+
1389
+ // src/translations/es-ES.source-hashes.generated.ts
1390
+ var esESGeneratedSourceHashes;
1391
+ var init_es_ES_source_hashes_generated = __esm({
1392
+ "src/translations/es-ES.source-hashes.generated.ts"() {
1393
+ "use strict";
1394
+ esESGeneratedSourceHashes = {
1395
+ "objects.sys_approval_action.fields.actor_id.label": "b155813f8a7f06e3",
1396
+ "objects.sys_approval_delegation._views.active.emptyState.message": "1095d61c017b6db2",
1397
+ "objects.sys_approval_delegation._views.active.emptyState.title": "799d28e51d88963e",
1398
+ "objects.sys_approval_delegation._views.active.label": "eee107cf4e466779",
1399
+ "objects.sys_approval_delegation.description": "432b0435aa15f6b9",
1400
+ "objects.sys_approval_delegation.fields.created_at.label": "1f02d416befb595b",
1401
+ "objects.sys_approval_delegation.fields.delegate_id.help": "9cfc641f7b0b04f9",
1402
+ "objects.sys_approval_delegation.fields.delegate_id.label": "afd6d8733dc5bc14",
1403
+ "objects.sys_approval_delegation.fields.delegator_id.help": "c4686c5c9f24e0be",
1404
+ "objects.sys_approval_delegation.fields.delegator_id.label": "f76b1f95f2fdabff",
1405
+ "objects.sys_approval_delegation.fields.id.label": "3383564051b4b76d",
1406
+ "objects.sys_approval_delegation.fields.organization_id.help": "f02982e88229d9ca",
1407
+ "objects.sys_approval_delegation.fields.organization_id.label": "3e55836156e1c1de",
1408
+ "objects.sys_approval_delegation.fields.reason.help": "712516f1461dff2b",
1409
+ "objects.sys_approval_delegation.fields.reason.label": "ac399f03d7f2e64d",
1410
+ "objects.sys_approval_delegation.fields.updated_at.label": "aba63dc2a9c79b8d",
1411
+ "objects.sys_approval_delegation.fields.valid_from.help": "01386010c454bfbd",
1412
+ "objects.sys_approval_delegation.fields.valid_from.label": "02eaf201e57c9eda",
1413
+ "objects.sys_approval_delegation.fields.valid_until.help": "a48ea34e65933e9d",
1414
+ "objects.sys_approval_delegation.fields.valid_until.label": "a68db5bd4fc72a13",
1415
+ "objects.sys_approval_delegation.label": "4389c4fe5f5c9746",
1416
+ "objects.sys_approval_delegation.pluralLabel": "f07fec3ffd74e6db",
1417
+ "objects.sys_approval_request.fields.flow_node_id.help": "154aa23b4eee4cae",
1418
+ "objects.sys_approval_request.fields.flow_node_id.label": "052ad568aa41227c",
1419
+ "objects.sys_approval_request.fields.flow_run_id.help": "35c92818f5e11090",
1420
+ "objects.sys_approval_request.fields.flow_run_id.label": "d0124e323da966f9",
1421
+ "objects.sys_approval_request.fields.node_config_json.help": "7ee00ef9b3ca7e80",
1422
+ "objects.sys_approval_request.fields.node_config_json.label": "e64eb4bf7d7ea033"
1423
+ };
1424
+ }
1425
+ });
1426
+
1300
1427
  // src/translations/index.ts
1301
1428
  var translations_exports = {};
1302
1429
  __export(translations_exports, {
1303
1430
  ApprovalsTranslations: () => ApprovalsTranslations
1304
1431
  });
1305
- var ApprovalsTranslations;
1432
+ import { withSourceFallback } from "@objectstack/platform-objects/apps";
1433
+ var enSource, ApprovalsTranslations;
1306
1434
  var init_translations = __esm({
1307
1435
  "src/translations/index.ts"() {
1308
1436
  "use strict";
@@ -1310,18 +1438,27 @@ var init_translations = __esm({
1310
1438
  init_zh_CN_objects_generated();
1311
1439
  init_ja_JP_objects_generated();
1312
1440
  init_es_ES_objects_generated();
1441
+ init_zh_CN_source_hashes_generated();
1442
+ init_ja_JP_source_hashes_generated();
1443
+ init_es_ES_source_hashes_generated();
1444
+ enSource = { objects: enObjects };
1313
1445
  ApprovalsTranslations = {
1314
- en: { objects: enObjects },
1315
- "zh-CN": { objects: zhCNObjects },
1316
- "ja-JP": { objects: jaJPObjects },
1317
- "es-ES": { objects: esESObjects }
1446
+ en: enSource,
1447
+ "zh-CN": withSourceFallback({ objects: zhCNObjects }, enSource, void 0, zhCNGeneratedSourceHashes),
1448
+ "ja-JP": withSourceFallback({ objects: jaJPObjects }, enSource, void 0, jaJPGeneratedSourceHashes),
1449
+ "es-ES": withSourceFallback({ objects: esESObjects }, enSource, void 0, esESGeneratedSourceHashes)
1318
1450
  };
1319
1451
  }
1320
1452
  });
1321
1453
 
1322
1454
  // src/sys-approval-request.object.ts
1323
1455
  import { ObjectSchema, Field } from "@objectstack/spec/data";
1324
- import { APPROVAL_STATUSES, APPROVAL_STATUS_LABELS } from "@objectstack/spec/contracts";
1456
+ import {
1457
+ APPROVAL_STATUSES,
1458
+ APPROVAL_STATUS_LABELS,
1459
+ APPROVAL_CANCEL_REASONS,
1460
+ APPROVAL_CANCEL_REASON_LABELS
1461
+ } from "@objectstack/spec/contracts";
1325
1462
  var SysApprovalRequest = ObjectSchema.create({
1326
1463
  name: "sys_approval_request",
1327
1464
  label: "Approval Request",
@@ -1368,7 +1505,10 @@ var SysApprovalRequest = ObjectSchema.create({
1368
1505
  label: "Completed",
1369
1506
  data: { provider: "object", object: "sys_approval_request" },
1370
1507
  columns: ["process_name", "object_name", "record_id", "status", "submitter_id", "completed_at"],
1371
- filter: [{ field: "status", operator: "in", value: ["approved", "rejected", "recalled"] }],
1508
+ // Every terminal state, `cancelled` included (#13568) a platform-voided
1509
+ // request is kept as audit evidence, and a terminal state absent from the
1510
+ // only curated terminal view is evidence nobody can find.
1511
+ filter: [{ field: "status", operator: "in", value: ["approved", "rejected", "recalled", "cancelled"] }],
1372
1512
  sort: [{ field: "completed_at", order: "desc" }],
1373
1513
  pagination: { pageSize: 25 }
1374
1514
  },
@@ -1384,46 +1524,40 @@ var SysApprovalRequest = ObjectSchema.create({
1384
1524
  },
1385
1525
  fields: {
1386
1526
  id: Field.text({ label: "Request ID", required: true, readonly: true, group: "System" }),
1387
- // ⚠️ MEASURED DEFECT, cloud#1395 read this before trusting the column.
1388
- //
1389
- // An approval request DOES belong to an organization: it is read through the
1390
- // organization wall by the approvals inbox, and on a shared-database
1391
- // deployment a row carrying no organization is not filtered BY that wall —
1392
- // it is either invisible to everyone or visible to everyone, decided by
1393
- // whatever filter each surface happens to apply rather than by the data.
1527
+ // [#10101, the cloud#1395 Option A ruling] The SUBJECT record's
1528
+ // organization, with the acting context as fallback — resolved by the
1529
+ // SHARED platform-row resolver (`resolveRecordOrganizationField`,
1530
+ // `@objectstack/metadata-core`) in `openNodeRequest`, the row's only
1531
+ // writer. The same `requestOrg` stamps `sys_approval_action` and the
1532
+ // `sys_approval_approver` index, so all three move together.
1394
1533
  //
1395
- // The value is resolved from the ACTING CONTEXT only (`openNodeRequest`'s
1396
- // `ctxOrg`), so it is NULL whenever the flow that opened the request ran
1397
- // without one — every schedule / time-relative / api triggered run, none of
1398
- // which sets a tenant. On a walled single-database HotCRM SaaS boot this
1399
- // measured 27 of 27 rows org-less, each naming an `object_name` /
1400
- // `record_id` owned by a specific customer.
1534
+ // Why subject-first: an approval request is read through the organization
1535
+ // wall by the approvals inbox, and the acting context is NULL on every
1536
+ // schedule / time-relative / api triggered run (none carries a tenant, by
1537
+ // construction). Stamped from the actor alone the pre-#10101 behaviour,
1538
+ // measured on cloud#1395 as 27 of 27 rows org-less on a walled HotCRM SaaS
1539
+ // boot such a request LOCKED the record it was about while being
1540
+ // invisible in every inbox, its owner's included. Subject-first is also
1541
+ // what `sys_audit_log`'s writer already did (#8707 honouring #8287's
1542
+ // ruling), so an approval row and an audit row about the same record now
1543
+ // land behind the same wall instead of two.
1401
1544
  //
1402
- // Do NOT read that as "platform tables do not carry an organization".
1403
- // `sys_audit_log` (1669 rows) was correctly attributed on the SAME boot,
1404
- // because its writer takes the organization from the RECORD the row is
1405
- // about, with the session only as fallback (plugin-audit
1406
- // `resolveRecordOrganizationField`, #8707 honouring #8287's ruling). Two
1407
- // writers read the actor; a third reads the subject. That disagreement is
1408
- // the defect.
1409
- //
1410
- // Which of the two a side-table row should follow is an open contract
1411
- // question on cloud#1395 — the audit resolver is scope-pinned to audit
1412
- // stamping by the #8778 ruling, so this writer needs its own. The same
1413
- // `ctxOrg` also stamps `sys_approval_action` and `sys_approval_approver`,
1414
- // so all three move together.
1545
+ // The `sys_api_key` divergence is deliberate and preserved: its
1546
+ // `tenancy.organizationField: 'active_organization_id'` (stamp-only,
1547
+ // #8778) wins limb 0 of the shared resolver, while the credential table
1548
+ // itself stays unwalled (`tenancy.enabled: false`) who a row is ABOUT
1549
+ // and what an object is WALLED by remain different questions.
1415
1550
  organization_id: Field.lookup("sys_organization", {
1416
1551
  label: "Organization",
1417
1552
  required: false,
1418
1553
  group: "System",
1419
- // String unchanged on purpose: it is extracted into the generated i18n
1420
- // bundles (`translations/*.objects.generated.ts`, as `help`), so rewording
1421
- // it is a translation-regeneration change and not a comment. The
1422
- // correction it needs it claims a propagation that measurably does not
1423
- // happen, and says "Tenant" where ADR-0120 §Terminology requires
1424
- // "organization" — rides the cloud#1395 write-side fix, which rewrites the
1425
- // sentence and regenerates the four locales in one pass.
1426
- description: "Tenant that owns this approval request (propagated from submitter context)"
1554
+ // Reworded with the #10101 write-side fix (was "Tenant that owns this
1555
+ // approval request (propagated from submitter context)" it claimed a
1556
+ // propagation that measurably did not happen, and said "Tenant" where
1557
+ // ADR-0120 §Terminology requires "organization"). Extracted into the
1558
+ // generated i18n bundles as `help`; the four locales regenerate in the
1559
+ // same pass.
1560
+ description: "Organization of the record this request is about (falls back to the acting context when the record has none)"
1427
1561
  }),
1428
1562
  process_name: Field.text({
1429
1563
  label: "Source",
@@ -1442,7 +1576,38 @@ var SysApprovalRequest = ObjectSchema.create({
1442
1576
  label: "Record ID",
1443
1577
  required: true,
1444
1578
  maxLength: 100,
1445
- group: "Target"
1579
+ // [#11386] The id half of this object's pointer pair (ADR-0052 §5),
1580
+ // adopting the #11339 carrier. VERIFIED for THIS object: the pair is not
1581
+ // decoration but the key the approval machinery QUERIES ON —
1582
+ // `approval-service.ts` finds a record's pending request with
1583
+ // `where: { object_name, record_id, status: 'pending' }`, and
1584
+ // `lifecycle-hooks.ts` holds the record LOCK on the same pair
1585
+ // (single-record and `$in` batch forms). `submit()` writes it from
1586
+ // `input.object` / `input.recordId`, so a stored value is always a
1587
+ // record id of the object the sibling names.
1588
+ //
1589
+ // Consequence of declaring, sharper here than elsewhere: a seeded
1590
+ // request whose `record_id` stayed a verbatim natural key locked
1591
+ // NOTHING and appeared under no record — it looked like a pending
1592
+ // approval while being invisible to both queries that give the row its
1593
+ // meaning. That is now a loud seed-time refusal. Both halves are
1594
+ // `required: true`, so the un-addressable case (id half authored, type
1595
+ // half empty) is already unreachable on this object.
1596
+ group: "Target",
1597
+ //
1598
+ // ⚠️ ORDERING CONSTRAINT — this id half is `required: true`, and that
1599
+ // makes it ORDER-DEPENDENT in seeds even though a pointer pair
1600
+ // contributes no static ordering edge (#11674, measured against the real
1601
+ // engine in `packages/objectql/src/engine-seed-required-deferral.test.ts`):
1602
+ // the seed loader defers an unresolvable reference by DELETING the column
1603
+ // from the pass-1 insert, required-validation rejects that row, and pass 2
1604
+ // is then left with no row to back-fill. So the pass-2 healing that makes
1605
+ // an OPTIONAL id half order-independent (`sys_audit_log`) does not reach
1606
+ // this one. ⇒ SEED THE TARGET DATASET FIRST. The failure if you do not is
1607
+ // loud in three places — a write error naming this column, a
1608
+ // dropped-deferral error, and `success: false` — and since #11674 the
1609
+ // loader also WARNS at load time, before the engine rejects the row.
1610
+ referenceVia: "object_name"
1446
1611
  }),
1447
1612
  submitter_id: Field.lookup("sys_user", {
1448
1613
  label: "Submitter",
@@ -1470,6 +1635,29 @@ var SysApprovalRequest = ObjectSchema.create({
1470
1635
  group: "State"
1471
1636
  }
1472
1637
  ),
1638
+ // [#13568] Why a `cancelled` request was cancelled — the machine-readable
1639
+ // half of the maintainer's 2026-08-31 ruling ("状态转 cancelled + 机器可读
1640
+ // 原因"). Derived from `APPROVAL_CANCEL_REASONS`, never re-typed, on the
1641
+ // same #3786 rule the `status` column above follows.
1642
+ //
1643
+ // On the ROW rather than on the `sys_approval_action` audit entry, because
1644
+ // the readers are the inbox and the tombstone presentation, which page the
1645
+ // request table: making them join the append-only action log to learn why a
1646
+ // row they already hold is cancelled buys nothing and would make the reason
1647
+ // unavailable to a plain list view. The action row still records the event
1648
+ // (`action: 'cancel'`); this column records the STATE.
1649
+ //
1650
+ // Null on every non-cancelled row, and on rows written before the column
1651
+ // existed — "not recorded", never "cancelled for no reason".
1652
+ cancel_reason: Field.select(
1653
+ APPROVAL_CANCEL_REASONS.map((value) => ({ value, label: APPROVAL_CANCEL_REASON_LABELS[value] })),
1654
+ {
1655
+ label: "Cancellation Reason",
1656
+ required: false,
1657
+ description: "Why the platform voided this request (set only when the status is cancelled)",
1658
+ group: "State"
1659
+ }
1660
+ ),
1473
1661
  current_step: Field.text({
1474
1662
  label: "Current Step",
1475
1663
  required: false,
@@ -1489,6 +1677,22 @@ var SysApprovalRequest = ObjectSchema.create({
1489
1677
  description: "Comma-separated user ids who can act on the current step",
1490
1678
  group: "State"
1491
1679
  }),
1680
+ // The module docstring above used to justify this column with "used by
1681
+ // notifications so they can render before the record is locked or
1682
+ // changed". That consumer does not exist, and the claim was cited as the
1683
+ // reason the column holds a FULL row before anyone checked it. Measured
1684
+ // against every `this.notify(...)` call site in `approval-service.ts` —
1685
+ // all 12 of them: each passes `{ title, message, actionUrl }` (two also
1686
+ // `actions`), built from `object_name` / `record_id` and the caller's
1687
+ // comment. None reads this column or the parsed `payload`. The real
1688
+ // readers are the serve path (`rowFromRequest` -> `payload`, redacted per
1689
+ // reader), the decide-time approver re-resolution and the org backfill,
1690
+ // both under SYSTEM_CTX, and the free-text predicate.
1691
+ //
1692
+ // The `description` below is deliberately UNCHANGED: it is accurate, and
1693
+ // it is extracted into the four generated i18n bundles as `help`. The
1694
+ // false sentence lived only in the JSDoc above, which is not extracted —
1695
+ // so this correction moves no translation leaf.
1492
1696
  payload_json: Field.textarea({
1493
1697
  label: "Snapshot",
1494
1698
  required: false,
@@ -1559,11 +1763,14 @@ var SysApprovalRequest = ObjectSchema.create({
1559
1763
  // per-viewer block (#3310): approver actions on `record.viewer.can_act`
1560
1764
  // (the caller is a current pending approver — same check the service
1561
1765
  // authorizes a decision with, so position/team approvers resolve correctly),
1562
- // submitter actions on `record.viewer.is_submitter`. The core decision levers
1563
- // (approve/reject/reassign) additionally OR in `record.viewer.can_override`
1564
- // (#3424) so a platform/tenant admin can rescue a request routed to an
1766
+ // submitter actions on `record.viewer.is_submitter`. The four levers the
1767
+ // #3424 override covers (approve/reject/reassign, and recall since #12716)
1768
+ // additionally OR in `record.viewer.can_override`
1769
+ // so a platform/tenant admin can rescue a request routed to an
1565
1770
  // unstaffed position — otherwise undecidable, locking the record forever — by
1566
- // approving, rejecting, or reassigning it to a real approver. `viewer` is
1771
+ // approving, rejecting, reassigning it to a real approver, or recalling it
1772
+ // (the lever that releases the record without recording a decision nobody
1773
+ // made). `viewer` is
1567
1774
  // attached by getRequest/listRequests; where it is absent the predicate fails
1568
1775
  // closed.
1569
1776
  //
@@ -1699,8 +1906,13 @@ var SysApprovalRequest = ObjectSchema.create({
1699
1906
  // Remind / recall (pending) and resubmit / recall (returned). These are the
1700
1907
  // submitter's own levers, so `visible` gates on `record.viewer.is_submitter`
1701
1908
  // (server-computed on the current viewer). The service re-checks ownership;
1702
- // the predicate keeps a non-submitter from ever seeing a button they cannot
1703
- // use.
1909
+ // the predicate keeps a plain non-submitter from ever seeing a button they
1910
+ // cannot use.
1911
+ //
1912
+ // `recall` is the one exception, and it is not a widening: it ALSO ORs in
1913
+ // the #3424 admin override (#12716), because an override admin is a caller
1914
+ // `ApprovalService.recall` already authorises. Remind and resubmit keep no
1915
+ // override arm.
1704
1916
  {
1705
1917
  name: "approval_remind",
1706
1918
  label: "Send reminder",
@@ -1731,7 +1943,26 @@ var SysApprovalRequest = ObjectSchema.create({
1731
1943
  ],
1732
1944
  // Recall applies while the request is live for the submitter — pending
1733
1945
  // (withdraw) or returned (abandon the revision instead of resubmitting).
1734
- visible: 'has(record.status) && (record.status == "pending" || record.status == "returned") && has(record.viewer) && has(record.viewer.is_submitter) && record.viewer.is_submitter == true',
1946
+ //
1947
+ // The second arm is the #3424 admin override, spelled byte-identically to
1948
+ // the three core decision levers above (#12716). `ApprovalService.recall`
1949
+ // has admitted the override caller since #3424 — `isOverrideActor`'s own
1950
+ // doc block names recall as one of the four levers — so until this arm
1951
+ // landed, recall was the one authorised capability with no button: an
1952
+ // admin could approve or reject their way out of a stuck request (writing
1953
+ // a decision that did not happen) or reassign it, but could not withdraw.
1954
+ //
1955
+ // The override arm carries no status test of its own, on purpose, because
1956
+ // it does not need one and the siblings do not have one either: the flag
1957
+ // is already status-scoped where it is COMPUTED. `attachViewers` in
1958
+ // `approval-service.ts` sets
1959
+ // `can_override: row.status === 'pending' && isOverrideActor(...)` —
1960
+ // ANDed — so `record.viewer.can_override` can never be true off `pending`,
1961
+ // and this arm is pending-only in effect however CEL groups the
1962
+ // expression. Pinned in both directions in
1963
+ // `action-predicate-sparse-face.test.ts`, with the flag's own scoping
1964
+ // pinned against the real service in `approval-revise.test.ts`.
1965
+ 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',
1735
1966
  locations: ["record_section"],
1736
1967
  successMessage: "Recalled.",
1737
1968
  refreshAfter: true
@@ -2092,6 +2323,7 @@ import {
2092
2323
  normalizeDecisionOutputs
2093
2324
  } from "@objectstack/spec/automation";
2094
2325
  import { ExpressionEngine, collectCelRootIdentifiers } from "@objectstack/formula";
2326
+ import { createRecordOrganizationResolver } from "@objectstack/metadata-core";
2095
2327
  import { keysetWalk } from "@objectstack/types";
2096
2328
  import {
2097
2329
  ADMIN_FULL_ACCESS,
@@ -2102,6 +2334,7 @@ import {
2102
2334
  } from "@objectstack/spec/identity";
2103
2335
  import { RESUME_AUTHORITY_SERVICE } from "@objectstack/spec/contracts";
2104
2336
  import { isFileIdToken } from "@objectstack/spec/data";
2337
+ import { renderOperationMessage } from "@objectstack/spec/system";
2105
2338
  import { isGrantActive } from "@objectstack/core";
2106
2339
 
2107
2340
  // src/approver-org-scope.ts
@@ -2220,11 +2453,44 @@ async function filterApproversWhoCanRead(deps, userIds, requestOrgId, context) {
2220
2453
  return userIds.filter((u) => canRead.has(u));
2221
2454
  }
2222
2455
 
2456
+ // src/payload-redaction.ts
2457
+ function redactSnapshot(payload, readable) {
2458
+ if (readable === void 0) return { payload, redactedKeys: [] };
2459
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload)) {
2460
+ return { payload, redactedKeys: [] };
2461
+ }
2462
+ const allowed = new Set(readable.map((f) => String(f)));
2463
+ const source = payload;
2464
+ const redactedKeys = [];
2465
+ const kept = {};
2466
+ for (const key of Object.keys(source)) {
2467
+ if (allowed.has(key)) kept[key] = source[key];
2468
+ else redactedKeys.push(key);
2469
+ }
2470
+ if (redactedKeys.length === 0) return { payload, redactedKeys: [] };
2471
+ return { payload: kept, redactedKeys: redactedKeys.sort() };
2472
+ }
2473
+ async function resolveReadableSnapshotFields(security, objectName, context, logger) {
2474
+ if (!security || typeof security.getReadableFields !== "function") return void 0;
2475
+ const object = String(objectName ?? "").trim();
2476
+ if (!object) return void 0;
2477
+ try {
2478
+ return await security.getReadableFields(object, context);
2479
+ } catch (err) {
2480
+ logger?.warn?.("[approvals] payload redaction could not resolve readable fields \u2014 serving the snapshot unredacted", {
2481
+ object,
2482
+ error: err?.message ?? String(err)
2483
+ });
2484
+ return void 0;
2485
+ }
2486
+ }
2487
+
2223
2488
  // src/approval-service.ts
2224
2489
  var REMIND_COOLDOWN_MS = 4 * 60 * 60 * 1e3;
2225
2490
  var ESCALATION_JOB_NAME = "approvals-sla-escalation";
2226
2491
  var ESCALATION_SCAN_INTERVAL_MS = 5 * 60 * 1e3;
2227
2492
  var SLA_ACTOR_ID = "system:sla";
2493
+ var ESCALATION_ENABLED_FLIP_CUTOFF_MS = Date.parse("2026-08-28T00:00:00Z");
2228
2494
  var DEAD_RUN_ACTOR_ID = "system:dead-run";
2229
2495
  var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
2230
2496
  "completed",
@@ -2233,13 +2499,43 @@ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
2233
2499
  "timed_out"
2234
2500
  ]);
2235
2501
  var STRANDABLE_REQUEST_STATUSES = ["approved", "rejected", "returned"];
2502
+ function classifyStrandedRunState(run) {
2503
+ if (!run) return "missing";
2504
+ switch (run.status) {
2505
+ // The resume consumed the pause and a downstream node threw. Reported.
2506
+ case "failed":
2507
+ return "failed";
2508
+ // ── The negatives, each for its own reason ──────────────────────────────
2509
+ // The decision advanced the flow and the flow finished. Healthy.
2510
+ case "completed":
2511
+ return void 0;
2512
+ // Deliberately terminated by an operator (`cancelRun`, ADR-0044). The run
2513
+ // stopping is the intended outcome, exactly as `recalled` is on the request
2514
+ // side — reporting it would bury the real findings under expected ones.
2515
+ case "cancelled":
2516
+ return void 0;
2517
+ // The history's last row says `paused` while the suspension store says no
2518
+ // live pause. That is AMBIGUOUS, and the ambiguity is not resolvable from
2519
+ // one scan: a resume in flight right now has consumed the suspension and
2520
+ // not yet written its terminal row, and reads exactly like a process that
2521
+ // died in the same window. Condemning it would name every concurrently
2522
+ // resuming approval — so this stays SKIPPED, the conservative arm this
2523
+ // whole method is built on.
2524
+ case "paused":
2525
+ return void 0;
2526
+ default:
2527
+ return void 0;
2528
+ }
2529
+ }
2236
2530
  var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
2237
2531
  var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
2532
+ var RECORD_DELETE_CANCEL_LIMIT = 200;
2238
2533
  function actingUserId(context) {
2239
2534
  const userId = context?.userId;
2240
2535
  return typeof userId === "string" && userId ? userId : null;
2241
2536
  }
2242
2537
  var OOO_MAX_CHAIN = 8;
2538
+ var MEMBER_SCREEN_READ_LIMIT = 5e4;
2243
2539
  var GRAPH_APPROVER_TYPES = /* @__PURE__ */ new Set([
2244
2540
  "team",
2245
2541
  "department",
@@ -2294,6 +2590,10 @@ function rowFromRequest(row) {
2294
2590
  submitter_id: row.submitter_id ?? void 0,
2295
2591
  submitter_comment: row.submitter_comment ?? void 0,
2296
2592
  status: row.status ?? "pending",
2593
+ // [#13568] Why a cancelled row was cancelled. Surfaced on every read for
2594
+ // the same reason `status` is: a client that can see the terminal state and
2595
+ // not the machine-readable cause is back to guessing from prose.
2596
+ cancel_reason: row.cancel_reason ?? void 0,
2297
2597
  current_step: row.current_step ?? void 0,
2298
2598
  current_step_index: row.current_step_index ?? void 0,
2299
2599
  pending_approvers: csvSplit(row.pending_approvers),
@@ -2386,12 +2686,15 @@ function rowFromAction(row) {
2386
2686
  var _ApprovalService = class _ApprovalService {
2387
2687
  constructor(opts) {
2388
2688
  this.engine = opts.engine;
2689
+ this.recordOrgResolver = createRecordOrganizationResolver(opts.engine);
2389
2690
  this.clock = opts.clock ?? { now: () => /* @__PURE__ */ new Date() };
2390
2691
  this.logger = opts.logger;
2391
2692
  this.automation = opts.automation;
2392
2693
  this.messaging = opts.messaging;
2393
2694
  this.publicBaseUrl = (opts.publicBaseUrl ?? "").replace(/\/$/, "");
2394
2695
  this.tenancyPosture = opts.tenancyPosture;
2696
+ this.fieldVisibility = opts.fieldVisibility;
2697
+ this.messageTranslator = opts.messageTranslator;
2395
2698
  this.recordReaderVisibleObjects = new Set(
2396
2699
  (Array.isArray(opts.recordReaderVisibleObjects) ? opts.recordReaderVisibleObjects : []).map((n) => String(n ?? "").trim()).filter(Boolean)
2397
2700
  );
@@ -2400,6 +2703,57 @@ var _ApprovalService = class _ApprovalService {
2400
2703
  attachTenancyPosture(provider) {
2401
2704
  this.tenancyPosture = provider;
2402
2705
  }
2706
+ /**
2707
+ * [#10749] Attach (or replace) the field-visibility authority the payload
2708
+ * redaction seam reads. Late-bound: plugin load order does not guarantee the
2709
+ * security service exists when this one is constructed.
2710
+ */
2711
+ attachFieldVisibility(source) {
2712
+ this.fieldVisibility = source;
2713
+ }
2714
+ /**
2715
+ * [#10749] Redact each row's payload snapshot down to the fields the READING
2716
+ * caller may see on that row's subject object.
2717
+ *
2718
+ * Runs BEFORE {@link ApprovalService.enrichRows}, and that ordering is
2719
+ * load-bearing rather than incidental: `enrichRows` derives `payload_display`
2720
+ * (lookup foreign keys inside the snapshot resolved to referenced record
2721
+ * titles) and `payload_labels` (a label per snapshot key) by WALKING THE
2722
+ * SNAPSHOT'S OWN KEYS. Redact first and both derived maps are clean for free;
2723
+ * redact after and a restricted field's name, its authored label and the
2724
+ * title of the record it points at all still ship — the value would be gone
2725
+ * and the disclosure would not.
2726
+ *
2727
+ * Rows are grouped by subject object so one `getReadableFields` call covers a
2728
+ * whole page of same-object requests.
2729
+ */
2730
+ async redactPayloads(rows, context) {
2731
+ const withPayload = rows.filter((r) => r?.payload != null);
2732
+ if (withPayload.length === 0) return;
2733
+ const byObject = /* @__PURE__ */ new Map();
2734
+ for (const r of withPayload) {
2735
+ const key = String(r.object_name ?? "");
2736
+ let list = byObject.get(key);
2737
+ if (!list) {
2738
+ list = [];
2739
+ byObject.set(key, list);
2740
+ }
2741
+ list.push(r);
2742
+ }
2743
+ for (const [object, group] of byObject) {
2744
+ const readable = await resolveReadableSnapshotFields(
2745
+ this.fieldVisibility,
2746
+ object,
2747
+ context,
2748
+ this.logger
2749
+ );
2750
+ if (readable === void 0) continue;
2751
+ for (const r of group) {
2752
+ const { payload } = redactSnapshot(r.payload, readable);
2753
+ r.payload = payload;
2754
+ }
2755
+ }
2756
+ }
2403
2757
  /** Deps bundle for the ADR-0105 D9 org-scope helpers. */
2404
2758
  get orgScopeDeps() {
2405
2759
  return {
@@ -2493,6 +2847,41 @@ var _ApprovalService = class _ApprovalService {
2493
2847
  const actorTenant = context.tenantId ?? context.organizationId ?? null;
2494
2848
  return requestOrg == null || actorTenant != null && String(requestOrg) === String(actorTenant);
2495
2849
  }
2850
+ /**
2851
+ * [#11993] The END USER's half of an approval refusal.
2852
+ *
2853
+ * `handleApprovalError` in `@objectstack/rest` maps this service's
2854
+ * `CODE: message` throws onto the wire by testing the prefix for the status
2855
+ * and then STRIPPING it (a leading run of `[A-Z_]` plus a colon and any
2856
+ * following whitespace), shipping what remains as the body's
2857
+ * human-readable `error` — which Console splices under its own localized
2858
+ * label ("撤回审批失败: …"). A hardcoded English reason therefore reaches an
2859
+ * operator in a fully Chinese deployment as a Chinese prefix glued onto an
2860
+ * English sentence they cannot act on.
2861
+ *
2862
+ * Rendered through the SHARED Operation Message Catalog
2863
+ * (`@objectstack/spec/system`), not a second mechanism: same
2864
+ * `errors.<key>` override address, same resolution ladder (deployment
2865
+ * override -> locale catalog -> `en` -> the key), same guarantee that a
2866
+ * misbehaving i18n service cannot turn a 403 into a 500. The catalog's
2867
+ * header names this seat explicitly; `plugin-security`'s
2868
+ * `userFacingDenialMessage` is the sibling consumer this mirrors.
2869
+ *
2870
+ * The CODE PREFIX stays on the message. It is not user copy — it is how the
2871
+ * REST layer derives the status and the ADR-0112 wire code, and it is
2872
+ * stripped before the sentence reaches a body. The developer's half moves
2873
+ * to the log, where the ids it names are legible to an operator and to
2874
+ * nobody else.
2875
+ */
2876
+ userFacingRefusal(messageKey, context) {
2877
+ let translate;
2878
+ try {
2879
+ translate = this.messageTranslator?.();
2880
+ } catch {
2881
+ translate = void 0;
2882
+ }
2883
+ return renderOperationMessage({ messageKey }, { locale: context?.locale, translate });
2884
+ }
2496
2885
  /**
2497
2886
  * Pin the acting identity to the AUTHENTICATED CALLER (#3800).
2498
2887
  *
@@ -2666,7 +3055,7 @@ var _ApprovalService = class _ApprovalService {
2666
3055
  }) : users;
2667
3056
  try {
2668
3057
  if (type === "team") {
2669
- const users = await this.expandTeamUsers(String(a.value));
3058
+ const users = await this.expandTeamUsers(String(a.value), organizationId);
2670
3059
  if (users.length) return users;
2671
3060
  } else if (type === "department" || type === "business_unit" || type === "bu") {
2672
3061
  const users = await bounded(await this.expandBusinessUnitUsers(String(a.value), directoryOrg));
@@ -2680,7 +3069,7 @@ var _ApprovalService = class _ApprovalService {
2680
3069
  } else if (type === "manager" && record) {
2681
3070
  const subject = record[a.value] ?? record.owner_id;
2682
3071
  if (subject) {
2683
- const mgr = await this.lookupManager(String(subject));
3072
+ const mgr = await this.lookupManager(String(subject), organizationId);
2684
3073
  if (mgr) return this.applyOooDelegation(mgr, now, organizationId, substitutions);
2685
3074
  }
2686
3075
  }
@@ -2777,7 +3166,7 @@ var _ApprovalService = class _ApprovalService {
2777
3166
  try {
2778
3167
  if (resolveAs === "department") users = await this.expandBusinessUnitUsers(key, directoryOrg);
2779
3168
  else if (resolveAs === "position") users = await this.expandPositionUsers(key, directoryOrg);
2780
- else if (resolveAs === "team") users = await this.expandTeamUsers(key);
3169
+ else if (resolveAs === "team") users = await this.expandTeamUsers(key, directoryOrg);
2781
3170
  else {
2782
3171
  throw new Error(
2783
3172
  `VALIDATION_FAILED: expression approver has unknown resolveAs '${resolveAs}' \u2014 use 'user', 'department', 'position', or 'team'`
@@ -2802,9 +3191,50 @@ var _ApprovalService = class _ApprovalService {
2802
3191
  }
2803
3192
  return { slots, raw };
2804
3193
  }
2805
- /** Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy). */
2806
- async expandTeamUsers(teamId) {
3194
+ /**
3195
+ * Flat team — `sys_team` is better-auth's collaboration grouping (no hierarchy).
3196
+ *
3197
+ * Takes an organization for the reason every sibling expansion does
3198
+ * ({@link expandBusinessUnitUsers}, {@link expandPositionUsers},
3199
+ * {@link expandMembershipTierUsers}): an approver expansion answers "who, in
3200
+ * THIS organization". Before #10230 this one did not ask, and it was the last
3201
+ * expansion that did not — a `team` approver naming ANOTHER organization's
3202
+ * team routed that organization's people an approval over a record they are
3203
+ * not a tenant of.
3204
+ *
3205
+ * TWO screens run here, and they assert different things (#10230, #10547):
3206
+ *
3207
+ * 1. the TEAM must not provably belong to another organization
3208
+ * ({@link teamIsProvablyOutsideOrg}) — `sys_team` carries
3209
+ * `organization_id` outright
3210
+ * (`packages/platform-objects/src/identity/sys-team.object.ts`), so a
3211
+ * team id transitively names exactly one organization and ONE row
3212
+ * answers the question;
3213
+ * 2. each expanded MEMBER must not provably hold membership only in other
3214
+ * organizations ({@link dropMembersProvablyOutsideOrg}) —
3215
+ * `sys_team_member` carries `team_id` and `user_id` and NO tenancy
3216
+ * column at all, so passing (1) says nothing whatever about the people
3217
+ * it lists.
3218
+ *
3219
+ * #10230 landed (1) alone and deferred (2) on purpose. What closed the
3220
+ * deferral is that (1) does not imply (2) even a little: a member removed
3221
+ * from the organization but left on the team, a team re-parented across
3222
+ * organizations (`/organization/update-team` accepts `organizationId` in its
3223
+ * partial body), or a `sys_team_member` row written by a seed rather than
3224
+ * through better-auth all produce a team that passes (1) carrying a user who
3225
+ * is provably a tenant of somewhere else. Measured on this tree, not read off
3226
+ * the schema — the probe is quoted in `team-member-org-screen.test.ts`.
3227
+ *
3228
+ * (2) is the SAME assertion as {@link managerIsProvablyOutsideOrg}, one hop
3229
+ * further out, and it is asserted the same way: `sys_user` carries no tenancy
3230
+ * fact, so `sys_member` rows are the only evidence that a person is placed
3231
+ * anywhere. Like that screen, this one grants no reads and applies no read
3232
+ * screen to any approver type that lacks one today, so it decides nothing
3233
+ * #7497 (does approver routing imply record read visibility?) asks.
3234
+ */
3235
+ async expandTeamUsers(teamId, organizationId) {
2807
3236
  if (!teamId) return [];
3237
+ if (await this.teamIsProvablyOutsideOrg(teamId, organizationId)) return [];
2808
3238
  let rows = [];
2809
3239
  try {
2810
3240
  rows = await this.engine.find("sys_team_member", {
@@ -2816,7 +3246,149 @@ var _ApprovalService = class _ApprovalService {
2816
3246
  } catch {
2817
3247
  rows = [];
2818
3248
  }
2819
- return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
3249
+ const users = Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
3250
+ return await this.dropMembersProvablyOutsideOrg(teamId, users, organizationId);
3251
+ }
3252
+ /**
3253
+ * Is `teamId` PROVABLY a team of a DIFFERENT organization? (#10230)
3254
+ *
3255
+ * "Provably" carries the same posture the sibling screen states at length in
3256
+ * {@link managerIsProvablyOutsideOrg}, for the same reasons:
3257
+ *
3258
+ * - the team row carries an `organization_id` and it is not the request's
3259
+ * ⇒ the tenancy fact is present and NEGATIVE ⇒ screen it out;
3260
+ * - the row carries no `organization_id`, does not exist, or the read failed
3261
+ * ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
3262
+ *
3263
+ * The `organization_id = null` limb is not timidity — it is the reading
3264
+ * {@link businessUnitOrgScope} settled on one screen below, for the identical
3265
+ * shape: null on a platform object means "owned by no organization", which is
3266
+ * what a seed writes because a seed cannot know the organization id the
3267
+ * runtime mints at boot. Treating null as "not mine" would delete every
3268
+ * seeded team approver at once — a larger behaviour change than the hole
3269
+ * being closed. Measured, and not hypothetically: this package's own
3270
+ * `team_ok` expansion fixture is exactly such a stack (it has
3271
+ * `sys_team_member` rows, a request carrying an organization, and no
3272
+ * `sys_team` row at all).
3273
+ *
3274
+ * Screening the TEAM before reading its members is also what keeps the cost
3275
+ * at one row: a team that fails the screen never fans out.
3276
+ */
3277
+ async teamIsProvablyOutsideOrg(teamId, organizationId) {
3278
+ const requestOrg = organizationId ? String(organizationId) : "";
3279
+ if (!requestOrg) return false;
3280
+ let rows = [];
3281
+ try {
3282
+ rows = await this.engine.find("sys_team", {
3283
+ where: { id: teamId },
3284
+ fields: ["id", "organization_id"],
3285
+ limit: 1,
3286
+ context: SYSTEM_CTX2
3287
+ });
3288
+ } catch {
3289
+ return false;
3290
+ }
3291
+ const row = Array.isArray(rows) ? rows[0] : null;
3292
+ const teamOrg = row?.organization_id ? String(row.organization_id) : "";
3293
+ if (!teamOrg) return false;
3294
+ if (teamOrg === requestOrg) return false;
3295
+ this.logger?.warn?.(
3296
+ `[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.`,
3297
+ { teamId, teamOrganizationId: teamOrg, requestOrganizationId: requestOrg }
3298
+ );
3299
+ return true;
3300
+ }
3301
+ /**
3302
+ * Drop the expanded team members who are PROVABLY tenants of other
3303
+ * organizations and not of `organizationId`. (#10547)
3304
+ *
3305
+ * Returns the survivors, in the order they were expanded.
3306
+ *
3307
+ * Posture — identical to {@link managerIsProvablyOutsideOrg} and
3308
+ * {@link teamIsProvablyOutsideOrg}, deliberately, because it is the same
3309
+ * assertion about the same table:
3310
+ *
3311
+ * - membership rows exist for this user, none in `organizationId`
3312
+ * ⇒ the tenancy fact is present and NEGATIVE ⇒ drop him;
3313
+ * - no membership rows at all for him, the read failed, or the request
3314
+ * carries no organization
3315
+ * ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
3316
+ *
3317
+ * The absent limb is load-bearing rather than timid, and #3807 is the recorded
3318
+ * cost of getting it wrong: a stack that stamps an organization on requests
3319
+ * but never materializes `sys_member` rows would otherwise lose EVERY team
3320
+ * approver at once. This package's own `team_ok` expansion fixture and
3321
+ * #10230's T2/T3 fixtures are exactly such stacks — they carry team rows and
3322
+ * a request organization and no `sys_member` table at all — so the absent
3323
+ * limb is exercised by neighbours on every run of this suite.
3324
+ *
3325
+ * ONE read for the whole slate, never one per person: the expansion is capped
3326
+ * at 10000 members and a per-user query would turn a single team approver
3327
+ * into 10000 round trips.
3328
+ *
3329
+ * ⚠️ A TRUNCATED read fails open, and that is the subtle half. This read is
3330
+ * the only evidence that a member IS a tenant here, so a result cut off at
3331
+ * the limit could be missing the very row that keeps a legitimate approver on
3332
+ * the slate — screening him out on missing evidence, which inverts the
3333
+ * posture into fail-CLOSED precisely where it must not. When the read comes
3334
+ * back at the cap it is treated as no evidence at all.
3335
+ */
3336
+ async dropMembersProvablyOutsideOrg(teamId, userIds, organizationId) {
3337
+ const requestOrg = organizationId ? String(organizationId) : "";
3338
+ if (!requestOrg || !userIds.length) return userIds;
3339
+ let rows = [];
3340
+ try {
3341
+ rows = await this.engine.find("sys_member", {
3342
+ where: { user_id: { $in: userIds } },
3343
+ fields: ["user_id", "organization_id"],
3344
+ limit: MEMBER_SCREEN_READ_LIMIT,
3345
+ context: SYSTEM_CTX2
3346
+ });
3347
+ } catch {
3348
+ return userIds;
3349
+ }
3350
+ if ((rows?.length ?? 0) >= MEMBER_SCREEN_READ_LIMIT) {
3351
+ this.logger?.warn?.(
3352
+ `[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.`,
3353
+ { teamId, requestOrganizationId: requestOrg, rowsRead: rows.length }
3354
+ );
3355
+ return userIds;
3356
+ }
3357
+ const orgsByUser = /* @__PURE__ */ new Map();
3358
+ for (const r of rows ?? []) {
3359
+ const uid2 = String(r?.user_id ?? "");
3360
+ const org = String(r?.organization_id ?? "");
3361
+ if (!uid2 || !org) continue;
3362
+ const seen = orgsByUser.get(uid2);
3363
+ if (seen) seen.push(org);
3364
+ else orgsByUser.set(uid2, [org]);
3365
+ }
3366
+ const kept = [];
3367
+ const dropped = [];
3368
+ for (const uid2 of userIds) {
3369
+ const orgs = orgsByUser.get(uid2);
3370
+ if (!orgs?.length) {
3371
+ kept.push(uid2);
3372
+ continue;
3373
+ }
3374
+ if (orgs.includes(requestOrg)) {
3375
+ kept.push(uid2);
3376
+ continue;
3377
+ }
3378
+ dropped.push({ userId: uid2, organizationIds: orgs });
3379
+ }
3380
+ if (dropped.length) {
3381
+ this.logger?.warn?.(
3382
+ `[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.`,
3383
+ {
3384
+ teamId,
3385
+ requestOrganizationId: requestOrg,
3386
+ droppedUserIds: dropped.map((d) => d.userId),
3387
+ droppedMemberOrganizationIds: dropped.map((d) => d.organizationIds)
3388
+ }
3389
+ );
3390
+ }
3391
+ return kept;
2820
3392
  }
2821
3393
  /**
2822
3394
  * Tenant scope for a `sys_business_unit` read that may legitimately be
@@ -2985,7 +3557,36 @@ var _ApprovalService = class _ApprovalService {
2985
3557
  }
2986
3558
  return Array.from(new Set((rows ?? []).map((r) => String(r.user_id ?? "")).filter(Boolean)));
2987
3559
  }
2988
- async lookupManager(userId) {
3560
+ /**
3561
+ * `sys_user.manager_id`, screened to the request's organization (#10153).
3562
+ *
3563
+ * Takes an organization argument for the same reason its siblings do
3564
+ * ({@link expandPositionUsers}, {@link expandMembershipTierUsers}): an
3565
+ * approver expansion answers "who, in THIS organization". Before #10153 this
3566
+ * one did not ask, and it was the only expansion that did not — a
3567
+ * `manager_id` pointing at a person in another organization routed that
3568
+ * person an approval over a record they are not a tenant of.
3569
+ *
3570
+ * ⚠️ The screen reads `sys_member`, which LOOKS like the D2 read-visibility
3571
+ * filter next to it ({@link filterApproversWhoCanRead}). It is not, and this
3572
+ * comment exists so the next reader does not conclude that #7497 (does
3573
+ * approver routing imply record read visibility?) was settled here. It was
3574
+ * not. Two facts make this the SIBLING treatment rather than a
3575
+ * read-visibility ruling:
3576
+ *
3577
+ * 1. Two of the three org-scoped expansions already screen on exactly this
3578
+ * column — `expandMembershipTierUsers` filters `sys_member.organization_id`
3579
+ * outright, and it is also the second limb of `expandPositionUsers`. So
3580
+ * `sys_member.organization_id` is already this file's answer to "which
3581
+ * organization is this person in", independent of what they may read.
3582
+ * 2. `sys_user` carries no `organization_id` at all. It is a GLOBAL identity
3583
+ * table, so a membership row is the only tenancy fact that exists for a
3584
+ * user — there is no other read this screen could have been written with.
3585
+ *
3586
+ * This change grants no reads and applies no read screen to any type that
3587
+ * lacks one today, so it decides nothing #7497 asks.
3588
+ */
3589
+ async lookupManager(userId, organizationId) {
2989
3590
  try {
2990
3591
  const rows = await this.engine.find("sys_user", {
2991
3592
  where: { id: userId },
@@ -2994,11 +3595,64 @@ var _ApprovalService = class _ApprovalService {
2994
3595
  context: SYSTEM_CTX2
2995
3596
  });
2996
3597
  const row = Array.isArray(rows) ? rows[0] : null;
2997
- return row?.manager_id ? String(row.manager_id) : null;
3598
+ const managerId = row?.manager_id ? String(row.manager_id) : null;
3599
+ if (!managerId) return null;
3600
+ if (await this.managerIsProvablyOutsideOrg(managerId, organizationId)) return null;
3601
+ return managerId;
2998
3602
  } catch {
2999
3603
  return null;
3000
3604
  }
3001
3605
  }
3606
+ /**
3607
+ * Is `managerId` PROVABLY a member of other organizations and not of
3608
+ * `organizationId`? (#10153)
3609
+ *
3610
+ * "Provably" is the whole shape of this screen, and it is deliberate rather
3611
+ * than a weaker version of "must prove membership":
3612
+ *
3613
+ * - membership rows exist for this user, none in the request's org
3614
+ * ⇒ the tenancy fact is present and NEGATIVE ⇒ screen him out;
3615
+ * - no membership rows at all, or the read failed
3616
+ * ⇒ the tenancy fact is ABSENT ⇒ leave routing exactly as it was.
3617
+ *
3618
+ * The fail-open half is not timidity, it is this file's ruled posture on
3619
+ * addressing paths, stated twice already: {@link filterApproversWhoCanRead}
3620
+ * refuses to empty a live slate on an infrastructure hiccup, and
3621
+ * {@link expandPositionUsers} carries "a step routing to nobody is worse than
3622
+ * one routing to a lapsed holder". It is also load-bearing in practice — a
3623
+ * stack that stamps an organization on its requests but does not materialize
3624
+ * `sys_member` rows would otherwise lose every manager approver at once,
3625
+ * which is a bigger behaviour change than the hole being closed. Measured:
3626
+ * this repo's own `type:manager` out-of-office fixture is such a stack.
3627
+ *
3628
+ * Screening the MANAGER only, before OOO delegation, is deliberate too: the
3629
+ * delegate arrives from `sys_approval_delegation`, whose rows already carry
3630
+ * (and are already filtered by) an `organization_id` in
3631
+ * {@link lookupActiveDelegation}. This card is about `sys_user.manager_id`.
3632
+ */
3633
+ async managerIsProvablyOutsideOrg(managerId, organizationId) {
3634
+ const requestOrg = organizationId ? String(organizationId) : "";
3635
+ if (!requestOrg) return false;
3636
+ let rows = [];
3637
+ try {
3638
+ rows = await this.engine.find("sys_member", {
3639
+ where: { user_id: managerId },
3640
+ fields: ["user_id", "organization_id"],
3641
+ limit: 1e3,
3642
+ context: SYSTEM_CTX2
3643
+ });
3644
+ } catch {
3645
+ return false;
3646
+ }
3647
+ const orgs = (rows ?? []).map((r) => String(r?.organization_id ?? "")).filter(Boolean);
3648
+ if (!orgs.length) return false;
3649
+ if (orgs.includes(requestOrg)) return false;
3650
+ this.logger?.warn?.(
3651
+ `[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.`,
3652
+ { managerId, requestOrganizationId: requestOrg, managerOrganizationIds: orgs }
3653
+ );
3654
+ return true;
3655
+ }
3002
3656
  /**
3003
3657
  * Out-of-office auto-skip (#1322 M1). Given an individually-routed approver
3004
3658
  * id, follow any active `sys_approval_delegation` chain and return the id the
@@ -3168,11 +3822,13 @@ var _ApprovalService = class _ApprovalService {
3168
3822
  const substitutions = [];
3169
3823
  const groups = {};
3170
3824
  const liveRecord = await this.loadLiveRecord(input.object, input.recordId, input.record);
3825
+ const subjectOrg = this.recordOrgResolver.organizationOf(input.object, liveRecord, input.record);
3826
+ const requestOrg = subjectOrg ?? ctxOrg;
3171
3827
  const resolvedFrom = {};
3172
3828
  const approvers = await this.expandApprovers(
3173
3829
  { approvers: input.config.approvers },
3174
3830
  liveRecord,
3175
- ctxOrg,
3831
+ requestOrg,
3176
3832
  {
3177
3833
  now: nowDate.getTime(),
3178
3834
  substitutions,
@@ -3236,16 +3892,16 @@ var _ApprovalService = class _ApprovalService {
3236
3892
  flow_run_id: input.runId,
3237
3893
  flow_node_id: input.nodeId,
3238
3894
  node_config_json: JSON.stringify(configSnapshot),
3239
- organization_id: ctxOrg,
3895
+ organization_id: requestOrg,
3240
3896
  created_at: now,
3241
3897
  updated_at: now
3242
3898
  };
3243
3899
  await this.engine.insert("sys_approval_request", row, { context: SYSTEM_CTX2 });
3244
- await this.syncApproverIndex(id, approvers, ctxOrg, now);
3900
+ await this.syncApproverIndex(id, approvers, requestOrg, now);
3245
3901
  await this.engine.insert("sys_approval_action", {
3246
3902
  id: uid("aact"),
3247
3903
  request_id: id,
3248
- organization_id: ctxOrg,
3904
+ organization_id: requestOrg,
3249
3905
  step_name: input.nodeId,
3250
3906
  step_index: 0,
3251
3907
  action: "submit",
@@ -3257,7 +3913,7 @@ var _ApprovalService = class _ApprovalService {
3257
3913
  await this.engine.insert("sys_approval_action", {
3258
3914
  id: uid("aact"),
3259
3915
  request_id: id,
3260
- organization_id: ctxOrg,
3916
+ organization_id: requestOrg,
3261
3917
  step_name: input.nodeId,
3262
3918
  step_index: 0,
3263
3919
  action: "ooo_substitute",
@@ -3699,6 +4355,13 @@ var _ApprovalService = class _ApprovalService {
3699
4355
  * is then paused at the revise-window node (no reject edge), so it is
3700
4356
  * terminally cancelled via {@link ApprovalResumeSurface.cancelRun} rather
3701
4357
  * than resumed.
4358
+ *
4359
+ * The #3424 privileged override reaches a PENDING request only (#12775,
4360
+ * maintainer ruling 2026-09-02). On `returned` an override actor is refused
4361
+ * exactly as any other non-submitter: the gate is spelled as `attachViewers`
4362
+ * computes `viewer.can_override`, so the gate, the
4363
+ * {@link ApprovalService.isOverrideActor} doc block and the viewer flag agree
4364
+ * at one point.
3702
4365
  */
3703
4366
  async recall(requestId, input, context) {
3704
4367
  if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
@@ -3714,8 +4377,18 @@ var _ApprovalService = class _ApprovalService {
3714
4377
  if (raw.status !== "pending" && !inReviseWindow) {
3715
4378
  throw new Error(`INVALID_STATE: request is ${raw.status}`);
3716
4379
  }
3717
- if (!this.isOverrideActor(context, raw.organization_id ?? null) && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
3718
- throw new Error(`FORBIDDEN: only the submitter may recall this request`);
4380
+ const overrideAdmits = raw.status === "pending" && this.isOverrideActor(context, raw.organization_id ?? null);
4381
+ if (!overrideAdmits && raw.submitter_id && String(raw.submitter_id) !== String(actorId)) {
4382
+ 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)`;
4383
+ this.logger?.warn?.(developerMessage, {
4384
+ request: requestId,
4385
+ actor: actorId,
4386
+ submitter: String(raw.submitter_id),
4387
+ status: raw.status
4388
+ });
4389
+ throw new Error(
4390
+ `FORBIDDEN: ${this.userFacingRefusal("approval_recall_not_submitter", context)}`
4391
+ );
3719
4392
  }
3720
4393
  if (inReviseWindow) await this.assertLatestForRun(raw);
3721
4394
  const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
@@ -3791,6 +4464,127 @@ var _ApprovalService = class _ApprovalService {
3791
4464
  const fresh = await this.readBackRequest(requestId, context);
3792
4465
  return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
3793
4466
  }
4467
+ // ── Record-delete lifecycle linkage (#13568) ─────────────────
4468
+ /**
4469
+ * Void every `pending` request about a record that has just been DELETED
4470
+ * (#13568, maintainer ruling 2026-08-31 「同意」).
4471
+ *
4472
+ * ## What the ruling asked for, and what each half costs
4473
+ *
4474
+ * status → `cancelled`, a machine-readable `cancel_reason`, the row KEPT
4475
+ * for audit, and the request out of the pending count and the inbox's
4476
+ * default view.
4477
+ *
4478
+ * The last clause needs both writes below, not just the status one: the
4479
+ * inbox's "My Pending" tab pages the request table by `status`, but the
4480
+ * approver filter resolves through the normalized `sys_approval_approver`
4481
+ * index (#1745), which is keyed on nothing but `request_id`. Leaving the
4482
+ * index rows behind would keep the request in `approverRequestIds`' answer
4483
+ * for every approver forever — so {@link syncApproverIndex} clears them, the
4484
+ * same call every other exit from `pending` makes.
4485
+ *
4486
+ * ## Three things this deliberately does NOT do
4487
+ *
4488
+ * 1. **No status mirror-back.** Every other terminal transition calls
4489
+ * {@link mirrorStatusField} to write the outcome onto the subject record's
4490
+ * `approvalStatusField`. Here the subject record is precisely what no
4491
+ * longer exists, so that write is guaranteed to fail — it is the very
4492
+ * `update_record(...) failed: Record ... not found` this card's own
4493
+ * forensics recorded on the reject door. Skipped by construction, not by a
4494
+ * swallowed error.
4495
+ *
4496
+ * 2. **No flow resume, and no run cancel.** A cancellation is a status write
4497
+ * plus a reason; it is not a decision, so there is no branch to resume
4498
+ * down and no downstream node that should run. The suspended run this
4499
+ * request gated is reported (below) and left alone — what becomes of an
4500
+ * approval run whose request was voided is a lifecycle question that
4501
+ * belongs to the automation service, not to a delete hook, and answering
4502
+ * it here by guessing would be exactly the kind of consumer-side
4503
+ * accommodation PD #12 refuses.
4504
+ *
4505
+ * 3. **No refusal of the delete.** The delete has already landed by the time
4506
+ * this runs, and the "forbid delete while an approval is pending"
4507
+ * direction was VETOED in the same ruling (`lockRecord` already blocks the
4508
+ * edit; blocking the delete too locks an author onto a record they cannot
4509
+ * fix). So nothing here throws — a failure degrades to a warning and the
4510
+ * stale row, which is the pre-existing state, never to a failed delete.
4511
+ *
4512
+ * Terminal rows are untouched: the `where` below names `status: 'pending'`,
4513
+ * so an `approved` / `rejected` / `recalled` / `returned` request about the
4514
+ * same record keeps its recorded outcome. That is the ruling's second half —
4515
+ * history is kept; only the presentation of its dead record reference is
4516
+ * someone else's card.
4517
+ *
4518
+ * @returns the request ids cancelled and the suspended run ids left behind.
4519
+ */
4520
+ async cancelForDeletedRecord(objectName, recordId) {
4521
+ const empty = { cancelled: [], suspendedRuns: [] };
4522
+ const object = String(objectName ?? "").trim();
4523
+ const record = String(recordId ?? "").trim();
4524
+ if (!object || !record) return empty;
4525
+ let pending;
4526
+ try {
4527
+ const rows = await this.engine.find("sys_approval_request", {
4528
+ where: { object_name: object, record_id: record, status: "pending" },
4529
+ limit: RECORD_DELETE_CANCEL_LIMIT,
4530
+ context: SYSTEM_CTX2
4531
+ });
4532
+ pending = Array.isArray(rows) ? rows : [];
4533
+ } catch (err) {
4534
+ this.logger?.warn?.(
4535
+ "[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",
4536
+ { object, record, error: err?.message ?? String(err) }
4537
+ );
4538
+ return empty;
4539
+ }
4540
+ if (pending.length === 0) return empty;
4541
+ const now = this.clock.now().toISOString();
4542
+ const cancelled = [];
4543
+ const suspendedRuns = [];
4544
+ for (const raw of pending) {
4545
+ const requestId = String(raw?.id ?? "");
4546
+ if (!requestId) continue;
4547
+ const org = raw?.organization_id ?? null;
4548
+ const nodeId = raw?.flow_node_id ?? raw?.current_step ?? null;
4549
+ try {
4550
+ await this.engine.insert("sys_approval_action", {
4551
+ id: uid("aact"),
4552
+ request_id: requestId,
4553
+ organization_id: org,
4554
+ step_name: nodeId,
4555
+ step_index: 0,
4556
+ action: "cancel",
4557
+ actor_id: null,
4558
+ comment: `Auto-cancelled: the ${object} record '${record}' this request is about was deleted`,
4559
+ created_at: now
4560
+ }, { context: SYSTEM_CTX2 });
4561
+ await this.engine.update("sys_approval_request", {
4562
+ id: requestId,
4563
+ status: "cancelled",
4564
+ cancel_reason: "record_deleted",
4565
+ pending_approvers: null,
4566
+ completed_at: now,
4567
+ updated_at: now
4568
+ }, { context: SYSTEM_CTX2 });
4569
+ await this.syncApproverIndex(requestId, [], org, now);
4570
+ cancelled.push(requestId);
4571
+ const runId = raw?.flow_run_id ? String(raw.flow_run_id) : "";
4572
+ if (runId) suspendedRuns.push(runId);
4573
+ } catch (err) {
4574
+ this.logger?.warn?.(
4575
+ "[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",
4576
+ { object, record, request: requestId, error: err?.message ?? String(err) }
4577
+ );
4578
+ }
4579
+ }
4580
+ if (suspendedRuns.length) {
4581
+ this.logger?.warn?.(
4582
+ "[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",
4583
+ { object, record, requests: cancelled, runs: suspendedRuns }
4584
+ );
4585
+ }
4586
+ return { cancelled, suspendedRuns };
4587
+ }
3794
4588
  // ── Send back for revision / resubmit (ADR-0044) ─────────────
3795
4589
  /**
3796
4590
  * ADR-0044 send back for revision. Finalises the pending request as
@@ -4421,7 +5215,10 @@ var _ApprovalService = class _ApprovalService {
4421
5215
  * `escalation.timeoutHours` and whose deadline has passed is escalated
4422
5216
  * **at most once, ever** — the `escalate` audit row is the idempotency
4423
5217
  * marker, written before any mutation (audit-first, like reassign). One
4424
- * bad row never stops the sweep.
5218
+ * bad row never stops the sweep. A block that explicitly declares
5219
+ * `enabled: false` is skipped (#12278 — the declared switch is enforced;
5220
+ * pre-flip snapshots ride the legacy window on
5221
+ * {@link ESCALATION_ENABLED_FLIP_CUTOFF_MS}).
4425
5222
  */
4426
5223
  async runEscalations() {
4427
5224
  let rows = [];
@@ -4443,6 +5240,7 @@ var _ApprovalService = class _ApprovalService {
4443
5240
  const cfg = parseJson(raw.node_config_json, void 0);
4444
5241
  const esc2 = cfg?.escalation;
4445
5242
  if (!esc2 || typeof esc2.timeoutHours !== "number" || esc2.timeoutHours <= 0) continue;
5243
+ if (esc2.enabled === false && Date.parse(raw.created_at ?? "") >= ESCALATION_ENABLED_FLIP_CUTOFF_MS) continue;
4446
5244
  const due = slaDueAt(raw.created_at, cfg);
4447
5245
  if (!due || Date.parse(due) > this.clock.now().getTime()) continue;
4448
5246
  const prior = await this.engine.find("sys_approval_action", {
@@ -4523,9 +5321,35 @@ var _ApprovalService = class _ApprovalService {
4523
5321
  * live pause exists. It THROWS when the store cannot be read, and that
4524
5322
  * case is SKIPPED, never counted as dead: an unreadable store means
4525
5323
  * "unknown", and a storage outage must not be published as a lost run.
4526
- * - `getRun(runId) == null` — no terminal history row either (the `run_`
4527
- * prefixed rows in `sys_automation_run`). A run that merely finished is
4528
- * not stranded; a request whose run neither waits nor ever completed is.
5324
+ * - `classifyStrandedRunState` over `getRun(runId)` — the run's own
5325
+ * history row (the `run_` prefixed rows in `sys_automation_run`). A run
5326
+ * that merely finished is not stranded; a request whose run neither waits
5327
+ * nor completed is.
5328
+ *
5329
+ * **The second oracle was widened (#13909), and this is the whole point of
5330
+ * that card's first slice.** It used to be `if (terminal) continue` — the
5331
+ * existence of ANY history row ended the check, on the reading "the run ran to
5332
+ * a terminal state, it is not dangling". That is true of a run that COMPLETED
5333
+ * and false of one that FAILED: the engine consumes a suspension *before*
5334
+ * running the downstream nodes (`AutomationEngine.resumeInternal` calls
5335
+ * `forgetSuspendedRun(run, 'resumed')` and only then `traverseNext`), so a
5336
+ * downstream node that merely threw threw with the pause already gone, and the
5337
+ * catch arm wrote a terminal `failed` row. The decision is durable, the
5338
+ * continuation stopped half-way, `resume` answers `RUN_NOT_FOUND` and
5339
+ * `cancelRun` is a no-op — and the terminal row this oracle used to read as
5340
+ * health is written BY the very failure that stranded it. So this inspection
5341
+ * reported `0` for the one shape an operator most needs to see.
5342
+ *
5343
+ * ⚠️ The widening does NOT reverse the conservatism: `completed`, `cancelled`
5344
+ * and `paused` are each still skipped, for reasons named one at a time in
5345
+ * `classifyStrandedRunState`, and an unrecognised status is skipped too.
5346
+ * What the widening buys is that a `failed` run is now reported with
5347
+ * `runState: 'failed'` instead of counted as healthy.
5348
+ *
5349
+ * ⚠️ **What this can and cannot size.** It makes the condition *visible* in a
5350
+ * deployment; it is not itself a census, and it says nothing about this
5351
+ * repository. How many runs are already in this state can only be answered
5352
+ * against a real deployment's tables — see the card.
4529
5353
  *
4530
5354
  * **Reports; never rewrites.** No status is changed and no run is cancelled.
4531
5355
  * The decision genuinely happened — a human approved or rejected — and
@@ -4583,7 +5407,8 @@ var _ApprovalService = class _ApprovalService {
4583
5407
  });
4584
5408
  continue;
4585
5409
  }
4586
- if (terminal) continue;
5410
+ const runState = classifyStrandedRunState(terminal);
5411
+ if (!runState) continue;
4587
5412
  const config = parseJson(
4588
5413
  raw.node_config_json,
4589
5414
  { approvers: [], behavior: "first_response" }
@@ -4606,6 +5431,7 @@ var _ApprovalService = class _ApprovalService {
4606
5431
  requestId: String(raw.id),
4607
5432
  status: raw.status,
4608
5433
  runId,
5434
+ runState,
4609
5435
  flowName: typeof raw.process_name === "string" ? raw.process_name.replace(/^flow:/, "") : void 0,
4610
5436
  nodeId: raw.flow_node_id ?? raw.current_step ?? void 0,
4611
5437
  objectName: raw.object_name,
@@ -4617,11 +5443,13 @@ var _ApprovalService = class _ApprovalService {
4617
5443
  });
4618
5444
  }
4619
5445
  if (stranded.length || undetermined) {
4620
- this.logger?.warn?.("[approvals] stranded terminal requests (decision recorded, flow run gone)", {
5446
+ this.logger?.warn?.("[approvals] stranded terminal requests (decision recorded, flow run unrecoverable)", {
4621
5447
  scanned: rows.length,
4622
5448
  stranded: stranded.length,
4623
5449
  undetermined,
4624
- requests: stranded.map((s) => `${s.requestId}@${s.nodeId ?? "?"} \u2192 run ${s.runId}`)
5450
+ runMissing: stranded.filter((s) => s.runState === "missing").length,
5451
+ runFailed: stranded.filter((s) => s.runState === "failed").length,
5452
+ requests: stranded.map((s) => `${s.requestId}@${s.nodeId ?? "?"} \u2192 run ${s.runId} (${s.runState})`)
4625
5453
  });
4626
5454
  }
4627
5455
  return { scanned: rows.length, stranded, undetermined };
@@ -5124,8 +5952,70 @@ var _ApprovalService = class _ApprovalService {
5124
5952
  return { requests: desired.size, inserted, deleted };
5125
5953
  }
5126
5954
  // ── Read API ─────────────────────────────────────────────────
5955
+ /**
5956
+ * [#11040] May the free-text pushdown carry an arm on the SNAPSHOT column,
5957
+ * for THIS caller over THIS query's scope?
5958
+ *
5959
+ * `payload_json` is the one searched column whose contents the serve path
5960
+ * masks per reader (`redactPayloads`, #10749). A predicate over it is
5961
+ * evaluated by the driver against the column AT REST — unmasked, before
5962
+ * anything is served — so for a caller whose view of the snapshot is masked,
5963
+ * row membership answers questions about contents that caller may not read.
5964
+ * The other four arms are columns of `sys_approval_request` itself, which
5965
+ * every caller who can see the row reads whole; they are untouched.
5966
+ *
5967
+ * ## The invariant this method exists to hold
5968
+ *
5969
+ * "This caller's view is masked" is read from **the same authority and the
5970
+ * same per-caller call as the serve path** — `resolveReadableSnapshotFields`,
5971
+ * asked as the CALLER (never `SYSTEM_CTX`). A second, independently derived
5972
+ * notion of "redacted" — comparing the readable set against the object's
5973
+ * schema, say — would be a fresh source of drift, and drift between the
5974
+ * serve rule and the filter rule IS the defect this closes, reconstituted one
5975
+ * layer down. So the only "not masked" answer accepted here is the one serve
5976
+ * itself acts on: `undefined`, the seam's documented do-not-narrow branch.
5977
+ * When the seam holds a concrete list the mask is IN FORCE, whether or not it
5978
+ * happens to remove a key from any particular row — a row-dependent question
5979
+ * no predicate can answer before rows exist.
5980
+ *
5981
+ * ## The two predicate-time cases
5982
+ *
5983
+ * Redaction is decided per ROW (each row names its own subject object, hence
5984
+ * its own readable set), but a filter is built before any row exists:
5985
+ *
5986
+ * - **authority absent** — `resolveReadableSnapshotFields` answers
5987
+ * `undefined` for EVERY object, so serve hands over every snapshot whole.
5988
+ * Keeping the arm leaks nothing serve does not already hand over, and this
5989
+ * is the shape every deployment that has not wired the security plugin
5990
+ * gets: search is byte-for-byte unchanged. Checked first, and that order
5991
+ * is load-bearing — see the object-scope note below.
5992
+ * - **authority wired** — the readable set is per object, so the arm is
5993
+ * admissible only for a scope of exactly one KNOWN object. With
5994
+ * `filter.object` present that object is known at predicate time and the
5995
+ * seam is asked about it directly. Absent, the query spans every object
5996
+ * and there is nothing sound to ask, so the arm is dropped.
5997
+ *
5998
+ * Dropping an arm is strictly NARROWING: it never refuses a query and never
5999
+ * widens what comes back, so the fail-closed direction is cheap here and is
6000
+ * taken rather than reaching for a per-object predicate machine to avoid it.
6001
+ * Refusing the query outright would be the louder behaviour change, and is
6002
+ * deliberately not what this does.
6003
+ */
6004
+ async freeTextMayMatchSnapshot(objectInScope, context) {
6005
+ const authority = this.fieldVisibility;
6006
+ if (!authority || typeof authority.getReadableFields !== "function") return true;
6007
+ const object = String(objectInScope ?? "").trim();
6008
+ if (!object) return false;
6009
+ const readable = await resolveReadableSnapshotFields(
6010
+ authority,
6011
+ object,
6012
+ context,
6013
+ this.logger
6014
+ );
6015
+ return readable === void 0;
6016
+ }
5127
6017
  /** Filter type accepted by {@link listRequests} / {@link countRequests}. */
5128
- buildRequestWhere(filter, context) {
6018
+ async buildRequestWhere(filter, context) {
5129
6019
  const f = {};
5130
6020
  if (filter?.object) f.object_name = filter.object;
5131
6021
  if (filter?.recordId) f.record_id = filter.recordId;
@@ -5134,13 +6024,16 @@ var _ApprovalService = class _ApprovalService {
5134
6024
  if (tenantOrg) f.organization_id = tenantOrg;
5135
6025
  const q = filter?.q?.trim();
5136
6026
  if (q) {
5137
- f.$or = [
6027
+ const arms = [
5138
6028
  { process_name: { $contains: q } },
5139
6029
  { object_name: { $contains: q } },
5140
6030
  { record_id: { $contains: q } },
5141
- { submitter_id: { $contains: q } },
5142
- { payload_json: { $contains: q } }
6031
+ { submitter_id: { $contains: q } }
5143
6032
  ];
6033
+ if (await this.freeTextMayMatchSnapshot(filter?.object, context)) {
6034
+ arms.push({ payload_json: { $contains: q } });
6035
+ }
6036
+ f.$or = arms;
5144
6037
  }
5145
6038
  if (Array.isArray(filter?.status)) {
5146
6039
  const statuses = filter.status.filter(Boolean);
@@ -5343,7 +6236,7 @@ var _ApprovalService = class _ApprovalService {
5343
6236
  return true;
5344
6237
  }
5345
6238
  async listRequests(filter, context) {
5346
- const { where, tenantOrg } = this.buildRequestWhere(filter, context);
6239
+ const { where, tenantOrg } = await this.buildRequestWhere(filter, context);
5347
6240
  const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
5348
6241
  const ids = await this.approverRequestIds(approverTargets, tenantOrg);
5349
6242
  if (ids) {
@@ -5367,12 +6260,13 @@ var _ApprovalService = class _ApprovalService {
5367
6260
  }
5368
6261
  const rows = await this.engine.find("sys_approval_request", findOpts);
5369
6262
  const list = Array.isArray(rows) ? rows.map(rowFromRequest) : [];
6263
+ await this.redactPayloads(list, context);
5370
6264
  await this.enrichRows(list);
5371
6265
  this.attachViewers(list, context);
5372
6266
  return list;
5373
6267
  }
5374
6268
  async countRequests(filter, context) {
5375
- const { where, tenantOrg } = this.buildRequestWhere(filter, context);
6269
+ const { where, tenantOrg } = await this.buildRequestWhere(filter, context);
5376
6270
  const approverTargets = (Array.isArray(filter?.approverId) ? filter.approverId : filter?.approverId ? [filter.approverId] : []).map((t) => String(t).trim()).filter(Boolean);
5377
6271
  const ids = await this.approverRequestIds(approverTargets, tenantOrg);
5378
6272
  if (ids) {
@@ -5409,9 +6303,28 @@ var _ApprovalService = class _ApprovalService {
5409
6303
  * flow-driven resume, a service-to-service call), turning a successful write
5410
6304
  * into a `null` result. Gating belongs on the read API, not on an
5411
6305
  * operation's own return value.
6306
+ *
6307
+ * [#12769] The read still narrows by the CALLER's organization
6308
+ * (`loadRequest` — a deliberate tenancy wall, untouched here), and an
6309
+ * org-less request row is invisible inside an org-scoped caller's narrowing.
6310
+ * Org-less rows arise by construction on every schedule / time-relative /
6311
+ * api trigger run (#10131; #9132 pinned that behaviour rather than repairing
6312
+ * it), so this is a live state, not an edge case: the write IS recorded and
6313
+ * the echo cannot be built. It used to escape as `null` behind the callers'
6314
+ * `fresh!` non-null assertions — a well-formed 200 whose declared-non-null
6315
+ * `request` was `null` on the wire. Now it refuses loudly instead, which
6316
+ * keeps every declared result type true: a result that cannot be built is
6317
+ * never returned. Callers must NOT catch this to fall back to `null` —
6318
+ * that would re-open the type-lie one level up.
5412
6319
  */
5413
6320
  async readBackRequest(requestId, context) {
5414
- return this.loadRequest(requestId, context, false);
6321
+ const fresh = await this.loadRequest(requestId, context, false);
6322
+ if (!fresh) {
6323
+ throw new Error(
6324
+ `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.`
6325
+ );
6326
+ }
6327
+ return fresh;
5415
6328
  }
5416
6329
  async getRequest(requestId, context) {
5417
6330
  return this.loadRequest(requestId, context, true);
@@ -5435,6 +6348,7 @@ var _ApprovalService = class _ApprovalService {
5435
6348
  if (visible && !visible.has(String(rows[0].id))) return null;
5436
6349
  }
5437
6350
  const row = rowFromRequest(rows[0]);
6351
+ await this.redactPayloads([row], context);
5438
6352
  await this.enrichRows([row]);
5439
6353
  await this.attachFlowSteps(row);
5440
6354
  await this.attachDecisionProgress(row, rows[0]);
@@ -5902,6 +6816,41 @@ function bindApprovalLockHook(engine, logger) {
5902
6816
  }, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
5903
6817
  logger?.info?.("[approvals] record-lock hook bound");
5904
6818
  }
6819
+ var APPROVALS_OWN_OBJECTS = [
6820
+ "sys_approval_request",
6821
+ "sys_approval_action",
6822
+ "sys_approval_approver",
6823
+ "sys_approval_token",
6824
+ "sys_approval_delegation"
6825
+ ];
6826
+ function bindRecordDeleteCancelHook(engine, service, logger) {
6827
+ engine.registerHook("afterDelete", async (ctx) => {
6828
+ const object = ctx?.object ?? ctx?.objectName;
6829
+ if (!object) return;
6830
+ const raw = ctx?.previous?.id ?? ctx?.input?.id;
6831
+ const recordId = raw == null ? "" : String(raw);
6832
+ if (!recordId) {
6833
+ logger?.warn?.(
6834
+ "[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",
6835
+ { object }
6836
+ );
6837
+ return;
6838
+ }
6839
+ try {
6840
+ await service.cancelForDeletedRecord(String(object), recordId);
6841
+ } catch (err) {
6842
+ logger?.warn?.(
6843
+ "[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",
6844
+ { object, record: recordId, error: err?.message ?? String(err) }
6845
+ );
6846
+ }
6847
+ }, {
6848
+ excludeObjects: [...APPROVALS_OWN_OBJECTS],
6849
+ packageId: APPROVALS_HOOK_PACKAGE,
6850
+ priority: 50
6851
+ });
6852
+ logger?.info?.("[approvals] record-delete cancel hook bound");
6853
+ }
5905
6854
  var DELEGATION_OBJECT = "sys_approval_delegation";
5906
6855
  function bindDelegationWriteGuard(engine, logger) {
5907
6856
  const makeGuard = (isInsert) => async (ctx) => {
@@ -5938,6 +6887,52 @@ function unbindAllHooks(engine) {
5938
6887
  return engine.unregisterHooksByPackage(APPROVALS_HOOK_PACKAGE);
5939
6888
  }
5940
6889
 
6890
+ // src/payload-redaction-middleware.ts
6891
+ var APPROVAL_REQUEST_OBJECT = "sys_approval_request";
6892
+ function parseSnapshot(raw) {
6893
+ if (typeof raw !== "string" || raw.trim() === "") return { ok: false, value: void 0 };
6894
+ try {
6895
+ return { ok: true, value: JSON.parse(raw) };
6896
+ } catch {
6897
+ return { ok: false, value: void 0 };
6898
+ }
6899
+ }
6900
+ async function redactRowsInPlace(rows, security, context, logger) {
6901
+ const list = Array.isArray(rows) ? rows : rows ? [rows] : [];
6902
+ if (list.length === 0) return;
6903
+ const cache = /* @__PURE__ */ new Map();
6904
+ for (const row of list) {
6905
+ if (!row || typeof row !== "object") continue;
6906
+ const raw = row.payload_json;
6907
+ const parsed = parseSnapshot(raw);
6908
+ if (!parsed.ok) continue;
6909
+ const object = String(row.object_name ?? "").trim();
6910
+ if (!object) continue;
6911
+ if (!cache.has(object)) {
6912
+ cache.set(object, await resolveReadableSnapshotFields(security, object, context, logger));
6913
+ }
6914
+ const readable = cache.get(object);
6915
+ if (readable === void 0) continue;
6916
+ const { payload, redactedKeys } = redactSnapshot(parsed.value, readable);
6917
+ if (redactedKeys.length === 0) continue;
6918
+ row.payload_json = JSON.stringify(payload);
6919
+ }
6920
+ }
6921
+ function bindSnapshotRedactionMiddleware(engine, getSecurity, logger) {
6922
+ engine.registerMiddleware(async (opCtx, next) => {
6923
+ await next();
6924
+ if (opCtx?.operation !== "find" && opCtx?.operation !== "findOne") return;
6925
+ if (opCtx?.context?.isSystem) return;
6926
+ try {
6927
+ await redactRowsInPlace(opCtx.result, getSecurity(), opCtx.context, logger);
6928
+ } catch (err) {
6929
+ logger?.warn?.("[approvals] snapshot redaction middleware failed", {
6930
+ error: err?.message ?? String(err)
6931
+ });
6932
+ }
6933
+ }, { object: APPROVAL_REQUEST_OBJECT });
6934
+ }
6935
+
5941
6936
  // src/approval-node.ts
5942
6937
  import {
5943
6938
  defineActionDescriptor as defineActionDescriptor2,
@@ -6207,13 +7202,46 @@ var ApprovalsServicePlugin = class {
6207
7202
  } catch {
6208
7203
  return void 0;
6209
7204
  }
7205
+ },
7206
+ // [#11993] Deployment override lookup for user-facing refusal copy.
7207
+ // Resolved LAZILY for the reason the two providers above are: the i18n
7208
+ // service is contributed by another plugin (ADR-0029 D8) and may start
7209
+ // after this one. Without it the service still renders the built-in
7210
+ // catalog in the caller's locale; with it, a deployment's own
7211
+ // `translation` for `errors.approval_recall_not_submitter` wins — the
7212
+ // override address the catalog documents, made real for this emitter.
7213
+ messageTranslator: () => {
7214
+ try {
7215
+ const i18n = ctx.getService("i18n");
7216
+ const t = i18n?.t;
7217
+ if (typeof t !== "function") return void 0;
7218
+ return (key, locale, params) => t.call(i18n, key, locale, params);
7219
+ } catch {
7220
+ return void 0;
7221
+ }
7222
+ }
7223
+ });
7224
+ const fieldVisibility = () => {
7225
+ try {
7226
+ const sec = ctx.getService("security");
7227
+ return sec && typeof sec.getReadableFields === "function" ? sec : void 0;
7228
+ } catch {
7229
+ return void 0;
7230
+ }
7231
+ };
7232
+ this.service.attachFieldVisibility({
7233
+ getReadableFields: (object, context) => {
7234
+ const sec = fieldVisibility();
7235
+ return sec ? sec.getReadableFields(object, context) : Promise.resolve(void 0);
6210
7236
  }
6211
7237
  });
6212
7238
  if (!this.options.disableAutoHooks) {
6213
7239
  try {
6214
7240
  unbindAllHooks(engine);
6215
7241
  bindApprovalLockHook(engine, ctx.logger);
7242
+ bindRecordDeleteCancelHook(engine, this.service, ctx.logger);
6216
7243
  bindDelegationWriteGuard(engine, ctx.logger);
7244
+ bindSnapshotRedactionMiddleware(engine, fieldVisibility, ctx.logger);
6217
7245
  } catch (err) {
6218
7246
  ctx.logger.warn?.("[approvals] failed to bind approval hooks", { error: err?.message });
6219
7247
  }
@@ -6253,6 +7281,7 @@ var ApprovalsServicePlugin = class {
6253
7281
  }
6254
7282
  };
6255
7283
  await jobs.schedule(ESCALATION_JOB_NAME, { type: "interval", intervalMs }, sweep);
7284
+ this.jobService = jobs;
6256
7285
  this.escalationJobScheduled = true;
6257
7286
  void sweep().catch((err) => {
6258
7287
  ctx.logger.warn?.("[approvals] boot sweep failed", { error: err?.message });
@@ -6339,14 +7368,32 @@ var ApprovalsServicePlugin = class {
6339
7368
  );
6340
7369
  }
6341
7370
  }
6342
- async stop(ctx) {
7371
+ /**
7372
+ * The kernel's teardown hook (`Plugin.destroy?()`, core `types.ts`) — the
7373
+ * ONLY teardown entry point `ObjectKernel.performShutdown()` and
7374
+ * `LiteKernel.destroy()` invoke.
7375
+ *
7376
+ * [#10371] IT USED TO BE `stop()`, WHICH NOTHING CALLED. `Plugin` declares
7377
+ * `init()`, `start?()` and `destroy?()` and no `stop()`, so the kernel walked
7378
+ * past this plugin at shutdown: the SLA escalation job stayed scheduled and
7379
+ * this plugin's ObjectQL hooks stayed bound to an engine the kernel had
7380
+ * finished with. `start()` IS on the interface, so the pair read as symmetric
7381
+ * in review — that asymmetry is what let the same shape survive in six
7382
+ * packages at once.
7383
+ *
7384
+ * This member owns no timer of its own (the escalation clock belongs to
7385
+ * `service-job`), so it never cost a merge-queue eviction the way the
7386
+ * `plugin-reports` / `service-messaging` members did (#9371). The class is
7387
+ * the same one either way: a teardown the kernel does not reach.
7388
+ */
7389
+ async destroy() {
6343
7390
  if (this.escalationJobScheduled) {
6344
7391
  try {
6345
- const jobs = ctx.getService("job");
6346
- await jobs?.cancel?.(ESCALATION_JOB_NAME);
7392
+ await this.jobService?.cancel?.(ESCALATION_JOB_NAME);
6347
7393
  } catch {
6348
7394
  }
6349
7395
  this.escalationJobScheduled = false;
7396
+ this.jobService = void 0;
6350
7397
  }
6351
7398
  if (this.engine) {
6352
7399
  try {
@@ -6355,6 +7402,17 @@ var ApprovalsServicePlugin = class {
6355
7402
  }
6356
7403
  }
6357
7404
  }
7405
+ /**
7406
+ * Retained alias for {@link destroy}. Kept because it is public API of an
7407
+ * exported class, and removing it would break an embedder who learned to call
7408
+ * it directly precisely BECAUSE the kernel never did. The parameter is now
7409
+ * optional and ignored: `destroy()` takes no context, so teardown uses the
7410
+ * job service captured when the escalation clock was wired. Prefer kernel
7411
+ * shutdown; direct callers keep working unchanged.
7412
+ */
7413
+ async stop(_ctx) {
7414
+ await this.destroy();
7415
+ }
6358
7416
  };
6359
7417
  export {
6360
7418
  APPROVAL_REVISE_CORRELATION_PREFIX,