@objectstack/plugin-approvals 17.0.0-rc.1 → 17.0.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -245,6 +245,10 @@ 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
+ },
248
252
  reassign_from: {
249
253
  label: "Reassigned From",
250
254
  help: "User whose pending-approver slot was handed over (reassign actions only)"
@@ -562,6 +566,10 @@ var init_zh_CN_objects_generated = __esm({
562
566
  comment: {
563
567
  label: "\u8BC4\u8BBA"
564
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
+ },
565
573
  reassign_from: {
566
574
  label: "\u8F6C\u51FA\u4EBA",
567
575
  help: "\u88AB\u79FB\u4EA4\u5F85\u5BA1\u6279\u69FD\u4F4D\u7684\u7528\u6237\uFF08\u4EC5\u8F6C\u7B7E\u64CD\u4F5C\uFF09"
@@ -879,6 +887,10 @@ var init_ja_JP_objects_generated = __esm({
879
887
  comment: {
880
888
  label: "\u30B3\u30E1\u30F3\u30C8"
881
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
+ },
882
894
  reassign_from: {
883
895
  label: "\u5F15\u304D\u7D99\u304E\u5143",
884
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"
@@ -1196,6 +1208,10 @@ var init_es_ES_objects_generated = __esm({
1196
1208
  comment: {
1197
1209
  label: "Comentario"
1198
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
+ },
1199
1215
  reassign_from: {
1200
1216
  label: "Reasignado de",
1201
1217
  help: "Usuario cuyo turno de aprobaci\xF3n pendiente fue traspasado (solo acciones de reasignaci\xF3n)"
@@ -1689,7 +1705,7 @@ var SysApprovalAction = ObjectSchema2.create({
1689
1705
  nameField: "id",
1690
1706
  // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
1691
1707
  titleFormat: "{action} \xB7 {step_name}",
1692
- highlightFields: ["request_id", "step_name", "action", "actor_id", "created_at"],
1708
+ highlightFields: ["request_id", "step_name", "action", "actor_id", "via_override", "created_at"],
1693
1709
  // ADR-0104 D3 wave 2. `attachments` is a media field, so the files it holds
1694
1710
  // are OWNED by this row — and the storage service would otherwise authorize
1695
1711
  // their download by testing whether the caller can READ this row. It cannot:
@@ -1704,7 +1720,7 @@ var SysApprovalAction = ObjectSchema2.create({
1704
1720
  name: "recent",
1705
1721
  label: "Recent",
1706
1722
  data: { provider: "object", object: "sys_approval_action" },
1707
- 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"],
1708
1724
  sort: [{ field: "created_at", order: "desc" }],
1709
1725
  pagination: { pageSize: 50 },
1710
1726
  emptyState: { title: "No approval actions yet", message: "Actions are logged automatically when approvals progress." }
@@ -1724,7 +1740,7 @@ var SysApprovalAction = ObjectSchema2.create({
1724
1740
  name: "all_actions",
1725
1741
  label: "All",
1726
1742
  data: { provider: "object", object: "sys_approval_action" },
1727
- 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"],
1728
1744
  sort: [{ field: "created_at", order: "desc" }],
1729
1745
  pagination: { pageSize: 100 }
1730
1746
  }
@@ -1772,6 +1788,29 @@ var SysApprovalAction = ObjectSchema2.create({
1772
1788
  group: "Action"
1773
1789
  }),
1774
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
+ }),
1775
1814
  // Structured hand-off parties for `action: 'reassign'` (#4365). Before
1776
1815
  // these existed the pair lived only inside a default free-text comment
1777
1816
  // ("<from_id> → <to_id>"), which no client could parse or render readably.
@@ -1879,12 +1918,11 @@ var SysApprovalDelegation = ObjectSchema4.create({
1879
1918
  pluralLabel: "Approval Delegations",
1880
1919
  icon: "user-clock",
1881
1920
  isSystem: true,
1882
- managedBy: "system",
1883
- // [ADR-0103] Admin/user-writable DATA on a platform-defined schema: a user
1884
- // authors their own out-of-office delegation. Affordance only (matches the
1885
- // full-CRUD apiMethods below) — RLS/permission sets are the authz; opening it
1886
- // keeps the system write guard from rejecting the self-service write.
1887
- 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",
1888
1926
  description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
1889
1927
  titleFormat: "{delegator_id} \u2192 {delegate_id}",
1890
1928
  highlightFields: ["delegator_id", "delegate_id", "valid_from", "valid_until"],
@@ -2120,6 +2158,7 @@ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
2120
2158
  "cancelled",
2121
2159
  "timed_out"
2122
2160
  ]);
2161
+ var STRANDABLE_REQUEST_STATUSES = ["approved", "rejected", "returned"];
2123
2162
  var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
2124
2163
  var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
2125
2164
  function actingUserId(context) {
@@ -2259,6 +2298,11 @@ function rowFromAction(row) {
2259
2298
  // Structured reassign hand-off parties (#4365).
2260
2299
  reassign_from: row.reassign_from ?? void 0,
2261
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,
2262
2306
  // Decision attachments (#3266): rich descriptors carrying the display name +
2263
2307
  // download URL, so consumers label/open them without reading `sys_file`.
2264
2308
  attachments: attachments.length ? attachments : void 0,
@@ -3190,6 +3234,7 @@ var _ApprovalService = class _ApprovalService {
3190
3234
  if (!isSlotHolder && !isOverride) {
3191
3235
  throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
3192
3236
  }
3237
+ const viaOverride = isOverride && !isSlotHolder;
3193
3238
  const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
3194
3239
  const org = raw.organization_id ?? null;
3195
3240
  const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
@@ -3227,6 +3272,7 @@ var _ApprovalService = class _ApprovalService {
3227
3272
  );
3228
3273
  }
3229
3274
  }
3275
+ await this.assertRunResumable(runId, requestId);
3230
3276
  await this.engine.insert("sys_approval_action", {
3231
3277
  id: uid("aact"),
3232
3278
  request_id: requestId,
@@ -3236,6 +3282,10 @@ var _ApprovalService = class _ApprovalService {
3236
3282
  action: input.decision,
3237
3283
  actor_id: actorId,
3238
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,
3239
3289
  attachments: input.attachments?.length ? input.attachments : null,
3240
3290
  created_at: now
3241
3291
  }, { context: SYSTEM_CTX2 });
@@ -3322,22 +3372,131 @@ var _ApprovalService = class _ApprovalService {
3322
3372
  * Callers still guard on `typeof this.automation?.resume === 'function'`
3323
3373
  * (approvals runs fine with no automation attached) and keep their own
3324
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.
3325
3382
  */
3326
3383
  async serviceResume(runId, signal) {
3327
- 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
+ }
3328
3474
  }
3329
3475
  /**
3330
3476
  * Public contract entrypoint (ADR-0019). Records a decision on a node-driven
3331
3477
  * request via {@link ApprovalService.decideNode} and, when it finalizes,
3332
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.
3333
3488
  */
3334
3489
  async decide(requestId, input, context) {
3335
3490
  const result = await this.decideNode(requestId, input, context);
3336
3491
  let resumed = false;
3492
+ let resumeError;
3337
3493
  if (result.finalized && result.runId && typeof this.automation?.resume === "function") {
3338
3494
  const branchLabel = result.decision === "approve" ? APPROVAL_BRANCH_LABELS.approve : APPROVAL_BRANCH_LABELS.reject;
3339
- try {
3340
- await this.serviceResume(result.runId, {
3495
+ const outcome = await this.resumeRecordedOutcome(
3496
+ result.runId,
3497
+ requestId,
3498
+ `the ${result.decision} decision`,
3499
+ {
3341
3500
  branchLabel,
3342
3501
  // #3447 P2: accepted decision outputs ride the resume envelope and
3343
3502
  // land as `<nodeId>.<key>` flow variables — a later approval node's
@@ -3345,22 +3504,18 @@ var _ApprovalService = class _ApprovalService {
3345
3504
  // Reserved keys are spread LAST so no output can shadow them (the
3346
3505
  // whitelist already rejects them; this is defense in depth).
3347
3506
  output: { ...result.outputs ?? {}, decision: result.decision, requestId }
3348
- });
3349
- resumed = true;
3350
- } catch (err) {
3351
- this.logger?.warn?.("[approvals] resume after decision failed", {
3352
- request: requestId,
3353
- run: result.runId,
3354
- error: err?.message ?? String(err)
3355
- });
3356
- }
3507
+ }
3508
+ );
3509
+ resumed = outcome.resumed;
3510
+ resumeError = outcome.resumeError;
3357
3511
  }
3358
3512
  return {
3359
3513
  request: result.request,
3360
3514
  finalized: result.finalized,
3361
3515
  decision: result.decision,
3362
3516
  runId: result.runId,
3363
- resumed
3517
+ resumed,
3518
+ ...resumeError ? { resumeError } : {}
3364
3519
  };
3365
3520
  }
3366
3521
  /**
@@ -3428,15 +3583,17 @@ var _ApprovalService = class _ApprovalService {
3428
3583
  );
3429
3584
  }
3430
3585
  let resumed = false;
3586
+ let resumeError;
3431
3587
  if (inReviseWindow) {
3432
3588
  if (runId && typeof this.automation?.cancelRun === "function") {
3433
3589
  try {
3434
3590
  await this.automation.cancelRun(runId, `approval request ${requestId} recalled during revision`);
3435
3591
  } catch (err) {
3436
- 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", {
3437
3594
  request: requestId,
3438
3595
  run: runId,
3439
- error: err?.message ?? String(err)
3596
+ error: resumeError
3440
3597
  });
3441
3598
  }
3442
3599
  }
@@ -3448,15 +3605,16 @@ var _ApprovalService = class _ApprovalService {
3448
3605
  });
3449
3606
  resumed = true;
3450
3607
  } catch (err) {
3451
- 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", {
3452
3610
  request: requestId,
3453
3611
  run: runId,
3454
- error: err?.message ?? String(err)
3612
+ error: resumeError
3455
3613
  });
3456
3614
  }
3457
3615
  }
3458
3616
  const fresh = await this.readBackRequest(requestId, context);
3459
- return { request: fresh, runId, resumed };
3617
+ return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
3460
3618
  }
3461
3619
  // ── Send back for revision / resubmit (ADR-0044) ─────────────
3462
3620
  /**
@@ -3484,6 +3642,7 @@ var _ApprovalService = class _ApprovalService {
3484
3642
  const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
3485
3643
  const runId = raw.flow_run_id ?? null;
3486
3644
  await this.assertReviseEdge(raw, nodeId);
3645
+ await this.assertRunResumable(runId, requestId);
3487
3646
  const now = this.clock.now().toISOString();
3488
3647
  const maxRevisions = typeof config.maxRevisions === "number" ? config.maxRevisions : 3;
3489
3648
  let priorSendBacks = 0;
@@ -3536,20 +3695,19 @@ var _ApprovalService = class _ApprovalService {
3536
3695
  );
3537
3696
  }
3538
3697
  let resumed2 = false;
3698
+ let resumeError2;
3539
3699
  if (runId && typeof this.automation?.resume === "function") {
3540
- try {
3541
- await this.serviceResume(runId, {
3700
+ const outcome = await this.resumeRecordedOutcome(
3701
+ runId,
3702
+ requestId,
3703
+ "the auto-rejection",
3704
+ {
3542
3705
  branchLabel: APPROVAL_BRANCH_LABELS.reject,
3543
3706
  output: { decision: "reject", autoRejected: true, requestId }
3544
- });
3545
- resumed2 = true;
3546
- } catch (err) {
3547
- this.logger?.warn?.("[approvals] resume after auto-reject failed", {
3548
- request: requestId,
3549
- run: runId,
3550
- error: err?.message ?? String(err)
3551
- });
3552
- }
3707
+ }
3708
+ );
3709
+ resumed2 = outcome.resumed;
3710
+ resumeError2 = outcome.resumeError;
3553
3711
  }
3554
3712
  if (raw.submitter_id) {
3555
3713
  await this.notify({
@@ -3565,7 +3723,7 @@ var _ApprovalService = class _ApprovalService {
3565
3723
  });
3566
3724
  }
3567
3725
  const fresh2 = await this.readBackRequest(requestId, context);
3568
- return { request: fresh2, runId, resumed: resumed2, autoRejected: true };
3726
+ return { request: fresh2, runId, resumed: resumed2, autoRejected: true, ...resumeError2 ? { resumeError: resumeError2 } : {} };
3569
3727
  }
3570
3728
  await this.engine.update("sys_approval_request", {
3571
3729
  id: requestId,
@@ -3585,20 +3743,19 @@ var _ApprovalService = class _ApprovalService {
3585
3743
  );
3586
3744
  }
3587
3745
  let resumed = false;
3746
+ let resumeError;
3588
3747
  if (runId && typeof this.automation?.resume === "function") {
3589
- try {
3590
- await this.serviceResume(runId, {
3748
+ const outcome = await this.resumeRecordedOutcome(
3749
+ runId,
3750
+ requestId,
3751
+ "the send-back",
3752
+ {
3591
3753
  branchLabel: APPROVAL_BRANCH_LABELS.revise,
3592
3754
  output: { decision: "revise", requestId }
3593
- });
3594
- resumed = true;
3595
- } catch (err) {
3596
- this.logger?.warn?.("[approvals] resume after send-back failed", {
3597
- request: requestId,
3598
- run: runId,
3599
- error: err?.message ?? String(err)
3600
- });
3601
- }
3755
+ }
3756
+ );
3757
+ resumed = outcome.resumed;
3758
+ resumeError = outcome.resumeError;
3602
3759
  }
3603
3760
  if (raw.submitter_id) {
3604
3761
  await this.notify({
@@ -3614,7 +3771,7 @@ var _ApprovalService = class _ApprovalService {
3614
3771
  });
3615
3772
  }
3616
3773
  const fresh = await this.readBackRequest(requestId, context);
3617
- return { request: fresh, runId, resumed };
3774
+ return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
3618
3775
  }
3619
3776
  /**
3620
3777
  * ADR-0044 resubmit after rework. Valid on the LATEST `returned` request of
@@ -3653,6 +3810,7 @@ var _ApprovalService = class _ApprovalService {
3653
3810
  const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
3654
3811
  const runId = raw.flow_run_id ?? null;
3655
3812
  const now = this.clock.now().toISOString();
3813
+ await this.assertRunResumable(runId, requestId);
3656
3814
  await this.engine.insert("sys_approval_action", {
3657
3815
  id: uid("aact"),
3658
3816
  request_id: requestId,
@@ -3665,23 +3823,22 @@ var _ApprovalService = class _ApprovalService {
3665
3823
  created_at: now
3666
3824
  }, { context: SYSTEM_CTX2 });
3667
3825
  let resumed = false;
3826
+ let resumeError;
3668
3827
  if (runId && typeof this.automation?.resume === "function") {
3669
- try {
3670
- await this.serviceResume(runId, {
3828
+ const outcome = await this.resumeRecordedOutcome(
3829
+ runId,
3830
+ requestId,
3831
+ "the resubmit",
3832
+ {
3671
3833
  branchLabel: APPROVAL_BRANCH_LABELS.resubmit,
3672
3834
  output: { resubmitted: true, requestId }
3673
- });
3674
- resumed = true;
3675
- } catch (err) {
3676
- this.logger?.warn?.("[approvals] resume after resubmit failed", {
3677
- request: requestId,
3678
- run: runId,
3679
- error: err?.message ?? String(err)
3680
- });
3681
- }
3835
+ }
3836
+ );
3837
+ resumed = outcome.resumed;
3838
+ resumeError = outcome.resumeError;
3682
3839
  }
3683
3840
  const fresh = await this.readBackRequest(requestId, context);
3684
- return { request: fresh, runId, resumed };
3841
+ return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
3685
3842
  }
3686
3843
  /**
3687
3844
  * ADR-0044 guard: the flow's approval node must declare a `revise`
@@ -3742,6 +3899,7 @@ var _ApprovalService = class _ApprovalService {
3742
3899
  }
3743
3900
  const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
3744
3901
  const from = String(input.from ?? actorId).trim();
3902
+ const viaOverride = isOverride && !pending.includes(actorId);
3745
3903
  let next;
3746
3904
  if (pending.includes(from)) {
3747
3905
  if (!context.isSystem && !isOverride && actorId !== from && !pending.includes(actorId)) {
@@ -3767,6 +3925,7 @@ var _ApprovalService = class _ApprovalService {
3767
3925
  actor_id: actorId,
3768
3926
  reassign_from: from,
3769
3927
  reassign_to: to,
3928
+ via_override: viaOverride,
3770
3929
  comment: input.comment ?? null,
3771
3930
  created_at: now
3772
3931
  }, { context: SYSTEM_CTX2 });
@@ -4131,6 +4290,137 @@ var _ApprovalService = class _ApprovalService {
4131
4290
  * the real cause and {@link DEAD_RUN_ACTOR_ID} the real actor, so a dead-run
4132
4291
  * release is never mistaken for a submitter's withdrawal.
4133
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
+ }
4134
4424
  async releaseDeadRunRequests() {
4135
4425
  if (typeof this.automation?.getRun !== "function") return { scanned: 0, released: 0 };
4136
4426
  let rows = [];
@@ -5191,21 +5481,109 @@ function parseJson2(raw, fallback) {
5191
5481
  }
5192
5482
  return raw;
5193
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
+ }
5194
5502
  async function pendingRequestFor(engine, objectName, recordId) {
5195
5503
  try {
5196
5504
  const rows = await engine.find("sys_approval_request", {
5197
5505
  where: { object_name: objectName, record_id: String(recordId), status: "pending" },
5198
- limit: 1
5506
+ limit: 1,
5507
+ context: { ...SYSTEM_CTX3 }
5199
5508
  });
5200
5509
  return Array.isArray(rows) && rows[0] ? rows[0] : null;
5201
5510
  } catch {
5202
5511
  return null;
5203
5512
  }
5204
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
+ }
5205
5585
  function bindApprovalLockHook(engine, logger) {
5206
5586
  engine.registerHook("beforeUpdate", async (ctx) => {
5207
- const id = String(ctx?.input?.id ?? "");
5208
- if (!id) return;
5209
5587
  const object = ctx?.object ?? ctx?.objectName;
5210
5588
  if (!object || String(object).startsWith("sys_approval")) return;
5211
5589
  const data = ctx?.input?.data ?? {};
@@ -5214,18 +5592,19 @@ function bindApprovalLockHook(engine, logger) {
5214
5592
  if (ctx?.session?.isSystem) return;
5215
5593
  const roles = ctx?.session?.roles ?? [];
5216
5594
  if (Array.isArray(roles) && roles.includes("admin")) return;
5217
- const pending = await pendingRequestFor(engine, object, id);
5218
- if (!pending) return;
5595
+ const gating = await gatingRequests(engine, ctx, object);
5596
+ if (gating.length === 0) return;
5219
5597
  const writerRun = ctx?.provenance?.flowRunId;
5220
- if (writerRun && pending.flow_run_id && String(writerRun) === String(pending.flow_run_id)) return;
5221
- const config = parseJson2(pending.node_config_json, {});
5222
- if (config?.lockRecord === false) return;
5223
- const mirror = config?.approvalStatusField;
5224
- if (typeof mirror === "string" && mirror && changedFields.every((f) => f === mirror)) return;
5225
- const err = new Error("RECORD_LOCKED: record is locked while an approval is in progress");
5226
- err.code = "RECORD_LOCKED";
5227
- err.statusCode = 409;
5228
- 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
+ }
5229
5608
  }, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
5230
5609
  logger?.info?.("[approvals] record-lock hook bound");
5231
5610
  }
@@ -5274,7 +5653,7 @@ import {
5274
5653
  getApprovalNodeConfigJsonSchema,
5275
5654
  APPROVAL_NODE_TYPE
5276
5655
  } from "@objectstack/spec/automation";
5277
- var SYSTEM_CTX3 = { isSystem: true, positions: [], permissions: [] };
5656
+ var SYSTEM_CTX4 = { isSystem: true, positions: [], permissions: [] };
5278
5657
  function nestVariables(variables) {
5279
5658
  const vars = {};
5280
5659
  for (const [key, value] of variables) {
@@ -5353,7 +5732,7 @@ function registerApprovalNode(automation, service, logger) {
5353
5732
  // their `trigger.*` snapshot root.
5354
5733
  variables: nestVariables(variables)
5355
5734
  }, {
5356
- ...SYSTEM_CTX3,
5735
+ ...SYSTEM_CTX4,
5357
5736
  userId: context?.userId,
5358
5737
  organizationId: context?.organizationId,
5359
5738
  tenantId: context?.tenantId
@@ -5497,7 +5876,13 @@ var ApprovalsServicePlugin = class {
5497
5876
  const sweep = async () => {
5498
5877
  const results = await Promise.allSettled([
5499
5878
  svc.runEscalations(),
5500
- 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()
5501
5886
  ]);
5502
5887
  for (const r of results) {
5503
5888
  if (r.status === "rejected") {
@@ -5572,14 +5957,19 @@ var ApprovalsServicePlugin = class {
5572
5957
  await mountActionPages();
5573
5958
  await backfillApproverIndex();
5574
5959
  }
5960
+ let automation;
5575
5961
  try {
5576
- const automation = ctx.getService("automation");
5577
- if (automation && typeof automation.registerNodeExecutor === "function") {
5578
- this.service.attachAutomation(automation);
5579
- registerApprovalNode(automation, this.service, ctx.logger);
5580
- }
5962
+ automation = ctx.getService("automation");
5581
5963
  } catch {
5582
- 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
+ );
5583
5973
  }
5584
5974
  }
5585
5975
  async stop(ctx) {