@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.js CHANGED
@@ -2509,10 +2509,22 @@ var TERMINAL_RUN_STATUSES = /* @__PURE__ */ new Set([
2509
2509
  "timed_out"
2510
2510
  ]);
2511
2511
  var STRANDABLE_REQUEST_STATUSES = ["approved", "rejected", "returned"];
2512
+ var STRANDED_CONTINUATION_KEY = "__strandedContinuation";
2513
+ var CONTINUATIONS_A_STATUS_CAN_ISSUE = {
2514
+ approved: ["approve"],
2515
+ rejected: ["reject"],
2516
+ returned: ["revise", "resubmit"],
2517
+ recalled: ["recall"]
2518
+ };
2512
2519
  function classifyStrandedRunState(run) {
2513
2520
  if (!run) return "missing";
2514
2521
  switch (run.status) {
2515
- // The resume consumed the pause and a downstream node threw. Reported.
2522
+ // A terminal `failed` row. Reported and, when the engine can be asked,
2523
+ // refined by the third oracle ({@link refineFailedRunState}) into which of
2524
+ // the three `failed` shapes it is. This arm alone cannot tell them apart:
2525
+ // the row reads identically for a resume that consumed the pause and
2526
+ // threw downstream (repairable) and for an ancestor `failAncestors`
2527
+ // cascade-failed (not). `'failed'` here means "reported, undifferentiated".
2516
2528
  case "failed":
2517
2529
  return "failed";
2518
2530
  // ── The negatives, each for its own reason ──────────────────────────────
@@ -2537,6 +2549,28 @@ function classifyStrandedRunState(run) {
2537
2549
  return void 0;
2538
2550
  }
2539
2551
  }
2552
+ function refineFailedRunState(verdict) {
2553
+ if (verdict.repairable) return "repairable";
2554
+ switch (verdict.reason) {
2555
+ // The strand happened; the store could not keep the snapshot, and the
2556
+ // engine asked holds no hot copy. Its own class — see the type below.
2557
+ case "SNAPSHOT_DROPPED":
2558
+ return "snapshot_dropped";
2559
+ // Neither witness holds anything: cascade-failed, or never paused.
2560
+ case "NO_CONSUMED_SUSPENSION":
2561
+ return "unrepairable";
2562
+ // Re-armed between the two reads (an operator's restore landed while this
2563
+ // scan was running): the run is alive and resumable, which is what the
2564
+ // first oracle would have said a moment later. Not stranded.
2565
+ case "RUN_SUSPENDED":
2566
+ return void 0;
2567
+ // An answer this build does not know (an engine ahead of this plugin).
2568
+ // Fail-closed exactly as an absent member: reported, undifferentiated —
2569
+ // never condemned on a word this code cannot read.
2570
+ default:
2571
+ return "failed";
2572
+ }
2573
+ }
2540
2574
  var ACTION_TOKEN_TTL_MS = 72 * 60 * 60 * 1e3;
2541
2575
  var SYSTEM_CTX2 = { isSystem: true, positions: [], permissions: [] };
2542
2576
  var RECORD_DELETE_CANCEL_LIMIT = 200;
@@ -2850,7 +2884,7 @@ var _ApprovalService = class _ApprovalService {
2850
2884
  const perms = Array.isArray(context.permissions) ? context.permissions : [];
2851
2885
  const positions = Array.isArray(context.positions) ? context.positions : [];
2852
2886
  const posture = context.posture;
2853
- const isPlatformAdmin = posture === "PLATFORM_ADMIN" || perms.includes(import_identity.ADMIN_FULL_ACCESS) || positions.includes(import_identity.BUILTIN_IDENTITY_PLATFORM_ADMIN);
2887
+ const isPlatformAdmin = posture === "PLATFORM_ADMIN" || perms.includes(import_identity.ADMIN_FULL_ACCESS);
2854
2888
  if (isPlatformAdmin) return true;
2855
2889
  const isTenantAdmin = posture === "TENANT_ADMIN" || import_identity.ORGANIZATION_ADMIN_GRANTS.some((n) => perms.includes(n)) || positions.includes(import_identity.BUILTIN_IDENTITY_ORG_OWNER) || positions.includes(import_identity.BUILTIN_IDENTITY_ORG_ADMIN);
2856
2890
  if (!isTenantAdmin) return false;
@@ -3424,7 +3458,56 @@ var _ApprovalService = class _ApprovalService {
3424
3458
  if (!organizationId) return filter;
3425
3459
  return { ...filter, $or: [{ organization_id: organizationId }, { organization_id: null }] };
3426
3460
  }
3427
- /** Recursive department — walks `sys_business_unit.parent_business_unit_id`. */
3461
+ /**
3462
+ * Tenant scope for the `sys_business_unit_member` read — a STRICT equality,
3463
+ * deliberately NOT {@link businessUnitOrgScope} (#14946).
3464
+ *
3465
+ * The two screens answer different questions. The UNIT is the anchor the
3466
+ * approver NAMES, and a seeded unit carries `organization_id = null` by
3467
+ * construction (a seed cannot know the id the runtime mints at boot), so
3468
+ * #3807 admits the null there on purpose. The MEMBER rows are the SET BEING
3469
+ * ROUTED TO — enumerated by the platform, never named by anyone — and a
3470
+ * seeded unit id exists identically in every tenant. Before this screen the
3471
+ * member read carried no organization predicate at all, under
3472
+ * {@link SYSTEM_CTX} which carries no tenant either, so tenant A's request
3473
+ * resolved the shared unit and then collected EVERY tenant's membership rows
3474
+ * hanging off it: approval authority over A's record, routed to B's users.
3475
+ *
3476
+ * Why the null arm is NOT copied here — measured on this tree:
3477
+ * - `sys_business_unit_member` declares no `organization_id`; the column
3478
+ * is injected (`applySystemFields`) and the tenancy census lists it in;
3479
+ * - REST / session writes fill it (`SqlDriver.injectTenantOnInsert`);
3480
+ * - seed replay does NOT (`seed-loader.ts` withholds its `fallbackOrgId`
3481
+ * from every `sys_` object), and elevated system-context writes do NOT
3482
+ * (`unclassified` in `PLATFORM_OBJECT_TENANCY`, tracked as #14570).
3483
+ * So a NULL on a member row means UNKNOWN tenancy, not "platform-global",
3484
+ * and unknown tenancy is not a member of this organization. This is the
3485
+ * ruling `plugin-sharing`'s `memberScope` already applies to the same rows
3486
+ * (#14547 / #14949), and the posture this file already takes for
3487
+ * `sys_team_member` and `sys_user_position`.
3488
+ *
3489
+ * The cost is declared, not hidden: an organization whose MEMBERSHIP rows
3490
+ * were seeded or system-written expands to nobody even on a unit it can
3491
+ * see. That is not silent — the graph-type fallback in `expandApprover`
3492
+ * warns `expanded to nobody` (#3807) and `onEmptyApprovers` governs the
3493
+ * request as for any unstaffed target — and the repair is to stamp the
3494
+ * membership rows, never to widen this screen. ⛔ Do not "unify" the two
3495
+ * screens: one method serving both re-opens whichever half it does not
3496
+ * implement.
3497
+ */
3498
+ businessUnitMemberScope(filter, organizationId) {
3499
+ if (!organizationId) return filter;
3500
+ return { ...filter, organization_id: organizationId };
3501
+ }
3502
+ /**
3503
+ * Recursive department — walks `sys_business_unit.parent_business_unit_id`.
3504
+ *
3505
+ * Two tenant screens, and they are different on purpose: the UNIT rows
3506
+ * (seed check and descent) go through the null-inclusive
3507
+ * {@link businessUnitOrgScope}; the MEMBER read goes through the strict
3508
+ * {@link businessUnitMemberScope}. `organizationId` is the DIRECTORY
3509
+ * organization the approver resolves in (ADR-0105 D9), for both.
3510
+ */
3428
3511
  async expandBusinessUnitUsers(businessUnitId, organizationId) {
3429
3512
  if (!businessUnitId) return [];
3430
3513
  try {
@@ -3464,7 +3547,11 @@ var _ApprovalService = class _ApprovalService {
3464
3547
  let rows = [];
3465
3548
  try {
3466
3549
  rows = await this.engine.find("sys_business_unit_member", {
3467
- where: { business_unit_id: { $in: Array.from(seen) } },
3550
+ // #14946: tenant-screened {@link businessUnitMemberScope} is STRICT
3551
+ // on purpose and is not {@link businessUnitOrgScope}. The units above
3552
+ // proved their tenancy (or are seeded); these rows have not, and the
3553
+ // shared seeded unit id is exactly where other tenants' rows sit.
3554
+ where: this.businessUnitMemberScope({ business_unit_id: { $in: Array.from(seen) } }, organizationId),
3468
3555
  fields: ["user_id"],
3469
3556
  limit: 1e4,
3470
3557
  context: SYSTEM_CTX2
@@ -4177,6 +4264,7 @@ var _ApprovalService = class _ApprovalService {
4177
4264
  `resume of run '${runId}' failed${reported.code ? ` [${reported.code}]` : ""}: ${reported.error ?? "unknown error"}`
4178
4265
  );
4179
4266
  err.resumeCode = reported.code;
4267
+ err.resumeStatus = reported.status;
4180
4268
  throw err;
4181
4269
  }
4182
4270
  }
@@ -4184,6 +4272,22 @@ var _ApprovalService = class _ApprovalService {
4184
4272
  static resumeCodeOf(err) {
4185
4273
  return err?.resumeCode;
4186
4274
  }
4275
+ /**
4276
+ * The engine's own run-state discriminator behind a {@link serviceResume}
4277
+ * rejection — `AutomationResult.status` — if the engine reported one
4278
+ * (#13807).
4279
+ *
4280
+ * Read as a SIBLING of {@link resumeCodeOf}, never as a substitute: the two
4281
+ * answer different questions and the stranded exit proves they are not
4282
+ * interchangeable. It reports `status: 'stranded'` and **no `code` at all**
4283
+ * (`service-automation` `engine.ts`, the resume catch arm), so a door that
4284
+ * reads only the code sees an unnamed failure and cannot tell a repairable
4285
+ * strand from a dead run — which is how the platform's own repairability
4286
+ * signal had a producer and zero consumers until this call site.
4287
+ */
4288
+ static resumeStatusOf(err) {
4289
+ return err?.resumeStatus;
4290
+ }
4187
4291
  /**
4188
4292
  * Refuse an operation whose whole point is to advance a flow run when that
4189
4293
  * run no longer exists — BEFORE anything is written down (#4420).
@@ -4277,10 +4381,33 @@ var _ApprovalService = class _ApprovalService {
4277
4381
  * which cannot throw without breaking every standalone deployment — it
4278
4382
  * reports through `resumeError` instead.
4279
4383
  *
4384
+ * ## The throw is truthful, not merely loud (#13807)
4385
+ *
4386
+ * Maintainer ruling 2026-09-04 (decision batch #37, option B): this door
4387
+ * KEEPS its status code — the effect landing while the run strands is still
4388
+ * a failure and must still be reported as one — and stops discarding what
4389
+ * the engine said. ⛔ Not "return 200", which the card forbids; ⛔ not
4390
+ * atomic, because rolling a real human decision back is excluded by the
4391
+ * #13937 shape-4 ruling, which binds this door's own writes too (a machine
4392
+ * that re-armed strandings by itself would re-run the node that threw,
4393
+ * forever, with nobody deciding it should).
4394
+ *
4395
+ * So the error carries {@link StrandedDecisionDetails} beside its prose:
4396
+ * `finalized` (the decision stands), `decision`, `runId`, and `repairable`
4397
+ * derived from the engine's `'stranded'` discriminator. Before this a caller
4398
+ * had a 500 and a sentence — and 500 alone reads as "the rejection did not
4399
+ * happen", which is the misreading that makes a caller retry or escalate
4400
+ * against a decision that IS durable.
4401
+ *
4280
4402
  * @param what - how the recorded outcome reads in the error, e.g.
4281
4403
  * `"the approve decision"`.
4404
+ * @param decision - the outcome label for the machine-readable envelope
4405
+ * (`'approve'` / `'reject'` / `'revise'` / `'resubmit'`). Passed
4406
+ * explicitly rather than parsed back out of `what` or the signal: the
4407
+ * prose is for humans and `output` is the flow's, and neither is a place
4408
+ * to keep a wire value.
4282
4409
  */
4283
- async resumeRecordedOutcome(runId, requestId, what, signal) {
4410
+ async resumeRecordedOutcome(runId, requestId, what, signal, decision) {
4284
4411
  const missing = this.missingRunCapability(runId, requestId, what, "resume");
4285
4412
  if (missing) return { resumed: false, resumeError: missing };
4286
4413
  try {
@@ -4296,14 +4423,27 @@ var _ApprovalService = class _ApprovalService {
4296
4423
  });
4297
4424
  return { resumed: false, resumeError: reason };
4298
4425
  }
4426
+ const status = _ApprovalService.resumeStatusOf(err);
4427
+ const repairable = status === "stranded";
4299
4428
  this.logger?.error?.("[approvals] resume failed \u2014 the run is stranded", {
4300
4429
  request: requestId,
4301
4430
  run: runId,
4302
4431
  outcome: what,
4303
- error: reason
4432
+ error: reason,
4433
+ status,
4434
+ repairable
4304
4435
  });
4305
- throw new Error(
4306
- `RESUME_FAILED: ${what} was recorded on request ${requestId}, but its flow run '${runId}' could not be resumed and is now stranded: ${reason}`
4436
+ if (repairable) {
4437
+ await this.journalStrandedContinuation(requestId, {
4438
+ branchLabel: signal.branchLabel,
4439
+ output: signal.output,
4440
+ decision,
4441
+ what
4442
+ });
4443
+ }
4444
+ throw (0, import_types.strandedDecisionFailure)(
4445
+ `RESUME_FAILED: ${what} was recorded on request ${requestId}, but its flow run '${runId}' could not be resumed and is now stranded: ${reason}`,
4446
+ { finalized: true, decision, runId, repairable }
4307
4447
  );
4308
4448
  }
4309
4449
  }
@@ -4339,7 +4479,8 @@ var _ApprovalService = class _ApprovalService {
4339
4479
  // Reserved keys are spread LAST so no output can shadow them (the
4340
4480
  // whitelist already rejects them; this is defense in depth).
4341
4481
  output: { ...result.outputs ?? {}, decision: result.decision, requestId }
4342
- }
4482
+ },
4483
+ result.decision
4343
4484
  );
4344
4485
  resumed = outcome.resumed;
4345
4486
  resumeError = outcome.resumeError;
@@ -4354,7 +4495,7 @@ var _ApprovalService = class _ApprovalService {
4354
4495
  };
4355
4496
  }
4356
4497
  /**
4357
- * Withdraw a pending request (submitter only). Finalises the row as
4498
+ * Withdraw an undecided request. Finalises the row as
4358
4499
  * `recalled`, releases the record lock (keyed on pending status), mirrors
4359
4500
  * the status field when configured, and resumes the owning flow run down
4360
4501
  * the `reject` branch with `output.decision = 'recall'` — leaving the run
@@ -4468,6 +4609,14 @@ var _ApprovalService = class _ApprovalService {
4468
4609
  run: runId,
4469
4610
  error: resumeError
4470
4611
  });
4612
+ if (_ApprovalService.resumeStatusOf(err) === "stranded") {
4613
+ await this.journalStrandedContinuation(requestId, {
4614
+ branchLabel: import_automation.APPROVAL_BRANCH_LABELS.reject,
4615
+ output: { decision: "recall", requestId },
4616
+ decision: "recall",
4617
+ what: "the recall"
4618
+ });
4619
+ }
4471
4620
  }
4472
4621
  }
4473
4622
  }
@@ -4683,7 +4832,8 @@ var _ApprovalService = class _ApprovalService {
4683
4832
  {
4684
4833
  branchLabel: import_automation.APPROVAL_BRANCH_LABELS.reject,
4685
4834
  output: { decision: "reject", autoRejected: true, requestId }
4686
- }
4835
+ },
4836
+ "reject"
4687
4837
  );
4688
4838
  resumed2 = outcome.resumed;
4689
4839
  resumeError2 = outcome.resumeError;
@@ -4731,7 +4881,8 @@ var _ApprovalService = class _ApprovalService {
4731
4881
  {
4732
4882
  branchLabel: import_automation.APPROVAL_BRANCH_LABELS.revise,
4733
4883
  output: { decision: "revise", requestId }
4734
- }
4884
+ },
4885
+ "revise"
4735
4886
  );
4736
4887
  resumed = outcome.resumed;
4737
4888
  resumeError = outcome.resumeError;
@@ -4811,7 +4962,8 @@ var _ApprovalService = class _ApprovalService {
4811
4962
  {
4812
4963
  branchLabel: import_automation.APPROVAL_BRANCH_LABELS.resubmit,
4813
4964
  output: { resubmitted: true, requestId }
4814
- }
4965
+ },
4966
+ "resubmit"
4815
4967
  );
4816
4968
  resumed = outcome.resumed;
4817
4969
  resumeError = outcome.resumeError;
@@ -5353,8 +5505,25 @@ var _ApprovalService = class _ApprovalService {
5353
5505
  * ⚠️ The widening does NOT reverse the conservatism: `completed`, `cancelled`
5354
5506
  * and `paused` are each still skipped, for reasons named one at a time in
5355
5507
  * `classifyStrandedRunState`, and an unrecognised status is skipped too.
5356
- * What the widening buys is that a `failed` run is now reported with
5357
- * `runState: 'failed'` instead of counted as healthy.
5508
+ * What the widening buys is that a `failed` run is now reported instead of
5509
+ * counted as healthy.
5510
+ *
5511
+ * **A THIRD oracle tells the `failed` rows apart (#15358).** `status ===
5512
+ * 'failed'` over-reports in one specific direction: a cascade-failed run
5513
+ * — an ancestor `failAncestors` failed while it was parked at its `subflow`
5514
+ * node, whose pause `failSuspendedRun` consumed and journalled nothing — has
5515
+ * the same terminal row as the #13909 strand, and `restoreConsumedSuspension`
5516
+ * refuses it. The engine's discriminator (the consumed-suspension snapshot)
5517
+ * is deliberately NOT on the object `getRun` answers, so it is asked through
5518
+ * a dedicated read-only member, `inspectConsumedSuspension`, and only for
5519
+ * `failed` rows: the answer splits `'failed'` into `'repairable'`,
5520
+ * `'snapshot_dropped'` and `'unrepairable'` (see {@link StrandedRunState}).
5521
+ * A surface without that member leaves the row `'failed'` — reported,
5522
+ * undifferentiated — because absence of the discriminator is not evidence
5523
+ * of anything. So does a read that THREW or answered a malformed verdict
5524
+ * (#16709): by the time this oracle is asked the row is already known to be
5525
+ * stranded, so a failure to differentiate it is not a reason to drop it from
5526
+ * a report — it is counted `undetermined` as telemetry AND reported.
5358
5527
  *
5359
5528
  * ⚠️ **What this can and cannot size.** It makes the condition *visible* in a
5360
5529
  * deployment; it is not itself a census, and it says nothing about this
@@ -5417,8 +5586,27 @@ var _ApprovalService = class _ApprovalService {
5417
5586
  });
5418
5587
  continue;
5419
5588
  }
5420
- const runState = classifyStrandedRunState(terminal);
5589
+ let runState = classifyStrandedRunState(terminal);
5421
5590
  if (!runState) continue;
5591
+ if (runState === "failed" && typeof this.automation.inspectConsumedSuspension === "function") {
5592
+ let refined;
5593
+ let differentiated = true;
5594
+ try {
5595
+ refined = refineFailedRunState(await this.automation.inspectConsumedSuspension(runId));
5596
+ } catch (err) {
5597
+ differentiated = false;
5598
+ undetermined++;
5599
+ this.logger?.warn?.("[approvals] stranded-request scan could not read the consumed-suspension state", {
5600
+ request: raw?.id,
5601
+ run: runId,
5602
+ error: err?.message ?? String(err)
5603
+ });
5604
+ }
5605
+ if (differentiated) {
5606
+ if (!refined) continue;
5607
+ runState = refined;
5608
+ }
5609
+ }
5422
5610
  const config = parseJson(
5423
5611
  raw.node_config_json,
5424
5612
  { approvers: [], behavior: "first_response" }
@@ -5459,11 +5647,389 @@ var _ApprovalService = class _ApprovalService {
5459
5647
  undetermined,
5460
5648
  runMissing: stranded.filter((s) => s.runState === "missing").length,
5461
5649
  runFailed: stranded.filter((s) => s.runState === "failed").length,
5650
+ runRepairable: stranded.filter((s) => s.runState === "repairable").length,
5651
+ runSnapshotDropped: stranded.filter((s) => s.runState === "snapshot_dropped").length,
5652
+ runUnrepairable: stranded.filter((s) => s.runState === "unrepairable").length,
5462
5653
  requests: stranded.map((s) => `${s.requestId}@${s.nodeId ?? "?"} \u2192 run ${s.runId} (${s.runState})`)
5463
5654
  });
5464
5655
  }
5465
5656
  return { scanned: rows.length, stranded, undetermined };
5466
5657
  }
5658
+ /**
5659
+ * Stash the continuation a door just failed to deliver, so it can be issued
5660
+ * again after the pause is re-armed (#15389).
5661
+ *
5662
+ * `AutomationEngine.restoreConsumedSuspension` puts a stranded approval run
5663
+ * back on its pause and tells the operator to *re-issue the continuation* —
5664
+ * but for an `approval` node the only issuers are this service's doors, and
5665
+ * every one of them guards on a `pending` request that the stranding call
5666
+ * itself just made terminal. Re-opening the row is excluded (it would let a
5667
+ * decided request be decided again), so what is kept instead is the SIGNAL:
5668
+ * the exact `branchLabel` + `output` the failed resume carried.
5669
+ *
5670
+ * ⚠️ Best-effort by construction, and it must stay that way: the decision is
5671
+ * already durable and its caller is already owed a `RESUME_FAILED` throw. A
5672
+ * failure to write recovery bookkeeping must not replace that throw with a
5673
+ * storage error — {@link ApprovalService.continueRestoredRun} rebuilds the
5674
+ * signal from the row when the stash is absent, so this failing costs
5675
+ * fidelity on one shape, not the repair path.
5676
+ */
5677
+ async journalStrandedContinuation(requestId, signal) {
5678
+ try {
5679
+ const rows = await this.engine.find("sys_approval_request", {
5680
+ where: { id: requestId },
5681
+ limit: 1,
5682
+ context: SYSTEM_CTX2
5683
+ });
5684
+ const raw = Array.isArray(rows) ? rows[0] : null;
5685
+ if (!raw) return;
5686
+ const config = parseJson(raw.node_config_json, {});
5687
+ await this.engine.update("sys_approval_request", {
5688
+ id: requestId,
5689
+ node_config_json: JSON.stringify({ ...config, [STRANDED_CONTINUATION_KEY]: signal })
5690
+ }, { context: SYSTEM_CTX2 });
5691
+ } catch (err) {
5692
+ this.logger?.warn?.(
5693
+ "[approvals] could not journal the stranded continuation \u2014 the repair path falls back to rebuilding it from the row",
5694
+ { request: requestId, error: err?.message ?? String(err) }
5695
+ );
5696
+ }
5697
+ }
5698
+ /**
5699
+ * The continuation to re-issue for a request whose recorded outcome stranded
5700
+ * its run — the journalled one when there is one, otherwise rebuilt from the
5701
+ * row (#15389).
5702
+ *
5703
+ * ## Why a rebuild path exists at all
5704
+ *
5705
+ * The journal only covers runs stranded by a build that HAS it. The card is
5706
+ * explicitly about *"the runs already in this state"*, and one of those can
5707
+ * still be restored whenever the durable run-history row carried its
5708
+ * suspension snapshot — so a repair verb that only served future strands
5709
+ * would miss the population the card was filed for.
5710
+ *
5711
+ * ## Which statuses it rebuilds, which it discriminates, and which it refuses
5712
+ *
5713
+ * ⛔ A status is NOT the same thing as a continuation. Three of the four
5714
+ * terminal statuses have more than one writer or more than one issuer, so
5715
+ * "one status, one signal" is false and is not what this relies on. Each row
5716
+ * below states its own population and its own discriminator:
5717
+ *
5718
+ * | status | writers / issuers | rebuilt as | how it is decided |
5719
+ * |---|---|---|---|
5720
+ * | `approved` | 1 (`decide`; escalation auto-approve routes through it) | `approve` | unambiguous |
5721
+ * | `rejected` | 2 (`decide`; ADR-0044 revision-limit auto-reject) | `reject`, or REFUSED | a `revise` action row means the auto-reject arm is possible |
5722
+ * | `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` |
5723
+ * | `recalled` | 2 writers, 3 behaviours, 2 issuing NO continuation | REFUSED | nothing on the row distinguishes them |
5724
+ *
5725
+ * ⚠️ **Both refusals are deliberate and neither is best-effort.** The failure
5726
+ * mode of a wrong rebuild is a flow advanced down a branch nobody chose —
5727
+ * strictly worse than the dead end this verb exists to open. Where the signal
5728
+ * cannot be proved, this refuses and names what the operator can do instead;
5729
+ * the journal is what makes both shapes recoverable going forward.
5730
+ *
5731
+ * ⛔ `pending` and `cancelled` are refused outright: neither names a recorded
5732
+ * outcome to replay. A `pending` request's continuation is an ordinary
5733
+ * decision through the front door, which is exactly the guard this verb
5734
+ * exists to avoid weakening.
5735
+ */
5736
+ async resolveRecordedContinuation(raw, requestId) {
5737
+ const config = parseJson(raw.node_config_json, {});
5738
+ const status = String(raw.status ?? "");
5739
+ const stashed = config?.[STRANDED_CONTINUATION_KEY];
5740
+ if (stashed && typeof stashed === "object" && typeof stashed.decision === "string") {
5741
+ const issuable = CONTINUATIONS_A_STATUS_CAN_ISSUE[status];
5742
+ const journalled = String(stashed.decision);
5743
+ if (!issuable?.includes(journalled)) {
5744
+ throw new Error(
5745
+ `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.`
5746
+ );
5747
+ }
5748
+ return { signal: stashed, source: "journal" };
5749
+ }
5750
+ const outputs = { ...config?.__decisionOutputs ?? {} };
5751
+ if (status === "approved" || status === "rejected") {
5752
+ if (status === "rejected") {
5753
+ const priorRevise = await this.engine.find("sys_approval_action", {
5754
+ where: { request_id: requestId, action: "revise" },
5755
+ limit: 1,
5756
+ context: SYSTEM_CTX2
5757
+ });
5758
+ if (Array.isArray(priorRevise) && priorRevise.length) {
5759
+ throw new Error(
5760
+ `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.`
5761
+ );
5762
+ }
5763
+ }
5764
+ const decision = status === "approved" ? "approve" : "reject";
5765
+ return {
5766
+ source: "reconstructed",
5767
+ signal: {
5768
+ branchLabel: status === "approved" ? import_automation.APPROVAL_BRANCH_LABELS.approve : import_automation.APPROVAL_BRANCH_LABELS.reject,
5769
+ output: { ...outputs, decision, requestId },
5770
+ decision,
5771
+ what: `the ${decision} decision`
5772
+ }
5773
+ };
5774
+ }
5775
+ if (status === "returned") {
5776
+ const resubmitted = await this.engine.find("sys_approval_action", {
5777
+ where: { request_id: requestId, action: "resubmit" },
5778
+ limit: 1,
5779
+ context: SYSTEM_CTX2
5780
+ });
5781
+ if (Array.isArray(resubmitted) && resubmitted.length) {
5782
+ return {
5783
+ source: "reconstructed",
5784
+ signal: {
5785
+ branchLabel: import_automation.APPROVAL_BRANCH_LABELS.resubmit,
5786
+ output: { resubmitted: true, requestId },
5787
+ decision: "resubmit",
5788
+ what: "the resubmit"
5789
+ }
5790
+ };
5791
+ }
5792
+ return {
5793
+ source: "reconstructed",
5794
+ signal: {
5795
+ branchLabel: import_automation.APPROVAL_BRANCH_LABELS.revise,
5796
+ output: { decision: "revise", requestId },
5797
+ decision: "revise",
5798
+ what: "the send-back"
5799
+ }
5800
+ };
5801
+ }
5802
+ if (status === "recalled") {
5803
+ throw new Error(
5804
+ `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.`
5805
+ );
5806
+ }
5807
+ throw new Error(
5808
+ `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)`
5809
+ );
5810
+ }
5811
+ /**
5812
+ * WHERE the pause a recorded continuation was refused on actually sits
5813
+ * (#15389) — the expected node guard 3 compares the run's parked node against.
5814
+ *
5815
+ * ⚠️ This is signal-aware, and that is the whole point of it. "This request's
5816
+ * own node" is the right answer for three of the four signals and the WRONG
5817
+ * answer for the fourth:
5818
+ *
5819
+ * | signal | issued from | why |
5820
+ * |---|---|---|
5821
+ * | `approve` / `reject` | the request's own approval node | the decision is taken at the pause it gates |
5822
+ * | `revise` (send-back) | the request's own approval node | send-back resumes that same pause down the `revise` edge |
5823
+ * | `recall` | the request's own approval node | recall-on-pending resumes that same pause down `reject` |
5824
+ * | `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 |
5825
+ *
5826
+ * Measured before this existed: a `returned` row whose resubmit stranded was
5827
+ * refused by guard 3 on both the journal and the rebuild paths — the pause
5828
+ * re-armed at the revise window while the row's `flow_node_id` still read the
5829
+ * approval node — and the refusal told the operator the pause was not this
5830
+ * request's when it was exactly this request's. A refusal may ship; a refusal
5831
+ * that names a cause the code did not take may not.
5832
+ *
5833
+ * ⛔ It stays FAIL-CLOSED: the revise window is derived from the flow
5834
+ * definition the same way {@link ApprovalService.assertReviseEdge} derives it
5835
+ * — a `revise` out-edge of this request's node into a node the flow declares
5836
+ * as `{@link APPROVAL_REVISE_NODE_TYPE}`, which is the pause only this service
5837
+ * can continue. No engine, no flow, no such edge, or more than one candidate
5838
+ * ⇒ refuse. It needs no automation surface `assertReviseEdge` did not already
5839
+ * use (`getFlow`), and no engine change.
5840
+ *
5841
+ * ⚠️ It does not widen what guard 3 admits beyond that one signal: for every
5842
+ * other decision the answer is byte-identical to the row's own node.
5843
+ *
5844
+ * ⛔ It is NOT what keeps the recall-in-revise-window shape (row `recalled`,
5845
+ * run at the revise window) refused, and an earlier revision of this comment
5846
+ * claimed it was — on the reasoning that such a row's journalled signal is
5847
+ * `recall` rather than `resubmit`. That is false: a recall taken inside the
5848
+ * revise window calls `cancelRun` and journals NOTHING, so the journal on
5849
+ * such a row is whatever an EARLIER strand left there — a `resubmit`, most
5850
+ * often, since the resubmit is what the window exists to receive. Measured:
5851
+ * with the journal returned before the row's status was looked at, that
5852
+ * stale `resubmit` reached this method, was answered with the revise window,
5853
+ * matched the parked node, and opened a fresh `pending` round on a withdrawn
5854
+ * request. What refuses it is the journal/status compatibility check in
5855
+ * {@link ApprovalService.resolveRecordedContinuation} — see
5856
+ * {@link CONTINUATIONS_A_STATUS_CAN_ISSUE} — which runs BEFORE this method
5857
+ * and never hands it a signal the row's status cannot have issued.
5858
+ */
5859
+ async expectedPauseNode(raw, signal, requestId, runId) {
5860
+ const ownNode = raw.flow_node_id ?? raw.current_step ?? null;
5861
+ if (!ownNode) {
5862
+ throw new Error(
5863
+ `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`
5864
+ );
5865
+ }
5866
+ if (signal.decision !== "resubmit") {
5867
+ return { nodeId: ownNode, describe: `its own approval node '${ownNode}'` };
5868
+ }
5869
+ const processName = String(raw.process_name ?? "");
5870
+ const flowName = processName.startsWith("flow:") ? processName.slice("flow:".length) : "";
5871
+ if (!flowName || typeof this.automation?.getFlow !== "function") {
5872
+ throw new Error(
5873
+ `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`
5874
+ );
5875
+ }
5876
+ const flow = await this.automation.getFlow(flowName);
5877
+ const nodeTypeById = new Map(
5878
+ (Array.isArray(flow?.nodes) ? flow.nodes : []).filter((n) => typeof n?.id === "string").map((n) => [n.id, typeof n.type === "string" ? n.type : ""])
5879
+ );
5880
+ const windows = Array.from(new Set(
5881
+ (Array.isArray(flow?.edges) ? flow.edges : []).filter((e) => e?.source === ownNode && e?.label === import_automation.APPROVAL_BRANCH_LABELS.revise).map((e) => typeof e?.target === "string" ? e.target : "").filter((t) => t && nodeTypeById.get(t) === import_automation.APPROVAL_REVISE_NODE_TYPE)
5882
+ ));
5883
+ if (windows.length !== 1) {
5884
+ throw new Error(
5885
+ `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`
5886
+ );
5887
+ }
5888
+ return {
5889
+ nodeId: windows[0],
5890
+ describe: `the revise window '${windows[0]}' that its approval node '${ownNode}' sends back to`
5891
+ };
5892
+ }
5893
+ /**
5894
+ * Re-issue the continuation for a run an operator has re-armed with
5895
+ * `AutomationEngine.restoreConsumedSuspension` — the missing half of that
5896
+ * repair verb, for approvals (#15389).
5897
+ *
5898
+ * ## The dead end this exits
5899
+ *
5900
+ * A decision whose downstream node throws strands the run: the suspension is
5901
+ * consumed, the decision is durable, and the caller gets `RESUME_FAILED`
5902
+ * carrying `repairable: true`. `restoreConsumedSuspension` then genuinely
5903
+ * re-arms the pause — measured `restored: true`, `hasSuspendedRun` back to
5904
+ * `true` — and its own reason string tells the operator to *re-issue the
5905
+ * continuation*. For an `approval` node there was then nobody who could:
5906
+ *
5907
+ * - `decide` / `recall` / `sendBack` / `resubmit` all guard on a `pending`
5908
+ * request, and the row is terminal — written by the very call that
5909
+ * stranded the run;
5910
+ * - the generic `engine.resume` refuses, because the `approval` node
5911
+ * declares `resumeAuthority: 'service'` and the #3801 gate turns away any
5912
+ * resume that is not the tail of a decision this service authorized.
5913
+ *
5914
+ * So the only verb left was `cancelRun`, which discards the branch's
5915
+ * downstream work. Measured on the real engine and the real door: the
5916
+ * restored pause IS resumable, and a `resumeAuthority`-marked resume walks
5917
+ * the reject branch to completion. Nothing was missing in the engine — what
5918
+ * was missing was an ISSUER on this side. This is that issuer.
5919
+ *
5920
+ * ## What it deliberately does NOT do
5921
+ *
5922
+ * ⛔ It does not re-open, re-decide, or rewrite the request row: all four
5923
+ * `pending` guards stay exactly as they are, and no status, mirror field or
5924
+ * audit row is written. A person decided this once; this replays what they
5925
+ * decided onto the pause that was put back, and replays nothing else.
5926
+ * ⛔ It does not relax `resumeAuthority: 'service'` — the resume goes through
5927
+ * {@link ApprovalService.serviceResume} like every other, so the marker is
5928
+ * still stamped in exactly one place.
5929
+ * ⛔ It grants no capability that in-process code did not already have:
5930
+ * `RESUME_AUTHORITY_SERVICE` is importable by anything in the host, so the
5931
+ * raw form of this call was always available. What this adds is the GUARDED
5932
+ * form, and the guards are the substance of it — three, each with its own
5933
+ * reverse-control pin, because the raw marker is not a guard and an
5934
+ * unguarded repair verb advances flows nobody decided:
5935
+ *
5936
+ * 1. {@link assertLatestForRun} — this request is still the newest on its
5937
+ * run, so a superseded row cannot drive a later round or a later node;
5938
+ * 2. `hasSuspendedRun` — a pause exists at all (strict: an unreadable store
5939
+ * throws rather than reading as "not suspended");
5940
+ * 3. node identity — that pause is parked where THIS request's recorded
5941
+ * outcome was issued from: its own approval node for `approve`,
5942
+ * `reject`, `revise` and `recall`, and — for a `resubmit`, which is only
5943
+ * reachable from a revise window — the `approval_revise` node its own
5944
+ * `revise` edge leads to. {@link ApprovalService.expectedPauseNode}
5945
+ * derives it, fail-closed.
5946
+ *
5947
+ * Guard 3 is not redundant with guard 2: existence is not identity, and a
5948
+ * boolean cannot tell this request's re-armed pause from any other live
5949
+ * pause on the same run.
5950
+ *
5951
+ * ## Posture, and why it takes no `ExecutionContext`
5952
+ *
5953
+ * Deliberately shaped like the engine verb it completes: an in-process
5954
+ * operator repair, reachable from a host or a console script, with no REST
5955
+ * route and no entry in the spec `ApprovalService` contract — exactly as
5956
+ * `restoreConsumedSuspension` is a class method on `AutomationEngine` and
5957
+ * appears in no contract. It authorizes nothing new: the decision it replays
5958
+ * was authorized and recorded when it was made, and re-authorizing it here
5959
+ * against a present-day actor would be a different and wrong question (the
5960
+ * original approver may be long gone). `requestedBy` / `reason` ride the log
5961
+ * for the same reason they do on the restore.
5962
+ *
5963
+ * @returns what was replayed and whether the run moved — never a silent
5964
+ * `false`. A resume that fails again throws the same `RESUME_FAILED`
5965
+ * envelope the original decision did, `repairable` and all, so a second
5966
+ * restore-and-continue is possible.
5967
+ */
5968
+ async continueRestoredRun(requestId, options) {
5969
+ if (!requestId) throw new Error("VALIDATION_FAILED: requestId is required");
5970
+ const rows = await this.engine.find("sys_approval_request", {
5971
+ where: { id: requestId },
5972
+ limit: 1,
5973
+ context: SYSTEM_CTX2
5974
+ });
5975
+ const raw = Array.isArray(rows) ? rows[0] : null;
5976
+ if (!raw) throw new Error(`REQUEST_NOT_FOUND: ${requestId}`);
5977
+ const runId = raw.flow_run_id ?? null;
5978
+ if (!runId) {
5979
+ throw new Error(
5980
+ `INVALID_STATE: request ${requestId} names no flow run \u2014 there is no continuation to re-issue`
5981
+ );
5982
+ }
5983
+ await this.assertLatestForRun(raw);
5984
+ const { signal, source } = await this.resolveRecordedContinuation(raw, requestId);
5985
+ if (typeof this.automation?.hasSuspendedRun === "function") {
5986
+ const parked = await this.automation.hasSuspendedRun(runId);
5987
+ if (!parked) {
5988
+ throw new Error(
5989
+ `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`
5990
+ );
5991
+ }
5992
+ }
5993
+ const expected = await this.expectedPauseNode(raw, signal, requestId, runId);
5994
+ if (typeof this.automation?.listSuspendedRunsDurable !== "function") {
5995
+ throw new Error(
5996
+ `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`
5997
+ );
5998
+ }
5999
+ const parkedAt = (await this.automation.listSuspendedRunsDurable()).find((r) => String(r.runId) === String(runId))?.nodeId;
6000
+ if (parkedAt !== expected.nodeId) {
6001
+ throw new Error(
6002
+ `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`
6003
+ );
6004
+ }
6005
+ this.logger?.warn?.(
6006
+ "[approvals] re-issuing the continuation for a restored approval suspension",
6007
+ {
6008
+ request: requestId,
6009
+ run: runId,
6010
+ decision: signal.decision,
6011
+ branchLabel: signal.branchLabel,
6012
+ source,
6013
+ requestedBy: options?.requestedBy ?? "not recorded",
6014
+ reason: options?.reason ?? "not recorded"
6015
+ }
6016
+ );
6017
+ const outcome = await this.resumeRecordedOutcome(
6018
+ runId,
6019
+ requestId,
6020
+ signal.what,
6021
+ { branchLabel: signal.branchLabel, output: signal.output },
6022
+ signal.decision
6023
+ );
6024
+ return {
6025
+ resumed: outcome.resumed,
6026
+ runId,
6027
+ decision: signal.decision,
6028
+ branchLabel: signal.branchLabel,
6029
+ source,
6030
+ ...outcome.resumeError ? { resumeError: outcome.resumeError } : {}
6031
+ };
6032
+ }
5467
6033
  async releaseDeadRunRequests() {
5468
6034
  if (typeof this.automation?.getRun !== "function") return { scanned: 0, released: 0 };
5469
6035
  let rows = [];