@objectstack/plugin-approvals 17.0.0-rc.1 → 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.
package/dist/index.js CHANGED
@@ -257,6 +257,10 @@ var init_en_objects_generated = __esm({
257
257
  comment: {
258
258
  label: "Comment"
259
259
  },
260
+ via_override: {
261
+ label: "Via Admin Override",
262
+ help: "True when the actor was admitted to this action only by the privileged-override path (#3424) \u2014 they held no slot in the request\u2019s pending-approver slate."
263
+ },
260
264
  reassign_from: {
261
265
  label: "Reassigned From",
262
266
  help: "User whose pending-approver slot was handed over (reassign actions only)"
@@ -574,6 +578,10 @@ var init_zh_CN_objects_generated = __esm({
574
578
  comment: {
575
579
  label: "\u8BC4\u8BBA"
576
580
  },
581
+ via_override: {
582
+ label: "\u7BA1\u7406\u5458\u8D8A\u6743\u64CD\u4F5C",
583
+ help: "\u4E3A\u771F\u8868\u793A\u8BE5\u64CD\u4F5C\u8005\u53EA\u662F\u51ED\u7279\u6743\u8D8A\u6743\u8DEF\u5F84\uFF08#3424\uFF09\u88AB\u653E\u884C\u2014\u2014\u4ED6\u4EEC\u5E76\u4E0D\u5728\u8BE5\u8BF7\u6C42\u7684\u5F85\u5BA1\u6279\u4EBA\u540D\u5355\u4E2D\u3002"
584
+ },
577
585
  reassign_from: {
578
586
  label: "\u8F6C\u51FA\u4EBA",
579
587
  help: "\u88AB\u79FB\u4EA4\u5F85\u5BA1\u6279\u69FD\u4F4D\u7684\u7528\u6237\uFF08\u4EC5\u8F6C\u7B7E\u64CD\u4F5C\uFF09"
@@ -891,6 +899,10 @@ var init_ja_JP_objects_generated = __esm({
891
899
  comment: {
892
900
  label: "\u30B3\u30E1\u30F3\u30C8"
893
901
  },
902
+ via_override: {
903
+ label: "\u7BA1\u7406\u8005\u30AA\u30FC\u30D0\u30FC\u30E9\u30A4\u30C9\u7D4C\u7531",
904
+ help: "true \u306E\u5834\u5408\u3001\u5B9F\u884C\u8005\u306F\u7279\u6A29\u30AA\u30FC\u30D0\u30FC\u30E9\u30A4\u30C9\u7D4C\u8DEF\uFF08#3424\uFF09\u306B\u3088\u3063\u3066\u306E\u307F\u8A31\u53EF\u3055\u308C\u305F\u3053\u3068\u3092\u793A\u3057\u307E\u3059 \u2014 \u5F53\u8A72\u30EA\u30AF\u30A8\u30B9\u30C8\u306E\u627F\u8A8D\u5F85\u3061\u30EA\u30B9\u30C8\u306B\u306F\u542B\u307E\u308C\u3066\u3044\u307E\u305B\u3093\u3002"
905
+ },
894
906
  reassign_from: {
895
907
  label: "\u5F15\u304D\u7D99\u304E\u5143",
896
908
  help: "\u627F\u8A8D\u5F85\u3061\u30B9\u30ED\u30C3\u30C8\u3092\u5F15\u304D\u6E21\u3057\u305F\u30E6\u30FC\u30B6\u30FC\uFF08\u5F15\u304D\u7D99\u304E\u64CD\u4F5C\u306E\u307F\uFF09"
@@ -1208,6 +1220,10 @@ var init_es_ES_objects_generated = __esm({
1208
1220
  comment: {
1209
1221
  label: "Comentario"
1210
1222
  },
1223
+ via_override: {
1224
+ label: "Mediante anulaci\xF3n de administrador",
1225
+ help: "Verdadero cuando el actor fue admitido en esta acci\xF3n \xFAnicamente por la v\xEDa de anulaci\xF3n privilegiada (#3424): no ocupaba ning\xFAn puesto en la lista de aprobadores pendientes de la solicitud."
1226
+ },
1211
1227
  reassign_from: {
1212
1228
  label: "Reasignado de",
1213
1229
  help: "Usuario cuyo turno de aprobaci\xF3n pendiente fue traspasado (solo acciones de reasignaci\xF3n)"
@@ -1714,7 +1730,7 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
1714
1730
  nameField: "id",
1715
1731
  // [ADR-0079] canonical primary-title pointer (mirrors deprecated displayNameField)
1716
1732
  titleFormat: "{action} \xB7 {step_name}",
1717
- highlightFields: ["request_id", "step_name", "action", "actor_id", "created_at"],
1733
+ highlightFields: ["request_id", "step_name", "action", "actor_id", "via_override", "created_at"],
1718
1734
  // ADR-0104 D3 wave 2. `attachments` is a media field, so the files it holds
1719
1735
  // are OWNED by this row — and the storage service would otherwise authorize
1720
1736
  // their download by testing whether the caller can READ this row. It cannot:
@@ -1729,7 +1745,7 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
1729
1745
  name: "recent",
1730
1746
  label: "Recent",
1731
1747
  data: { provider: "object", object: "sys_approval_action" },
1732
- columns: ["created_at", "request_id", "step_name", "action", "actor_id", "comment"],
1748
+ columns: ["created_at", "request_id", "step_name", "action", "actor_id", "via_override", "comment"],
1733
1749
  sort: [{ field: "created_at", order: "desc" }],
1734
1750
  pagination: { pageSize: 50 },
1735
1751
  emptyState: { title: "No approval actions yet", message: "Actions are logged automatically when approvals progress." }
@@ -1749,7 +1765,7 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
1749
1765
  name: "all_actions",
1750
1766
  label: "All",
1751
1767
  data: { provider: "object", object: "sys_approval_action" },
1752
- columns: ["created_at", "request_id", "step_name", "action", "actor_id", "comment"],
1768
+ columns: ["created_at", "request_id", "step_name", "action", "actor_id", "via_override", "comment"],
1753
1769
  sort: [{ field: "created_at", order: "desc" }],
1754
1770
  pagination: { pageSize: 100 }
1755
1771
  }
@@ -1797,6 +1813,29 @@ var SysApprovalAction = import_data2.ObjectSchema.create({
1797
1813
  group: "Action"
1798
1814
  }),
1799
1815
  comment: import_data2.Field.textarea({ label: "Comment", required: false, group: "Action" }),
1816
+ // #4466 — the one bit of "who really decided this" that was still dropped.
1817
+ // A privileged admin may act on a request whose staffed approver slate they
1818
+ // hold no slot in (the #3424 override path); before this column, that
1819
+ // decision was byte-for-byte identical to the designated approver's own
1820
+ // approval. A reader of the timeline saw `approve` by the admin and could
1821
+ // not tell whether the admin WAS an approver or OVERRODE the ones who were,
1822
+ // and the bypassed approver's later `409 INVALID_STATE` was the only trace
1823
+ // — existing only if they happened to try.
1824
+ //
1825
+ // The platform KNOWS at decision time: it took the `isOverrideActor` branch
1826
+ // to admit the call at all. This is dropped information, not unavailable
1827
+ // information.
1828
+ //
1829
+ // Set on exactly the decisions that were admitted BY that branch — an admin
1830
+ // who is also a genuine slot holder is approving normally and is recorded
1831
+ // as such. Nullable and additive: rows written before this column exists
1832
+ // carry `null`, which reads as "not recorded", never as "not an override".
1833
+ via_override: import_data2.Field.boolean({
1834
+ label: "Via Admin Override",
1835
+ required: false,
1836
+ group: "Action",
1837
+ description: "True when the actor was admitted to this action only by the privileged-override path (#3424) \u2014 they held no slot in the request\u2019s pending-approver slate."
1838
+ }),
1800
1839
  // Structured hand-off parties for `action: 'reassign'` (#4365). Before
1801
1840
  // these existed the pair lived only inside a default free-text comment
1802
1841
  // ("<from_id> → <to_id>"), which no client could parse or render readably.
@@ -1904,12 +1943,11 @@ var SysApprovalDelegation = import_data4.ObjectSchema.create({
1904
1943
  pluralLabel: "Approval Delegations",
1905
1944
  icon: "user-clock",
1906
1945
  isSystem: true,
1907
- managedBy: "system",
1908
- // [ADR-0103] Admin/user-writable DATA on a platform-defined schema: a user
1909
- // authors their own out-of-office delegation. Affordance only (matches the
1910
- // full-CRUD apiMethods below) — RLS/permission sets are the authz; opening it
1911
- // keeps the system write guard from rejecting the self-service write.
1912
- userActions: { create: true, edit: true, delete: true },
1946
+ // [ADR-0103, #3355] Admin/user-writable DATA on a platform-defined schema: a
1947
+ // user authors their own out-of-office delegation. The bucket default is full
1948
+ // CRUD (matching the full-CRUD apiMethods below), so no `userActions` block is
1949
+ // needed — RLS/permission sets are the authz.
1950
+ managedBy: "system-data",
1913
1951
  description: "Self-service out-of-office rule: route this user's approver slots to a delegate within a time window (#1322 M1).",
1914
1952
  titleFormat: "{delegator_id} \u2192 {delegate_id}",
1915
1953
  highlightFields: ["delegator_id", "delegate_id", "valid_from", "valid_until"],
@@ -2134,6 +2172,7 @@ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
2134
2172
  "cancelled",
2135
2173
  "timed_out"
2136
2174
  ]);
2175
+ var STRANDABLE_REQUEST_STATUSES = ["approved", "rejected", "returned"];
2137
2176
  var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
2138
2177
  var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
2139
2178
  function actingUserId(context) {
@@ -2273,6 +2312,11 @@ function rowFromAction(row) {
2273
2312
  // Structured reassign hand-off parties (#4365).
2274
2313
  reassign_from: row.reassign_from ?? void 0,
2275
2314
  reassign_to: row.reassign_to ?? void 0,
2315
+ // #4466 — surfaced so a timeline can SAY "overridden the approver slate"
2316
+ // rather than render an override identically to an ordinary approval.
2317
+ // `null` (a row written before the column existed) stays `undefined`:
2318
+ // "not recorded" is not the same claim as "not an override".
2319
+ via_override: row.via_override == null ? void 0 : row.via_override === true,
2276
2320
  // Decision attachments (#3266): rich descriptors carrying the display name +
2277
2321
  // download URL, so consumers label/open them without reading `sys_file`.
2278
2322
  attachments: attachments.length ? attachments : void 0,
@@ -3204,6 +3248,7 @@ var _ApprovalService = class _ApprovalService {
3204
3248
  if (!isSlotHolder && !isOverride) {
3205
3249
  throw new Error(`FORBIDDEN: actor '${actorId}' is not a pending approver`);
3206
3250
  }
3251
+ const viaOverride = isOverride && !isSlotHolder;
3207
3252
  const config = parseJson(raw.node_config_json, { approvers: [], behavior: "first_response" });
3208
3253
  const org = raw.organization_id ?? null;
3209
3254
  const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
@@ -3241,6 +3286,7 @@ var _ApprovalService = class _ApprovalService {
3241
3286
  );
3242
3287
  }
3243
3288
  }
3289
+ await this.assertRunResumable(runId, requestId);
3244
3290
  await this.engine.insert("sys_approval_action", {
3245
3291
  id: uid("aact"),
3246
3292
  request_id: requestId,
@@ -3250,6 +3296,10 @@ var _ApprovalService = class _ApprovalService {
3250
3296
  action: input.decision,
3251
3297
  actor_id: actorId,
3252
3298
  comment: input.comment ?? null,
3299
+ // #4466: the override is recorded on the DECISION, not inferred later.
3300
+ // Written as an explicit `false` for an ordinary decision so a reader can
3301
+ // tell "checked, and it was not an override" from a legacy row's `null`.
3302
+ via_override: viaOverride,
3253
3303
  attachments: input.attachments?.length ? input.attachments : null,
3254
3304
  created_at: now
3255
3305
  }, { context: SYSTEM_CTX2 });
@@ -3336,22 +3386,131 @@ var _ApprovalService = class _ApprovalService {
3336
3386
  * Callers still guard on `typeof this.automation?.resume === 'function'`
3337
3387
  * (approvals runs fine with no automation attached) and keep their own
3338
3388
  * try/catch, because what a failed resume means differs per path.
3389
+ *
3390
+ * Throws when the engine REPORTS failure, not only when it throws one. The
3391
+ * engine answers a lost run with `{ success: false, code: 'RUN_NOT_FOUND' }`
3392
+ * — a plain return value that every caller here used to discard, which is
3393
+ * how an approval could be recorded, reported as resumed, and leave its flow
3394
+ * stranded forever (#4420). The thrown error carries {@link resumeCodeOf}'s
3395
+ * `resumeCode` so callers can tell a benign duplicate from a dead run.
3339
3396
  */
3340
3397
  async serviceResume(runId, signal) {
3341
- await this.automation.resume(runId, { ...signal, [import_contracts3.RESUME_AUTHORITY_SERVICE]: true });
3398
+ const result = await this.automation.resume(runId, { ...signal, [import_contracts3.RESUME_AUTHORITY_SERVICE]: true });
3399
+ const reported = result;
3400
+ if (reported && typeof reported === "object" && reported.success === false) {
3401
+ const err = new Error(
3402
+ `resume of run '${runId}' failed${reported.code ? ` [${reported.code}]` : ""}: ${reported.error ?? "unknown error"}`
3403
+ );
3404
+ err.resumeCode = reported.code;
3405
+ throw err;
3406
+ }
3407
+ }
3408
+ /** The engine failure code behind a {@link serviceResume} rejection, if any. */
3409
+ static resumeCodeOf(err) {
3410
+ return err?.resumeCode;
3411
+ }
3412
+ /**
3413
+ * Refuse an operation whose whole point is to advance a flow run when that
3414
+ * run no longer exists — BEFORE anything is written down (#4420).
3415
+ *
3416
+ * The half-state this prevents is the one the issue reported: a request
3417
+ * flipped to `approved`, a success toast, and a flow that never moves. Once
3418
+ * the decision row is written there is nothing left to fail cleanly.
3419
+ *
3420
+ * Deliberately permissive at the edges:
3421
+ * - no automation attached, or an engine without `hasSuspendedRun` → no
3422
+ * pre-flight at all (standalone approvals compositions are unaffected);
3423
+ * - the store cannot be READ → fail OPEN. A transient outage must not block
3424
+ * every decision in the tenant; the post-resume check still catches a real
3425
+ * failure and reports it loudly.
3426
+ */
3427
+ async assertRunResumable(runId, requestId) {
3428
+ if (!runId) return;
3429
+ if (typeof this.automation?.resume !== "function") return;
3430
+ if (typeof this.automation?.hasSuspendedRun !== "function") return;
3431
+ let alive;
3432
+ try {
3433
+ alive = await this.automation.hasSuspendedRun(runId);
3434
+ } catch (err) {
3435
+ this.logger?.warn?.("[approvals] could not verify the flow run is resumable \u2014 proceeding", {
3436
+ request: requestId,
3437
+ run: runId,
3438
+ error: err?.message ?? String(err)
3439
+ });
3440
+ return;
3441
+ }
3442
+ if (!alive) {
3443
+ throw new Error(
3444
+ `RESUME_TARGET_LOST: the flow run '${runId}' behind request ${requestId} no longer exists (it was cancelled, or it paused in a process that did not persist suspended runs). Nothing was recorded. An administrator can recall the request to release the record.`
3445
+ );
3446
+ }
3447
+ }
3448
+ /**
3449
+ * Resume the run behind an outcome that has ALREADY been written down, and
3450
+ * fail loudly when it cannot be (#4420).
3451
+ *
3452
+ * For the operations whose product is the resume — a finalised decision, a
3453
+ * send-back, a resubmit. Their rows are durable by the time this runs, so a
3454
+ * failure here cannot be undone; the one thing left worth doing is refusing
3455
+ * to call it success. {@link assertRunResumable} is what keeps this rare:
3456
+ * everything it catches never reaches a write.
3457
+ *
3458
+ * `RESUME_IN_PROGRESS` is the exception — a concurrent resume is already
3459
+ * advancing the run, so the outcome stands and only `resumed` is false.
3460
+ *
3461
+ * @param what - how the recorded outcome reads in the error, e.g.
3462
+ * `"the approve decision"`.
3463
+ */
3464
+ async resumeRecordedOutcome(runId, requestId, what, signal) {
3465
+ try {
3466
+ await this.serviceResume(runId, signal);
3467
+ return { resumed: true };
3468
+ } catch (err) {
3469
+ const reason = err?.message ?? String(err);
3470
+ if (_ApprovalService.resumeCodeOf(err) === "RESUME_IN_PROGRESS") {
3471
+ this.logger?.warn?.("[approvals] resume skipped \u2014 already in progress", {
3472
+ request: requestId,
3473
+ run: runId,
3474
+ outcome: what
3475
+ });
3476
+ return { resumed: false, resumeError: reason };
3477
+ }
3478
+ this.logger?.error?.("[approvals] resume failed \u2014 the run is stranded", {
3479
+ request: requestId,
3480
+ run: runId,
3481
+ outcome: what,
3482
+ error: reason
3483
+ });
3484
+ throw new Error(
3485
+ `RESUME_FAILED: ${what} was recorded on request ${requestId}, but its flow run '${runId}' could not be resumed and is now stranded: ${reason}`
3486
+ );
3487
+ }
3342
3488
  }
3343
3489
  /**
3344
3490
  * Public contract entrypoint (ADR-0019). Records a decision on a node-driven
3345
3491
  * request via {@link ApprovalService.decideNode} and, when it finalizes,
3346
3492
  * resumes the owning flow run down the matching `approve` / `reject` edge.
3493
+ *
3494
+ * A finalising decision whose run cannot be resumed FAILS (#4420). The
3495
+ * decision is already durable by then, so the failure cannot be rolled back
3496
+ * — but it must not be reported as success either: this used to answer HTTP
3497
+ * 200 with `resumed: true` while the flow stayed parked forever, which left
3498
+ * the approver with no signal and the record mirroring a stage it never
3499
+ * reached. `decideNode`'s pre-flight means the common case (the run died
3500
+ * before the decision) never gets this far; what survives here is a genuine
3501
+ * race, and it names the stranded run.
3347
3502
  */
3348
3503
  async decide(requestId, input, context) {
3349
3504
  const result = await this.decideNode(requestId, input, context);
3350
3505
  let resumed = false;
3506
+ let resumeError;
3351
3507
  if (result.finalized && result.runId && typeof this.automation?.resume === "function") {
3352
3508
  const branchLabel = result.decision === "approve" ? import_automation.APPROVAL_BRANCH_LABELS.approve : import_automation.APPROVAL_BRANCH_LABELS.reject;
3353
- try {
3354
- await this.serviceResume(result.runId, {
3509
+ const outcome = await this.resumeRecordedOutcome(
3510
+ result.runId,
3511
+ requestId,
3512
+ `the ${result.decision} decision`,
3513
+ {
3355
3514
  branchLabel,
3356
3515
  // #3447 P2: accepted decision outputs ride the resume envelope and
3357
3516
  // land as `<nodeId>.<key>` flow variables — a later approval node's
@@ -3359,22 +3518,18 @@ var _ApprovalService = class _ApprovalService {
3359
3518
  // Reserved keys are spread LAST so no output can shadow them (the
3360
3519
  // whitelist already rejects them; this is defense in depth).
3361
3520
  output: { ...result.outputs ?? {}, decision: result.decision, requestId }
3362
- });
3363
- resumed = true;
3364
- } catch (err) {
3365
- this.logger?.warn?.("[approvals] resume after decision failed", {
3366
- request: requestId,
3367
- run: result.runId,
3368
- error: err?.message ?? String(err)
3369
- });
3370
- }
3521
+ }
3522
+ );
3523
+ resumed = outcome.resumed;
3524
+ resumeError = outcome.resumeError;
3371
3525
  }
3372
3526
  return {
3373
3527
  request: result.request,
3374
3528
  finalized: result.finalized,
3375
3529
  decision: result.decision,
3376
3530
  runId: result.runId,
3377
- resumed
3531
+ resumed,
3532
+ ...resumeError ? { resumeError } : {}
3378
3533
  };
3379
3534
  }
3380
3535
  /**
@@ -3442,15 +3597,17 @@ var _ApprovalService = class _ApprovalService {
3442
3597
  );
3443
3598
  }
3444
3599
  let resumed = false;
3600
+ let resumeError;
3445
3601
  if (inReviseWindow) {
3446
3602
  if (runId && typeof this.automation?.cancelRun === "function") {
3447
3603
  try {
3448
3604
  await this.automation.cancelRun(runId, `approval request ${requestId} recalled during revision`);
3449
3605
  } catch (err) {
3450
- this.logger?.warn?.("[approvals] cancelRun after revise-window recall failed", {
3606
+ resumeError = err?.message ?? String(err);
3607
+ this.logger?.error?.("[approvals] cancelRun after revise-window recall failed \u2014 the run may be stranded", {
3451
3608
  request: requestId,
3452
3609
  run: runId,
3453
- error: err?.message ?? String(err)
3610
+ error: resumeError
3454
3611
  });
3455
3612
  }
3456
3613
  }
@@ -3462,15 +3619,16 @@ var _ApprovalService = class _ApprovalService {
3462
3619
  });
3463
3620
  resumed = true;
3464
3621
  } catch (err) {
3465
- this.logger?.warn?.("[approvals] resume after recall failed", {
3622
+ resumeError = err?.message ?? String(err);
3623
+ this.logger?.error?.("[approvals] resume after recall failed \u2014 the run may be stranded", {
3466
3624
  request: requestId,
3467
3625
  run: runId,
3468
- error: err?.message ?? String(err)
3626
+ error: resumeError
3469
3627
  });
3470
3628
  }
3471
3629
  }
3472
3630
  const fresh = await this.readBackRequest(requestId, context);
3473
- return { request: fresh, runId, resumed };
3631
+ return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
3474
3632
  }
3475
3633
  // ── Send back for revision / resubmit (ADR-0044) ─────────────
3476
3634
  /**
@@ -3498,6 +3656,7 @@ var _ApprovalService = class _ApprovalService {
3498
3656
  const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
3499
3657
  const runId = raw.flow_run_id ?? null;
3500
3658
  await this.assertReviseEdge(raw, nodeId);
3659
+ await this.assertRunResumable(runId, requestId);
3501
3660
  const now = this.clock.now().toISOString();
3502
3661
  const maxRevisions = typeof config.maxRevisions === "number" ? config.maxRevisions : 3;
3503
3662
  let priorSendBacks = 0;
@@ -3550,20 +3709,19 @@ var _ApprovalService = class _ApprovalService {
3550
3709
  );
3551
3710
  }
3552
3711
  let resumed2 = false;
3712
+ let resumeError2;
3553
3713
  if (runId && typeof this.automation?.resume === "function") {
3554
- try {
3555
- await this.serviceResume(runId, {
3714
+ const outcome = await this.resumeRecordedOutcome(
3715
+ runId,
3716
+ requestId,
3717
+ "the auto-rejection",
3718
+ {
3556
3719
  branchLabel: import_automation.APPROVAL_BRANCH_LABELS.reject,
3557
3720
  output: { decision: "reject", autoRejected: true, requestId }
3558
- });
3559
- resumed2 = true;
3560
- } catch (err) {
3561
- this.logger?.warn?.("[approvals] resume after auto-reject failed", {
3562
- request: requestId,
3563
- run: runId,
3564
- error: err?.message ?? String(err)
3565
- });
3566
- }
3721
+ }
3722
+ );
3723
+ resumed2 = outcome.resumed;
3724
+ resumeError2 = outcome.resumeError;
3567
3725
  }
3568
3726
  if (raw.submitter_id) {
3569
3727
  await this.notify({
@@ -3579,7 +3737,7 @@ var _ApprovalService = class _ApprovalService {
3579
3737
  });
3580
3738
  }
3581
3739
  const fresh2 = await this.readBackRequest(requestId, context);
3582
- return { request: fresh2, runId, resumed: resumed2, autoRejected: true };
3740
+ return { request: fresh2, runId, resumed: resumed2, autoRejected: true, ...resumeError2 ? { resumeError: resumeError2 } : {} };
3583
3741
  }
3584
3742
  await this.engine.update("sys_approval_request", {
3585
3743
  id: requestId,
@@ -3599,20 +3757,19 @@ var _ApprovalService = class _ApprovalService {
3599
3757
  );
3600
3758
  }
3601
3759
  let resumed = false;
3760
+ let resumeError;
3602
3761
  if (runId && typeof this.automation?.resume === "function") {
3603
- try {
3604
- await this.serviceResume(runId, {
3762
+ const outcome = await this.resumeRecordedOutcome(
3763
+ runId,
3764
+ requestId,
3765
+ "the send-back",
3766
+ {
3605
3767
  branchLabel: import_automation.APPROVAL_BRANCH_LABELS.revise,
3606
3768
  output: { decision: "revise", requestId }
3607
- });
3608
- resumed = true;
3609
- } catch (err) {
3610
- this.logger?.warn?.("[approvals] resume after send-back failed", {
3611
- request: requestId,
3612
- run: runId,
3613
- error: err?.message ?? String(err)
3614
- });
3615
- }
3769
+ }
3770
+ );
3771
+ resumed = outcome.resumed;
3772
+ resumeError = outcome.resumeError;
3616
3773
  }
3617
3774
  if (raw.submitter_id) {
3618
3775
  await this.notify({
@@ -3628,7 +3785,7 @@ var _ApprovalService = class _ApprovalService {
3628
3785
  });
3629
3786
  }
3630
3787
  const fresh = await this.readBackRequest(requestId, context);
3631
- return { request: fresh, runId, resumed };
3788
+ return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
3632
3789
  }
3633
3790
  /**
3634
3791
  * ADR-0044 resubmit after rework. Valid on the LATEST `returned` request of
@@ -3667,6 +3824,7 @@ var _ApprovalService = class _ApprovalService {
3667
3824
  const nodeId = raw.flow_node_id ?? raw.current_step ?? null;
3668
3825
  const runId = raw.flow_run_id ?? null;
3669
3826
  const now = this.clock.now().toISOString();
3827
+ await this.assertRunResumable(runId, requestId);
3670
3828
  await this.engine.insert("sys_approval_action", {
3671
3829
  id: uid("aact"),
3672
3830
  request_id: requestId,
@@ -3679,23 +3837,22 @@ var _ApprovalService = class _ApprovalService {
3679
3837
  created_at: now
3680
3838
  }, { context: SYSTEM_CTX2 });
3681
3839
  let resumed = false;
3840
+ let resumeError;
3682
3841
  if (runId && typeof this.automation?.resume === "function") {
3683
- try {
3684
- await this.serviceResume(runId, {
3842
+ const outcome = await this.resumeRecordedOutcome(
3843
+ runId,
3844
+ requestId,
3845
+ "the resubmit",
3846
+ {
3685
3847
  branchLabel: import_automation.APPROVAL_BRANCH_LABELS.resubmit,
3686
3848
  output: { resubmitted: true, requestId }
3687
- });
3688
- resumed = true;
3689
- } catch (err) {
3690
- this.logger?.warn?.("[approvals] resume after resubmit failed", {
3691
- request: requestId,
3692
- run: runId,
3693
- error: err?.message ?? String(err)
3694
- });
3695
- }
3849
+ }
3850
+ );
3851
+ resumed = outcome.resumed;
3852
+ resumeError = outcome.resumeError;
3696
3853
  }
3697
3854
  const fresh = await this.readBackRequest(requestId, context);
3698
- return { request: fresh, runId, resumed };
3855
+ return { request: fresh, runId, resumed, ...resumeError ? { resumeError } : {} };
3699
3856
  }
3700
3857
  /**
3701
3858
  * ADR-0044 guard: the flow's approval node must declare a `revise`
@@ -3756,6 +3913,7 @@ var _ApprovalService = class _ApprovalService {
3756
3913
  }
3757
3914
  const isOverride = this.isOverrideActor(context, raw.organization_id ?? null);
3758
3915
  const from = String(input.from ?? actorId).trim();
3916
+ const viaOverride = isOverride && !pending.includes(actorId);
3759
3917
  let next;
3760
3918
  if (pending.includes(from)) {
3761
3919
  if (!context.isSystem && !isOverride && actorId !== from && !pending.includes(actorId)) {
@@ -3781,6 +3939,7 @@ var _ApprovalService = class _ApprovalService {
3781
3939
  actor_id: actorId,
3782
3940
  reassign_from: from,
3783
3941
  reassign_to: to,
3942
+ via_override: viaOverride,
3784
3943
  comment: input.comment ?? null,
3785
3944
  created_at: now
3786
3945
  }, { context: SYSTEM_CTX2 });
@@ -4145,6 +4304,137 @@ var _ApprovalService = class _ApprovalService {
4145
4304
  * the real cause and {@link DEAD_RUN_ACTOR_ID} the real actor, so a dead-run
4146
4305
  * release is never mistaken for a submitter's withdrawal.
4147
4306
  */
4307
+ /**
4308
+ * Read-only inspection for the OTHER dead-run shape: a request that is
4309
+ * already TERMINAL while its `flow_run_id` points at nothing (#4469).
4310
+ *
4311
+ * #4460 stopped new ones being produced; nothing found the ones already
4312
+ * stuck. The failure mode (#4420) is a request row flipped to `approved` /
4313
+ * `rejected` / `returned` whose owning run no longer exists — the decision
4314
+ * landed, the flow never moved. Any deployment on 17.0.0-rc.1 that hit the
4315
+ * wiring hole and crossed a restart mid-approval can be carrying these rows.
4316
+ *
4317
+ * {@link releaseDeadRunRequests} cannot see them, for a reason worth naming:
4318
+ * it scans `status: 'pending'`, and the very step that zombified the request
4319
+ * is the one that took it OUT of `pending`. The act of breaking it removed it
4320
+ * from the only sweeper's field of view — which is a large part of why this
4321
+ * class of failure stayed silent.
4322
+ *
4323
+ * It also could not have answered the question even if it looked: its
4324
+ * liveness oracle is `getRun`, which reads the execution LOG, and after a
4325
+ * restart that returns `null` for a perfectly ALIVE suspended run. It treats
4326
+ * `null` as alive (conservative, correct) — but that means it has no way to
4327
+ * say "this run is really gone".
4328
+ *
4329
+ * So this uses BOTH oracles, and a row must fail both to be reported:
4330
+ *
4331
+ * - `hasSuspendedRun(runId) === false` — the suspension store itself says no
4332
+ * live pause exists. It THROWS when the store cannot be read, and that
4333
+ * case is SKIPPED, never counted as dead: an unreadable store means
4334
+ * "unknown", and a storage outage must not be published as a lost run.
4335
+ * - `getRun(runId) == null` — no terminal history row either (the `run_`
4336
+ * prefixed rows in `sys_automation_run`). A run that merely finished is
4337
+ * not stranded; a request whose run neither waits nor ever completed is.
4338
+ *
4339
+ * **Reports; never rewrites.** No status is changed and no run is cancelled.
4340
+ * The decision genuinely happened — a human approved or rejected — and
4341
+ * silently rolling it back would make the audit trail disagree with the
4342
+ * facts. What an operator needs first is visibility: which requests are stuck
4343
+ * at which step, and what the mirrored status field on the business record
4344
+ * still says. Whether to re-run the downstream actions or re-open the
4345
+ * approval is a judgement call this cannot make.
4346
+ */
4347
+ async inspectStrandedRequests(options) {
4348
+ const empty = { scanned: 0, stranded: [], undetermined: 0 };
4349
+ if (typeof this.automation?.hasSuspendedRun !== "function") return empty;
4350
+ if (typeof this.automation?.getRun !== "function") return empty;
4351
+ const limit = options?.limit ?? 500;
4352
+ let rows = [];
4353
+ try {
4354
+ rows = await this.engine.find("sys_approval_request", {
4355
+ where: { status: { $in: [...STRANDABLE_REQUEST_STATUSES] } },
4356
+ limit,
4357
+ context: SYSTEM_CTX2
4358
+ }) ?? [];
4359
+ } catch (err) {
4360
+ this.logger?.warn?.("[approvals] stranded-request scan failed to list requests", {
4361
+ error: err?.message ?? String(err)
4362
+ });
4363
+ return empty;
4364
+ }
4365
+ const stranded = [];
4366
+ let undetermined = 0;
4367
+ for (const raw of rows) {
4368
+ const runId = raw?.flow_run_id ? String(raw.flow_run_id) : "";
4369
+ if (!runId) continue;
4370
+ let suspended;
4371
+ try {
4372
+ suspended = await this.automation.hasSuspendedRun(runId);
4373
+ } catch (err) {
4374
+ undetermined++;
4375
+ this.logger?.warn?.("[approvals] stranded-request scan could not read the suspension store", {
4376
+ request: raw?.id,
4377
+ run: runId,
4378
+ error: err?.message ?? String(err)
4379
+ });
4380
+ continue;
4381
+ }
4382
+ if (suspended) continue;
4383
+ let terminal = null;
4384
+ try {
4385
+ terminal = await this.automation.getRun(runId);
4386
+ } catch (err) {
4387
+ undetermined++;
4388
+ this.logger?.warn?.("[approvals] stranded-request scan could not read the run history", {
4389
+ request: raw?.id,
4390
+ run: runId,
4391
+ error: err?.message ?? String(err)
4392
+ });
4393
+ continue;
4394
+ }
4395
+ if (terminal) continue;
4396
+ const config = parseJson(
4397
+ raw.node_config_json,
4398
+ { approvers: [], behavior: "first_response" }
4399
+ );
4400
+ const mirrorField = config.approvalStatusField;
4401
+ let mirroredStatus;
4402
+ if (mirrorField) {
4403
+ try {
4404
+ const recs = await this.engine.find(raw.object_name, {
4405
+ where: { id: raw.record_id },
4406
+ limit: 1,
4407
+ context: SYSTEM_CTX2
4408
+ });
4409
+ const rec = Array.isArray(recs) ? recs[0] : null;
4410
+ if (rec) mirroredStatus = rec[mirrorField] ?? void 0;
4411
+ } catch {
4412
+ }
4413
+ }
4414
+ stranded.push({
4415
+ requestId: String(raw.id),
4416
+ status: raw.status,
4417
+ runId,
4418
+ flowName: typeof raw.process_name === "string" ? raw.process_name.replace(/^flow:/, "") : void 0,
4419
+ nodeId: raw.flow_node_id ?? raw.current_step ?? void 0,
4420
+ objectName: raw.object_name,
4421
+ recordId: raw.record_id,
4422
+ organizationId: raw.organization_id ?? null,
4423
+ completedAt: raw.completed_at ?? void 0,
4424
+ mirrorField,
4425
+ mirroredStatus
4426
+ });
4427
+ }
4428
+ if (stranded.length || undetermined) {
4429
+ this.logger?.warn?.("[approvals] stranded terminal requests (decision recorded, flow run gone)", {
4430
+ scanned: rows.length,
4431
+ stranded: stranded.length,
4432
+ undetermined,
4433
+ requests: stranded.map((s) => `${s.requestId}@${s.nodeId ?? "?"} \u2192 run ${s.runId}`)
4434
+ });
4435
+ }
4436
+ return { scanned: rows.length, stranded, undetermined };
4437
+ }
4148
4438
  async releaseDeadRunRequests() {
4149
4439
  if (typeof this.automation?.getRun !== "function") return { scanned: 0, released: 0 };
4150
4440
  let rows = [];
@@ -5205,21 +5495,109 @@ function parseJson2(raw, fallback) {
5205
5495
  }
5206
5496
  return raw;
5207
5497
  }
5498
+ var PENDING_LOCK_LIMIT = 1e3;
5499
+ var SYSTEM_CTX3 = { isSystem: true, positions: [], permissions: [] };
5500
+ function lockedError(message) {
5501
+ const err = new Error(`RECORD_LOCKED: ${message}`);
5502
+ err.code = "RECORD_LOCKED";
5503
+ err.statusCode = 409;
5504
+ throw err;
5505
+ }
5506
+ function asIdList(id) {
5507
+ if (typeof id === "number") return [id];
5508
+ if (typeof id === "string") return id === "" ? null : [id];
5509
+ if (id && typeof id === "object" && Array.isArray(id.$in)) {
5510
+ const raw = id.$in;
5511
+ const scalars = raw.filter((v) => typeof v === "string" || typeof v === "number");
5512
+ return scalars.length === raw.length ? scalars : null;
5513
+ }
5514
+ return null;
5515
+ }
5208
5516
  async function pendingRequestFor(engine, objectName, recordId) {
5209
5517
  try {
5210
5518
  const rows = await engine.find("sys_approval_request", {
5211
5519
  where: { object_name: objectName, record_id: String(recordId), status: "pending" },
5212
- limit: 1
5520
+ limit: 1,
5521
+ context: { ...SYSTEM_CTX3 }
5213
5522
  });
5214
5523
  return Array.isArray(rows) && rows[0] ? rows[0] : null;
5215
5524
  } catch {
5216
5525
  return null;
5217
5526
  }
5218
5527
  }
5528
+ async function pendingRequestsForRecords(engine, objectName, recordIds) {
5529
+ if (recordIds.length === 0) return [];
5530
+ if (recordIds.length > PENDING_LOCK_LIMIT) {
5531
+ lockedError(
5532
+ `refusing to authorize an update naming more than ${PENDING_LOCK_LIMIT} records of '${objectName}' \u2014 the approval lock cannot check them row by row; scope the write`
5533
+ );
5534
+ }
5535
+ if (recordIds.length === 1) {
5536
+ const one = await pendingRequestFor(engine, objectName, String(recordIds[0]));
5537
+ return one ? [one] : [];
5538
+ }
5539
+ try {
5540
+ const rows = await engine.find("sys_approval_request", {
5541
+ where: { object_name: objectName, record_id: { $in: recordIds.map(String) }, status: "pending" },
5542
+ limit: PENDING_LOCK_LIMIT + 1,
5543
+ context: { ...SYSTEM_CTX3 }
5544
+ });
5545
+ return Array.isArray(rows) ? rows : [];
5546
+ } catch {
5547
+ return [];
5548
+ }
5549
+ }
5550
+ async function pendingRequestsForObject(engine, objectName) {
5551
+ try {
5552
+ const rows = await engine.find("sys_approval_request", {
5553
+ where: { object_name: objectName, status: "pending" },
5554
+ limit: PENDING_LOCK_LIMIT + 1,
5555
+ context: { ...SYSTEM_CTX3 }
5556
+ });
5557
+ return Array.isArray(rows) ? rows : [];
5558
+ } catch {
5559
+ return null;
5560
+ }
5561
+ }
5562
+ async function narrowToMatchedRecords(engine, objectName, where, candidates) {
5563
+ const lockedIds = candidates.map((c) => String(c?.record_id ?? ""));
5564
+ let rows;
5565
+ try {
5566
+ rows = await engine.find(objectName, {
5567
+ where: { $and: [where, { id: { $in: lockedIds } }] },
5568
+ fields: ["id"],
5569
+ limit: lockedIds.length,
5570
+ context: { ...SYSTEM_CTX3 }
5571
+ });
5572
+ } catch (err) {
5573
+ lockedError(
5574
+ `cannot determine which rows a predicate update on '${objectName}' would touch (${err?.message ?? String(err)}); ${candidates.length} record(s) of it carry a pending approval, so the write is refused`
5575
+ );
5576
+ }
5577
+ const matched = new Set((Array.isArray(rows) ? rows : []).map((r) => String(r?.id)));
5578
+ return candidates.filter((c) => matched.has(String(c?.record_id ?? "")));
5579
+ }
5580
+ async function gatingRequests(engine, ctx, objectName) {
5581
+ const byId = asIdList(ctx?.input?.id);
5582
+ if (byId) return pendingRequestsForRecords(engine, objectName, byId);
5583
+ const rawWhere = ctx?.input?.options?.where;
5584
+ const hasWhere = rawWhere !== void 0 && rawWhere !== null;
5585
+ const whereObj = hasWhere && typeof rawWhere === "object" && !Array.isArray(rawWhere) ? rawWhere : null;
5586
+ const namedIds = whereObj ? asIdList(whereObj.id) : null;
5587
+ const candidates = namedIds ? await pendingRequestsForRecords(engine, objectName, namedIds) : await pendingRequestsForObject(engine, objectName);
5588
+ if (candidates === null) return [];
5589
+ if (candidates.length === 0) return [];
5590
+ if (candidates.length > PENDING_LOCK_LIMIT) {
5591
+ lockedError(
5592
+ `refusing a predicate update on '${objectName}': more than ${PENDING_LOCK_LIMIT} of its records carry a pending approval, so the lock cannot decide row by row; scope the write to the rows you mean`
5593
+ );
5594
+ }
5595
+ if (!hasWhere) return candidates;
5596
+ if (namedIds && whereObj && Object.keys(whereObj).every((k) => k === "id")) return candidates;
5597
+ return narrowToMatchedRecords(engine, objectName, rawWhere, candidates);
5598
+ }
5219
5599
  function bindApprovalLockHook(engine, logger) {
5220
5600
  engine.registerHook("beforeUpdate", async (ctx) => {
5221
- const id = String(ctx?.input?.id ?? "");
5222
- if (!id) return;
5223
5601
  const object = ctx?.object ?? ctx?.objectName;
5224
5602
  if (!object || String(object).startsWith("sys_approval")) return;
5225
5603
  const data = ctx?.input?.data ?? {};
@@ -5228,18 +5606,19 @@ function bindApprovalLockHook(engine, logger) {
5228
5606
  if (ctx?.session?.isSystem) return;
5229
5607
  const roles = ctx?.session?.roles ?? [];
5230
5608
  if (Array.isArray(roles) && roles.includes("admin")) return;
5231
- const pending = await pendingRequestFor(engine, object, id);
5232
- if (!pending) return;
5609
+ const gating = await gatingRequests(engine, ctx, object);
5610
+ if (gating.length === 0) return;
5233
5611
  const writerRun = ctx?.provenance?.flowRunId;
5234
- if (writerRun && pending.flow_run_id && String(writerRun) === String(pending.flow_run_id)) return;
5235
- const config = parseJson2(pending.node_config_json, {});
5236
- if (config?.lockRecord === false) return;
5237
- const mirror = config?.approvalStatusField;
5238
- if (typeof mirror === "string" && mirror && changedFields.every((f) => f === mirror)) return;
5239
- const err = new Error("RECORD_LOCKED: record is locked while an approval is in progress");
5240
- err.code = "RECORD_LOCKED";
5241
- err.statusCode = 409;
5242
- throw err;
5612
+ for (const pending of gating) {
5613
+ if (writerRun && pending?.flow_run_id && String(writerRun) === String(pending.flow_run_id)) continue;
5614
+ const config = parseJson2(pending?.node_config_json, {});
5615
+ if (config?.lockRecord === false) continue;
5616
+ const mirror = config?.approvalStatusField;
5617
+ if (typeof mirror === "string" && mirror && changedFields.every((f) => f === mirror)) continue;
5618
+ lockedError(
5619
+ `record '${String(pending?.record_id ?? "")}' of '${object}' is locked while an approval is in progress`
5620
+ );
5621
+ }
5243
5622
  }, { packageId: APPROVALS_HOOK_PACKAGE, priority: 50 });
5244
5623
  logger?.info?.("[approvals] record-lock hook bound");
5245
5624
  }
@@ -5283,7 +5662,7 @@ function unbindAllHooks(engine) {
5283
5662
 
5284
5663
  // src/approval-node.ts
5285
5664
  var import_automation2 = require("@objectstack/spec/automation");
5286
- var SYSTEM_CTX3 = { isSystem: true, positions: [], permissions: [] };
5665
+ var SYSTEM_CTX4 = { isSystem: true, positions: [], permissions: [] };
5287
5666
  function nestVariables(variables) {
5288
5667
  const vars = {};
5289
5668
  for (const [key, value] of variables) {
@@ -5362,7 +5741,7 @@ function registerApprovalNode(automation, service, logger) {
5362
5741
  // their `trigger.*` snapshot root.
5363
5742
  variables: nestVariables(variables)
5364
5743
  }, {
5365
- ...SYSTEM_CTX3,
5744
+ ...SYSTEM_CTX4,
5366
5745
  userId: context?.userId,
5367
5746
  organizationId: context?.organizationId,
5368
5747
  tenantId: context?.tenantId
@@ -5506,7 +5885,13 @@ var ApprovalsServicePlugin = class {
5506
5885
  const sweep = async () => {
5507
5886
  const results = await Promise.allSettled([
5508
5887
  svc.runEscalations(),
5509
- svc.releaseDeadRunRequests()
5888
+ svc.releaseDeadRunRequests(),
5889
+ // #4469 — the other half of the dead-run picture, and the one no
5890
+ // sweeper could see: a request already TERMINAL whose run is gone.
5891
+ // Read-only by design (it reports; it never rewrites a decision
5892
+ // that really happened), so it rides the same clock purely to make
5893
+ // the finding surface without an operator knowing to go looking.
5894
+ svc.inspectStrandedRequests()
5510
5895
  ]);
5511
5896
  for (const r of results) {
5512
5897
  if (r.status === "rejected") {
@@ -5581,14 +5966,19 @@ var ApprovalsServicePlugin = class {
5581
5966
  await mountActionPages();
5582
5967
  await backfillApproverIndex();
5583
5968
  }
5969
+ let automation;
5584
5970
  try {
5585
- const automation = ctx.getService("automation");
5586
- if (automation && typeof automation.registerNodeExecutor === "function") {
5587
- this.service.attachAutomation(automation);
5588
- registerApprovalNode(automation, this.service, ctx.logger);
5589
- }
5971
+ automation = ctx.getService("automation");
5590
5972
  } catch {
5591
- ctx.logger.info("ApprovalsServicePlugin: no automation engine \u2014 approval node not registered");
5973
+ automation = void 0;
5974
+ }
5975
+ if (automation && typeof automation.registerNodeExecutor === "function") {
5976
+ this.service.attachAutomation(automation);
5977
+ registerApprovalNode(automation, this.service, ctx.logger);
5978
+ } else {
5979
+ ctx.logger.warn(
5980
+ "ApprovalsServicePlugin: no automation engine \u2014 the `approval` flow node is NOT registered. Every ADR-0019 approval flow in this deployment fails at execution time with NO_EXECUTOR. Add @objectstack/service-automation to the stack to enable them."
5981
+ );
5592
5982
  }
5593
5983
  }
5594
5984
  async stop(ctx) {