@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.mjs CHANGED
@@ -245,6 +245,18 @@ var init_en_objects_generated = __esm({
245
245
  comment: {
246
246
  label: "Comment"
247
247
  },
248
+ via_override: {
249
+ label: "Via Admin Override",
250
+ 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."
251
+ },
252
+ reassign_from: {
253
+ label: "Reassigned From",
254
+ help: "User whose pending-approver slot was handed over (reassign actions only)"
255
+ },
256
+ reassign_to: {
257
+ label: "Reassigned To",
258
+ help: "User who received the pending-approver slot (reassign actions only)"
259
+ },
248
260
  attachments: {
249
261
  label: "Attachments",
250
262
  help: "Files supporting this action \u2014 e.g. a signed contract or evidence (#3266)."
@@ -554,6 +566,18 @@ var init_zh_CN_objects_generated = __esm({
554
566
  comment: {
555
567
  label: "\u8BC4\u8BBA"
556
568
  },
569
+ via_override: {
570
+ label: "\u7BA1\u7406\u5458\u8D8A\u6743\u64CD\u4F5C",
571
+ 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"
572
+ },
573
+ reassign_from: {
574
+ label: "\u8F6C\u51FA\u4EBA",
575
+ help: "\u88AB\u79FB\u4EA4\u5F85\u5BA1\u6279\u69FD\u4F4D\u7684\u7528\u6237\uFF08\u4EC5\u8F6C\u7B7E\u64CD\u4F5C\uFF09"
576
+ },
577
+ reassign_to: {
578
+ label: "\u8F6C\u5165\u4EBA",
579
+ help: "\u63A5\u6536\u5F85\u5BA1\u6279\u69FD\u4F4D\u7684\u7528\u6237\uFF08\u4EC5\u8F6C\u7B7E\u64CD\u4F5C\uFF09"
580
+ },
557
581
  attachments: {
558
582
  label: "\u9644\u4EF6",
559
583
  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"
@@ -863,6 +887,18 @@ var init_ja_JP_objects_generated = __esm({
863
887
  comment: {
864
888
  label: "\u30B3\u30E1\u30F3\u30C8"
865
889
  },
890
+ via_override: {
891
+ label: "\u7BA1\u7406\u8005\u30AA\u30FC\u30D0\u30FC\u30E9\u30A4\u30C9\u7D4C\u7531",
892
+ 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"
893
+ },
894
+ reassign_from: {
895
+ label: "\u5F15\u304D\u7D99\u304E\u5143",
896
+ 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"
897
+ },
898
+ reassign_to: {
899
+ label: "\u5F15\u304D\u7D99\u304E\u5148",
900
+ 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"
901
+ },
866
902
  attachments: {
867
903
  label: "\u6DFB\u4ED8\u30D5\u30A1\u30A4\u30EB",
868
904
  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"
@@ -1172,6 +1208,18 @@ var init_es_ES_objects_generated = __esm({
1172
1208
  comment: {
1173
1209
  label: "Comentario"
1174
1210
  },
1211
+ via_override: {
1212
+ label: "Mediante anulaci\xF3n de administrador",
1213
+ 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."
1214
+ },
1215
+ reassign_from: {
1216
+ label: "Reasignado de",
1217
+ help: "Usuario cuyo turno de aprobaci\xF3n pendiente fue traspasado (solo acciones de reasignaci\xF3n)"
1218
+ },
1219
+ reassign_to: {
1220
+ label: "Reasignado a",
1221
+ help: "Usuario que recibi\xF3 el turno de aprobaci\xF3n pendiente (solo acciones de reasignaci\xF3n)"
1222
+ },
1175
1223
  attachments: {
1176
1224
  label: "Adjuntos",
1177
1225
  help: "Archivos que respaldan esta acci\xF3n, p. ej. un contrato firmado o pruebas (#3266)."
@@ -1273,6 +1321,7 @@ var init_translations = __esm({
1273
1321
 
1274
1322
  // src/sys-approval-request.object.ts
1275
1323
  import { ObjectSchema, Field } from "@objectstack/spec/data";
1324
+ import { APPROVAL_STATUSES } from "@objectstack/spec/contracts";
1276
1325
  var SysApprovalRequest = ObjectSchema.create({
1277
1326
  name: "sys_approval_request",
1278
1327
  label: "Approval Request",
@@ -1371,9 +1420,10 @@ var SysApprovalRequest = ObjectSchema.create({
1371
1420
  group: "Target"
1372
1421
  }),
1373
1422
  status: Field.select(
1374
- // Keep in sync with `ApprovalStatus` (spec/contracts). `returned` =
1375
- // sent back for revision (ADR-0044) terminal for this round.
1376
- ["pending", "approved", "rejected", "recalled", "returned"],
1423
+ // Spread from the contract, not re-typed (#3786). `APPROVAL_STATUSES` is
1424
+ // where the list and the reason for each entry live; `ApprovalStatus` is
1425
+ // derived from it, so this column and the contract cannot disagree.
1426
+ [...APPROVAL_STATUSES],
1377
1427
  {
1378
1428
  label: "Status",
1379
1429
  required: true,
@@ -1642,6 +1692,7 @@ var SysApprovalRequest = ObjectSchema.create({
1642
1692
 
1643
1693
  // src/sys-approval-action.object.ts
1644
1694
  import { ObjectSchema as ObjectSchema2, Field as Field2 } from "@objectstack/spec/data";
1695
+ import { APPROVAL_ACTION_KINDS } from "@objectstack/spec/contracts";
1645
1696
  var SysApprovalAction = ObjectSchema2.create({
1646
1697
  name: "sys_approval_action",
1647
1698
  label: "Approval Action",
@@ -1654,7 +1705,7 @@ var SysApprovalAction = ObjectSchema2.create({
1654
1705
  nameField: "id",
1655
1706
  // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
1656
1707
  titleFormat: "{action} \xB7 {step_name}",
1657
- highlightFields: ["request_id", "step_name", "action", "actor_id", "created_at"],
1708
+ highlightFields: ["request_id", "step_name", "action", "actor_id", "via_override", "created_at"],
1658
1709
  // ADR-0104 D3 wave 2. `attachments` is a media field, so the files it holds
1659
1710
  // are OWNED by this row — and the storage service would otherwise authorize
1660
1711
  // their download by testing whether the caller can READ this row. It cannot:
@@ -1669,7 +1720,7 @@ var SysApprovalAction = ObjectSchema2.create({
1669
1720
  name: "recent",
1670
1721
  label: "Recent",
1671
1722
  data: { provider: "object", object: "sys_approval_action" },
1672
- columns: ["created_at", "request_id", "step_name", "action", "actor_id", "comment"],
1723
+ columns: ["created_at", "request_id", "step_name", "action", "actor_id", "via_override", "comment"],
1673
1724
  sort: [{ field: "created_at", order: "desc" }],
1674
1725
  pagination: { pageSize: 50 },
1675
1726
  emptyState: { title: "No approval actions yet", message: "Actions are logged automatically when approvals progress." }
@@ -1689,7 +1740,7 @@ var SysApprovalAction = ObjectSchema2.create({
1689
1740
  name: "all_actions",
1690
1741
  label: "All",
1691
1742
  data: { provider: "object", object: "sys_approval_action" },
1692
- columns: ["created_at", "request_id", "step_name", "action", "actor_id", "comment"],
1743
+ columns: ["created_at", "request_id", "step_name", "action", "actor_id", "via_override", "comment"],
1693
1744
  sort: [{ field: "created_at", order: "desc" }],
1694
1745
  pagination: { pageSize: 100 }
1695
1746
  }
@@ -1720,12 +1771,11 @@ var SysApprovalAction = ObjectSchema2.create({
1720
1771
  group: "Target"
1721
1772
  }),
1722
1773
  action: Field2.select(
1723
- // Keep in sync with `ApprovalActionKind` (spec/contracts). reassign /
1724
- // remind / request_info / comment are thread interactions they never
1725
- // move the flow. revise / resubmit (ADR-0044) DO move it: send back for
1726
- // revision and the later resubmission. ooo_substitute (#1322 M1) is a
1727
- // system-recorded reroute of an out-of-office approver — no flow movement.
1728
- ["submit", "approve", "reject", "recall", "escalate", "reassign", "remind", "request_info", "comment", "revise", "resubmit", "ooo_substitute"],
1774
+ // Spread from the contract, not re-typed (#3786). `APPROVAL_ACTION_KINDS`
1775
+ // is where the list and the per-kind notes live (which kinds move the flow
1776
+ // and which are thread-only); `ApprovalActionKind` is derived from it, so
1777
+ // this column and the contract cannot disagree.
1778
+ [...APPROVAL_ACTION_KINDS],
1729
1779
  {
1730
1780
  label: "Action",
1731
1781
  required: true,
@@ -1738,6 +1788,46 @@ var SysApprovalAction = ObjectSchema2.create({
1738
1788
  group: "Action"
1739
1789
  }),
1740
1790
  comment: Field2.textarea({ label: "Comment", required: false, group: "Action" }),
1791
+ // #4466 — the one bit of "who really decided this" that was still dropped.
1792
+ // A privileged admin may act on a request whose staffed approver slate they
1793
+ // hold no slot in (the #3424 override path); before this column, that
1794
+ // decision was byte-for-byte identical to the designated approver's own
1795
+ // approval. A reader of the timeline saw `approve` by the admin and could
1796
+ // not tell whether the admin WAS an approver or OVERRODE the ones who were,
1797
+ // and the bypassed approver's later `409 INVALID_STATE` was the only trace
1798
+ // — existing only if they happened to try.
1799
+ //
1800
+ // The platform KNOWS at decision time: it took the `isOverrideActor` branch
1801
+ // to admit the call at all. This is dropped information, not unavailable
1802
+ // information.
1803
+ //
1804
+ // Set on exactly the decisions that were admitted BY that branch — an admin
1805
+ // who is also a genuine slot holder is approving normally and is recorded
1806
+ // as such. Nullable and additive: rows written before this column exists
1807
+ // carry `null`, which reads as "not recorded", never as "not an override".
1808
+ via_override: Field2.boolean({
1809
+ label: "Via Admin Override",
1810
+ required: false,
1811
+ group: "Action",
1812
+ 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."
1813
+ }),
1814
+ // Structured hand-off parties for `action: 'reassign'` (#4365). Before
1815
+ // these existed the pair lived only inside a default free-text comment
1816
+ // ("<from_id> → <to_id>"), which no client could parse or render readably.
1817
+ // `comment` is pure user input again; timelines render "from A to B" from
1818
+ // these fields.
1819
+ reassign_from: Field2.lookup("sys_user", {
1820
+ label: "Reassigned From",
1821
+ required: false,
1822
+ group: "Action",
1823
+ description: "User whose pending-approver slot was handed over (reassign actions only)"
1824
+ }),
1825
+ reassign_to: Field2.lookup("sys_user", {
1826
+ label: "Reassigned To",
1827
+ required: false,
1828
+ group: "Action",
1829
+ description: "User who received the pending-approver slot (reassign actions only)"
1830
+ }),
1741
1831
  attachments: Field2.file({
1742
1832
  label: "Attachments",
1743
1833
  required: false,
@@ -1828,12 +1918,11 @@ var SysApprovalDelegation = ObjectSchema4.create({
1828
1918
  pluralLabel: "Approval Delegations",
1829
1919
  icon: "user-clock",
1830
1920
  isSystem: true,
1831
- managedBy: "system",
1832
- // [ADR-0103] Admin/user-writable DATA on a platform-defined schema: a user
1833
- // authors their own out-of-office delegation. Affordance only (matches the
1834
- // full-CRUD apiMethods below) — RLS/permission sets are the authz; opening it
1835
- // keeps the system write guard from rejecting the self-service write.
1836
- userActions: { create: true, edit: true, delete: true },
1921
+ // [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema: a
1922
+ // user authors their own out-of-office delegation. The bucket default is full
1923
+ // CRUD (matching the full-CRUD apiMethods below), so no `userActions` block is
1924
+ // needed — RLS/permission sets are the authz.
1925
+ managedBy: "system-data",
1837
1926
  description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
1838
1927
  titleFormat: "{delegator_id} \u2192 {delegate_id}",
1839
1928
  highlightFields: ["delegator_id", "delegate_id", "valid_from", "valid_until"],
@@ -1929,6 +2018,7 @@ import {
1929
2018
  normalizeDecisionOutputs
1930
2019
  } from "@objectstack/spec/automation";
1931
2020
  import { ExpressionEngine, collectCelRootIdentifiers } from "@objectstack/formula";
2021
+ import { keysetWalk } from "@objectstack/types";
1932
2022
  import {
1933
2023
  ADMIN_FULL_ACCESS,
1934
2024
  ORGANIZATION_ADMIN_GRANTS,
@@ -1949,7 +2039,7 @@ function fail(message) {
1949
2039
  async function findOrg(engine, where) {
1950
2040
  try {
1951
2041
  const rows = await engine.find("sys_organization", {
1952
- filter: where,
2042
+ where,
1953
2043
  fields: ["id", "slug", "parent_organization_id"],
1954
2044
  limit: 1,
1955
2045
  context: SYSTEM_CTX
@@ -2036,7 +2126,7 @@ async function filterApproversWhoCanRead(deps, userIds, requestOrgId, context) {
2036
2126
  let members = [];
2037
2127
  try {
2038
2128
  members = await deps.engine.find("sys_member", {
2039
- filter: { organization_id: requestOrg, user_id: { $in: userIds } },
2129
+ where: { organization_id: requestOrg, user_id: { $in: userIds } },
2040
2130
  fields: ["user_id"],
2041
2131
  limit: 1e4,
2042
2132
  context: SYSTEM_CTX
@@ -2068,6 +2158,7 @@ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
2068
2158
  "cancelled",
2069
2159
  "timed_out"
2070
2160
  ]);
2161
+ var STRANDABLE_REQUEST_STATUSES = ["approved", "rejected", "returned"];
2071
2162
  var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
2072
2163
  var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
2073
2164
  function actingUserId(context) {
@@ -2106,6 +2197,12 @@ function csvSplit(raw) {
2106
2197
  if (Array.isArray(raw)) return raw.map(String).filter(Boolean);
2107
2198
  return String(raw).split(",").map((s) => s.trim()).filter(Boolean);
2108
2199
  }
2200
+ function isBlankDecisionOutput(value) {
2201
+ if (value === void 0 || value === null) return true;
2202
+ if (typeof value === "string") return value.trim() === "";
2203
+ if (Array.isArray(value)) return value.filter((v) => v !== null && v !== void 0 && String(v).trim() !== "").length === 0;
2204
+ return false;
2205
+ }
2109
2206
  function prettifyMachineName(raw) {
2110
2207
  if (!raw) return void 0;
2111
2208
  const base = String(raw).replace(/^flow:/, "").trim();
@@ -2198,6 +2295,14 @@ function rowFromAction(row) {
2198
2295
  action: row.action,
2199
2296
  actor_id: row.actor_id ?? void 0,
2200
2297
  comment: row.comment ?? void 0,
2298
+ // Structured reassign hand-off parties (#4365).
2299
+ reassign_from: row.reassign_from ?? void 0,
2300
+ reassign_to: row.reassign_to ?? void 0,
2301
+ // #4466 — surfaced so a timeline can SAY "overridden the approver slate"
2302
+ // rather than render an override identically to an ordinary approval.
2303
+ // `null` (a row written before the column existed) stays `undefined`:
2304
+ // "not recorded" is not the same claim as "not an override".
2305
+ via_override: row.via_override == null ? void 0 : row.via_override === true,
2201
2306
  // Decision attachments (#3266): rich descriptors carrying the display name +
2202
2307
  // download URL, so consumers label/open them without reading `sys_file`.
2203
2308
  attachments: attachments.length ? attachments : void 0,
@@ -2337,9 +2442,10 @@ var _ApprovalService = class _ApprovalService {
2337
2442
  * (having also put them on the context). Those are the only two callers that
2338
2443
  * hold a trustworthy actor with no session behind them.
2339
2444
  *
2340
- * A caller with NO identity at all cannot act. That case is reachable: the
2341
- * REST anonymous-deny only fires when `api.requireAuth` is set, so without it
2342
- * an anonymous request previously decided approvals outright by naming one.
2445
+ * A caller with NO identity at all cannot act. Belt-and-suspenders: the REST
2446
+ * anonymous-deny now denies every anonymous request (#3963), but this service
2447
+ * must not rely on a caller upstream an anonymous actor could otherwise
2448
+ * decide approvals outright by naming one.
2343
2449
  */
2344
2450
  async resolveActor(actorId, context) {
2345
2451
  if (context?.isSystem) {
@@ -2625,7 +2731,7 @@ var _ApprovalService = class _ApprovalService {
2625
2731
  let rows = [];
2626
2732
  try {
2627
2733
  rows = await this.engine.find("sys_team_member", {
2628
- filter: { team_id: teamId },
2734
+ where: { team_id: teamId },
2629
2735
  fields: ["user_id"],
2630
2736
  limit: 1e4,
2631
2737
  context: SYSTEM_CTX2
@@ -2664,7 +2770,7 @@ var _ApprovalService = class _ApprovalService {
2664
2770
  if (!businessUnitId) return [];
2665
2771
  try {
2666
2772
  const seed = await this.engine.find("sys_business_unit", {
2667
- filter: this.businessUnitOrgScope({ id: businessUnitId }, organizationId),
2773
+ where: this.businessUnitOrgScope({ id: businessUnitId }, organizationId),
2668
2774
  fields: ["id", "active"],
2669
2775
  limit: 1,
2670
2776
  context: SYSTEM_CTX2
@@ -2699,7 +2805,7 @@ var _ApprovalService = class _ApprovalService {
2699
2805
  let rows = [];
2700
2806
  try {
2701
2807
  rows = await this.engine.find("sys_business_unit_member", {
2702
- filter: { business_unit_id: { $in: Array.from(seen) } },
2808
+ where: { business_unit_id: { $in: Array.from(seen) } },
2703
2809
  fields: ["user_id"],
2704
2810
  limit: 1e4,
2705
2811
  context: SYSTEM_CTX2
@@ -2759,7 +2865,7 @@ var _ApprovalService = class _ApprovalService {
2759
2865
  async lookupManager(userId) {
2760
2866
  try {
2761
2867
  const rows = await this.engine.find("sys_user", {
2762
- filter: { id: userId },
2868
+ where: { id: userId },
2763
2869
  fields: ["id", "manager_id"],
2764
2870
  limit: 1,
2765
2871
  context: SYSTEM_CTX2
@@ -2809,7 +2915,7 @@ var _ApprovalService = class _ApprovalService {
2809
2915
  let rows = [];
2810
2916
  try {
2811
2917
  rows = await this.engine.find("sys_approval_delegation", {
2812
- filter: { delegator_id: delegatorId },
2918
+ where: { delegator_id: delegatorId },
2813
2919
  fields: ["id", "delegator_id", "delegate_id", "valid_from", "valid_until", "reason", "organization_id"],
2814
2920
  limit: 50,
2815
2921
  context: SYSTEM_CTX2
@@ -3128,6 +3234,7 @@ var _ApprovalService = class _ApprovalService {
3128
3234
  if (!isSlotHolder && !isOverride) {
3129
3235
  throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
3130
3236
  }
3237
+ const viaOverride = isOverride && !isSlotHolder;
3131
3238
  const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
3132
3239
  const org = raw.organization_id ?? null;
3133
3240
  const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
@@ -3135,8 +3242,9 @@ var _ApprovalService = class _ApprovalService {
3135
3242
  const now = this.clock.now().toISOString();
3136
3243
  const outputKeys = input.outputs ? Object.keys(input.outputs) : [];
3137
3244
  let acceptedOutputs;
3245
+ const declaredDefs = normalizeDecisionOutputs(config.decisionOutputs);
3138
3246
  if (outputKeys.length) {
3139
- const declared = normalizeDecisionOutputs(config.decisionOutputs).map((d) => d.key);
3247
+ const declared = declaredDefs.map((d) => d.key);
3140
3248
  if (!declared.length) {
3141
3249
  throw new Error(
3142
3250
  `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.`
@@ -3156,6 +3264,15 @@ var _ApprovalService = class _ApprovalService {
3156
3264
  }
3157
3265
  acceptedOutputs = { ...input.outputs };
3158
3266
  }
3267
+ if (input.decision === "approve") {
3268
+ const missing = declaredDefs.filter((d) => d.required === true && isBlankDecisionOutput(input.outputs?.[d.key])).map((d) => d.key);
3269
+ if (missing.length) {
3270
+ throw new Error(
3271
+ `VALIDATION_FAILED: decision output(s) \`${missing.join("`, `")}\` are required to approve this request \u2014 open the approval and fill them in before approving.`
3272
+ );
3273
+ }
3274
+ }
3275
+ await this.assertRunResumable(runId, requestId);
3159
3276
  await this.engine.insert("sys_approval_action", {
3160
3277
  id: uid("aact"),
3161
3278
  request_id: requestId,
@@ -3165,6 +3282,10 @@ var _ApprovalService = class _ApprovalService {
3165
3282
  action: input.decision,
3166
3283
  actor_id: actorId,
3167
3284
  comment: input.comment ?? null,
3285
+ // #4466: the override is recorded on the DECISION, not inferred later.
3286
+ // Written as an explicit `false` for an ordinary decision so a reader can
3287
+ // tell "checked, and it was not an override" from a legacy row's `null`.
3288
+ via_override: viaOverride,
3168
3289
  attachments: input.attachments?.length ? input.attachments : null,
3169
3290
  created_at: now
3170
3291
  }, { context: SYSTEM_CTX2 });
@@ -3251,22 +3372,131 @@ var _ApprovalService = class _ApprovalService {
3251
3372
  * Callers still guard on `typeof this.automation?.resume === 'function'`
3252
3373
  * (approvals runs fine with no automation attached) and keep their own
3253
3374
  * try/catch, because what a failed resume means differs per path.
3375
+ *
3376
+ * Throws when the engine REPORTS failure, not only when it throws one. The
3377
+ * engine answers a lost run with `{ success: false, code: 'RUN_NOT_FOUND' }`
3378
+ * — a plain return value that every caller here used to discard, which is
3379
+ * how an approval could be recorded, reported as resumed, and leave its flow
3380
+ * stranded forever (#4420). The thrown error carries {@link resumeCodeOf}'s
3381
+ * `resumeCode` so callers can tell a benign duplicate from a dead run.
3254
3382
  */
3255
3383
  async serviceResume(runId, signal) {
3256
- await this.automation.resume(runId, { ...signal, [RESUME_AUTHORITY_SERVICE]: true });
3384
+ const result = await this.automation.resume(runId, { ...signal, [RESUME_AUTHORITY_SERVICE]: true });
3385
+ const reported = result;
3386
+ if (reported && typeof reported === "object" && reported.success === false) {
3387
+ const err = new Error(
3388
+ `resume of run '${runId}' failed${reported.code ? ` [${reported.code}]` : ""}: ${reported.error ?? "unknown error"}`
3389
+ );
3390
+ err.resumeCode = reported.code;
3391
+ throw err;
3392
+ }
3393
+ }
3394
+ /** The engine failure code behind a {@link serviceResume} rejection, if any. */
3395
+ static resumeCodeOf(err) {
3396
+ return err?.resumeCode;
3397
+ }
3398
+ /**
3399
+ * Refuse an operation whose whole point is to advance a flow run when that
3400
+ * run no longer exists — BEFORE anything is written down (#4420).
3401
+ *
3402
+ * The half-state this prevents is the one the issue reported: a request
3403
+ * flipped to `approved`, a success toast, and a flow that never moves. Once
3404
+ * the decision row is written there is nothing left to fail cleanly.
3405
+ *
3406
+ * Deliberately permissive at the edges:
3407
+ * - no automation attached, or an engine without `hasSuspendedRun` → no
3408
+ * pre-flight at all (standalone approvals compositions are unaffected);
3409
+ * - the store cannot be READ → fail OPEN. A transient outage must not block
3410
+ * every decision in the tenant; the post-resume check still catches a real
3411
+ * failure and reports it loudly.
3412
+ */
3413
+ async assertRunResumable(runId, requestId) {
3414
+ if (!runId) return;
3415
+ if (typeof this.automation?.resume !== "function") return;
3416
+ if (typeof this.automation?.hasSuspendedRun !== "function") return;
3417
+ let alive;
3418
+ try {
3419
+ alive = await this.automation.hasSuspendedRun(runId);
3420
+ } catch (err) {
3421
+ this.logger?.warn?.("[approvals] could not verify the flow run is resumable \u2014 proceeding", {
3422
+ request: requestId,
3423
+ run: runId,
3424
+ error: err?.message ?? String(err)
3425
+ });
3426
+ return;
3427
+ }
3428
+ if (!alive) {
3429
+ throw new Error(
3430
+ `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.`
3431
+ );
3432
+ }
3433
+ }
3434
+ /**
3435
+ * Resume the run behind an outcome that has ALREADY been written down, and
3436
+ * fail loudly when it cannot be (#4420).
3437
+ *
3438
+ * For the operations whose product is the resume — a finalised decision, a
3439
+ * send-back, a resubmit. Their rows are durable by the time this runs, so a
3440
+ * failure here cannot be undone; the one thing left worth doing is refusing
3441
+ * to call it success. {@link assertRunResumable} is what keeps this rare:
3442
+ * everything it catches never reaches a write.
3443
+ *
3444
+ * `RESUME_IN_PROGRESS` is the exception — a concurrent resume is already
3445
+ * advancing the run, so the outcome stands and only `resumed` is false.
3446
+ *
3447
+ * @param what - how the recorded outcome reads in the error, e.g.
3448
+ * `"the approve decision"`.
3449
+ */
3450
+ async resumeRecordedOutcome(runId, requestId, what, signal) {
3451
+ try {
3452
+ await this.serviceResume(runId, signal);
3453
+ return { resumed: true };
3454
+ } catch (err) {
3455
+ const reason = err?.message ?? String(err);
3456
+ if (_ApprovalService.resumeCodeOf(err) === "RESUME_IN_PROGRESS") {
3457
+ this.logger?.warn?.("[approvals] resume skipped \u2014 already in progress", {
3458
+ request: requestId,
3459
+ run: runId,
3460
+ outcome: what
3461
+ });
3462
+ return { resumed: false, resumeError: reason };
3463
+ }
3464
+ this.logger?.error?.("[approvals] resume failed \u2014 the run is stranded", {
3465
+ request: requestId,
3466
+ run: runId,
3467
+ outcome: what,
3468
+ error: reason
3469
+ });
3470
+ throw new Error(
3471
+ `RESUME_FAILED: ${what} was recorded on request ${requestId}, but its flow run '${runId}' could not be resumed and is now stranded: ${reason}`
3472
+ );
3473
+ }
3257
3474
  }
3258
3475
  /**
3259
3476
  * Public contract entrypoint (ADR-0019). Records a decision on a node-driven
3260
3477
  * request via {@link ApprovalService.decideNode} and, when it finalizes,
3261
3478
  * resumes the owning flow run down the matching `approve` / `reject` edge.
3479
+ *
3480
+ * A finalising decision whose run cannot be resumed FAILS (#4420). The
3481
+ * decision is already durable by then, so the failure cannot be rolled back
3482
+ * — but it must not be reported as success either: this used to answer HTTP
3483
+ * 200 with `resumed: true` while the flow stayed parked forever, which left
3484
+ * the approver with no signal and the record mirroring a stage it never
3485
+ * reached. `decideNode`'s pre-flight means the common case (the run died
3486
+ * before the decision) never gets this far; what survives here is a genuine
3487
+ * race, and it names the stranded run.
3262
3488
  */
3263
3489
  async decide(requestId, input, context) {
3264
3490
  const result = await this.decideNode(requestId, input, context);
3265
3491
  let resumed = false;
3492
+ let resumeError;
3266
3493
  if (result.finalized && result.runId && typeof this.automation?.resume === "function") {
3267
3494
  const branchLabel = result.decision === "approve" ? APPROVAL_BRANCH_LABELS.approve : APPROVAL_BRANCH_LABELS.reject;
3268
- try {
3269
- await this.serviceResume(result.runId, {
3495
+ const outcome = await this.resumeRecordedOutcome(
3496
+ result.runId,
3497
+ requestId,
3498
+ `the ${result.decision} decision`,
3499
+ {
3270
3500
  branchLabel,
3271
3501
  // #3447 P2: accepted decision outputs ride the resume envelope and
3272
3502
  // land as `<nodeId>.<key>` flow variables — a later approval node's
@@ -3274,22 +3504,18 @@ var _ApprovalService = class _ApprovalService {
3274
3504
  // Reserved keys are spread LAST so no output can shadow them (the
3275
3505
  // whitelist already rejects them; this is defense in depth).
3276
3506
  output: { ...result.outputs ?? {}, decision: result.decision, requestId }
3277
- });
3278
- resumed = true;
3279
- } catch (err) {
3280
- this.logger?.warn?.("[approvals] resume after decision failed", {
3281
- request: requestId,
3282
- run: result.runId,
3283
- error: err?.message ?? String(err)
3284
- });
3285
- }
3507
+ }
3508
+ );
3509
+ resumed = outcome.resumed;
3510
+ resumeError = outcome.resumeError;
3286
3511
  }
3287
3512
  return {
3288
3513
  request: result.request,
3289
3514
  finalized: result.finalized,
3290
3515
  decision: result.decision,
3291
3516
  runId: result.runId,
3292
- resumed
3517
+ resumed,
3518
+ ...resumeError ? { resumeError } : {}
3293
3519
  };
3294
3520
  }
3295
3521
  /**
@@ -3357,15 +3583,17 @@ var _ApprovalService = class _ApprovalService {
3357
3583
  );
3358
3584
  }
3359
3585
  let resumed = false;
3586
+ let resumeError;
3360
3587
  if (inReviseWindow) {
3361
3588
  if (runId && typeof this.automation?.cancelRun === "function") {
3362
3589
  try {
3363
3590
  await this.automation.cancelRun(runId, `approval request ${requestId} recalled during revision`);
3364
3591
  } catch (err) {
3365
- this.logger?.warn?.("[approvals] cancelRun after revise-window recall failed", {
3592
+ resumeError = err?.message ?? String(err);
3593
+ this.logger?.error?.("[approvals] cancelRun after revise-window recall failed \u2014 the run may be stranded", {
3366
3594
  request: requestId,
3367
3595
  run: runId,
3368
- error: err?.message ?? String(err)
3596
+ error: resumeError
3369
3597
  });
3370
3598
  }
3371
3599
  }
@@ -3377,15 +3605,16 @@ var _ApprovalService = class _ApprovalService {
3377
3605
  });
3378
3606
  resumed = true;
3379
3607
  } catch (err) {
3380
- this.logger?.warn?.("[approvals] resume after recall failed", {
3608
+ resumeError = err?.message ?? String(err);
3609
+ this.logger?.error?.("[approvals] resume after recall failed \u2014 the run may be stranded", {
3381
3610
  request: requestId,
3382
3611
  run: runId,
3383
- error: err?.message ?? String(err)
3612
+ error: resumeError
3384
3613
  });
3385
3614
  }
3386
3615
  }
3387
3616
  const fresh = await this.readBackRequest(requestId, context);
3388
- return { request: fresh, runId, resumed };
3617
+ return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
3389
3618
  }
3390
3619
  // ── Send back for revision / resubmit (ADR-0044) ─────────────
3391
3620
  /**
@@ -3413,6 +3642,7 @@ var _ApprovalService = class _ApprovalService {
3413
3642
  const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
3414
3643
  const runId = raw.flow_run_id ?? null;
3415
3644
  await this.assertReviseEdge(raw, nodeId);
3645
+ await this.assertRunResumable(runId, requestId);
3416
3646
  const now = this.clock.now().toISOString();
3417
3647
  const maxRevisions = typeof config.maxRevisions === "number" ? config.maxRevisions : 3;
3418
3648
  let priorSendBacks = 0;
@@ -3465,20 +3695,19 @@ var _ApprovalService = class _ApprovalService {
3465
3695
  );
3466
3696
  }
3467
3697
  let resumed2 = false;
3698
+ let resumeError2;
3468
3699
  if (runId && typeof this.automation?.resume === "function") {
3469
- try {
3470
- await this.serviceResume(runId, {
3700
+ const outcome = await this.resumeRecordedOutcome(
3701
+ runId,
3702
+ requestId,
3703
+ "the auto-rejection",
3704
+ {
3471
3705
  branchLabel: APPROVAL_BRANCH_LABELS.reject,
3472
3706
  output: { decision: "reject", autoRejected: true, requestId }
3473
- });
3474
- resumed2 = true;
3475
- } catch (err) {
3476
- this.logger?.warn?.("[approvals] resume after auto-reject failed", {
3477
- request: requestId,
3478
- run: runId,
3479
- error: err?.message ?? String(err)
3480
- });
3481
- }
3707
+ }
3708
+ );
3709
+ resumed2 = outcome.resumed;
3710
+ resumeError2 = outcome.resumeError;
3482
3711
  }
3483
3712
  if (raw.submitter_id) {
3484
3713
  await this.notify({
@@ -3494,7 +3723,7 @@ var _ApprovalService = class _ApprovalService {
3494
3723
  });
3495
3724
  }
3496
3725
  const fresh2 = await this.readBackRequest(requestId, context);
3497
- return { request: fresh2, runId, resumed: resumed2, autoRejected: true };
3726
+ return { request: fresh2, runId, resumed: resumed2, autoRejected: true, ...resumeError2 ? { resumeError: resumeError2 } : {} };
3498
3727
  }
3499
3728
  await this.engine.update("sys_approval_request", {
3500
3729
  id: requestId,
@@ -3514,20 +3743,19 @@ var _ApprovalService = class _ApprovalService {
3514
3743
  );
3515
3744
  }
3516
3745
  let resumed = false;
3746
+ let resumeError;
3517
3747
  if (runId && typeof this.automation?.resume === "function") {
3518
- try {
3519
- await this.serviceResume(runId, {
3748
+ const outcome = await this.resumeRecordedOutcome(
3749
+ runId,
3750
+ requestId,
3751
+ "the send-back",
3752
+ {
3520
3753
  branchLabel: APPROVAL_BRANCH_LABELS.revise,
3521
3754
  output: { decision: "revise", requestId }
3522
- });
3523
- resumed = true;
3524
- } catch (err) {
3525
- this.logger?.warn?.("[approvals] resume after send-back failed", {
3526
- request: requestId,
3527
- run: runId,
3528
- error: err?.message ?? String(err)
3529
- });
3530
- }
3755
+ }
3756
+ );
3757
+ resumed = outcome.resumed;
3758
+ resumeError = outcome.resumeError;
3531
3759
  }
3532
3760
  if (raw.submitter_id) {
3533
3761
  await this.notify({
@@ -3543,7 +3771,7 @@ var _ApprovalService = class _ApprovalService {
3543
3771
  });
3544
3772
  }
3545
3773
  const fresh = await this.readBackRequest(requestId, context);
3546
- return { request: fresh, runId, resumed };
3774
+ return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
3547
3775
  }
3548
3776
  /**
3549
3777
  * ADR-0044 resubmit after rework. Valid on the LATEST `returned` request of
@@ -3582,6 +3810,7 @@ var _ApprovalService = class _ApprovalService {
3582
3810
  const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
3583
3811
  const runId = raw.flow_run_id ?? null;
3584
3812
  const now = this.clock.now().toISOString();
3813
+ await this.assertRunResumable(runId, requestId);
3585
3814
  await this.engine.insert("sys_approval_action", {
3586
3815
  id: uid("aact"),
3587
3816
  request_id: requestId,
@@ -3594,23 +3823,22 @@ var _ApprovalService = class _ApprovalService {
3594
3823
  created_at: now
3595
3824
  }, { context: SYSTEM_CTX2 });
3596
3825
  let resumed = false;
3826
+ let resumeError;
3597
3827
  if (runId && typeof this.automation?.resume === "function") {
3598
- try {
3599
- await this.serviceResume(runId, {
3828
+ const outcome = await this.resumeRecordedOutcome(
3829
+ runId,
3830
+ requestId,
3831
+ "the resubmit",
3832
+ {
3600
3833
  branchLabel: APPROVAL_BRANCH_LABELS.resubmit,
3601
3834
  output: { resubmitted: true, requestId }
3602
- });
3603
- resumed = true;
3604
- } catch (err) {
3605
- this.logger?.warn?.("[approvals] resume after resubmit failed", {
3606
- request: requestId,
3607
- run: runId,
3608
- error: err?.message ?? String(err)
3609
- });
3610
- }
3835
+ }
3836
+ );
3837
+ resumed = outcome.resumed;
3838
+ resumeError = outcome.resumeError;
3611
3839
  }
3612
3840
  const fresh = await this.readBackRequest(requestId, context);
3613
- return { request: fresh, runId, resumed };
3841
+ return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
3614
3842
  }
3615
3843
  /**
3616
3844
  * ADR-0044 guard: the flow's approval node must declare a `revise`
@@ -3671,6 +3899,7 @@ var _ApprovalService = class _ApprovalService {
3671
3899
  }
3672
3900
  const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
3673
3901
  const from = String(input.from ?? actorId).trim();
3902
+ const viaOverride = isOverride && !pending.includes(actorId);
3674
3903
  let next;
3675
3904
  if (pending.includes(from)) {
3676
3905
  if (!context.isSystem && !isOverride && actorId !== from && !pending.includes(actorId)) {
@@ -3690,8 +3919,14 @@ var _ApprovalService = class _ApprovalService {
3690
3919
  step_name: raw.flow_node_id ?? raw.current_step ?? null,
3691
3920
  step_index: 0,
3692
3921
  action: "reassign",
3922
+ // The hand-off parties are STRUCTURED fields (#4365) — the old default
3923
+ // comment (`"<from> → <to>"`) baked raw user ids into user-facing text.
3924
+ // `comment` is pure user input: absent unless the actor wrote one.
3693
3925
  actor_id: actorId,
3694
- comment: input.comment ?? `${from} \u2192 ${to}`,
3926
+ reassign_from: from,
3927
+ reassign_to: to,
3928
+ via_override: viaOverride,
3929
+ comment: input.comment ?? null,
3695
3930
  created_at: now
3696
3931
  }, { context: SYSTEM_CTX2 });
3697
3932
  let configPatch = {};
@@ -4055,6 +4290,137 @@ var _ApprovalService = class _ApprovalService {
4055
4290
  * the real cause and {@link DEAD_RUN_ACTOR_ID} the real actor, so a dead-run
4056
4291
  * release is never mistaken for a submitter's withdrawal.
4057
4292
  */
4293
+ /**
4294
+ * Read-only inspection for the OTHER dead-run shape: a request that is
4295
+ * already TERMINAL while its `flow_run_id` points at nothing (#4469).
4296
+ *
4297
+ * #4460 stopped new ones being produced; nothing found the ones already
4298
+ * stuck. The failure mode (#4420) is a request row flipped to `approved` /
4299
+ * `rejected` / `returned` whose owning run no longer exists — the decision
4300
+ * landed, the flow never moved. Any deployment on 17.0.0-rc.1 that hit the
4301
+ * wiring hole and crossed a restart mid-approval can be carrying these rows.
4302
+ *
4303
+ * {@link releaseDeadRunRequests} cannot see them, for a reason worth naming:
4304
+ * it scans `status: 'pending'`, and the very step that zombified the request
4305
+ * is the one that took it OUT of `pending`. The act of breaking it removed it
4306
+ * from the only sweeper's field of view — which is a large part of why this
4307
+ * class of failure stayed silent.
4308
+ *
4309
+ * It also could not have answered the question even if it looked: its
4310
+ * liveness oracle is `getRun`, which reads the execution LOG, and after a
4311
+ * restart that returns `null` for a perfectly ALIVE suspended run. It treats
4312
+ * `null` as alive (conservative, correct) — but that means it has no way to
4313
+ * say "this run is really gone".
4314
+ *
4315
+ * So this uses BOTH oracles, and a row must fail both to be reported:
4316
+ *
4317
+ * - `hasSuspendedRun(runId) === false` — the suspension store itself says no
4318
+ * live pause exists. It THROWS when the store cannot be read, and that
4319
+ * case is SKIPPED, never counted as dead: an unreadable store means
4320
+ * "unknown", and a storage outage must not be published as a lost run.
4321
+ * - `getRun(runId) == null` — no terminal history row either (the `run_`
4322
+ * prefixed rows in `sys_automation_run`). A run that merely finished is
4323
+ * not stranded; a request whose run neither waits nor ever completed is.
4324
+ *
4325
+ * **Reports; never rewrites.** No status is changed and no run is cancelled.
4326
+ * The decision genuinely happened — a human approved or rejected — and
4327
+ * silently rolling it back would make the audit trail disagree with the
4328
+ * facts. What an operator needs first is visibility: which requests are stuck
4329
+ * at which step, and what the mirrored status field on the business record
4330
+ * still says. Whether to re-run the downstream actions or re-open the
4331
+ * approval is a judgement call this cannot make.
4332
+ */
4333
+ async inspectStrandedRequests(options) {
4334
+ const empty = { scanned: 0, stranded: [], undetermined: 0 };
4335
+ if (typeof this.automation?.hasSuspendedRun !== "function") return empty;
4336
+ if (typeof this.automation?.getRun !== "function") return empty;
4337
+ const limit = options?.limit ?? 500;
4338
+ let rows = [];
4339
+ try {
4340
+ rows = await this.engine.find("sys_approval_request", {
4341
+ where: { status: { $in: [...STRANDABLE_REQUEST_STATUSES] } },
4342
+ limit,
4343
+ context: SYSTEM_CTX2
4344
+ }) ?? [];
4345
+ } catch (err) {
4346
+ this.logger?.warn?.("[approvals] stranded-request scan failed to list requests", {
4347
+ error: err?.message ?? String(err)
4348
+ });
4349
+ return empty;
4350
+ }
4351
+ const stranded = [];
4352
+ let undetermined = 0;
4353
+ for (const raw of rows) {
4354
+ const runId = raw?.flow_run_id ? String(raw.flow_run_id) : "";
4355
+ if (!runId) continue;
4356
+ let suspended;
4357
+ try {
4358
+ suspended = await this.automation.hasSuspendedRun(runId);
4359
+ } catch (err) {
4360
+ undetermined++;
4361
+ this.logger?.warn?.("[approvals] stranded-request scan could not read the suspension store", {
4362
+ request: raw?.id,
4363
+ run: runId,
4364
+ error: err?.message ?? String(err)
4365
+ });
4366
+ continue;
4367
+ }
4368
+ if (suspended) continue;
4369
+ let terminal = null;
4370
+ try {
4371
+ terminal = await this.automation.getRun(runId);
4372
+ } catch (err) {
4373
+ undetermined++;
4374
+ this.logger?.warn?.("[approvals] stranded-request scan could not read the run history", {
4375
+ request: raw?.id,
4376
+ run: runId,
4377
+ error: err?.message ?? String(err)
4378
+ });
4379
+ continue;
4380
+ }
4381
+ if (terminal) continue;
4382
+ const config = parseJson(
4383
+ raw.node_config_json,
4384
+ { approvers: [], behavior: "first_response" }
4385
+ );
4386
+ const mirrorField = config.approvalStatusField;
4387
+ let mirroredStatus;
4388
+ if (mirrorField) {
4389
+ try {
4390
+ const recs = await this.engine.find(raw.object_name, {
4391
+ where: { id: raw.record_id },
4392
+ limit: 1,
4393
+ context: SYSTEM_CTX2
4394
+ });
4395
+ const rec = Array.isArray(recs) ? recs[0] : null;
4396
+ if (rec) mirroredStatus = rec[mirrorField] ?? void 0;
4397
+ } catch {
4398
+ }
4399
+ }
4400
+ stranded.push({
4401
+ requestId: String(raw.id),
4402
+ status: raw.status,
4403
+ runId,
4404
+ flowName: typeof raw.process_name === "string" ? raw.process_name.replace(/^flow:/, "") : void 0,
4405
+ nodeId: raw.flow_node_id ?? raw.current_step ?? void 0,
4406
+ objectName: raw.object_name,
4407
+ recordId: raw.record_id,
4408
+ organizationId: raw.organization_id ?? null,
4409
+ completedAt: raw.completed_at ?? void 0,
4410
+ mirrorField,
4411
+ mirroredStatus
4412
+ });
4413
+ }
4414
+ if (stranded.length || undetermined) {
4415
+ this.logger?.warn?.("[approvals] stranded terminal requests (decision recorded, flow run gone)", {
4416
+ scanned: rows.length,
4417
+ stranded: stranded.length,
4418
+ undetermined,
4419
+ requests: stranded.map((s) => `${s.requestId}@${s.nodeId ?? "?"} \u2192 run ${s.runId}`)
4420
+ });
4421
+ }
4422
+ return { scanned: rows.length, stranded, undetermined };
4423
+ }
4058
4424
  async releaseDeadRunRequests() {
4059
4425
  if (typeof this.automation?.getRun !== "function") return { scanned: 0, released: 0 };
4060
4426
  let rows = [];
@@ -4499,35 +4865,28 @@ var _ApprovalService = class _ApprovalService {
4499
4865
  async rebuildApproverIndex() {
4500
4866
  const desired = /* @__PURE__ */ new Map();
4501
4867
  const PAGE = 500;
4502
- for (let offset = 0; ; offset += PAGE) {
4503
- const batch = await this.engine.find("sys_approval_request", {
4504
- where: { status: "pending" },
4868
+ const requests = keysetWalk(
4869
+ (q) => this.engine.find("sys_approval_request", {
4870
+ ...q,
4505
4871
  fields: ["id", "pending_approvers", "organization_id"],
4506
- limit: PAGE,
4507
- offset,
4508
4872
  context: SYSTEM_CTX2
4509
- });
4510
- const rows = Array.isArray(batch) ? batch : [];
4873
+ }),
4874
+ { where: { status: "pending" }, pageSize: PAGE }
4875
+ );
4876
+ for await (const rows of requests.pages()) {
4511
4877
  for (const r of rows) {
4512
4878
  desired.set(String(r.id), {
4513
4879
  approvers: new Set(csvSplit(r.pending_approvers)),
4514
4880
  org: r.organization_id ?? null
4515
4881
  });
4516
4882
  }
4517
- if (rows.length < PAGE) break;
4518
4883
  }
4519
4884
  const indexRows = [];
4520
- for (let offset = 0; ; offset += PAGE) {
4521
- const batch = await this.engine.find("sys_approval_approver", {
4522
- orderBy: [{ field: "created_at", order: "asc" }],
4523
- limit: PAGE,
4524
- offset,
4525
- context: SYSTEM_CTX2
4526
- });
4527
- const rows = Array.isArray(batch) ? batch : [];
4528
- indexRows.push(...rows);
4529
- if (rows.length < PAGE) break;
4530
- }
4885
+ const index = keysetWalk(
4886
+ (q) => this.engine.find("sys_approval_approver", { ...q, context: SYSTEM_CTX2 }),
4887
+ { pageSize: PAGE }
4888
+ );
4889
+ for await (const rows of index.pages()) indexRows.push(...rows);
4531
4890
  let inserted = 0;
4532
4891
  let deleted = 0;
4533
4892
  const seen = /* @__PURE__ */ new Map();
@@ -4911,11 +5270,15 @@ var _ApprovalService = class _ApprovalService {
4911
5270
  });
4912
5271
  const actions = Array.isArray(rows) ? rows.map(rowFromAction) : [];
4913
5272
  const names = await this.resolveUserNames(
4914
- actions.map((a) => a.actor_id).filter((id) => id && !id.includes(":"))
5273
+ actions.flatMap((a) => [a.actor_id, a.reassign_from, a.reassign_to]).filter((id) => id && !id.includes(":"))
4915
5274
  );
4916
5275
  for (const a of actions) {
4917
5276
  const n = a.actor_id ? names.get(String(a.actor_id)) : void 0;
4918
5277
  if (n) a.actor_name = n;
5278
+ const fromName = a.reassign_from ? names.get(String(a.reassign_from)) : void 0;
5279
+ if (fromName) a.reassign_from_name = fromName;
5280
+ const toName = a.reassign_to ? names.get(String(a.reassign_to)) : void 0;
5281
+ if (toName) a.reassign_to_name = toName;
4919
5282
  }
4920
5283
  return actions;
4921
5284
  }
@@ -5118,21 +5481,109 @@ function parseJson2(raw, fallback) {
5118
5481
  }
5119
5482
  return raw;
5120
5483
  }
5484
+ var PENDING_LOCK_LIMIT = 1e3;
5485
+ var SYSTEM_CTX3 = { isSystem: true, positions: [], permissions: [] };
5486
+ function lockedError(message) {
5487
+ const err = new Error(`RECORD_LOCKED: ${message}`);
5488
+ err.code = "RECORD_LOCKED";
5489
+ err.statusCode = 409;
5490
+ throw err;
5491
+ }
5492
+ function asIdList(id) {
5493
+ if (typeof id === "number") return [id];
5494
+ if (typeof id === "string") return id === "" ? null : [id];
5495
+ if (id && typeof id === "object" && Array.isArray(id.$in)) {
5496
+ const raw = id.$in;
5497
+ const scalars = raw.filter((v) => typeof v === "string" || typeof v === "number");
5498
+ return scalars.length === raw.length ? scalars : null;
5499
+ }
5500
+ return null;
5501
+ }
5121
5502
  async function pendingRequestFor(engine, objectName, recordId) {
5122
5503
  try {
5123
5504
  const rows = await engine.find("sys_approval_request", {
5124
5505
  where: { object_name: objectName, record_id: String(recordId), status: "pending" },
5125
- limit: 1
5506
+ limit: 1,
5507
+ context: { ...SYSTEM_CTX3 }
5126
5508
  });
5127
5509
  return Array.isArray(rows) && rows[0] ? rows[0] : null;
5128
5510
  } catch {
5129
5511
  return null;
5130
5512
  }
5131
5513
  }
5514
+ async function pendingRequestsForRecords(engine, objectName, recordIds) {
5515
+ if (recordIds.length === 0) return [];
5516
+ if (recordIds.length > PENDING_LOCK_LIMIT) {
5517
+ lockedError(
5518
+ `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`
5519
+ );
5520
+ }
5521
+ if (recordIds.length === 1) {
5522
+ const one = await pendingRequestFor(engine, objectName, String(recordIds[0]));
5523
+ return one ? [one] : [];
5524
+ }
5525
+ try {
5526
+ const rows = await engine.find("sys_approval_request", {
5527
+ where: { object_name: objectName, record_id: { $in: recordIds.map(String) }, status: "pending" },
5528
+ limit: PENDING_LOCK_LIMIT + 1,
5529
+ context: { ...SYSTEM_CTX3 }
5530
+ });
5531
+ return Array.isArray(rows) ? rows : [];
5532
+ } catch {
5533
+ return [];
5534
+ }
5535
+ }
5536
+ async function pendingRequestsForObject(engine, objectName) {
5537
+ try {
5538
+ const rows = await engine.find("sys_approval_request", {
5539
+ where: { object_name: objectName, status: "pending" },
5540
+ limit: PENDING_LOCK_LIMIT + 1,
5541
+ context: { ...SYSTEM_CTX3 }
5542
+ });
5543
+ return Array.isArray(rows) ? rows : [];
5544
+ } catch {
5545
+ return null;
5546
+ }
5547
+ }
5548
+ async function narrowToMatchedRecords(engine, objectName, where, candidates) {
5549
+ const lockedIds = candidates.map((c) => String(c?.record_id ?? ""));
5550
+ let rows;
5551
+ try {
5552
+ rows = await engine.find(objectName, {
5553
+ where: { $and: [where, { id: { $in: lockedIds } }] },
5554
+ fields: ["id"],
5555
+ limit: lockedIds.length,
5556
+ context: { ...SYSTEM_CTX3 }
5557
+ });
5558
+ } catch (err) {
5559
+ lockedError(
5560
+ `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`
5561
+ );
5562
+ }
5563
+ const matched = new Set((Array.isArray(rows) ? rows : []).map((r) => String(r?.id)));
5564
+ return candidates.filter((c) => matched.has(String(c?.record_id ?? "")));
5565
+ }
5566
+ async function gatingRequests(engine, ctx, objectName) {
5567
+ const byId = asIdList(ctx?.input?.id);
5568
+ if (byId) return pendingRequestsForRecords(engine, objectName, byId);
5569
+ const rawWhere = ctx?.input?.options?.where;
5570
+ const hasWhere = rawWhere !== void 0 && rawWhere !== null;
5571
+ const whereObj = hasWhere && typeof rawWhere === "object" && !Array.isArray(rawWhere) ? rawWhere : null;
5572
+ const namedIds = whereObj ? asIdList(whereObj.id) : null;
5573
+ const candidates = namedIds ? await pendingRequestsForRecords(engine, objectName, namedIds) : await pendingRequestsForObject(engine, objectName);
5574
+ if (candidates === null) return [];
5575
+ if (candidates.length === 0) return [];
5576
+ if (candidates.length > PENDING_LOCK_LIMIT) {
5577
+ lockedError(
5578
+ `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`
5579
+ );
5580
+ }
5581
+ if (!hasWhere) return candidates;
5582
+ if (namedIds && whereObj && Object.keys(whereObj).every((k) => k === "id")) return candidates;
5583
+ return narrowToMatchedRecords(engine, objectName, rawWhere, candidates);
5584
+ }
5132
5585
  function bindApprovalLockHook(engine, logger) {
5133
5586
  engine.registerHook("beforeUpdate", async (ctx) => {
5134
- const id = String(ctx?.input?.id ?? "");
5135
- if (!id) return;
5136
5587
  const object = ctx?.object ?? ctx?.objectName;
5137
5588
  if (!object || String(object).startsWith("sys_approval")) return;
5138
5589
  const data = ctx?.input?.data ?? {};
@@ -5141,18 +5592,19 @@ function bindApprovalLockHook(engine, logger) {
5141
5592
  if (ctx?.session?.isSystem) return;
5142
5593
  const roles = ctx?.session?.roles ?? [];
5143
5594
  if (Array.isArray(roles) && roles.includes("admin")) return;
5144
- const pending = await pendingRequestFor(engine, object, id);
5145
- if (!pending) return;
5595
+ const gating = await gatingRequests(engine, ctx, object);
5596
+ if (gating.length === 0) return;
5146
5597
  const writerRun = ctx?.provenance?.flowRunId;
5147
- if (writerRun && pending.flow_run_id && String(writerRun) === String(pending.flow_run_id)) return;
5148
- const config = parseJson2(pending.node_config_json, {});
5149
- if (config?.lockRecord === false) return;
5150
- const mirror = config?.approvalStatusField;
5151
- if (typeof mirror === "string" && mirror && changedFields.every((f) => f === mirror)) return;
5152
- const err = new Error("RECORD_LOCKED: record is locked while an approval is in progress");
5153
- err.code = "RECORD_LOCKED";
5154
- err.statusCode = 409;
5155
- throw err;
5598
+ for (const pending of gating) {
5599
+ if (writerRun && pending?.flow_run_id && String(writerRun) === String(pending.flow_run_id)) continue;
5600
+ const config = parseJson2(pending?.node_config_json, {});
5601
+ if (config?.lockRecord === false) continue;
5602
+ const mirror = config?.approvalStatusField;
5603
+ if (typeof mirror === "string" && mirror && changedFields.every((f) => f === mirror)) continue;
5604
+ lockedError(
5605
+ `record '${String(pending?.record_id ?? "")}' of '${object}' is locked while an approval is in progress`
5606
+ );
5607
+ }
5156
5608
  }, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
5157
5609
  logger?.info?.("[approvals] record-lock hook bound");
5158
5610
  }
@@ -5201,7 +5653,7 @@ import {
5201
5653
  getApprovalNodeConfigJsonSchema,
5202
5654
  APPROVAL_NODE_TYPE
5203
5655
  } from "@objectstack/spec/automation";
5204
- var SYSTEM_CTX3 = { isSystem: true, positions: [], permissions: [] };
5656
+ var SYSTEM_CTX4 = { isSystem: true, positions: [], permissions: [] };
5205
5657
  function nestVariables(variables) {
5206
5658
  const vars = {};
5207
5659
  for (const [key, value] of variables) {
@@ -5280,7 +5732,7 @@ function registerApprovalNode(automation, service, logger) {
5280
5732
  // their `trigger.*` snapshot root.
5281
5733
  variables: nestVariables(variables)
5282
5734
  }, {
5283
- ...SYSTEM_CTX3,
5735
+ ...SYSTEM_CTX4,
5284
5736
  userId: context?.userId,
5285
5737
  organizationId: context?.organizationId,
5286
5738
  tenantId: context?.tenantId
@@ -5424,7 +5876,13 @@ var ApprovalsServicePlugin = class {
5424
5876
  const sweep = async () => {
5425
5877
  const results = await Promise.allSettled([
5426
5878
  svc.runEscalations(),
5427
- svc.releaseDeadRunRequests()
5879
+ svc.releaseDeadRunRequests(),
5880
+ // #4469 — the other half of the dead-run picture, and the one no
5881
+ // sweeper could see: a request already TERMINAL whose run is gone.
5882
+ // Read-only by design (it reports; it never rewrites a decision
5883
+ // that really happened), so it rides the same clock purely to make
5884
+ // the finding surface without an operator knowing to go looking.
5885
+ svc.inspectStrandedRequests()
5428
5886
  ]);
5429
5887
  for (const r of results) {
5430
5888
  if (r.status === "rejected") {
@@ -5499,14 +5957,19 @@ var ApprovalsServicePlugin = class {
5499
5957
  await mountActionPages();
5500
5958
  await backfillApproverIndex();
5501
5959
  }
5960
+ let automation;
5502
5961
  try {
5503
- const automation = ctx.getService("automation");
5504
- if (automation && typeof automation.registerNodeExecutor === "function") {
5505
- this.service.attachAutomation(automation);
5506
- registerApprovalNode(automation, this.service, ctx.logger);
5507
- }
5962
+ automation = ctx.getService("automation");
5508
5963
  } catch {
5509
- ctx.logger.info("ApprovalsServicePlugin: no automation engine \u2014 approval node not registered");
5964
+ automation = void 0;
5965
+ }
5966
+ if (automation && typeof automation.registerNodeExecutor === "function") {
5967
+ this.service.attachAutomation(automation);
5968
+ registerApprovalNode(automation, this.service, ctx.logger);
5969
+ } else {
5970
+ ctx.logger.warn(
5971
+ "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."
5972
+ );
5510
5973
  }
5511
5974
  }
5512
5975
  async stop(ctx) {