@objectstack/plugin-approvals 17.3.0 → 17.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -2324,11 +2324,10 @@ import {
2324
2324
  } from "@objectstack/spec/automation";
2325
2325
  import { ExpressionEngine, collectCelRootIdentifiers } from "@objectstack/formula";
2326
2326
  import { createRecordOrganizationResolver } from "@objectstack/metadata-core";
2327
- import { keysetWalk } from "@objectstack/types";
2327
+ import { keysetWalk, strandedDecisionFailure } from "@objectstack/types";
2328
2328
  import {
2329
2329
  ADMIN_FULL_ACCESS,
2330
2330
  ORGANIZATION_ADMIN_GRANTS,
2331
- BUILTIN_IDENTITY_PLATFORM_ADMIN,
2332
2331
  BUILTIN_IDENTITY_ORG_OWNER,
2333
2332
  BUILTIN_IDENTITY_ORG_ADMIN
2334
2333
  } from "@objectstack/spec/identity";
@@ -2499,10 +2498,22 @@ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
2499
2498
  "timed_out"
2500
2499
  ]);
2501
2500
  var STRANDABLE_REQUEST_STATUSES = ["approved", "rejected", "returned"];
2501
+ var STRANDED_CONTINUATION_KEY = "__strandedContinuation";
2502
+ var CONTINUATIONS_A_STATUS_CAN_ISSUE = {
2503
+ approved: ["approve"],
2504
+ rejected: ["reject"],
2505
+ returned: ["revise", "resubmit"],
2506
+ recalled: ["recall"]
2507
+ };
2502
2508
  function classifyStrandedRunState(run) {
2503
2509
  if (!run) return "missing";
2504
2510
  switch (run.status) {
2505
- // The resume consumed the pause and a downstream node threw. Reported.
2511
+ // A terminal `failed` row. Reported and, when the engine can be asked,
2512
+ // refined by the third oracle ({@link refineFailedRunState}) into which of
2513
+ // the three `failed` shapes it is. This arm alone cannot tell them apart:
2514
+ // the row reads identically for a resume that consumed the pause and
2515
+ // threw downstream (repairable) and for an ancestor `failAncestors`
2516
+ // cascade-failed (not). `'failed'` here means "reported, undifferentiated".
2506
2517
  case "failed":
2507
2518
  return "failed";
2508
2519
  // ── The negatives, each for its own reason ──────────────────────────────
@@ -2527,6 +2538,28 @@ function classifyStrandedRunState(run) {
2527
2538
  return void 0;
2528
2539
  }
2529
2540
  }
2541
+ function refineFailedRunState(verdict) {
2542
+ if (verdict.repairable) return "repairable";
2543
+ switch (verdict.reason) {
2544
+ // The strand happened; the store could not keep the snapshot, and the
2545
+ // engine asked holds no hot copy. Its own class — see the type below.
2546
+ case "SNAPSHOT_DROPPED":
2547
+ return "snapshot_dropped";
2548
+ // Neither witness holds anything: cascade-failed, or never paused.
2549
+ case "NO_CONSUMED_SUSPENSION":
2550
+ return "unrepairable";
2551
+ // Re-armed between the two reads (an operator's restore landed while this
2552
+ // scan was running): the run is alive and resumable, which is what the
2553
+ // first oracle would have said a moment later. Not stranded.
2554
+ case "RUN_SUSPENDED":
2555
+ return void 0;
2556
+ // An answer this build does not know (an engine ahead of this plugin).
2557
+ // Fail-closed exactly as an absent member: reported, undifferentiated —
2558
+ // never condemned on a word this code cannot read.
2559
+ default:
2560
+ return "failed";
2561
+ }
2562
+ }
2530
2563
  var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
2531
2564
  var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
2532
2565
  var RECORD_DELETE_CANCEL_LIMIT = 200;
@@ -2840,7 +2873,7 @@ var _ApprovalService = class _ApprovalService {
2840
2873
  const perms = Array.isArray(context.permissions) ? context.permissions : [];
2841
2874
  const positions = Array.isArray(context.positions) ? context.positions : [];
2842
2875
  const posture = context.posture;
2843
- const isPlatformAdmin = posture === "PLATFORM_ADMIN" || perms.includes(ADMIN_FULL_ACCESS) || positions.includes(BUILTIN_IDENTITY_PLATFORM_ADMIN);
2876
+ const isPlatformAdmin = posture === "PLATFORM_ADMIN" || perms.includes(ADMIN_FULL_ACCESS);
2844
2877
  if (isPlatformAdmin) return true;
2845
2878
  const isTenantAdmin = posture === "TENANT_ADMIN" || ORGANIZATION_ADMIN_GRANTS.some((n) => perms.includes(n)) || positions.includes(BUILTIN_IDENTITY_ORG_OWNER) || positions.includes(BUILTIN_IDENTITY_ORG_ADMIN);
2846
2879
  if (!isTenantAdmin) return false;
@@ -3414,7 +3447,56 @@ var _ApprovalService = class _ApprovalService {
3414
3447
  if (!organizationId) return filter;
3415
3448
  return { ...filter, $or: [{ organization_id: organizationId }, { organization_id: null }] };
3416
3449
  }
3417
- /** Recursive department — walks `sys_business_unit.parent_business_unit_id`. */
3450
+ /**
3451
+ * Tenant scope for the `sys_business_unit_member` read — a STRICT equality,
3452
+ * deliberately NOT {@link businessUnitOrgScope} (#14946).
3453
+ *
3454
+ * The two screens answer different questions. The UNIT is the anchor the
3455
+ * approver NAMES, and a seeded unit carries `organization_id = null` by
3456
+ * construction (a seed cannot know the id the runtime mints at boot), so
3457
+ * #3807 admits the null there on purpose. The MEMBER rows are the SET BEING
3458
+ * ROUTED TO — enumerated by the platform, never named by anyone — and a
3459
+ * seeded unit id exists identically in every tenant. Before this screen the
3460
+ * member read carried no organization predicate at all, under
3461
+ * {@link SYSTEM_CTX} which carries no tenant either, so tenant A's request
3462
+ * resolved the shared unit and then collected EVERY tenant's membership rows
3463
+ * hanging off it: approval authority over A's record, routed to B's users.
3464
+ *
3465
+ * Why the null arm is NOT copied here — measured on this tree:
3466
+ * - `sys_business_unit_member` declares no `organization_id`; the column
3467
+ * is injected (`applySystemFields`) and the tenancy census lists it in;
3468
+ * - REST / session writes fill it (`SqlDriver.injectTenantOnInsert`);
3469
+ * - seed replay does NOT (`seed-loader.ts` withholds its `fallbackOrgId`
3470
+ * from every `sys_` object), and elevated system-context writes do NOT
3471
+ * (`unclassified` in `PLATFORM_OBJECT_TENANCY`, tracked as #14570).
3472
+ * So a NULL on a member row means UNKNOWN tenancy, not "platform-global",
3473
+ * and unknown tenancy is not a member of this organization. This is the
3474
+ * ruling `plugin-sharing`'s `memberScope` already applies to the same rows
3475
+ * (#14547 / #14949), and the posture this file already takes for
3476
+ * `sys_team_member` and `sys_user_position`.
3477
+ *
3478
+ * The cost is declared, not hidden: an organization whose MEMBERSHIP rows
3479
+ * were seeded or system-written expands to nobody even on a unit it can
3480
+ * see. That is not silent — the graph-type fallback in `expandApprover`
3481
+ * warns `expanded to nobody` (#3807) and `onEmptyApprovers` governs the
3482
+ * request as for any unstaffed target — and the repair is to stamp the
3483
+ * membership rows, never to widen this screen. ⛔ Do not "unify" the two
3484
+ * screens: one method serving both re-opens whichever half it does not
3485
+ * implement.
3486
+ */
3487
+ businessUnitMemberScope(filter, organizationId) {
3488
+ if (!organizationId) return filter;
3489
+ return { ...filter, organization_id: organizationId };
3490
+ }
3491
+ /**
3492
+ * Recursive department — walks `sys_business_unit.parent_business_unit_id`.
3493
+ *
3494
+ * Two tenant screens, and they are different on purpose: the UNIT rows
3495
+ * (seed check and descent) go through the null-inclusive
3496
+ * {@link businessUnitOrgScope}; the MEMBER read goes through the strict
3497
+ * {@link businessUnitMemberScope}. `organizationId` is the DIRECTORY
3498
+ * organization the approver resolves in (ADR-0105 D9), for both.
3499
+ */
3418
3500
  async expandBusinessUnitUsers(businessUnitId, organizationId) {
3419
3501
  if (!businessUnitId) return [];
3420
3502
  try {
@@ -3454,7 +3536,11 @@ var _ApprovalService = class _ApprovalService {
3454
3536
  let rows = [];
3455
3537
  try {
3456
3538
  rows = await this.engine.find("sys_business_unit_member", {
3457
- where: { business_unit_id: { $in: Array.from(seen) } },
3539
+ // #14946: tenant-screened {@link businessUnitMemberScope} is STRICT
3540
+ // on purpose and is not {@link businessUnitOrgScope}. The units above
3541
+ // proved their tenancy (or are seeded); these rows have not, and the
3542
+ // shared seeded unit id is exactly where other tenants' rows sit.
3543
+ where: this.businessUnitMemberScope({ business_unit_id: { $in: Array.from(seen) } }, organizationId),
3458
3544
  fields: ["user_id"],
3459
3545
  limit: 1e4,
3460
3546
  context: SYSTEM_CTX2
@@ -4167,6 +4253,7 @@ var _ApprovalService = class _ApprovalService {
4167
4253
  `resume of run '${runId}' failed${reported.code ? ` [${reported.code}]` : ""}: ${reported.error ?? "unknown error"}`
4168
4254
  );
4169
4255
  err.resumeCode = reported.code;
4256
+ err.resumeStatus = reported.status;
4170
4257
  throw err;
4171
4258
  }
4172
4259
  }
@@ -4174,6 +4261,22 @@ var _ApprovalService = class _ApprovalService {
4174
4261
  static resumeCodeOf(err) {
4175
4262
  return err?.resumeCode;
4176
4263
  }
4264
+ /**
4265
+ * The engine's own run-state discriminator behind a {@link serviceResume}
4266
+ * rejection — `AutomationResult.status` — if the engine reported one
4267
+ * (#13807).
4268
+ *
4269
+ * Read as a SIBLING of {@link resumeCodeOf}, never as a substitute: the two
4270
+ * answer different questions and the stranded exit proves they are not
4271
+ * interchangeable. It reports `status: 'stranded'` and **no `code` at all**
4272
+ * (`service-automation` `engine.ts`, the resume catch arm), so a door that
4273
+ * reads only the code sees an unnamed failure and cannot tell a repairable
4274
+ * strand from a dead run — which is how the platform's own repairability
4275
+ * signal had a producer and zero consumers until this call site.
4276
+ */
4277
+ static resumeStatusOf(err) {
4278
+ return err?.resumeStatus;
4279
+ }
4177
4280
  /**
4178
4281
  * Refuse an operation whose whole point is to advance a flow run when that
4179
4282
  * run no longer exists — BEFORE anything is written down (#4420).
@@ -4267,10 +4370,33 @@ var _ApprovalService = class _ApprovalService {
4267
4370
  * which cannot throw without breaking every standalone deployment — it
4268
4371
  * reports through `resumeError` instead.
4269
4372
  *
4373
+ * ## The throw is truthful, not merely loud (#13807)
4374
+ *
4375
+ * Maintainer ruling 2026-09-04 (decision batch #37, option B): this door
4376
+ * KEEPS its status code — the effect landing while the run strands is still
4377
+ * a failure and must still be reported as one — and stops discarding what
4378
+ * the engine said. ⛔ Not "return 200", which the card forbids; ⛔ not
4379
+ * atomic, because rolling a real human decision back is excluded by the
4380
+ * #13937 shape-4 ruling, which binds this door's own writes too (a machine
4381
+ * that re-armed strandings by itself would re-run the node that threw,
4382
+ * forever, with nobody deciding it should).
4383
+ *
4384
+ * So the error carries {@link StrandedDecisionDetails} beside its prose:
4385
+ * `finalized` (the decision stands), `decision`, `runId`, and `repairable`
4386
+ * derived from the engine's `'stranded'` discriminator. Before this a caller
4387
+ * had a 500 and a sentence — and 500 alone reads as "the rejection did not
4388
+ * happen", which is the misreading that makes a caller retry or escalate
4389
+ * against a decision that IS durable.
4390
+ *
4270
4391
  * @param what - how the recorded outcome reads in the error, e.g.
4271
4392
  * `"the approve decision"`.
4393
+ * @param decision - the outcome label for the machine-readable envelope
4394
+ * (`'approve'` / `'reject'` / `'revise'` / `'resubmit'`). Passed
4395
+ * explicitly rather than parsed back out of `what` or the signal: the
4396
+ * prose is for humans and `output` is the flow's, and neither is a place
4397
+ * to keep a wire value.
4272
4398
  */
4273
- async resumeRecordedOutcome(runId, requestId, what, signal) {
4399
+ async resumeRecordedOutcome(runId, requestId, what, signal, decision) {
4274
4400
  const missing = this.missingRunCapability(runId, requestId, what, "resume");
4275
4401
  if (missing) return { resumed: false, resumeError: missing };
4276
4402
  try {
@@ -4286,14 +4412,27 @@ var _ApprovalService = class _ApprovalService {
4286
4412
  });
4287
4413
  return { resumed: false, resumeError: reason };
4288
4414
  }
4415
+ const status = _ApprovalService.resumeStatusOf(err);
4416
+ const repairable = status === "stranded";
4289
4417
  this.logger?.error?.("[approvals] resume failed \u2014 the run is stranded", {
4290
4418
  request: requestId,
4291
4419
  run: runId,
4292
4420
  outcome: what,
4293
- error: reason
4421
+ error: reason,
4422
+ status,
4423
+ repairable
4294
4424
  });
4295
- throw new Error(
4296
- `RESUME_FAILED: ${what} was recorded on request ${requestId}, but its flow run '${runId}' could not be resumed and is now stranded: ${reason}`
4425
+ if (repairable) {
4426
+ await this.journalStrandedContinuation(requestId, {
4427
+ branchLabel: signal.branchLabel,
4428
+ output: signal.output,
4429
+ decision,
4430
+ what
4431
+ });
4432
+ }
4433
+ throw strandedDecisionFailure(
4434
+ `RESUME_FAILED: ${what} was recorded on request ${requestId}, but its flow run '${runId}' could not be resumed and is now stranded: ${reason}`,
4435
+ { finalized: true, decision, runId, repairable }
4297
4436
  );
4298
4437
  }
4299
4438
  }
@@ -4329,7 +4468,8 @@ var _ApprovalService = class _ApprovalService {
4329
4468
  // Reserved keys are spread LAST so no output can shadow them (the
4330
4469
  // whitelist already rejects them; this is defense in depth).
4331
4470
  output: { ...result.outputs ?? {}, decision: result.decision, requestId }
4332
- }
4471
+ },
4472
+ result.decision
4333
4473
  );
4334
4474
  resumed = outcome.resumed;
4335
4475
  resumeError = outcome.resumeError;
@@ -4344,7 +4484,7 @@ var _ApprovalService = class _ApprovalService {
4344
4484
  };
4345
4485
  }
4346
4486
  /**
4347
- * Withdraw a pending request (submitter only). Finalises the row as
4487
+ * Withdraw an undecided request. Finalises the row as
4348
4488
  * `recalled`, releases the record lock (keyed on pending status), mirrors
4349
4489
  * the status field when configured, and resumes the owning flow run down
4350
4490
  * the `reject` branch with `output.decision = 'recall'` — leaving the run
@@ -4458,6 +4598,14 @@ var _ApprovalService = class _ApprovalService {
4458
4598
  run: runId,
4459
4599
  error: resumeError
4460
4600
  });
4601
+ if (_ApprovalService.resumeStatusOf(err) === "stranded") {
4602
+ await this.journalStrandedContinuation(requestId, {
4603
+ branchLabel: APPROVAL_BRANCH_LABELS.reject,
4604
+ output: { decision: "recall", requestId },
4605
+ decision: "recall",
4606
+ what: "the recall"
4607
+ });
4608
+ }
4461
4609
  }
4462
4610
  }
4463
4611
  }
@@ -4673,7 +4821,8 @@ var _ApprovalService = class _ApprovalService {
4673
4821
  {
4674
4822
  branchLabel: APPROVAL_BRANCH_LABELS.reject,
4675
4823
  output: { decision: "reject", autoRejected: true, requestId }
4676
- }
4824
+ },
4825
+ "reject"
4677
4826
  );
4678
4827
  resumed2 = outcome.resumed;
4679
4828
  resumeError2 = outcome.resumeError;
@@ -4721,7 +4870,8 @@ var _ApprovalService = class _ApprovalService {
4721
4870
  {
4722
4871
  branchLabel: APPROVAL_BRANCH_LABELS.revise,
4723
4872
  output: { decision: "revise", requestId }
4724
- }
4873
+ },
4874
+ "revise"
4725
4875
  );
4726
4876
  resumed = outcome.resumed;
4727
4877
  resumeError = outcome.resumeError;
@@ -4801,7 +4951,8 @@ var _ApprovalService = class _ApprovalService {
4801
4951
  {
4802
4952
  branchLabel: APPROVAL_BRANCH_LABELS.resubmit,
4803
4953
  output: { resubmitted: true, requestId }
4804
- }
4954
+ },
4955
+ "resubmit"
4805
4956
  );
4806
4957
  resumed = outcome.resumed;
4807
4958
  resumeError = outcome.resumeError;
@@ -5343,8 +5494,25 @@ var _ApprovalService = class _ApprovalService {
5343
5494
  * ⚠️ The widening does NOT reverse the conservatism: `completed`, `cancelled`
5344
5495
  * and `paused` are each still skipped, for reasons named one at a time in
5345
5496
  * `classifyStrandedRunState`, and an unrecognised status is skipped too.
5346
- * What the widening buys is that a `failed` run is now reported with
5347
- * `runState: 'failed'` instead of counted as healthy.
5497
+ * What the widening buys is that a `failed` run is now reported instead of
5498
+ * counted as healthy.
5499
+ *
5500
+ * **A THIRD oracle tells the `failed` rows apart (#15358).** `status ===
5501
+ * 'failed'` over-reports in one specific direction: a cascade-failed run
5502
+ * — an ancestor `failAncestors` failed while it was parked at its `subflow`
5503
+ * node, whose pause `failSuspendedRun` consumed and journalled nothing — has
5504
+ * the same terminal row as the #13909 strand, and `restoreConsumedSuspension`
5505
+ * refuses it. The engine's discriminator (the consumed-suspension snapshot)
5506
+ * is deliberately NOT on the object `getRun` answers, so it is asked through
5507
+ * a dedicated read-only member, `inspectConsumedSuspension`, and only for
5508
+ * `failed` rows: the answer splits `'failed'` into `'repairable'`,
5509
+ * `'snapshot_dropped'` and `'unrepairable'` (see {@link StrandedRunState}).
5510
+ * A surface without that member leaves the row `'failed'` — reported,
5511
+ * undifferentiated — because absence of the discriminator is not evidence
5512
+ * of anything. So does a read that THREW or answered a malformed verdict
5513
+ * (#16709): by the time this oracle is asked the row is already known to be
5514
+ * stranded, so a failure to differentiate it is not a reason to drop it from
5515
+ * a report — it is counted `undetermined` as telemetry AND reported.
5348
5516
  *
5349
5517
  * ⚠️ **What this can and cannot size.** It makes the condition *visible* in a
5350
5518
  * deployment; it is not itself a census, and it says nothing about this
@@ -5407,8 +5575,27 @@ var _ApprovalService = class _ApprovalService {
5407
5575
  });
5408
5576
  continue;
5409
5577
  }
5410
- const runState = classifyStrandedRunState(terminal);
5578
+ let runState = classifyStrandedRunState(terminal);
5411
5579
  if (!runState) continue;
5580
+ if (runState === "failed" && typeof this.automation.inspectConsumedSuspension === "function") {
5581
+ let refined;
5582
+ let differentiated = true;
5583
+ try {
5584
+ refined = refineFailedRunState(await this.automation.inspectConsumedSuspension(runId));
5585
+ } catch (err) {
5586
+ differentiated = false;
5587
+ undetermined++;
5588
+ this.logger?.warn?.("[approvals] stranded-request scan could not read the consumed-suspension state", {
5589
+ request: raw?.id,
5590
+ run: runId,
5591
+ error: err?.message ?? String(err)
5592
+ });
5593
+ }
5594
+ if (differentiated) {
5595
+ if (!refined) continue;
5596
+ runState = refined;
5597
+ }
5598
+ }
5412
5599
  const config = parseJson(
5413
5600
  raw.node_config_json,
5414
5601
  { approvers: [], behavior: "first_response" }
@@ -5449,11 +5636,389 @@ var _ApprovalService = class _ApprovalService {
5449
5636
  undetermined,
5450
5637
  runMissing: stranded.filter((s) => s.runState === "missing").length,
5451
5638
  runFailed: stranded.filter((s) => s.runState === "failed").length,
5639
+ runRepairable: stranded.filter((s) => s.runState === "repairable").length,
5640
+ runSnapshotDropped: stranded.filter((s) => s.runState === "snapshot_dropped").length,
5641
+ runUnrepairable: stranded.filter((s) => s.runState === "unrepairable").length,
5452
5642
  requests: stranded.map((s) => `${s.requestId}@${s.nodeId ?? "?"} \u2192 run ${s.runId} (${s.runState})`)
5453
5643
  });
5454
5644
  }
5455
5645
  return { scanned: rows.length, stranded, undetermined };
5456
5646
  }
5647
+ /**
5648
+ * Stash the continuation a door just failed to deliver, so it can be issued
5649
+ * again after the pause is re-armed (#15389).
5650
+ *
5651
+ * `AutomationEngine.restoreConsumedSuspension` puts a stranded approval run
5652
+ * back on its pause and tells the operator to *re-issue the continuation* —
5653
+ * but for an `approval` node the only issuers are this service's doors, and
5654
+ * every one of them guards on a `pending` request that the stranding call
5655
+ * itself just made terminal. Re-opening the row is excluded (it would let a
5656
+ * decided request be decided again), so what is kept instead is the SIGNAL:
5657
+ * the exact `branchLabel` + `output` the failed resume carried.
5658
+ *
5659
+ * ⚠️ Best-effort by construction, and it must stay that way: the decision is
5660
+ * already durable and its caller is already owed a `RESUME_FAILED` throw. A
5661
+ * failure to write recovery bookkeeping must not replace that throw with a
5662
+ * storage error — {@link ApprovalService.continueRestoredRun} rebuilds the
5663
+ * signal from the row when the stash is absent, so this failing costs
5664
+ * fidelity on one shape, not the repair path.
5665
+ */
5666
+ async journalStrandedContinuation(requestId, signal) {
5667
+ try {
5668
+ const rows = await this.engine.find("sys_approval_request", {
5669
+ where: { id: requestId },
5670
+ limit: 1,
5671
+ context: SYSTEM_CTX2
5672
+ });
5673
+ const raw = Array.isArray(rows) ? rows[0] : null;
5674
+ if (!raw) return;
5675
+ const config = parseJson(raw.node_config_json, {});
5676
+ await this.engine.update("sys_approval_request", {
5677
+ id: requestId,
5678
+ node_config_json: JSON.stringify({ ...config, [STRANDED_CONTINUATION_KEY]: signal })
5679
+ }, { context: SYSTEM_CTX2 });
5680
+ } catch (err) {
5681
+ this.logger?.warn?.(
5682
+ "[approvals] could not journal the stranded continuation \u2014 the repair path falls back to rebuilding it from the row",
5683
+ { request: requestId, error: err?.message ?? String(err) }
5684
+ );
5685
+ }
5686
+ }
5687
+ /**
5688
+ * The continuation to re-issue for a request whose recorded outcome stranded
5689
+ * its run — the journalled one when there is one, otherwise rebuilt from the
5690
+ * row (#15389).
5691
+ *
5692
+ * ## Why a rebuild path exists at all
5693
+ *
5694
+ * The journal only covers runs stranded by a build that HAS it. The card is
5695
+ * explicitly about *"the runs already in this state"*, and one of those can
5696
+ * still be restored whenever the durable run-history row carried its
5697
+ * suspension snapshot — so a repair verb that only served future strands
5698
+ * would miss the population the card was filed for.
5699
+ *
5700
+ * ## Which statuses it rebuilds, which it discriminates, and which it refuses
5701
+ *
5702
+ * ⛔ A status is NOT the same thing as a continuation. Three of the four
5703
+ * terminal statuses have more than one writer or more than one issuer, so
5704
+ * "one status, one signal" is false and is not what this relies on. Each row
5705
+ * below states its own population and its own discriminator:
5706
+ *
5707
+ * | status | writers / issuers | rebuilt as | how it is decided |
5708
+ * |---|---|---|---|
5709
+ * | `approved` | 1 (`decide`; escalation auto-approve routes through it) | `approve` | unambiguous |
5710
+ * | `rejected` | 2 (`decide`; ADR-0044 revision-limit auto-reject) | `reject`, or REFUSED | a `revise` action row means the auto-reject arm is possible |
5711
+ * | `returned` | 1 writer, 2 issuers (`sendBack` → `revise`; a later `resubmit` → `resubmit`, writing no status) | `resubmit` or `revise` | a `resubmit` action row, whose sole writer is `resubmit` |
5712
+ * | `recalled` | 2 writers, 3 behaviours, 2 issuing NO continuation | REFUSED | nothing on the row distinguishes them |
5713
+ *
5714
+ * ⚠️ **Both refusals are deliberate and neither is best-effort.** The failure
5715
+ * mode of a wrong rebuild is a flow advanced down a branch nobody chose —
5716
+ * strictly worse than the dead end this verb exists to open. Where the signal
5717
+ * cannot be proved, this refuses and names what the operator can do instead;
5718
+ * the journal is what makes both shapes recoverable going forward.
5719
+ *
5720
+ * ⛔ `pending` and `cancelled` are refused outright: neither names a recorded
5721
+ * outcome to replay. A `pending` request's continuation is an ordinary
5722
+ * decision through the front door, which is exactly the guard this verb
5723
+ * exists to avoid weakening.
5724
+ */
5725
+ async resolveRecordedContinuation(raw, requestId) {
5726
+ const config = parseJson(raw.node_config_json, {});
5727
+ const status = String(raw.status ?? "");
5728
+ const stashed = config?.[STRANDED_CONTINUATION_KEY];
5729
+ if (stashed && typeof stashed === "object" && typeof stashed.decision === "string") {
5730
+ const issuable = CONTINUATIONS_A_STATUS_CAN_ISSUE[status];
5731
+ const journalled = String(stashed.decision);
5732
+ if (!issuable?.includes(journalled)) {
5733
+ throw new Error(
5734
+ `INVALID_STATE: request ${requestId} is '${status || "unknown"}' and its journalled continuation is the ${journalled}, which a '${status || "unknown"}' request cannot have issued \u2014 ${issuable ? `a '${status}' row is replayable only for ${issuable.map((d) => `'${d}'`).join(" or ")}` : `no continuation is replayable for '${status || "unknown"}'`}. The journal records what the last FAILED resume was carrying, so a later recall (or any other door that moved this row on) leaves a signal behind that the row's own status no longer stands behind, and replaying it would advance a step nobody is waiting on. Refusing to replay it: cancel the run with the engine's cancelRun('${raw.flow_run_id}') if the newer outcome should stand, or resume it by hand with the signal the flow expects.`
5735
+ );
5736
+ }
5737
+ return { signal: stashed, source: "journal" };
5738
+ }
5739
+ const outputs = { ...config?.__decisionOutputs ?? {} };
5740
+ if (status === "approved" || status === "rejected") {
5741
+ if (status === "rejected") {
5742
+ const priorRevise = await this.engine.find("sys_approval_action", {
5743
+ where: { request_id: requestId, action: "revise" },
5744
+ limit: 1,
5745
+ context: SYSTEM_CTX2
5746
+ });
5747
+ if (Array.isArray(priorRevise) && priorRevise.length) {
5748
+ throw new Error(
5749
+ `INVALID_STATE: request ${requestId} is 'rejected' and also carries a 'revise' action, so this service cannot tell a decided rejection from an ADR-0044 revision-limit auto-rejection \u2014 and the two resume the same edge with different flow output (\`autoRejected\`). Refusing to guess: replay it by hand with the signal the flow expects, or cancel the run.`
5750
+ );
5751
+ }
5752
+ }
5753
+ const decision = status === "approved" ? "approve" : "reject";
5754
+ return {
5755
+ source: "reconstructed",
5756
+ signal: {
5757
+ branchLabel: status === "approved" ? APPROVAL_BRANCH_LABELS.approve : APPROVAL_BRANCH_LABELS.reject,
5758
+ output: { ...outputs, decision, requestId },
5759
+ decision,
5760
+ what: `the ${decision} decision`
5761
+ }
5762
+ };
5763
+ }
5764
+ if (status === "returned") {
5765
+ const resubmitted = await this.engine.find("sys_approval_action", {
5766
+ where: { request_id: requestId, action: "resubmit" },
5767
+ limit: 1,
5768
+ context: SYSTEM_CTX2
5769
+ });
5770
+ if (Array.isArray(resubmitted) && resubmitted.length) {
5771
+ return {
5772
+ source: "reconstructed",
5773
+ signal: {
5774
+ branchLabel: APPROVAL_BRANCH_LABELS.resubmit,
5775
+ output: { resubmitted: true, requestId },
5776
+ decision: "resubmit",
5777
+ what: "the resubmit"
5778
+ }
5779
+ };
5780
+ }
5781
+ return {
5782
+ source: "reconstructed",
5783
+ signal: {
5784
+ branchLabel: APPROVAL_BRANCH_LABELS.revise,
5785
+ output: { decision: "revise", requestId },
5786
+ decision: "revise",
5787
+ what: "the send-back"
5788
+ }
5789
+ };
5790
+ }
5791
+ if (status === "recalled") {
5792
+ throw new Error(
5793
+ `INVALID_STATE: request ${requestId} is 'recalled' and carries no journalled continuation, so the signal cannot be rebuilt: a recall reaches this state three ways (resumed down 'reject', terminally cancelled inside a revision window, or swept as a dead run) and two of them issue no continuation at all \u2014 replaying the wrong one would re-open a request that was deliberately withdrawn. Refusing to guess: cancel the run with the engine's cancelRun('${raw.flow_run_id}') if the withdrawal should stand, or resume it by hand with the signal the flow expects.`
5794
+ );
5795
+ }
5796
+ throw new Error(
5797
+ `INVALID_STATE: request is ${status || "unknown"} \u2014 only a request whose recorded outcome already resumed its run can have that continuation re-issued (approved, rejected, returned, recalled)`
5798
+ );
5799
+ }
5800
+ /**
5801
+ * WHERE the pause a recorded continuation was refused on actually sits
5802
+ * (#15389) — the expected node guard 3 compares the run's parked node against.
5803
+ *
5804
+ * ⚠️ This is signal-aware, and that is the whole point of it. "This request's
5805
+ * own node" is the right answer for three of the four signals and the WRONG
5806
+ * answer for the fourth:
5807
+ *
5808
+ * | signal | issued from | why |
5809
+ * |---|---|---|
5810
+ * | `approve` / `reject` | the request's own approval node | the decision is taken at the pause it gates |
5811
+ * | `revise` (send-back) | the request's own approval node | send-back resumes that same pause down the `revise` edge |
5812
+ * | `recall` | the request's own approval node | recall-on-pending resumes that same pause down `reject` |
5813
+ * | `resubmit` | the **revise window** the request's `revise` edge leads to | by construction: a resubmit is only reachable AFTER a send-back moved the run there, and it resumes THAT pause down the `resubmit` back-edge |
5814
+ *
5815
+ * Measured before this existed: a `returned` row whose resubmit stranded was
5816
+ * refused by guard 3 on both the journal and the rebuild paths — the pause
5817
+ * re-armed at the revise window while the row's `flow_node_id` still read the
5818
+ * approval node — and the refusal told the operator the pause was not this
5819
+ * request's when it was exactly this request's. A refusal may ship; a refusal
5820
+ * that names a cause the code did not take may not.
5821
+ *
5822
+ * ⛔ It stays FAIL-CLOSED: the revise window is derived from the flow
5823
+ * definition the same way {@link ApprovalService.assertReviseEdge} derives it
5824
+ * — a `revise` out-edge of this request's node into a node the flow declares
5825
+ * as `{@link APPROVAL_REVISE_NODE_TYPE}`, which is the pause only this service
5826
+ * can continue. No engine, no flow, no such edge, or more than one candidate
5827
+ * ⇒ refuse. It needs no automation surface `assertReviseEdge` did not already
5828
+ * use (`getFlow`), and no engine change.
5829
+ *
5830
+ * ⚠️ It does not widen what guard 3 admits beyond that one signal: for every
5831
+ * other decision the answer is byte-identical to the row's own node.
5832
+ *
5833
+ * ⛔ It is NOT what keeps the recall-in-revise-window shape (row `recalled`,
5834
+ * run at the revise window) refused, and an earlier revision of this comment
5835
+ * claimed it was — on the reasoning that such a row's journalled signal is
5836
+ * `recall` rather than `resubmit`. That is false: a recall taken inside the
5837
+ * revise window calls `cancelRun` and journals NOTHING, so the journal on
5838
+ * such a row is whatever an EARLIER strand left there — a `resubmit`, most
5839
+ * often, since the resubmit is what the window exists to receive. Measured:
5840
+ * with the journal returned before the row's status was looked at, that
5841
+ * stale `resubmit` reached this method, was answered with the revise window,
5842
+ * matched the parked node, and opened a fresh `pending` round on a withdrawn
5843
+ * request. What refuses it is the journal/status compatibility check in
5844
+ * {@link ApprovalService.resolveRecordedContinuation} — see
5845
+ * {@link CONTINUATIONS_A_STATUS_CAN_ISSUE} — which runs BEFORE this method
5846
+ * and never hands it a signal the row's status cannot have issued.
5847
+ */
5848
+ async expectedPauseNode(raw, signal, requestId, runId) {
5849
+ const ownNode = raw.flow_node_id ?? raw.current_step ?? null;
5850
+ if (!ownNode) {
5851
+ throw new Error(
5852
+ `INVALID_STATE: request ${requestId} records no approval node, so the pause on run '${runId}' cannot be proved to be the one ${signal.what} was refused on \u2014 refusing rather than resuming a pause that may belong to another node`
5853
+ );
5854
+ }
5855
+ if (signal.decision !== "resubmit") {
5856
+ return { nodeId: ownNode, describe: `its own approval node '${ownNode}'` };
5857
+ }
5858
+ const processName = String(raw.process_name ?? "");
5859
+ const flowName = processName.startsWith("flow:") ? processName.slice("flow:".length) : "";
5860
+ if (!flowName || typeof this.automation?.getFlow !== "function") {
5861
+ throw new Error(
5862
+ `INVALID_STATE: ${signal.what} on request ${requestId} was issued from the revise window that approval node '${ownNode}' sends back to, and this service cannot read the owning flow definition to say which node that is \u2014 refusing, because continuing a pause it cannot identify advances a step nobody decided`
5863
+ );
5864
+ }
5865
+ const flow = await this.automation.getFlow(flowName);
5866
+ const nodeTypeById = new Map(
5867
+ (Array.isArray(flow?.nodes) ? flow.nodes : []).filter((n) => typeof n?.id === "string").map((n) => [n.id, typeof n.type === "string" ? n.type : ""])
5868
+ );
5869
+ const windows = Array.from(new Set(
5870
+ (Array.isArray(flow?.edges) ? flow.edges : []).filter((e) => e?.source === ownNode && e?.label === APPROVAL_BRANCH_LABELS.revise).map((e) => typeof e?.target === "string" ? e.target : "").filter((t) => t && nodeTypeById.get(t) === APPROVAL_REVISE_NODE_TYPE)
5871
+ ));
5872
+ if (windows.length !== 1) {
5873
+ throw new Error(
5874
+ `INVALID_STATE: ${signal.what} on request ${requestId} was issued from the revise window that approval node '${ownNode}' sends back to, and flow '${flowName}' declares ${windows.length === 0 ? "no such window" : `${windows.length} of them (${windows.join(", ")})`} \u2014 refusing, because a pause this service cannot identify must not be continued`
5875
+ );
5876
+ }
5877
+ return {
5878
+ nodeId: windows[0],
5879
+ describe: `the revise window '${windows[0]}' that its approval node '${ownNode}' sends back to`
5880
+ };
5881
+ }
5882
+ /**
5883
+ * Re-issue the continuation for a run an operator has re-armed with
5884
+ * `AutomationEngine.restoreConsumedSuspension` — the missing half of that
5885
+ * repair verb, for approvals (#15389).
5886
+ *
5887
+ * ## The dead end this exits
5888
+ *
5889
+ * A decision whose downstream node throws strands the run: the suspension is
5890
+ * consumed, the decision is durable, and the caller gets `RESUME_FAILED`
5891
+ * carrying `repairable: true`. `restoreConsumedSuspension` then genuinely
5892
+ * re-arms the pause — measured `restored: true`, `hasSuspendedRun` back to
5893
+ * `true` — and its own reason string tells the operator to *re-issue the
5894
+ * continuation*. For an `approval` node there was then nobody who could:
5895
+ *
5896
+ * - `decide` / `recall` / `sendBack` / `resubmit` all guard on a `pending`
5897
+ * request, and the row is terminal — written by the very call that
5898
+ * stranded the run;
5899
+ * - the generic `engine.resume` refuses, because the `approval` node
5900
+ * declares `resumeAuthority: 'service'` and the #3801 gate turns away any
5901
+ * resume that is not the tail of a decision this service authorized.
5902
+ *
5903
+ * So the only verb left was `cancelRun`, which discards the branch's
5904
+ * downstream work. Measured on the real engine and the real door: the
5905
+ * restored pause IS resumable, and a `resumeAuthority`-marked resume walks
5906
+ * the reject branch to completion. Nothing was missing in the engine — what
5907
+ * was missing was an ISSUER on this side. This is that issuer.
5908
+ *
5909
+ * ## What it deliberately does NOT do
5910
+ *
5911
+ * ⛔ It does not re-open, re-decide, or rewrite the request row: all four
5912
+ * `pending` guards stay exactly as they are, and no status, mirror field or
5913
+ * audit row is written. A person decided this once; this replays what they
5914
+ * decided onto the pause that was put back, and replays nothing else.
5915
+ * ⛔ It does not relax `resumeAuthority: 'service'` — the resume goes through
5916
+ * {@link ApprovalService.serviceResume} like every other, so the marker is
5917
+ * still stamped in exactly one place.
5918
+ * ⛔ It grants no capability that in-process code did not already have:
5919
+ * `RESUME_AUTHORITY_SERVICE` is importable by anything in the host, so the
5920
+ * raw form of this call was always available. What this adds is the GUARDED
5921
+ * form, and the guards are the substance of it — three, each with its own
5922
+ * reverse-control pin, because the raw marker is not a guard and an
5923
+ * unguarded repair verb advances flows nobody decided:
5924
+ *
5925
+ * 1. {@link assertLatestForRun} — this request is still the newest on its
5926
+ * run, so a superseded row cannot drive a later round or a later node;
5927
+ * 2. `hasSuspendedRun` — a pause exists at all (strict: an unreadable store
5928
+ * throws rather than reading as "not suspended");
5929
+ * 3. node identity — that pause is parked where THIS request's recorded
5930
+ * outcome was issued from: its own approval node for `approve`,
5931
+ * `reject`, `revise` and `recall`, and — for a `resubmit`, which is only
5932
+ * reachable from a revise window — the `approval_revise` node its own
5933
+ * `revise` edge leads to. {@link ApprovalService.expectedPauseNode}
5934
+ * derives it, fail-closed.
5935
+ *
5936
+ * Guard 3 is not redundant with guard 2: existence is not identity, and a
5937
+ * boolean cannot tell this request's re-armed pause from any other live
5938
+ * pause on the same run.
5939
+ *
5940
+ * ## Posture, and why it takes no `ExecutionContext`
5941
+ *
5942
+ * Deliberately shaped like the engine verb it completes: an in-process
5943
+ * operator repair, reachable from a host or a console script, with no REST
5944
+ * route and no entry in the spec `ApprovalService` contract — exactly as
5945
+ * `restoreConsumedSuspension` is a class method on `AutomationEngine` and
5946
+ * appears in no contract. It authorizes nothing new: the decision it replays
5947
+ * was authorized and recorded when it was made, and re-authorizing it here
5948
+ * against a present-day actor would be a different and wrong question (the
5949
+ * original approver may be long gone). `requestedBy` / `reason` ride the log
5950
+ * for the same reason they do on the restore.
5951
+ *
5952
+ * @returns what was replayed and whether the run moved — never a silent
5953
+ * `false`. A resume that fails again throws the same `RESUME_FAILED`
5954
+ * envelope the original decision did, `repairable` and all, so a second
5955
+ * restore-and-continue is possible.
5956
+ */
5957
+ async continueRestoredRun(requestId, options) {
5958
+ if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
5959
+ const rows = await this.engine.find("sys_approval_request", {
5960
+ where: { id: requestId },
5961
+ limit: 1,
5962
+ context: SYSTEM_CTX2
5963
+ });
5964
+ const raw = Array.isArray(rows) ? rows[0] : null;
5965
+ if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
5966
+ const runId = raw.flow_run_id ?? null;
5967
+ if (!runId) {
5968
+ throw new Error(
5969
+ `INVALID_STATE: request ${requestId} names no flow run \u2014 there is no continuation to re-issue`
5970
+ );
5971
+ }
5972
+ await this.assertLatestForRun(raw);
5973
+ const { signal, source } = await this.resolveRecordedContinuation(raw, requestId);
5974
+ if (typeof this.automation?.hasSuspendedRun === "function") {
5975
+ const parked = await this.automation.hasSuspendedRun(runId);
5976
+ if (!parked) {
5977
+ throw new Error(
5978
+ `INVALID_STATE: run '${runId}' behind request ${requestId} is not suspended, so there is no re-armed pause to continue \u2014 restore it first with the automation engine's restoreConsumedSuspension('${runId}'), which is what re-arms a consumed approval suspension`
5979
+ );
5980
+ }
5981
+ }
5982
+ const expected = await this.expectedPauseNode(raw, signal, requestId, runId);
5983
+ if (typeof this.automation?.listSuspendedRunsDurable !== "function") {
5984
+ throw new Error(
5985
+ `INVALID_STATE: this automation engine cannot report WHERE run '${runId}' is parked (no listSuspendedRunsDurable), so the pause cannot be proved to be the one ${signal.what} on request ${requestId} was refused on \u2014 refusing, because continuing the wrong pause advances a flow with no decision behind it`
5986
+ );
5987
+ }
5988
+ const parkedAt = (await this.automation.listSuspendedRunsDurable()).find((r) => String(r.runId) === String(runId))?.nodeId;
5989
+ if (parkedAt !== expected.nodeId) {
5990
+ throw new Error(
5991
+ `INVALID_STATE: run '${runId}' is parked at ${parkedAt ? `node '${parkedAt}'` : "no node this engine can see"}, but ${signal.what} on request ${requestId} was issued from ${expected.describe} \u2014 so the pause this verb was asked to continue is not the one that outcome was issued at, and continuing it would advance a step nobody decided`
5992
+ );
5993
+ }
5994
+ this.logger?.warn?.(
5995
+ "[approvals] re-issuing the continuation for a restored approval suspension",
5996
+ {
5997
+ request: requestId,
5998
+ run: runId,
5999
+ decision: signal.decision,
6000
+ branchLabel: signal.branchLabel,
6001
+ source,
6002
+ requestedBy: options?.requestedBy ?? "not recorded",
6003
+ reason: options?.reason ?? "not recorded"
6004
+ }
6005
+ );
6006
+ const outcome = await this.resumeRecordedOutcome(
6007
+ runId,
6008
+ requestId,
6009
+ signal.what,
6010
+ { branchLabel: signal.branchLabel, output: signal.output },
6011
+ signal.decision
6012
+ );
6013
+ return {
6014
+ resumed: outcome.resumed,
6015
+ runId,
6016
+ decision: signal.decision,
6017
+ branchLabel: signal.branchLabel,
6018
+ source,
6019
+ ...outcome.resumeError ? { resumeError: outcome.resumeError } : {}
6020
+ };
6021
+ }
5457
6022
  async releaseDeadRunRequests() {
5458
6023
  if (typeof this.automation?.getRun !== "function") return { scanned: 0, released: 0 };
5459
6024
  let rows = [];