@objectstack/plugin-approvals 17.0.0-rc.0 → 17.0.0-rc.2

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +863 -0
  2. package/dist/index.d.mts +2236 -2688
  3. package/dist/index.d.ts +2236 -2688
  4. package/dist/index.js +591 -128
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +590 -127
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +17 -10
  9. package/.turbo/turbo-build.log +0 -22
  10. package/scripts/i18n-extract.config.ts +0 -38
  11. package/src/action-link-pages.ts +0 -102
  12. package/src/approval-actor-impersonation.test.ts +0 -330
  13. package/src/approval-node.test.ts +0 -356
  14. package/src/approval-node.ts +0 -196
  15. package/src/approval-revise.test.ts +0 -418
  16. package/src/approval-service.test.ts +0 -2858
  17. package/src/approval-service.ts +0 -3617
  18. package/src/approvals-plugin.ts +0 -294
  19. package/src/approver-cross-org.integration.test.ts +0 -206
  20. package/src/approver-org-scope.test.ts +0 -201
  21. package/src/approver-org-scope.ts +0 -261
  22. package/src/index.ts +0 -42
  23. package/src/lifecycle-hooks.ts +0 -201
  24. package/src/nav-contribution.test.ts +0 -50
  25. package/src/record-lock-schedule-run.integration.test.ts +0 -206
  26. package/src/status-mirror-cascade.integration.test.ts +0 -224
  27. package/src/sys-approval-action.object.ts +0 -149
  28. package/src/sys-approval-approver.object.ts +0 -85
  29. package/src/sys-approval-delegation.object.test.ts +0 -42
  30. package/src/sys-approval-delegation.object.ts +0 -142
  31. package/src/sys-approval-request.object.test.ts +0 -116
  32. package/src/sys-approval-request.object.ts +0 -413
  33. package/src/sys-approval-token.object.ts +0 -101
  34. package/src/translations/bundle-ownership.test.ts +0 -48
  35. package/src/translations/en.objects.generated.ts +0 -311
  36. package/src/translations/es-ES.objects.generated.ts +0 -311
  37. package/src/translations/index.ts +0 -23
  38. package/src/translations/ja-JP.objects.generated.ts +0 -311
  39. package/src/translations/zh-CN.objects.generated.ts +0 -311
  40. package/tsconfig.json +0 -10
package/dist/index.js CHANGED
@@ -257,6 +257,18 @@ var init_en_objects_generated = __esm({
257
257
  comment: {
258
258
  label: "Comment"
259
259
  },
260
+ via_override: {
261
+ label: "Via Admin Override",
262
+ help: "True when the actor was admitted to this action only by the privileged-override path (#3424) \u2014 they held no slot in the request\u2019s pending-approver slate."
263
+ },
264
+ reassign_from: {
265
+ label: "Reassigned From",
266
+ help: "User whose pending-approver slot was handed over (reassign actions only)"
267
+ },
268
+ reassign_to: {
269
+ label: "Reassigned To",
270
+ help: "User who received the pending-approver slot (reassign actions only)"
271
+ },
260
272
  attachments: {
261
273
  label: "Attachments",
262
274
  help: "Files supporting this action \u2014 e.g. a signed contract or evidence (#3266)."
@@ -566,6 +578,18 @@ var init_zh_CN_objects_generated = __esm({
566
578
  comment: {
567
579
  label: "\u8BC4\u8BBA"
568
580
  },
581
+ via_override: {
582
+ label: "\u7BA1\u7406\u5458\u8D8A\u6743\u64CD\u4F5C",
583
+ help: "\u4E3A\u771F\u8868\u793A\u8BE5\u64CD\u4F5C\u8005\u53EA\u662F\u51ED\u7279\u6743\u8D8A\u6743\u8DEF\u5F84\uFF08#3424\uFF09\u88AB\u653E\u884C\u2014\u2014\u4ED6\u4EEC\u5E76\u4E0D\u5728\u8BE5\u8BF7\u6C42\u7684\u5F85\u5BA1\u6279\u4EBA\u540D\u5355\u4E2D\u3002"
584
+ },
585
+ reassign_from: {
586
+ label: "\u8F6C\u51FA\u4EBA",
587
+ help: "\u88AB\u79FB\u4EA4\u5F85\u5BA1\u6279\u69FD\u4F4D\u7684\u7528\u6237\uFF08\u4EC5\u8F6C\u7B7E\u64CD\u4F5C\uFF09"
588
+ },
589
+ reassign_to: {
590
+ label: "\u8F6C\u5165\u4EBA",
591
+ help: "\u63A5\u6536\u5F85\u5BA1\u6279\u69FD\u4F4D\u7684\u7528\u6237\uFF08\u4EC5\u8F6C\u7B7E\u64CD\u4F5C\uFF09"
592
+ },
569
593
  attachments: {
570
594
  label: "\u9644\u4EF6",
571
595
  help: "\u652F\u6301\u8BE5\u64CD\u4F5C\u7684\u6587\u4EF6\u2014\u2014\u4F8B\u5982\u5DF2\u7B7E\u7F72\u7684\u5408\u540C\u6216\u8BC1\u660E\u6750\u6599\uFF08#3266\uFF09\u3002"
@@ -875,6 +899,18 @@ var init_ja_JP_objects_generated = __esm({
875
899
  comment: {
876
900
  label: "\u30B3\u30E1\u30F3\u30C8"
877
901
  },
902
+ via_override: {
903
+ label: "\u7BA1\u7406\u8005\u30AA\u30FC\u30D0\u30FC\u30E9\u30A4\u30C9\u7D4C\u7531",
904
+ help: "true \u306E\u5834\u5408\u3001\u5B9F\u884C\u8005\u306F\u7279\u6A29\u30AA\u30FC\u30D0\u30FC\u30E9\u30A4\u30C9\u7D4C\u8DEF\uFF08#3424\uFF09\u306B\u3088\u3063\u3066\u306E\u307F\u8A31\u53EF\u3055\u308C\u305F\u3053\u3068\u3092\u793A\u3057\u307E\u3059 \u2014 \u5F53\u8A72\u30EA\u30AF\u30A8\u30B9\u30C8\u306E\u627F\u8A8D\u5F85\u3061\u30EA\u30B9\u30C8\u306B\u306F\u542B\u307E\u308C\u3066\u3044\u307E\u305B\u3093\u3002"
905
+ },
906
+ reassign_from: {
907
+ label: "\u5F15\u304D\u7D99\u304E\u5143",
908
+ help: "\u627F\u8A8D\u5F85\u3061\u30B9\u30ED\u30C3\u30C8\u3092\u5F15\u304D\u6E21\u3057\u305F\u30E6\u30FC\u30B6\u30FC\uFF08\u5F15\u304D\u7D99\u304E\u64CD\u4F5C\u306E\u307F\uFF09"
909
+ },
910
+ reassign_to: {
911
+ label: "\u5F15\u304D\u7D99\u304E\u5148",
912
+ help: "\u627F\u8A8D\u5F85\u3061\u30B9\u30ED\u30C3\u30C8\u3092\u53D7\u3051\u53D6\u3063\u305F\u30E6\u30FC\u30B6\u30FC\uFF08\u5F15\u304D\u7D99\u304E\u64CD\u4F5C\u306E\u307F\uFF09"
913
+ },
878
914
  attachments: {
879
915
  label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB",
880
916
  help: "\u3053\u306E\u64CD\u4F5C\u3092\u88CF\u4ED8\u3051\u308B\u30D5\u30A1\u30A4\u30EB\u2014\u2014\u7F72\u540D\u6E08\u307F\u5951\u7D04\u66F8\u3084\u8A3C\u6191\u306A\u3069\uFF08#3266\uFF09\u3002"
@@ -1184,6 +1220,18 @@ var init_es_ES_objects_generated = __esm({
1184
1220
  comment: {
1185
1221
  label: "Comentario"
1186
1222
  },
1223
+ via_override: {
1224
+ label: "Mediante anulaci\xF3n de administrador",
1225
+ help: "Verdadero cuando el actor fue admitido en esta acci\xF3n \xFAnicamente por la v\xEDa de anulaci\xF3n privilegiada (#3424): no ocupaba ning\xFAn puesto en la lista de aprobadores pendientes de la solicitud."
1226
+ },
1227
+ reassign_from: {
1228
+ label: "Reasignado de",
1229
+ help: "Usuario cuyo turno de aprobaci\xF3n pendiente fue traspasado (solo acciones de reasignaci\xF3n)"
1230
+ },
1231
+ reassign_to: {
1232
+ label: "Reasignado a",
1233
+ help: "Usuario que recibi\xF3 el turno de aprobaci\xF3n pendiente (solo acciones de reasignaci\xF3n)"
1234
+ },
1187
1235
  attachments: {
1188
1236
  label: "Adjuntos",
1189
1237
  help: "Archivos que respaldan esta acci\xF3n, p. ej. un contrato firmado o pruebas (#3266)."
@@ -1298,6 +1346,7 @@ module.exports = __toCommonJS(index_exports);
1298
1346
 
1299
1347
  // src/sys-approval-request.object.ts
1300
1348
  var import_data = require("@objectstack/spec/data");
1349
+ var import_contracts = require("@objectstack/spec/contracts");
1301
1350
  var SysApprovalRequest = import_data.ObjectSchema.create({
1302
1351
  name: "sys_approval_request",
1303
1352
  label: "Approval Request",
@@ -1396,9 +1445,10 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1396
1445
  group: "Target"
1397
1446
  }),
1398
1447
  status: import_data.Field.select(
1399
- // Keep in sync with `ApprovalStatus` (spec/contracts). `returned` =
1400
- // sent back for revision (ADR-0044) terminal for this round.
1401
- ["pending", "approved", "rejected", "recalled", "returned"],
1448
+ // Spread from the contract, not re-typed (#3786). `APPROVAL_STATUSES` is
1449
+ // where the list and the reason for each entry live; `ApprovalStatus` is
1450
+ // derived from it, so this column and the contract cannot disagree.
1451
+ [...import_contracts.APPROVAL_STATUSES],
1402
1452
  {
1403
1453
  label: "Status",
1404
1454
  required: true,
@@ -1667,6 +1717,7 @@ var SysApprovalRequest = import_data.ObjectSchema.create({
1667
1717
 
1668
1718
  // src/sys-approval-action.object.ts
1669
1719
  var import_data2 = require("@objectstack/spec/data");
1720
+ var import_contracts2 = require("@objectstack/spec/contracts");
1670
1721
  var SysApprovalAction = import_data2.ObjectSchema.create({
1671
1722
  name: "sys_approval_action",
1672
1723
  label: "Approval Action",
@@ -1679,7 +1730,7 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
1679
1730
  nameField: "id",
1680
1731
  // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
1681
1732
  titleFormat: "{action} \xB7 {step_name}",
1682
- highlightFields: ["request_id", "step_name", "action", "actor_id", "created_at"],
1733
+ highlightFields: ["request_id", "step_name", "action", "actor_id", "via_override", "created_at"],
1683
1734
  // ADR-0104 D3 wave 2. `attachments` is a media field, so the files it holds
1684
1735
  // are OWNED by this row — and the storage service would otherwise authorize
1685
1736
  // their download by testing whether the caller can READ this row. It cannot:
@@ -1694,7 +1745,7 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
1694
1745
  name: "recent",
1695
1746
  label: "Recent",
1696
1747
  data: { provider: "object", object: "sys_approval_action" },
1697
- columns: ["created_at", "request_id", "step_name", "action", "actor_id", "comment"],
1748
+ columns: ["created_at", "request_id", "step_name", "action", "actor_id", "via_override", "comment"],
1698
1749
  sort: [{ field: "created_at", order: "desc" }],
1699
1750
  pagination: { pageSize: 50 },
1700
1751
  emptyState: { title: "No approval actions yet", message: "Actions are logged automatically when approvals progress." }
@@ -1714,7 +1765,7 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
1714
1765
  name: "all_actions",
1715
1766
  label: "All",
1716
1767
  data: { provider: "object", object: "sys_approval_action" },
1717
- columns: ["created_at", "request_id", "step_name", "action", "actor_id", "comment"],
1768
+ columns: ["created_at", "request_id", "step_name", "action", "actor_id", "via_override", "comment"],
1718
1769
  sort: [{ field: "created_at", order: "desc" }],
1719
1770
  pagination: { pageSize: 100 }
1720
1771
  }
@@ -1745,12 +1796,11 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
1745
1796
  group: "Target"
1746
1797
  }),
1747
1798
  action: import_data2.Field.select(
1748
- // Keep in sync with `ApprovalActionKind` (spec/contracts). reassign /
1749
- // remind / request_info / comment are thread interactions they never
1750
- // move the flow. revise / resubmit (ADR-0044) DO move it: send back for
1751
- // revision and the later resubmission. ooo_substitute (#1322 M1) is a
1752
- // system-recorded reroute of an out-of-office approver — no flow movement.
1753
- ["submit", "approve", "reject", "recall", "escalate", "reassign", "remind", "request_info", "comment", "revise", "resubmit", "ooo_substitute"],
1799
+ // Spread from the contract, not re-typed (#3786). `APPROVAL_ACTION_KINDS`
1800
+ // is where the list and the per-kind notes live (which kinds move the flow
1801
+ // and which are thread-only); `ApprovalActionKind` is derived from it, so
1802
+ // this column and the contract cannot disagree.
1803
+ [...import_contracts2.APPROVAL_ACTION_KINDS],
1754
1804
  {
1755
1805
  label: "Action",
1756
1806
  required: true,
@@ -1763,6 +1813,46 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
1763
1813
  group: "Action"
1764
1814
  }),
1765
1815
  comment: import_data2.Field.textarea({ label: "Comment", required: false, group: "Action" }),
1816
+ // #4466 — the one bit of "who really decided this" that was still dropped.
1817
+ // A privileged admin may act on a request whose staffed approver slate they
1818
+ // hold no slot in (the #3424 override path); before this column, that
1819
+ // decision was byte-for-byte identical to the designated approver's own
1820
+ // approval. A reader of the timeline saw `approve` by the admin and could
1821
+ // not tell whether the admin WAS an approver or OVERRODE the ones who were,
1822
+ // and the bypassed approver's later `409 INVALID_STATE` was the only trace
1823
+ // — existing only if they happened to try.
1824
+ //
1825
+ // The platform KNOWS at decision time: it took the `isOverrideActor` branch
1826
+ // to admit the call at all. This is dropped information, not unavailable
1827
+ // information.
1828
+ //
1829
+ // Set on exactly the decisions that were admitted BY that branch — an admin
1830
+ // who is also a genuine slot holder is approving normally and is recorded
1831
+ // as such. Nullable and additive: rows written before this column exists
1832
+ // carry `null`, which reads as "not recorded", never as "not an override".
1833
+ via_override: import_data2.Field.boolean({
1834
+ label: "Via Admin Override",
1835
+ required: false,
1836
+ group: "Action",
1837
+ description: "True when the actor was admitted to this action only by the privileged-override path (#3424) \u2014 they held no slot in the request\u2019s pending-approver slate."
1838
+ }),
1839
+ // Structured hand-off parties for `action: 'reassign'` (#4365). Before
1840
+ // these existed the pair lived only inside a default free-text comment
1841
+ // ("<from_id> → <to_id>"), which no client could parse or render readably.
1842
+ // `comment` is pure user input again; timelines render "from A to B" from
1843
+ // these fields.
1844
+ reassign_from: import_data2.Field.lookup("sys_user", {
1845
+ label: "Reassigned From",
1846
+ required: false,
1847
+ group: "Action",
1848
+ description: "User whose pending-approver slot was handed over (reassign actions only)"
1849
+ }),
1850
+ reassign_to: import_data2.Field.lookup("sys_user", {
1851
+ label: "Reassigned To",
1852
+ required: false,
1853
+ group: "Action",
1854
+ description: "User who received the pending-approver slot (reassign actions only)"
1855
+ }),
1766
1856
  attachments: import_data2.Field.file({
1767
1857
  label: "Attachments",
1768
1858
  required: false,
@@ -1853,12 +1943,11 @@ var SysApprovalDelegation = import_data4.ObjectSchema.create({
1853
1943
  pluralLabel: "Approval Delegations",
1854
1944
  icon: "user-clock",
1855
1945
  isSystem: true,
1856
- managedBy: "system",
1857
- // [ADR-0103] Admin/user-writable DATA on a platform-defined schema: a user
1858
- // authors their own out-of-office delegation. Affordance only (matches the
1859
- // full-CRUD apiMethods below) — RLS/permission sets are the authz; opening it
1860
- // keeps the system write guard from rejecting the self-service write.
1861
- userActions: { create: true, edit: true, delete: true },
1946
+ // [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema: a
1947
+ // user authors their own out-of-office delegation. The bucket default is full
1948
+ // CRUD (matching the full-CRUD apiMethods below), so no `userActions` block is
1949
+ // needed — RLS/permission sets are the authz.
1950
+ managedBy: "system-data",
1862
1951
  description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
1863
1952
  titleFormat: "{delegator_id} \u2192 {delegate_id}",
1864
1953
  highlightFields: ["delegator_id", "delegate_id", "valid_from", "valid_until"],
@@ -1949,8 +2038,9 @@ var SysApprovalDelegation = import_data4.ObjectSchema.create({
1949
2038
  var import_node_crypto = require("crypto");
1950
2039
  var import_automation = require("@objectstack/spec/automation");
1951
2040
  var import_formula = require("@objectstack/formula");
2041
+ var import_types = require("@objectstack/types");
1952
2042
  var import_identity = require("@objectstack/spec/identity");
1953
- var import_contracts = require("@objectstack/spec/contracts");
2043
+ var import_contracts3 = require("@objectstack/spec/contracts");
1954
2044
  var import_data5 = require("@objectstack/spec/data");
1955
2045
  var import_core = require("@objectstack/core");
1956
2046
 
@@ -1963,7 +2053,7 @@ function fail(message) {
1963
2053
  async function findOrg(engine, where) {
1964
2054
  try {
1965
2055
  const rows = await engine.find("sys_organization", {
1966
- filter: where,
2056
+ where,
1967
2057
  fields: ["id", "slug", "parent_organization_id"],
1968
2058
  limit: 1,
1969
2059
  context: SYSTEM_CTX
@@ -2050,7 +2140,7 @@ async function filterApproversWhoCanRead(deps, userIds, requestOrgId, context) {
2050
2140
  let members = [];
2051
2141
  try {
2052
2142
  members = await deps.engine.find("sys_member", {
2053
- filter: { organization_id: requestOrg, user_id: { $in: userIds } },
2143
+ where: { organization_id: requestOrg, user_id: { $in: userIds } },
2054
2144
  fields: ["user_id"],
2055
2145
  limit: 1e4,
2056
2146
  context: SYSTEM_CTX
@@ -2082,6 +2172,7 @@ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
2082
2172
  "cancelled",
2083
2173
  "timed_out"
2084
2174
  ]);
2175
+ var STRANDABLE_REQUEST_STATUSES = ["approved", "rejected", "returned"];
2085
2176
  var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
2086
2177
  var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
2087
2178
  function actingUserId(context) {
@@ -2120,6 +2211,12 @@ function csvSplit(raw) {
2120
2211
  if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
2121
2212
  return String(raw).split(",").map((s) => s.trim()).filter(Boolean);
2122
2213
  }
2214
+ function isBlankDecisionOutput(value) {
2215
+ if (value === void 0 || value === null) return true;
2216
+ if (typeof value === "string") return value.trim() === "";
2217
+ if (Array.isArray(value)) return value.filter((v) => v !== null && v !== void 0 && String(v).trim() !== "").length === 0;
2218
+ return false;
2219
+ }
2123
2220
  function prettifyMachineName(raw) {
2124
2221
  if (!raw) return void 0;
2125
2222
  const base = String(raw).replace(/^flow:/, "").trim();
@@ -2212,6 +2309,14 @@ function rowFromAction(row) {
2212
2309
  action: row.action,
2213
2310
  actor_id: row.actor_id ?? void 0,
2214
2311
  comment: row.comment ?? void 0,
2312
+ // Structured reassign hand-off parties (#4365).
2313
+ reassign_from: row.reassign_from ?? void 0,
2314
+ reassign_to: row.reassign_to ?? void 0,
2315
+ // #4466 — surfaced so a timeline can SAY "overridden the approver slate"
2316
+ // rather than render an override identically to an ordinary approval.
2317
+ // `null` (a row written before the column existed) stays `undefined`:
2318
+ // "not recorded" is not the same claim as "not an override".
2319
+ via_override: row.via_override == null ? void 0 : row.via_override === true,
2215
2320
  // Decision attachments (#3266): rich descriptors carrying the display name +
2216
2321
  // download URL, so consumers label/open them without reading `sys_file`.
2217
2322
  attachments: attachments.length ? attachments : void 0,
@@ -2351,9 +2456,10 @@ var _ApprovalService = class _ApprovalService {
2351
2456
  * (having also put them on the context). Those are the only two callers that
2352
2457
  * hold a trustworthy actor with no session behind them.
2353
2458
  *
2354
- * A caller with NO identity at all cannot act. That case is reachable: the
2355
- * REST anonymous-deny only fires when `api.requireAuth` is set, so without it
2356
- * an anonymous request previously decided approvals outright by naming one.
2459
+ * A caller with NO identity at all cannot act. Belt-and-suspenders: the REST
2460
+ * anonymous-deny now denies every anonymous request (#3963), but this service
2461
+ * must not rely on a caller upstream an anonymous actor could otherwise
2462
+ * decide approvals outright by naming one.
2357
2463
  */
2358
2464
  async resolveActor(actorId, context) {
2359
2465
  if (context?.isSystem) {
@@ -2639,7 +2745,7 @@ var _ApprovalService = class _ApprovalService {
2639
2745
  let rows = [];
2640
2746
  try {
2641
2747
  rows = await this.engine.find("sys_team_member", {
2642
- filter: { team_id: teamId },
2748
+ where: { team_id: teamId },
2643
2749
  fields: ["user_id"],
2644
2750
  limit: 1e4,
2645
2751
  context: SYSTEM_CTX2
@@ -2678,7 +2784,7 @@ var _ApprovalService = class _ApprovalService {
2678
2784
  if (!businessUnitId) return [];
2679
2785
  try {
2680
2786
  const seed = await this.engine.find("sys_business_unit", {
2681
- filter: this.businessUnitOrgScope({ id: businessUnitId }, organizationId),
2787
+ where: this.businessUnitOrgScope({ id: businessUnitId }, organizationId),
2682
2788
  fields: ["id", "active"],
2683
2789
  limit: 1,
2684
2790
  context: SYSTEM_CTX2
@@ -2713,7 +2819,7 @@ var _ApprovalService = class _ApprovalService {
2713
2819
  let rows = [];
2714
2820
  try {
2715
2821
  rows = await this.engine.find("sys_business_unit_member", {
2716
- filter: { business_unit_id: { $in: Array.from(seen) } },
2822
+ where: { business_unit_id: { $in: Array.from(seen) } },
2717
2823
  fields: ["user_id"],
2718
2824
  limit: 1e4,
2719
2825
  context: SYSTEM_CTX2
@@ -2773,7 +2879,7 @@ var _ApprovalService = class _ApprovalService {
2773
2879
  async lookupManager(userId) {
2774
2880
  try {
2775
2881
  const rows = await this.engine.find("sys_user", {
2776
- filter: { id: userId },
2882
+ where: { id: userId },
2777
2883
  fields: ["id", "manager_id"],
2778
2884
  limit: 1,
2779
2885
  context: SYSTEM_CTX2
@@ -2823,7 +2929,7 @@ var _ApprovalService = class _ApprovalService {
2823
2929
  let rows = [];
2824
2930
  try {
2825
2931
  rows = await this.engine.find("sys_approval_delegation", {
2826
- filter: { delegator_id: delegatorId },
2932
+ where: { delegator_id: delegatorId },
2827
2933
  fields: ["id", "delegator_id", "delegate_id", "valid_from", "valid_until", "reason", "organization_id"],
2828
2934
  limit: 50,
2829
2935
  context: SYSTEM_CTX2
@@ -3142,6 +3248,7 @@ var _ApprovalService = class _ApprovalService {
3142
3248
  if (!isSlotHolder && !isOverride) {
3143
3249
  throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
3144
3250
  }
3251
+ const viaOverride = isOverride && !isSlotHolder;
3145
3252
  const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
3146
3253
  const org = raw.organization_id ?? null;
3147
3254
  const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
@@ -3149,8 +3256,9 @@ var _ApprovalService = class _ApprovalService {
3149
3256
  const now = this.clock.now().toISOString();
3150
3257
  const outputKeys = input.outputs ? Object.keys(input.outputs) : [];
3151
3258
  let acceptedOutputs;
3259
+ const declaredDefs = (0, import_automation.normalizeDecisionOutputs)(config.decisionOutputs);
3152
3260
  if (outputKeys.length) {
3153
- const declared = (0, import_automation.normalizeDecisionOutputs)(config.decisionOutputs).map((d) => d.key);
3261
+ const declared = declaredDefs.map((d) => d.key);
3154
3262
  if (!declared.length) {
3155
3263
  throw new Error(
3156
3264
  `VALIDATION_FAILED: this approval node declares no decisionOutputs \u2014 outputs are not accepted. Declare the keys on the node config (decisionOutputs: [${outputKeys.map((k) => `'${k}'`).join(", ")}]) to let approvers hand them to the flow.`
@@ -3170,6 +3278,15 @@ var _ApprovalService = class _ApprovalService {
3170
3278
  }
3171
3279
  acceptedOutputs = { ...input.outputs };
3172
3280
  }
3281
+ if (input.decision === "approve") {
3282
+ const missing = declaredDefs.filter((d) => d.required === true && isBlankDecisionOutput(input.outputs?.[d.key])).map((d) => d.key);
3283
+ if (missing.length) {
3284
+ throw new Error(
3285
+ `VALIDATION_FAILED: decision output(s) \`${missing.join("`, `")}\` are required to approve this request \u2014 open the approval and fill them in before approving.`
3286
+ );
3287
+ }
3288
+ }
3289
+ await this.assertRunResumable(runId, requestId);
3173
3290
  await this.engine.insert("sys_approval_action", {
3174
3291
  id: uid("aact"),
3175
3292
  request_id: requestId,
@@ -3179,6 +3296,10 @@ var _ApprovalService = class _ApprovalService {
3179
3296
  action: input.decision,
3180
3297
  actor_id: actorId,
3181
3298
  comment: input.comment ?? null,
3299
+ // #4466: the override is recorded on the DECISION, not inferred later.
3300
+ // Written as an explicit `false` for an ordinary decision so a reader can
3301
+ // tell "checked, and it was not an override" from a legacy row's `null`.
3302
+ via_override: viaOverride,
3182
3303
  attachments: input.attachments?.length ? input.attachments : null,
3183
3304
  created_at: now
3184
3305
  }, { context: SYSTEM_CTX2 });
@@ -3265,22 +3386,131 @@ var _ApprovalService = class _ApprovalService {
3265
3386
  * Callers still guard on `typeof this.automation?.resume === 'function'`
3266
3387
  * (approvals runs fine with no automation attached) and keep their own
3267
3388
  * try/catch, because what a failed resume means differs per path.
3389
+ *
3390
+ * Throws when the engine REPORTS failure, not only when it throws one. The
3391
+ * engine answers a lost run with `{ success: false, code: 'RUN_NOT_FOUND' }`
3392
+ * — a plain return value that every caller here used to discard, which is
3393
+ * how an approval could be recorded, reported as resumed, and leave its flow
3394
+ * stranded forever (#4420). The thrown error carries {@link resumeCodeOf}'s
3395
+ * `resumeCode` so callers can tell a benign duplicate from a dead run.
3268
3396
  */
3269
3397
  async serviceResume(runId, signal) {
3270
- await this.automation.resume(runId, { ...signal, [import_contracts.RESUME_AUTHORITY_SERVICE]: true });
3398
+ const result = await this.automation.resume(runId, { ...signal, [import_contracts3.RESUME_AUTHORITY_SERVICE]: true });
3399
+ const reported = result;
3400
+ if (reported && typeof reported === "object" && reported.success === false) {
3401
+ const err = new Error(
3402
+ `resume of run '${runId}' failed${reported.code ? ` [${reported.code}]` : ""}: ${reported.error ?? "unknown error"}`
3403
+ );
3404
+ err.resumeCode = reported.code;
3405
+ throw err;
3406
+ }
3407
+ }
3408
+ /** The engine failure code behind a {@link serviceResume} rejection, if any. */
3409
+ static resumeCodeOf(err) {
3410
+ return err?.resumeCode;
3411
+ }
3412
+ /**
3413
+ * Refuse an operation whose whole point is to advance a flow run when that
3414
+ * run no longer exists — BEFORE anything is written down (#4420).
3415
+ *
3416
+ * The half-state this prevents is the one the issue reported: a request
3417
+ * flipped to `approved`, a success toast, and a flow that never moves. Once
3418
+ * the decision row is written there is nothing left to fail cleanly.
3419
+ *
3420
+ * Deliberately permissive at the edges:
3421
+ * - no automation attached, or an engine without `hasSuspendedRun` → no
3422
+ * pre-flight at all (standalone approvals compositions are unaffected);
3423
+ * - the store cannot be READ → fail OPEN. A transient outage must not block
3424
+ * every decision in the tenant; the post-resume check still catches a real
3425
+ * failure and reports it loudly.
3426
+ */
3427
+ async assertRunResumable(runId, requestId) {
3428
+ if (!runId) return;
3429
+ if (typeof this.automation?.resume !== "function") return;
3430
+ if (typeof this.automation?.hasSuspendedRun !== "function") return;
3431
+ let alive;
3432
+ try {
3433
+ alive = await this.automation.hasSuspendedRun(runId);
3434
+ } catch (err) {
3435
+ this.logger?.warn?.("[approvals] could not verify the flow run is resumable \u2014 proceeding", {
3436
+ request: requestId,
3437
+ run: runId,
3438
+ error: err?.message ?? String(err)
3439
+ });
3440
+ return;
3441
+ }
3442
+ if (!alive) {
3443
+ throw new Error(
3444
+ `RESUME_TARGET_LOST: the flow run '${runId}' behind request ${requestId} no longer exists (it was cancelled, or it paused in a process that did not persist suspended runs). Nothing was recorded. An administrator can recall the request to release the record.`
3445
+ );
3446
+ }
3447
+ }
3448
+ /**
3449
+ * Resume the run behind an outcome that has ALREADY been written down, and
3450
+ * fail loudly when it cannot be (#4420).
3451
+ *
3452
+ * For the operations whose product is the resume — a finalised decision, a
3453
+ * send-back, a resubmit. Their rows are durable by the time this runs, so a
3454
+ * failure here cannot be undone; the one thing left worth doing is refusing
3455
+ * to call it success. {@link assertRunResumable} is what keeps this rare:
3456
+ * everything it catches never reaches a write.
3457
+ *
3458
+ * `RESUME_IN_PROGRESS` is the exception — a concurrent resume is already
3459
+ * advancing the run, so the outcome stands and only `resumed` is false.
3460
+ *
3461
+ * @param what - how the recorded outcome reads in the error, e.g.
3462
+ * `"the approve decision"`.
3463
+ */
3464
+ async resumeRecordedOutcome(runId, requestId, what, signal) {
3465
+ try {
3466
+ await this.serviceResume(runId, signal);
3467
+ return { resumed: true };
3468
+ } catch (err) {
3469
+ const reason = err?.message ?? String(err);
3470
+ if (_ApprovalService.resumeCodeOf(err) === "RESUME_IN_PROGRESS") {
3471
+ this.logger?.warn?.("[approvals] resume skipped \u2014 already in progress", {
3472
+ request: requestId,
3473
+ run: runId,
3474
+ outcome: what
3475
+ });
3476
+ return { resumed: false, resumeError: reason };
3477
+ }
3478
+ this.logger?.error?.("[approvals] resume failed \u2014 the run is stranded", {
3479
+ request: requestId,
3480
+ run: runId,
3481
+ outcome: what,
3482
+ error: reason
3483
+ });
3484
+ throw new Error(
3485
+ `RESUME_FAILED: ${what} was recorded on request ${requestId}, but its flow run '${runId}' could not be resumed and is now stranded: ${reason}`
3486
+ );
3487
+ }
3271
3488
  }
3272
3489
  /**
3273
3490
  * Public contract entrypoint (ADR-0019). Records a decision on a node-driven
3274
3491
  * request via {@link ApprovalService.decideNode} and, when it finalizes,
3275
3492
  * resumes the owning flow run down the matching `approve` / `reject` edge.
3493
+ *
3494
+ * A finalising decision whose run cannot be resumed FAILS (#4420). The
3495
+ * decision is already durable by then, so the failure cannot be rolled back
3496
+ * — but it must not be reported as success either: this used to answer HTTP
3497
+ * 200 with `resumed: true` while the flow stayed parked forever, which left
3498
+ * the approver with no signal and the record mirroring a stage it never
3499
+ * reached. `decideNode`'s pre-flight means the common case (the run died
3500
+ * before the decision) never gets this far; what survives here is a genuine
3501
+ * race, and it names the stranded run.
3276
3502
  */
3277
3503
  async decide(requestId, input, context) {
3278
3504
  const result = await this.decideNode(requestId, input, context);
3279
3505
  let resumed = false;
3506
+ let resumeError;
3280
3507
  if (result.finalized && result.runId && typeof this.automation?.resume === "function") {
3281
3508
  const branchLabel = result.decision === "approve" ? import_automation.APPROVAL_BRANCH_LABELS.approve : import_automation.APPROVAL_BRANCH_LABELS.reject;
3282
- try {
3283
- await this.serviceResume(result.runId, {
3509
+ const outcome = await this.resumeRecordedOutcome(
3510
+ result.runId,
3511
+ requestId,
3512
+ `the ${result.decision} decision`,
3513
+ {
3284
3514
  branchLabel,
3285
3515
  // #3447 P2: accepted decision outputs ride the resume envelope and
3286
3516
  // land as `<nodeId>.<key>` flow variables — a later approval node's
@@ -3288,22 +3518,18 @@ var _ApprovalService = class _ApprovalService {
3288
3518
  // Reserved keys are spread LAST so no output can shadow them (the
3289
3519
  // whitelist already rejects them; this is defense in depth).
3290
3520
  output: { ...result.outputs ?? {}, decision: result.decision, requestId }
3291
- });
3292
- resumed = true;
3293
- } catch (err) {
3294
- this.logger?.warn?.("[approvals] resume after decision failed", {
3295
- request: requestId,
3296
- run: result.runId,
3297
- error: err?.message ?? String(err)
3298
- });
3299
- }
3521
+ }
3522
+ );
3523
+ resumed = outcome.resumed;
3524
+ resumeError = outcome.resumeError;
3300
3525
  }
3301
3526
  return {
3302
3527
  request: result.request,
3303
3528
  finalized: result.finalized,
3304
3529
  decision: result.decision,
3305
3530
  runId: result.runId,
3306
- resumed
3531
+ resumed,
3532
+ ...resumeError ? { resumeError } : {}
3307
3533
  };
3308
3534
  }
3309
3535
  /**
@@ -3371,15 +3597,17 @@ var _ApprovalService = class _ApprovalService {
3371
3597
  );
3372
3598
  }
3373
3599
  let resumed = false;
3600
+ let resumeError;
3374
3601
  if (inReviseWindow) {
3375
3602
  if (runId && typeof this.automation?.cancelRun === "function") {
3376
3603
  try {
3377
3604
  await this.automation.cancelRun(runId, `approval request ${requestId} recalled during revision`);
3378
3605
  } catch (err) {
3379
- this.logger?.warn?.("[approvals] cancelRun after revise-window recall failed", {
3606
+ resumeError = err?.message ?? String(err);
3607
+ this.logger?.error?.("[approvals] cancelRun after revise-window recall failed \u2014 the run may be stranded", {
3380
3608
  request: requestId,
3381
3609
  run: runId,
3382
- error: err?.message ?? String(err)
3610
+ error: resumeError
3383
3611
  });
3384
3612
  }
3385
3613
  }
@@ -3391,15 +3619,16 @@ var _ApprovalService = class _ApprovalService {
3391
3619
  });
3392
3620
  resumed = true;
3393
3621
  } catch (err) {
3394
- this.logger?.warn?.("[approvals] resume after recall failed", {
3622
+ resumeError = err?.message ?? String(err);
3623
+ this.logger?.error?.("[approvals] resume after recall failed \u2014 the run may be stranded", {
3395
3624
  request: requestId,
3396
3625
  run: runId,
3397
- error: err?.message ?? String(err)
3626
+ error: resumeError
3398
3627
  });
3399
3628
  }
3400
3629
  }
3401
3630
  const fresh = await this.readBackRequest(requestId, context);
3402
- return { request: fresh, runId, resumed };
3631
+ return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
3403
3632
  }
3404
3633
  // ── Send back for revision / resubmit (ADR-0044) ─────────────
3405
3634
  /**
@@ -3427,6 +3656,7 @@ var _ApprovalService = class _ApprovalService {
3427
3656
  const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
3428
3657
  const runId = raw.flow_run_id ?? null;
3429
3658
  await this.assertReviseEdge(raw, nodeId);
3659
+ await this.assertRunResumable(runId, requestId);
3430
3660
  const now = this.clock.now().toISOString();
3431
3661
  const maxRevisions = typeof config.maxRevisions === "number" ? config.maxRevisions : 3;
3432
3662
  let priorSendBacks = 0;
@@ -3479,20 +3709,19 @@ var _ApprovalService = class _ApprovalService {
3479
3709
  );
3480
3710
  }
3481
3711
  let resumed2 = false;
3712
+ let resumeError2;
3482
3713
  if (runId && typeof this.automation?.resume === "function") {
3483
- try {
3484
- await this.serviceResume(runId, {
3714
+ const outcome = await this.resumeRecordedOutcome(
3715
+ runId,
3716
+ requestId,
3717
+ "the auto-rejection",
3718
+ {
3485
3719
  branchLabel: import_automation.APPROVAL_BRANCH_LABELS.reject,
3486
3720
  output: { decision: "reject", autoRejected: true, requestId }
3487
- });
3488
- resumed2 = true;
3489
- } catch (err) {
3490
- this.logger?.warn?.("[approvals] resume after auto-reject failed", {
3491
- request: requestId,
3492
- run: runId,
3493
- error: err?.message ?? String(err)
3494
- });
3495
- }
3721
+ }
3722
+ );
3723
+ resumed2 = outcome.resumed;
3724
+ resumeError2 = outcome.resumeError;
3496
3725
  }
3497
3726
  if (raw.submitter_id) {
3498
3727
  await this.notify({
@@ -3508,7 +3737,7 @@ var _ApprovalService = class _ApprovalService {
3508
3737
  });
3509
3738
  }
3510
3739
  const fresh2 = await this.readBackRequest(requestId, context);
3511
- return { request: fresh2, runId, resumed: resumed2, autoRejected: true };
3740
+ return { request: fresh2, runId, resumed: resumed2, autoRejected: true, ...resumeError2 ? { resumeError: resumeError2 } : {} };
3512
3741
  }
3513
3742
  await this.engine.update("sys_approval_request", {
3514
3743
  id: requestId,
@@ -3528,20 +3757,19 @@ var _ApprovalService = class _ApprovalService {
3528
3757
  );
3529
3758
  }
3530
3759
  let resumed = false;
3760
+ let resumeError;
3531
3761
  if (runId && typeof this.automation?.resume === "function") {
3532
- try {
3533
- await this.serviceResume(runId, {
3762
+ const outcome = await this.resumeRecordedOutcome(
3763
+ runId,
3764
+ requestId,
3765
+ "the send-back",
3766
+ {
3534
3767
  branchLabel: import_automation.APPROVAL_BRANCH_LABELS.revise,
3535
3768
  output: { decision: "revise", requestId }
3536
- });
3537
- resumed = true;
3538
- } catch (err) {
3539
- this.logger?.warn?.("[approvals] resume after send-back failed", {
3540
- request: requestId,
3541
- run: runId,
3542
- error: err?.message ?? String(err)
3543
- });
3544
- }
3769
+ }
3770
+ );
3771
+ resumed = outcome.resumed;
3772
+ resumeError = outcome.resumeError;
3545
3773
  }
3546
3774
  if (raw.submitter_id) {
3547
3775
  await this.notify({
@@ -3557,7 +3785,7 @@ var _ApprovalService = class _ApprovalService {
3557
3785
  });
3558
3786
  }
3559
3787
  const fresh = await this.readBackRequest(requestId, context);
3560
- return { request: fresh, runId, resumed };
3788
+ return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
3561
3789
  }
3562
3790
  /**
3563
3791
  * ADR-0044 resubmit after rework. Valid on the LATEST `returned` request of
@@ -3596,6 +3824,7 @@ var _ApprovalService = class _ApprovalService {
3596
3824
  const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
3597
3825
  const runId = raw.flow_run_id ?? null;
3598
3826
  const now = this.clock.now().toISOString();
3827
+ await this.assertRunResumable(runId, requestId);
3599
3828
  await this.engine.insert("sys_approval_action", {
3600
3829
  id: uid("aact"),
3601
3830
  request_id: requestId,
@@ -3608,23 +3837,22 @@ var _ApprovalService = class _ApprovalService {
3608
3837
  created_at: now
3609
3838
  }, { context: SYSTEM_CTX2 });
3610
3839
  let resumed = false;
3840
+ let resumeError;
3611
3841
  if (runId && typeof this.automation?.resume === "function") {
3612
- try {
3613
- await this.serviceResume(runId, {
3842
+ const outcome = await this.resumeRecordedOutcome(
3843
+ runId,
3844
+ requestId,
3845
+ "the resubmit",
3846
+ {
3614
3847
  branchLabel: import_automation.APPROVAL_BRANCH_LABELS.resubmit,
3615
3848
  output: { resubmitted: true, requestId }
3616
- });
3617
- resumed = true;
3618
- } catch (err) {
3619
- this.logger?.warn?.("[approvals] resume after resubmit failed", {
3620
- request: requestId,
3621
- run: runId,
3622
- error: err?.message ?? String(err)
3623
- });
3624
- }
3849
+ }
3850
+ );
3851
+ resumed = outcome.resumed;
3852
+ resumeError = outcome.resumeError;
3625
3853
  }
3626
3854
  const fresh = await this.readBackRequest(requestId, context);
3627
- return { request: fresh, runId, resumed };
3855
+ return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
3628
3856
  }
3629
3857
  /**
3630
3858
  * ADR-0044 guard: the flow's approval node must declare a `revise`
@@ -3685,6 +3913,7 @@ var _ApprovalService = class _ApprovalService {
3685
3913
  }
3686
3914
  const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
3687
3915
  const from = String(input.from ?? actorId).trim();
3916
+ const viaOverride = isOverride && !pending.includes(actorId);
3688
3917
  let next;
3689
3918
  if (pending.includes(from)) {
3690
3919
  if (!context.isSystem && !isOverride && actorId !== from && !pending.includes(actorId)) {
@@ -3704,8 +3933,14 @@ var _ApprovalService = class _ApprovalService {
3704
3933
  step_name: raw.flow_node_id ?? raw.current_step ?? null,
3705
3934
  step_index: 0,
3706
3935
  action: "reassign",
3936
+ // The hand-off parties are STRUCTURED fields (#4365) — the old default
3937
+ // comment (`"<from> → <to>"`) baked raw user ids into user-facing text.
3938
+ // `comment` is pure user input: absent unless the actor wrote one.
3707
3939
  actor_id: actorId,
3708
- comment: input.comment ?? `${from} \u2192 ${to}`,
3940
+ reassign_from: from,
3941
+ reassign_to: to,
3942
+ via_override: viaOverride,
3943
+ comment: input.comment ?? null,
3709
3944
  created_at: now
3710
3945
  }, { context: SYSTEM_CTX2 });
3711
3946
  let configPatch = {};
@@ -4069,6 +4304,137 @@ var _ApprovalService = class _ApprovalService {
4069
4304
  * the real cause and {@link DEAD_RUN_ACTOR_ID} the real actor, so a dead-run
4070
4305
  * release is never mistaken for a submitter's withdrawal.
4071
4306
  */
4307
+ /**
4308
+ * Read-only inspection for the OTHER dead-run shape: a request that is
4309
+ * already TERMINAL while its `flow_run_id` points at nothing (#4469).
4310
+ *
4311
+ * #4460 stopped new ones being produced; nothing found the ones already
4312
+ * stuck. The failure mode (#4420) is a request row flipped to `approved` /
4313
+ * `rejected` / `returned` whose owning run no longer exists — the decision
4314
+ * landed, the flow never moved. Any deployment on 17.0.0-rc.1 that hit the
4315
+ * wiring hole and crossed a restart mid-approval can be carrying these rows.
4316
+ *
4317
+ * {@link releaseDeadRunRequests} cannot see them, for a reason worth naming:
4318
+ * it scans `status: 'pending'`, and the very step that zombified the request
4319
+ * is the one that took it OUT of `pending`. The act of breaking it removed it
4320
+ * from the only sweeper's field of view — which is a large part of why this
4321
+ * class of failure stayed silent.
4322
+ *
4323
+ * It also could not have answered the question even if it looked: its
4324
+ * liveness oracle is `getRun`, which reads the execution LOG, and after a
4325
+ * restart that returns `null` for a perfectly ALIVE suspended run. It treats
4326
+ * `null` as alive (conservative, correct) — but that means it has no way to
4327
+ * say "this run is really gone".
4328
+ *
4329
+ * So this uses BOTH oracles, and a row must fail both to be reported:
4330
+ *
4331
+ * - `hasSuspendedRun(runId) === false` — the suspension store itself says no
4332
+ * live pause exists. It THROWS when the store cannot be read, and that
4333
+ * case is SKIPPED, never counted as dead: an unreadable store means
4334
+ * "unknown", and a storage outage must not be published as a lost run.
4335
+ * - `getRun(runId) == null` — no terminal history row either (the `run_`
4336
+ * prefixed rows in `sys_automation_run`). A run that merely finished is
4337
+ * not stranded; a request whose run neither waits nor ever completed is.
4338
+ *
4339
+ * **Reports; never rewrites.** No status is changed and no run is cancelled.
4340
+ * The decision genuinely happened — a human approved or rejected — and
4341
+ * silently rolling it back would make the audit trail disagree with the
4342
+ * facts. What an operator needs first is visibility: which requests are stuck
4343
+ * at which step, and what the mirrored status field on the business record
4344
+ * still says. Whether to re-run the downstream actions or re-open the
4345
+ * approval is a judgement call this cannot make.
4346
+ */
4347
+ async inspectStrandedRequests(options) {
4348
+ const empty = { scanned: 0, stranded: [], undetermined: 0 };
4349
+ if (typeof this.automation?.hasSuspendedRun !== "function") return empty;
4350
+ if (typeof this.automation?.getRun !== "function") return empty;
4351
+ const limit = options?.limit ?? 500;
4352
+ let rows = [];
4353
+ try {
4354
+ rows = await this.engine.find("sys_approval_request", {
4355
+ where: { status: { $in: [...STRANDABLE_REQUEST_STATUSES] } },
4356
+ limit,
4357
+ context: SYSTEM_CTX2
4358
+ }) ?? [];
4359
+ } catch (err) {
4360
+ this.logger?.warn?.("[approvals] stranded-request scan failed to list requests", {
4361
+ error: err?.message ?? String(err)
4362
+ });
4363
+ return empty;
4364
+ }
4365
+ const stranded = [];
4366
+ let undetermined = 0;
4367
+ for (const raw of rows) {
4368
+ const runId = raw?.flow_run_id ? String(raw.flow_run_id) : "";
4369
+ if (!runId) continue;
4370
+ let suspended;
4371
+ try {
4372
+ suspended = await this.automation.hasSuspendedRun(runId);
4373
+ } catch (err) {
4374
+ undetermined++;
4375
+ this.logger?.warn?.("[approvals] stranded-request scan could not read the suspension store", {
4376
+ request: raw?.id,
4377
+ run: runId,
4378
+ error: err?.message ?? String(err)
4379
+ });
4380
+ continue;
4381
+ }
4382
+ if (suspended) continue;
4383
+ let terminal = null;
4384
+ try {
4385
+ terminal = await this.automation.getRun(runId);
4386
+ } catch (err) {
4387
+ undetermined++;
4388
+ this.logger?.warn?.("[approvals] stranded-request scan could not read the run history", {
4389
+ request: raw?.id,
4390
+ run: runId,
4391
+ error: err?.message ?? String(err)
4392
+ });
4393
+ continue;
4394
+ }
4395
+ if (terminal) continue;
4396
+ const config = parseJson(
4397
+ raw.node_config_json,
4398
+ { approvers: [], behavior: "first_response" }
4399
+ );
4400
+ const mirrorField = config.approvalStatusField;
4401
+ let mirroredStatus;
4402
+ if (mirrorField) {
4403
+ try {
4404
+ const recs = await this.engine.find(raw.object_name, {
4405
+ where: { id: raw.record_id },
4406
+ limit: 1,
4407
+ context: SYSTEM_CTX2
4408
+ });
4409
+ const rec = Array.isArray(recs) ? recs[0] : null;
4410
+ if (rec) mirroredStatus = rec[mirrorField] ?? void 0;
4411
+ } catch {
4412
+ }
4413
+ }
4414
+ stranded.push({
4415
+ requestId: String(raw.id),
4416
+ status: raw.status,
4417
+ runId,
4418
+ flowName: typeof raw.process_name === "string" ? raw.process_name.replace(/^flow:/, "") : void 0,
4419
+ nodeId: raw.flow_node_id ?? raw.current_step ?? void 0,
4420
+ objectName: raw.object_name,
4421
+ recordId: raw.record_id,
4422
+ organizationId: raw.organization_id ?? null,
4423
+ completedAt: raw.completed_at ?? void 0,
4424
+ mirrorField,
4425
+ mirroredStatus
4426
+ });
4427
+ }
4428
+ if (stranded.length || undetermined) {
4429
+ this.logger?.warn?.("[approvals] stranded terminal requests (decision recorded, flow run gone)", {
4430
+ scanned: rows.length,
4431
+ stranded: stranded.length,
4432
+ undetermined,
4433
+ requests: stranded.map((s) => `${s.requestId}@${s.nodeId ?? "?"} \u2192 run ${s.runId}`)
4434
+ });
4435
+ }
4436
+ return { scanned: rows.length, stranded, undetermined };
4437
+ }
4072
4438
  async releaseDeadRunRequests() {
4073
4439
  if (typeof this.automation?.getRun !== "function") return { scanned: 0, released: 0 };
4074
4440
  let rows = [];
@@ -4513,35 +4879,28 @@ var _ApprovalService = class _ApprovalService {
4513
4879
  async rebuildApproverIndex() {
4514
4880
  const desired = /* @__PURE__ */ new Map();
4515
4881
  const PAGE = 500;
4516
- for (let offset = 0; ; offset += PAGE) {
4517
- const batch = await this.engine.find("sys_approval_request", {
4518
- where: { status: "pending" },
4882
+ const requests = (0, import_types.keysetWalk)(
4883
+ (q) => this.engine.find("sys_approval_request", {
4884
+ ...q,
4519
4885
  fields: ["id", "pending_approvers", "organization_id"],
4520
- limit: PAGE,
4521
- offset,
4522
4886
  context: SYSTEM_CTX2
4523
- });
4524
- const rows = Array.isArray(batch) ? batch : [];
4887
+ }),
4888
+ { where: { status: "pending" }, pageSize: PAGE }
4889
+ );
4890
+ for await (const rows of requests.pages()) {
4525
4891
  for (const r of rows) {
4526
4892
  desired.set(String(r.id), {
4527
4893
  approvers: new Set(csvSplit(r.pending_approvers)),
4528
4894
  org: r.organization_id ?? null
4529
4895
  });
4530
4896
  }
4531
- if (rows.length < PAGE) break;
4532
4897
  }
4533
4898
  const indexRows = [];
4534
- for (let offset = 0; ; offset += PAGE) {
4535
- const batch = await this.engine.find("sys_approval_approver", {
4536
- orderBy: [{ field: "created_at", order: "asc" }],
4537
- limit: PAGE,
4538
- offset,
4539
- context: SYSTEM_CTX2
4540
- });
4541
- const rows = Array.isArray(batch) ? batch : [];
4542
- indexRows.push(...rows);
4543
- if (rows.length < PAGE) break;
4544
- }
4899
+ const index = (0, import_types.keysetWalk)(
4900
+ (q) => this.engine.find("sys_approval_approver", { ...q, context: SYSTEM_CTX2 }),
4901
+ { pageSize: PAGE }
4902
+ );
4903
+ for await (const rows of index.pages()) indexRows.push(...rows);
4545
4904
  let inserted = 0;
4546
4905
  let deleted = 0;
4547
4906
  const seen = /* @__PURE__ */ new Map();
@@ -4925,11 +5284,15 @@ var _ApprovalService = class _ApprovalService {
4925
5284
  });
4926
5285
  const actions = Array.isArray(rows) ? rows.map(rowFromAction) : [];
4927
5286
  const names = await this.resolveUserNames(
4928
- actions.map((a) => a.actor_id).filter((id) => id && !id.includes(":"))
5287
+ actions.flatMap((a) => [a.actor_id, a.reassign_from, a.reassign_to]).filter((id) => id && !id.includes(":"))
4929
5288
  );
4930
5289
  for (const a of actions) {
4931
5290
  const n = a.actor_id ? names.get(String(a.actor_id)) : void 0;
4932
5291
  if (n) a.actor_name = n;
5292
+ const fromName = a.reassign_from ? names.get(String(a.reassign_from)) : void 0;
5293
+ if (fromName) a.reassign_from_name = fromName;
5294
+ const toName = a.reassign_to ? names.get(String(a.reassign_to)) : void 0;
5295
+ if (toName) a.reassign_to_name = toName;
4933
5296
  }
4934
5297
  return actions;
4935
5298
  }
@@ -5132,21 +5495,109 @@ function parseJson2(raw, fallback) {
5132
5495
  }
5133
5496
  return raw;
5134
5497
  }
5498
+ var PENDING_LOCK_LIMIT = 1e3;
5499
+ var SYSTEM_CTX3 = { isSystem: true, positions: [], permissions: [] };
5500
+ function lockedError(message) {
5501
+ const err = new Error(`RECORD_LOCKED: ${message}`);
5502
+ err.code = "RECORD_LOCKED";
5503
+ err.statusCode = 409;
5504
+ throw err;
5505
+ }
5506
+ function asIdList(id) {
5507
+ if (typeof id === "number") return [id];
5508
+ if (typeof id === "string") return id === "" ? null : [id];
5509
+ if (id && typeof id === "object" && Array.isArray(id.$in)) {
5510
+ const raw = id.$in;
5511
+ const scalars = raw.filter((v) => typeof v === "string" || typeof v === "number");
5512
+ return scalars.length === raw.length ? scalars : null;
5513
+ }
5514
+ return null;
5515
+ }
5135
5516
  async function pendingRequestFor(engine, objectName, recordId) {
5136
5517
  try {
5137
5518
  const rows = await engine.find("sys_approval_request", {
5138
5519
  where: { object_name: objectName, record_id: String(recordId), status: "pending" },
5139
- limit: 1
5520
+ limit: 1,
5521
+ context: { ...SYSTEM_CTX3 }
5140
5522
  });
5141
5523
  return Array.isArray(rows) && rows[0] ? rows[0] : null;
5142
5524
  } catch {
5143
5525
  return null;
5144
5526
  }
5145
5527
  }
5528
+ async function pendingRequestsForRecords(engine, objectName, recordIds) {
5529
+ if (recordIds.length === 0) return [];
5530
+ if (recordIds.length > PENDING_LOCK_LIMIT) {
5531
+ lockedError(
5532
+ `refusing to authorize an update naming more than ${PENDING_LOCK_LIMIT} records of '${objectName}' \u2014 the approval lock cannot check them row by row; scope the write`
5533
+ );
5534
+ }
5535
+ if (recordIds.length === 1) {
5536
+ const one = await pendingRequestFor(engine, objectName, String(recordIds[0]));
5537
+ return one ? [one] : [];
5538
+ }
5539
+ try {
5540
+ const rows = await engine.find("sys_approval_request", {
5541
+ where: { object_name: objectName, record_id: { $in: recordIds.map(String) }, status: "pending" },
5542
+ limit: PENDING_LOCK_LIMIT + 1,
5543
+ context: { ...SYSTEM_CTX3 }
5544
+ });
5545
+ return Array.isArray(rows) ? rows : [];
5546
+ } catch {
5547
+ return [];
5548
+ }
5549
+ }
5550
+ async function pendingRequestsForObject(engine, objectName) {
5551
+ try {
5552
+ const rows = await engine.find("sys_approval_request", {
5553
+ where: { object_name: objectName, status: "pending" },
5554
+ limit: PENDING_LOCK_LIMIT + 1,
5555
+ context: { ...SYSTEM_CTX3 }
5556
+ });
5557
+ return Array.isArray(rows) ? rows : [];
5558
+ } catch {
5559
+ return null;
5560
+ }
5561
+ }
5562
+ async function narrowToMatchedRecords(engine, objectName, where, candidates) {
5563
+ const lockedIds = candidates.map((c) => String(c?.record_id ?? ""));
5564
+ let rows;
5565
+ try {
5566
+ rows = await engine.find(objectName, {
5567
+ where: { $and: [where, { id: { $in: lockedIds } }] },
5568
+ fields: ["id"],
5569
+ limit: lockedIds.length,
5570
+ context: { ...SYSTEM_CTX3 }
5571
+ });
5572
+ } catch (err) {
5573
+ lockedError(
5574
+ `cannot determine which rows a predicate update on '${objectName}' would touch (${err?.message ?? String(err)}); ${candidates.length} record(s) of it carry a pending approval, so the write is refused`
5575
+ );
5576
+ }
5577
+ const matched = new Set((Array.isArray(rows) ? rows : []).map((r) => String(r?.id)));
5578
+ return candidates.filter((c) => matched.has(String(c?.record_id ?? "")));
5579
+ }
5580
+ async function gatingRequests(engine, ctx, objectName) {
5581
+ const byId = asIdList(ctx?.input?.id);
5582
+ if (byId) return pendingRequestsForRecords(engine, objectName, byId);
5583
+ const rawWhere = ctx?.input?.options?.where;
5584
+ const hasWhere = rawWhere !== void 0 && rawWhere !== null;
5585
+ const whereObj = hasWhere && typeof rawWhere === "object" && !Array.isArray(rawWhere) ? rawWhere : null;
5586
+ const namedIds = whereObj ? asIdList(whereObj.id) : null;
5587
+ const candidates = namedIds ? await pendingRequestsForRecords(engine, objectName, namedIds) : await pendingRequestsForObject(engine, objectName);
5588
+ if (candidates === null) return [];
5589
+ if (candidates.length === 0) return [];
5590
+ if (candidates.length > PENDING_LOCK_LIMIT) {
5591
+ lockedError(
5592
+ `refusing a predicate update on '${objectName}': more than ${PENDING_LOCK_LIMIT} of its records carry a pending approval, so the lock cannot decide row by row; scope the write to the rows you mean`
5593
+ );
5594
+ }
5595
+ if (!hasWhere) return candidates;
5596
+ if (namedIds && whereObj && Object.keys(whereObj).every((k) => k === "id")) return candidates;
5597
+ return narrowToMatchedRecords(engine, objectName, rawWhere, candidates);
5598
+ }
5146
5599
  function bindApprovalLockHook(engine, logger) {
5147
5600
  engine.registerHook("beforeUpdate", async (ctx) => {
5148
- const id = String(ctx?.input?.id ?? "");
5149
- if (!id) return;
5150
5601
  const object = ctx?.object ?? ctx?.objectName;
5151
5602
  if (!object || String(object).startsWith("sys_approval")) return;
5152
5603
  const data = ctx?.input?.data ?? {};
@@ -5155,18 +5606,19 @@ function bindApprovalLockHook(engine, logger) {
5155
5606
  if (ctx?.session?.isSystem) return;
5156
5607
  const roles = ctx?.session?.roles ?? [];
5157
5608
  if (Array.isArray(roles) && roles.includes("admin")) return;
5158
- const pending = await pendingRequestFor(engine, object, id);
5159
- if (!pending) return;
5609
+ const gating = await gatingRequests(engine, ctx, object);
5610
+ if (gating.length === 0) return;
5160
5611
  const writerRun = ctx?.provenance?.flowRunId;
5161
- if (writerRun && pending.flow_run_id && String(writerRun) === String(pending.flow_run_id)) return;
5162
- const config = parseJson2(pending.node_config_json, {});
5163
- if (config?.lockRecord === false) return;
5164
- const mirror = config?.approvalStatusField;
5165
- if (typeof mirror === "string" && mirror && changedFields.every((f) => f === mirror)) return;
5166
- const err = new Error("RECORD_LOCKED: record is locked while an approval is in progress");
5167
- err.code = "RECORD_LOCKED";
5168
- err.statusCode = 409;
5169
- throw err;
5612
+ for (const pending of gating) {
5613
+ if (writerRun && pending?.flow_run_id && String(writerRun) === String(pending.flow_run_id)) continue;
5614
+ const config = parseJson2(pending?.node_config_json, {});
5615
+ if (config?.lockRecord === false) continue;
5616
+ const mirror = config?.approvalStatusField;
5617
+ if (typeof mirror === "string" && mirror && changedFields.every((f) => f === mirror)) continue;
5618
+ lockedError(
5619
+ `record '${String(pending?.record_id ?? "")}' of '${object}' is locked while an approval is in progress`
5620
+ );
5621
+ }
5170
5622
  }, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
5171
5623
  logger?.info?.("[approvals] record-lock hook bound");
5172
5624
  }
@@ -5210,7 +5662,7 @@ function unbindAllHooks(engine) {
5210
5662
 
5211
5663
  // src/approval-node.ts
5212
5664
  var import_automation2 = require("@objectstack/spec/automation");
5213
- var SYSTEM_CTX3 = { isSystem: true, positions: [], permissions: [] };
5665
+ var SYSTEM_CTX4 = { isSystem: true, positions: [], permissions: [] };
5214
5666
  function nestVariables(variables) {
5215
5667
  const vars = {};
5216
5668
  for (const [key, value] of variables) {
@@ -5289,7 +5741,7 @@ function registerApprovalNode(automation, service, logger) {
5289
5741
  // their `trigger.*` snapshot root.
5290
5742
  variables: nestVariables(variables)
5291
5743
  }, {
5292
- ...SYSTEM_CTX3,
5744
+ ...SYSTEM_CTX4,
5293
5745
  userId: context?.userId,
5294
5746
  organizationId: context?.organizationId,
5295
5747
  tenantId: context?.tenantId
@@ -5433,7 +5885,13 @@ var ApprovalsServicePlugin = class {
5433
5885
  const sweep = async () => {
5434
5886
  const results = await Promise.allSettled([
5435
5887
  svc.runEscalations(),
5436
- svc.releaseDeadRunRequests()
5888
+ svc.releaseDeadRunRequests(),
5889
+ // #4469 — the other half of the dead-run picture, and the one no
5890
+ // sweeper could see: a request already TERMINAL whose run is gone.
5891
+ // Read-only by design (it reports; it never rewrites a decision
5892
+ // that really happened), so it rides the same clock purely to make
5893
+ // the finding surface without an operator knowing to go looking.
5894
+ svc.inspectStrandedRequests()
5437
5895
  ]);
5438
5896
  for (const r of results) {
5439
5897
  if (r.status === "rejected") {
@@ -5508,14 +5966,19 @@ var ApprovalsServicePlugin = class {
5508
5966
  await mountActionPages();
5509
5967
  await backfillApproverIndex();
5510
5968
  }
5969
+ let automation;
5511
5970
  try {
5512
- const automation = ctx.getService("automation");
5513
- if (automation && typeof automation.registerNodeExecutor === "function") {
5514
- this.service.attachAutomation(automation);
5515
- registerApprovalNode(automation, this.service, ctx.logger);
5516
- }
5971
+ automation = ctx.getService("automation");
5517
5972
  } catch {
5518
- ctx.logger.info("ApprovalsServicePlugin: no automation engine \u2014 approval node not registered");
5973
+ automation = void 0;
5974
+ }
5975
+ if (automation && typeof automation.registerNodeExecutor === "function") {
5976
+ this.service.attachAutomation(automation);
5977
+ registerApprovalNode(automation, this.service, ctx.logger);
5978
+ } else {
5979
+ ctx.logger.warn(
5980
+ "ApprovalsServicePlugin: no automation engine \u2014 the `approval` flow node is NOT registered. Every ADR-0019 approval flow in this deployment fails at execution time with NO_EXECUTOR. Add @objectstack/service-automation to the stack to enable them."
5981
+ );
5519
5982
  }
5520
5983
  }
5521
5984
  async stop(ctx) {