@sensigo/realm 0.31.2 → 0.33.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.
Files changed (51) hide show
  1. package/dist/engine/abandon-run.d.ts.map +1 -1
  2. package/dist/engine/abandon-run.js +17 -9
  3. package/dist/engine/abandon-run.js.map +1 -1
  4. package/dist/engine/apply-resume.d.ts +43 -0
  5. package/dist/engine/apply-resume.d.ts.map +1 -0
  6. package/dist/engine/apply-resume.js +80 -0
  7. package/dist/engine/apply-resume.js.map +1 -0
  8. package/dist/engine/defaulted-steps.d.ts +17 -0
  9. package/dist/engine/defaulted-steps.d.ts.map +1 -0
  10. package/dist/engine/defaulted-steps.js +26 -0
  11. package/dist/engine/defaulted-steps.js.map +1 -0
  12. package/dist/engine/eligibility.d.ts +20 -2
  13. package/dist/engine/eligibility.d.ts.map +1 -1
  14. package/dist/engine/eligibility.js +46 -24
  15. package/dist/engine/eligibility.js.map +1 -1
  16. package/dist/engine/execution-loop.d.ts +38 -3
  17. package/dist/engine/execution-loop.d.ts.map +1 -1
  18. package/dist/engine/execution-loop.js +1500 -112
  19. package/dist/engine/execution-loop.js.map +1 -1
  20. package/dist/engine/lifecycle.d.ts +14 -0
  21. package/dist/engine/lifecycle.d.ts.map +1 -1
  22. package/dist/engine/lifecycle.js +14 -0
  23. package/dist/engine/lifecycle.js.map +1 -1
  24. package/dist/engine/run-health.d.ts +1 -1
  25. package/dist/engine/run-health.d.ts.map +1 -1
  26. package/dist/engine/run-health.js +88 -4
  27. package/dist/engine/run-health.js.map +1 -1
  28. package/dist/engine/settlement.d.ts +38 -0
  29. package/dist/engine/settlement.d.ts.map +1 -0
  30. package/dist/engine/settlement.js +825 -0
  31. package/dist/engine/settlement.js.map +1 -0
  32. package/dist/index.d.ts +8 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +11 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/store/json-file-store.d.ts +56 -3
  37. package/dist/store/json-file-store.d.ts.map +1 -1
  38. package/dist/store/json-file-store.js +178 -22
  39. package/dist/store/json-file-store.js.map +1 -1
  40. package/dist/store/store-interface.d.ts +50 -1
  41. package/dist/store/store-interface.d.ts.map +1 -1
  42. package/dist/types/run-record.d.ts +90 -0
  43. package/dist/types/run-record.d.ts.map +1 -1
  44. package/dist/types/settlement.d.ts +248 -0
  45. package/dist/types/settlement.d.ts.map +1 -0
  46. package/dist/types/settlement.js +2 -0
  47. package/dist/types/settlement.js.map +1 -0
  48. package/dist/types/workflow-error.d.ts +1 -1
  49. package/dist/types/workflow-error.d.ts.map +1 -1
  50. package/dist/types/workflow-error.js.map +1 -1
  51. package/package.json +1 -1
@@ -3,10 +3,12 @@ import { WorkflowError } from '../types/workflow-error.js';
3
3
  import { persistsField } from '../store/store-fidelity.js';
4
4
  import { storeDeclaresSeal, storeDeclaresNonceCarriage } from '../store/trace-buffer-store.js';
5
5
  import { partitionBufferedEntries } from './trace-adoption.js';
6
+ import { deriveDefaultedSteps } from './defaulted-steps.js';
7
+ import { selectFinalizers } from './settlement.js';
6
8
  import { captureEvidence } from '../evidence/snapshot.js';
7
9
  import { validateInputSchema, validateOutputSchema, validateTraceSchema, } from '../validation/input-schema.js';
8
10
  import { normalizeTrace } from './trace-normalizer.js';
9
- import { TERMINAL_PHASES, isTerminalPhase, DRAIN_CEILING_SECONDS } from './lifecycle.js';
11
+ import { TERMINAL_PHASES, DRAIN_CEILING_SECONDS } from './lifecycle.js';
10
12
  import { omitClaim, shouldEnforceTimeout, DEFAULT_EXECUTION_TIMEOUT_SECONDS, resolveCapMs, sleepWouldExceedCap, } from './claim-liveness.js';
11
13
  import { computeBackoff } from './backoff.js';
12
14
  import { checkPreconditions, evaluateAllPreconditions, evaluateGuardConditions, } from './precondition.js';
@@ -376,11 +378,11 @@ function mergeWarnings(traceWarnings, ...extraWarnings) {
376
378
  return [...traceWarnings, ...defined];
377
379
  }
378
380
  /**
379
- * Pure helper (issue #220 PR-2, D6): scans `sealDraft`'s evidence for entries stamped
380
- * `diagnostics.settled_by_default === true` and, if any exist, returns the record with
381
- * `defaulted_steps` set to their distinct step names (declaration order of first occurrence) —
382
- * else returns the SAME record reference unchanged, so a run with no default-settle anywhere is
383
- * byte-identical to pre-PR-2 behavior (the damage-rail this preserves). Pure, no I/O.
381
+ * Pure helper (issue #220 PR-2, D6): if `sealDraft`'s evidence has any entries default-settled
382
+ * (per {@link deriveDefaultedSteps}, issue #232 the SHARED derivation the read surfaces also
383
+ * use), returns the record with `defaulted_steps` set to their distinct step names else returns
384
+ * the SAME record reference unchanged, so a run with no default-settle anywhere is byte-identical
385
+ * to pre-PR-2 behavior (the damage-rail this preserves). Pure, no I/O.
384
386
  *
385
387
  * Applied by WRAPPING the `buildFinalizedSeal` call inside the TERMINAL branch of each seal
386
388
  * ternary — `buildFinalizedSeal` itself stays byte-untouched (a chokepoint insertion was
@@ -388,21 +390,36 @@ function mergeWarnings(traceWarnings, ...extraWarnings) {
388
390
  * fragile two-touch above the damage-rail fast-path). Callers must pass ONLY a sealed record from
389
391
  * the `'complete'` branch — never a non-terminal draft, and never a fail/abort seal — so
390
392
  * `defaulted_steps` never leaks onto a persisted non-terminal record that a later FAIL seal
391
- * inherits (the FM-5 residual this guards).
393
+ * inherits (the FM-5 residual this guards). issue #232 note: this complete-only stamping is
394
+ * UNCHANGED — a run that fails/aborts still carries no persisted `defaulted_steps`; the failure-
395
+ * path disclosure gap is closed by the READ surfaces calling `deriveDefaultedSteps` directly, not
396
+ * by widening what gets persisted here.
392
397
  */
393
398
  function stampDefaultedSteps(sealDraft) {
394
- const steps = [];
395
- const seen = new Set();
396
- for (const snap of sealDraft.evidence) {
397
- if (snap.diagnostics?.settled_by_default === true && !seen.has(snap.step_id)) {
398
- seen.add(snap.step_id);
399
- steps.push(snap.step_id);
400
- }
401
- }
399
+ const steps = deriveDefaultedSteps(sealDraft.evidence);
402
400
  if (steps.length === 0)
403
401
  return sealDraft;
404
402
  return { ...sealDraft, defaulted_steps: steps };
405
403
  }
404
+ /**
405
+ * The compensating un-claim's own audit-evidence entry (issue #207 PR-2, D3 §5; extracted issue
406
+ * #279, increment 2, PR-D, Deliverable 1e — the `:679` semantics both the legacy
407
+ * `buildCompensatingUnclaim` below AND the migrated `release_step` delta's `evidence` field share
408
+ * verbatim).
409
+ */
410
+ function buildCompensatingUnclaimEvidence(stepName, now) {
411
+ return captureEvidence({
412
+ stepId: stepName,
413
+ startedAt: now,
414
+ completedAt: now,
415
+ input: {},
416
+ output: {
417
+ compensating_unclaim: true,
418
+ reason: 'adoption-read failure after claim',
419
+ unclaimed_at: now.toISOString(),
420
+ },
421
+ });
422
+ }
406
423
  /**
407
424
  * Compensating un-claim (issue #207 PR-2, D3 §5): built from `pendingRun` — the record OUR OWN
408
425
  * `claimStep` call returned, never a fresh get — removing the step from `in_progress_steps` AND
@@ -416,17 +433,7 @@ function stampDefaultedSteps(sealDraft) {
416
433
  * naturally idempotent) is simply a no-op mutation, not a special case.
417
434
  */
418
435
  function buildCompensatingUnclaim(pendingRun, stepName, now) {
419
- const auditEvidence = captureEvidence({
420
- stepId: stepName,
421
- startedAt: now,
422
- completedAt: now,
423
- input: {},
424
- output: {
425
- compensating_unclaim: true,
426
- reason: 'adoption-read failure after claim',
427
- unclaimed_at: now.toISOString(),
428
- },
429
- });
436
+ const auditEvidence = buildCompensatingUnclaimEvidence(stepName, now);
430
437
  return {
431
438
  ...pendingRun,
432
439
  in_progress_steps: pendingRun.in_progress_steps.filter((s) => s !== stepName),
@@ -595,6 +602,174 @@ function makeErrorEnvelope(options, run, err, definition, extraWarnings) {
595
602
  }
596
603
  return baseWithWarnings;
597
604
  }
605
+ /**
606
+ * Design record §6: a claim-time refusal envelope's advisory line when the fresh run carries
607
+ * finalizer_ledger pendings — points the caller at the recovery verb. `undefined` when there is
608
+ * nothing pending (the common case — never emits an empty/placeholder advisory).
609
+ */
610
+ function finalizerDrainAdvisory(run) {
611
+ const pendingCount = Object.values(run.finalizer_ledger ?? {}).filter((e) => e.status === 'pending').length;
612
+ if (pendingCount === 0)
613
+ return undefined;
614
+ return `${pendingCount} finalizer(s) not yet delivered — realm run drain ${run.id}`;
615
+ }
616
+ /**
617
+ * Re-arm disclosure (final-gate F10b, design record §6) — the settling caller's own comparison,
618
+ * NEVER inside the frozen `applySettlement` transform: any finalizer that was `'voided'` (an
619
+ * operator `--void`) in `before.finalizer_ledger` and is `'pending'` again in
620
+ * `after.finalizer_ledger` was just RE-ARMED by mintFresh on THIS terminal edge (a later
621
+ * fail-then-complete-differently — or any outcome that newly selects it — re-mints a clean pending
622
+ * entry per §4's mint rule; mintFresh has no memory of a prior void, by design). Called only when
623
+ * `result.transitioned === true` (mintFresh only ever runs on the terminal false→true edge).
624
+ */
625
+ function computeReArmWarnings(before, after) {
626
+ const warnings = [];
627
+ for (const [name, afterEntry] of Object.entries(after ?? {})) {
628
+ const beforeEntry = before?.[name];
629
+ if (beforeEntry?.status === 'voided' && afterEntry.status === 'pending') {
630
+ warnings.push(`finalizer '${name}' was operator-voided; re-armed by this terminal edge`);
631
+ }
632
+ }
633
+ return warnings;
634
+ }
635
+ /**
636
+ * Builds the ResponseEnvelope for a `settle_step`/`open_gate` REFUSAL (design record §7's
637
+ * result/code table) — shared by the three migrated `settle_step` seal sites (issue #279,
638
+ * increment 1, PR-B) AND the migrated gate-open site (issue #279, increment 2, PR-D; `kind:
639
+ * 'open_gate'`). `allEvidence` is attached ONLY for `claim_lost`: the dispatch DID run and produce
640
+ * evidence; it just was not recorded, so the caller should still see what happened. The reasons
641
+ * both callers can actually return are enumerated explicitly; every OTHER `SettlementRefusalReason`
642
+ * member is lease/mark/settle_gate/settle_guard/release_step-only and structurally unreachable
643
+ * here (a `default` throws rather than silently mis-rendering one) — `choice_not_eligible` +
644
+ * `gate_choice_conflict` + the settle_gate `gate_mismatch`/`run_terminal` variants are consumed at
645
+ * 1b's own `errorEnvelope` (submitHumanResponse), never here; `gate_open_wait` is chain-consumed
646
+ * (executeChainInternal's guard loop); `already_released` is site-handled at 1d/1e (never routed
647
+ * through this shared builder).
648
+ */
649
+ function buildSettlementRefusalEnvelope(options, definition, result, allEvidence, traceWarnings, kind = 'settle_step') {
650
+ const extraWarnings = traceWarnings.length > 0 ? traceWarnings : undefined;
651
+ switch (result.reason) {
652
+ case 'already_settled_by_other':
653
+ case 'settled_outcome_divergence': {
654
+ const persisted = result.run.settled?.[options.command]?.outcome;
655
+ // N1 (design record §2/§11): neutral wording — never amplify a "by_other" white lie. When
656
+ // the persisted entry's outcome is 'gate', the step was settled by a COMPLETED GATE (a
657
+ // human decision resolved elsewhere), not literally "a different attempt".
658
+ const settledByText = persisted === 'gate' ? 'by a completed gate' : 'by a different attempt';
659
+ const err = new WorkflowError(`Step '${options.command}' was already settled` +
660
+ (persisted !== undefined ? ` with outcome '${persisted}'` : '') +
661
+ ` ${settledByText}.`, {
662
+ code: 'STATE_STEP_ALREADY_SETTLED',
663
+ category: 'STATE',
664
+ agentAction: 'resolve_precondition',
665
+ retryable: false,
666
+ details: {
667
+ runId: options.runId,
668
+ step: options.command,
669
+ reason: result.reason,
670
+ ...(persisted !== undefined ? { persisted_outcome: persisted } : {}),
671
+ },
672
+ });
673
+ return makeErrorEnvelope(options, result.run, err, definition, extraWarnings);
674
+ }
675
+ case 'claim_lost': {
676
+ const err = new WorkflowError(`Step '${options.command}': this attempt's outcome was NOT recorded — the claim was lost ` +
677
+ `(settled by another writer, or the run advanced).`, {
678
+ code: 'STATE_CLAIM_LOST',
679
+ category: 'STATE',
680
+ agentAction: 'resolve_precondition',
681
+ retryable: false,
682
+ details: { runId: options.runId, step: options.command },
683
+ });
684
+ return {
685
+ ...makeErrorEnvelope(options, result.run, err, definition, extraWarnings),
686
+ evidence: allEvidence,
687
+ };
688
+ }
689
+ case 'run_terminal': {
690
+ const err = new WorkflowError(`Run '${options.runId}' is terminal; cannot settle step '${options.command}'.`, {
691
+ code: 'STATE_RUN_TERMINAL',
692
+ category: 'STATE',
693
+ agentAction: 'report_to_user',
694
+ retryable: false,
695
+ details: { runId: options.runId, run_phase: result.run.run_phase },
696
+ });
697
+ return makeErrorEnvelope(options, result.run, err, definition, extraWarnings);
698
+ }
699
+ case 'gate_mismatch': {
700
+ // kind-discriminated (Deliverable 2): open_gate's gate_mismatch means a DIFFERENT step's
701
+ // gate is open (this step stays claimed — L13); settle_step's means THIS step IS the open
702
+ // gate and must be resolved via submit_human_response instead.
703
+ const message = kind === 'open_gate'
704
+ ? `Step '${options.command}': a gate is open on another step — wait for its resolution; ` +
705
+ `this step stays claimed.`
706
+ : `Step '${options.command}' is the currently open gate; resolve it via ` +
707
+ `submit_human_response instead of settling it directly.`;
708
+ const err = new WorkflowError(message, {
709
+ code: 'STATE_BLOCKED',
710
+ category: 'STATE',
711
+ agentAction: 'resolve_precondition',
712
+ retryable: false,
713
+ details: { runId: options.runId, step: options.command },
714
+ });
715
+ return makeErrorEnvelope(options, result.run, err, definition, extraWarnings);
716
+ }
717
+ default:
718
+ // run_not_terminal / ledger_not_pending / lease_held / lease_lost / rank_blocked /
719
+ // not_eligible / already_leased / already_marked / already_open / already_released /
720
+ // gate_choice_conflict / choice_not_eligible / gate_open_wait are all consumed elsewhere
721
+ // (lease_finalizer/mark_finalizer's own drain loop; open_gate's own NOOP arms at the 1a call
722
+ // site; 1b's own errorEnvelope; 1d/1e's own site-handling; the guard chain) — settle_step and
723
+ // open_gate never return them here (design record §7).
724
+ throw new Error(`buildSettlementRefusalEnvelope: unreachable '${kind}' refusal reason '${result.reason}'`);
725
+ }
726
+ }
727
+ /**
728
+ * Ok-shaped envelope for a `settle_step` NOOP (`already_settled`) — the idempotent-retry case
729
+ * (design record §7: ok-shaped, calm context_hint, never `report_to_user`). Drain-aware: when the
730
+ * fresh run still carries pending finalizers, this retry attempts the SAME post-commit drain a
731
+ * fresh apply would have run — recovering an ambiguous-retry crash window (§6). A drain failure
732
+ * degrades to a warning (never an error status) — the settle itself is not in question here.
733
+ */
734
+ async function buildAlreadySettledEnvelope(store, definition, options, result, traceWarnings) {
735
+ let run = result.run;
736
+ let drainWarnings = [];
737
+ const hasPending = Object.values(run.finalizer_ledger ?? {}).some((e) => e.status === 'pending');
738
+ if (hasPending) {
739
+ try {
740
+ const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
741
+ run = drainOutcome.run;
742
+ drainWarnings = drainOutcome.warnings;
743
+ }
744
+ catch (err) {
745
+ drainWarnings = [
746
+ `post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`,
747
+ ];
748
+ }
749
+ }
750
+ const nextActions = run.terminal_state ? [] : buildNextActions(definition, run);
751
+ return {
752
+ command: options.command,
753
+ run_id: options.runId,
754
+ run_version: run.version,
755
+ status: 'ok',
756
+ data: {},
757
+ evidence: [],
758
+ warnings: mergeWarnings(traceWarnings, ...drainWarnings),
759
+ errors: [],
760
+ context_hint: `Step '${options.command}' was already settled (a duplicate/retried attempt) — no action was taken.`,
761
+ run_phase: run.run_phase,
762
+ next_actions: nextActions,
763
+ };
764
+ }
765
+ /**
766
+ * Design record §10 (I16, fail-closed dormancy): the ONE advisory warning every legacy
767
+ * (dormancy-fallback) seal-site envelope carries when `store.settleStep` is undeclared — never a
768
+ * hard requirement; the legacy read-then-update path remains fully functional. This dual branch
769
+ * persists until a major version (final-gate F4/R13).
770
+ */
771
+ const DORMANCY_ADVISORY = 'settled via the legacy compatibility path — this store does not declare atomic settlement ' +
772
+ '(RunStore.settleStep); upgrade the store to close the fan-out seal race (issue #279)';
598
773
  /**
599
774
  * Validates eligibility, claims the step, executes it through the dispatcher with retry
600
775
  * and timeout support, captures evidence, persists the updated run record, and returns
@@ -958,6 +1133,8 @@ export async function executeStep(store, definition, options) {
958
1133
  if (err instanceof WorkflowError) {
959
1134
  if (err.code === 'STATE_STEP_ALREADY_CLAIMED') {
960
1135
  const freshRun = await store.get(options.runId).catch(() => run);
1136
+ // Design record §6: append the drain advisory when the fresh run carries pendings.
1137
+ const claimedAdvisory = finalizerDrainAdvisory(freshRun);
961
1138
  return {
962
1139
  command: options.command,
963
1140
  run_id: options.runId,
@@ -965,7 +1142,7 @@ export async function executeStep(store, definition, options) {
965
1142
  status: 'blocked',
966
1143
  data: {},
967
1144
  evidence: [],
968
- warnings: [],
1145
+ warnings: claimedAdvisory !== undefined ? [claimedAdvisory] : [],
969
1146
  errors: [],
970
1147
  agent_action: 'resolve_precondition',
971
1148
  context_hint: `Step '${options.command}' was already claimed by another process.`,
@@ -977,6 +1154,19 @@ export async function executeStep(store, definition, options) {
977
1154
  },
978
1155
  };
979
1156
  }
1157
+ // Design record §6: STATE_STEP_NOT_ELIGIBLE (a claim-time eligibility re-check race) also
1158
+ // gets the drain advisory when the fresh run carries pendings — a fresh re-read since `run`
1159
+ // (Step 1's load) may be stale by the time claimStep's own re-check inside its lock raced.
1160
+ if (err.code === 'STATE_STEP_NOT_ELIGIBLE') {
1161
+ const freshRun = await store.get(options.runId).catch(() => run);
1162
+ const notEligibleAdvisory = finalizerDrainAdvisory(freshRun);
1163
+ const extraWarnings = notEligibleAdvisory !== undefined
1164
+ ? [...traceWarnings, notEligibleAdvisory]
1165
+ : traceWarnings.length > 0
1166
+ ? traceWarnings
1167
+ : undefined;
1168
+ return makeErrorEnvelope(options, freshRun, err, definition, extraWarnings);
1169
+ }
980
1170
  return makeErrorEnvelope(options, run, err, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
981
1171
  }
982
1172
  return makeErrorEnvelope(options, run, new WorkflowError('Failed to claim step', {
@@ -1013,13 +1203,43 @@ export async function executeStep(store, definition, options) {
1013
1203
  : [];
1014
1204
  }
1015
1205
  catch (err) {
1016
- try {
1017
- await store.update(buildCompensatingUnclaim(pendingRun, options.command, new Date()));
1206
+ // issue #279 (increment 2, PR-D, Deliverable 1e): the migrated path — settles this release
1207
+ // atomically against FRESH state via the store's own settleStep, evidence = the SAME
1208
+ // compensating_unclaim audit line (:679 semantics). LOG-ONLY for ALL results (applied / NOOP
1209
+ // / any refusal / a thrown infra error) — no envelope change on any settle outcome; the
1210
+ // ENGINE_STORE_FAILED envelope below is the disclosure regardless. Dormancy: an undeclaring
1211
+ // store falls through to the byte-identical legacy path (I16/#169 fail-closed dormancy).
1212
+ let unclaimDormancyWarning;
1213
+ if (store.settleStep !== undefined) {
1214
+ const unclaimToken = pendingRun.claims?.[options.command]?.token;
1215
+ const delta = {
1216
+ kind: 'release_step',
1217
+ step: options.command,
1218
+ ...(unclaimToken !== undefined ? { claimToken: unclaimToken } : {}),
1219
+ evidence: [buildCompensatingUnclaimEvidence(options.command, new Date())],
1220
+ };
1221
+ try {
1222
+ await store.settleStep(options.runId, delta, definition);
1223
+ }
1224
+ catch {
1225
+ // Log-only — see the comment above; never surfaces as its own error.
1226
+ }
1018
1227
  }
1019
- catch {
1020
- // CAS mismatch (someone else already resolved the claim) or any other failure to even
1021
- // un-claim: stop immediately, leave the claim exactly as it is — never retry here.
1228
+ else {
1229
+ // --- Legacy path (dormancy fallback byte-identical to pre-#279 behavior) ---
1230
+ try {
1231
+ await store.update(buildCompensatingUnclaim(pendingRun, options.command, new Date()));
1232
+ }
1233
+ catch {
1234
+ // CAS mismatch (someone else already resolved the claim) or any other failure to even
1235
+ // un-claim: stop immediately, leave the claim exactly as it is — never retry here.
1236
+ }
1237
+ // issue #279 (increment 2, PR-D): + the ONE dormancy advisory (I16) — this IS the legacy
1238
+ // path (store.settleStep undeclared); the ENGINE_STORE_FAILED envelope below is its only
1239
+ // carrier since this release is log-only.
1240
+ unclaimDormancyWarning = DORMANCY_ADVISORY;
1022
1241
  }
1242
+ const unclaimEnvelopeWarnings = mergeWarnings(traceWarnings, unclaimDormancyWarning);
1023
1243
  return makeErrorEnvelope(options, pendingRun, new WorkflowError('Failed to read trace buffer after claiming step', {
1024
1244
  code: 'ENGINE_STORE_FAILED',
1025
1245
  category: 'ENGINE',
@@ -1029,7 +1249,7 @@ export async function executeStep(store, definition, options) {
1029
1249
  step_id: options.command,
1030
1250
  cause: err instanceof Error ? err.message : String(err),
1031
1251
  },
1032
- }), definition, traceWarnings.length > 0 ? traceWarnings : undefined);
1252
+ }), definition, unclaimEnvelopeWarnings.length > 0 ? unclaimEnvelopeWarnings : undefined);
1033
1253
  }
1034
1254
  // issue #197 PR-2 (design §2): the SAME predicate as the pre-claim pass, now over the
1035
1255
  // complete post-claim set. Lifted to `adoptionPartition` (outer scope) — read again at the
@@ -1335,6 +1555,75 @@ export async function executeStep(store, definition, options) {
1335
1555
  }),
1336
1556
  status: 'skipped',
1337
1557
  };
1558
+ // issue #279 (increment 1, PR-B): the migrated path — a store declaring settleStep
1559
+ // settles this abort atomically against FRESH state (not `pendingRun`, which may be
1560
+ // stale relative to a concurrent sibling settle). Dormancy: an undeclaring store falls
1561
+ // through to the byte-identical legacy path below (I16/#169 fail-closed dormancy).
1562
+ if (store.settleStep !== undefined) {
1563
+ const abortClaimToken = pendingRun.claims?.[options.command]?.token;
1564
+ const delta = {
1565
+ kind: 'settle_step',
1566
+ step: options.command,
1567
+ outcome: 'abort',
1568
+ ...(abortClaimToken !== undefined ? { claimToken: abortClaimToken } : {}),
1569
+ evidence: [abortEvidence],
1570
+ abort: { stepId: options.command, abortMessage },
1571
+ };
1572
+ let result;
1573
+ try {
1574
+ result = await store.settleStep(options.runId, delta, definition);
1575
+ }
1576
+ catch (err) {
1577
+ // THROWN infra errors (lock exhaustion, run-not-found, I/O) — the complete-site's
1578
+ // existing catch shape, replicated at all three migrated sites.
1579
+ if (err instanceof WorkflowError) {
1580
+ return makeErrorEnvelope(options, pendingRun, err, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
1581
+ }
1582
+ const internal = new WorkflowError('Failed to persist run update', {
1583
+ code: 'ENGINE_STORE_FAILED',
1584
+ category: 'ENGINE',
1585
+ agentAction: 'stop',
1586
+ retryable: false,
1587
+ });
1588
+ return makeErrorEnvelope(options, pendingRun, internal, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
1589
+ }
1590
+ if (!result.applied) {
1591
+ if (result.reason === 'already_settled') {
1592
+ return buildAlreadySettledEnvelope(store, definition, options, { ...result, reason: 'already_settled' }, traceWarnings);
1593
+ }
1594
+ return buildSettlementRefusalEnvelope(options, definition, result, [abortEvidence], traceWarnings);
1595
+ }
1596
+ // applied: true — abort is UNCONDITIONALLY terminal (transitioned is always true here;
1597
+ // isTerminal(fresh) was already refused above inside applySettlement).
1598
+ let finalRun = result.run;
1599
+ let drainWarnings = [];
1600
+ const reArmWarnings = computeReArmWarnings(pendingRun.finalizer_ledger, result.run.finalizer_ledger);
1601
+ try {
1602
+ const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
1603
+ finalRun = drainOutcome.run;
1604
+ drainWarnings = drainOutcome.warnings;
1605
+ }
1606
+ catch (err) {
1607
+ // A drain failure is NEVER the step's own failure — the abort already committed.
1608
+ drainWarnings = [
1609
+ `post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`,
1610
+ ];
1611
+ }
1612
+ return {
1613
+ command: options.command,
1614
+ run_id: options.runId,
1615
+ run_version: finalRun.version,
1616
+ status: 'ok',
1617
+ data: {},
1618
+ evidence: [abortEvidence],
1619
+ warnings: mergeWarnings(traceWarnings, ...reArmWarnings, ...drainWarnings),
1620
+ errors: [],
1621
+ context_hint: `Handler step '${options.command}' aborted the run: ${abortMessage}`,
1622
+ run_phase: finalRun.run_phase,
1623
+ next_actions: [],
1624
+ };
1625
+ }
1626
+ // --- Legacy path (dormancy fallback — byte-identical to pre-#279 behavior) ---
1338
1627
  const withHandlerSkipped = {
1339
1628
  ...pendingRun,
1340
1629
  in_progress_steps: pendingRun.in_progress_steps.filter((s) => s !== options.command),
@@ -1382,7 +1671,9 @@ export async function executeStep(store, definition, options) {
1382
1671
  evidence: [abortEvidence],
1383
1672
  // Issue #140: was hardcoded `[]` — now threads traceWarnings (e.g. the programmatic
1384
1673
  // on_timeout/idempotent gate advisory above) so it survives this settle path too.
1385
- warnings: mergeWarnings(traceWarnings),
1674
+ // Issue #279 (increment 1, PR-B): + the ONE dormancy advisory (I16) — this IS the
1675
+ // legacy path (store.settleStep undeclared).
1676
+ warnings: mergeWarnings(traceWarnings, DORMANCY_ADVISORY),
1386
1677
  errors: [],
1387
1678
  context_hint: `Handler step '${options.command}' aborted the run: ${abortMessage}`,
1388
1679
  run_phase: (persistedAbortRun ?? abortedRun).run_phase,
@@ -1694,13 +1985,61 @@ export async function executeStep(store, definition, options) {
1694
1985
  // Non-terminal: recompute the phase so the store-fail fallback below is correct too
1695
1986
  // (on the happy path store.update recomputes it identically via deriveRunPhase).
1696
1987
  const blockedRun = { ...blockedDraft, run_phase: deriveRunPhase(blockedDraft) };
1988
+ // issue #279 (increment 2, PR-D, Deliverable 1d): the migrated path — settles this release
1989
+ // atomically against FRESH state via the store's own settleStep. This site NEVER calls the
1990
+ // shared buildSettlementRefusalEnvelope: regardless of write outcome (applied / NOOP
1991
+ // already_released / any OTHER refusal / a thrown infra error), the RETURNED envelope is
1992
+ // ALWAYS this SAME capability-block report — only whether the internal capability_blocks
1993
+ // marker got durably persisted varies, disclosed via blockStoreWarning. Dormancy: an
1994
+ // undeclaring store falls through to the byte-identical legacy path below (I16/#169
1995
+ // fail-closed dormancy).
1697
1996
  let persistedBlockedRun;
1698
1997
  let blockStoreWarning;
1699
- try {
1700
- persistedBlockedRun = await store.update(blockedRun);
1998
+ let dormancyWarning;
1999
+ if (store.settleStep !== undefined) {
2000
+ const releaseClaimToken = pendingRun.claims?.[options.command]?.token;
2001
+ const delta = {
2002
+ kind: 'release_step',
2003
+ step: options.command,
2004
+ ...(releaseClaimToken !== undefined ? { claimToken: releaseClaimToken } : {}),
2005
+ capabilityBlock: {
2006
+ requirement: requirement !== undefined
2007
+ ? { kind: requirement.kind, name: requirement.name }
2008
+ : {
2009
+ kind: recoverableCode === 'ENGINE_HANDLER_NOT_REGISTERED' ? 'handler' : 'adapter',
2010
+ name: 'unknown',
2011
+ },
2012
+ code: recoverableCode,
2013
+ },
2014
+ // The current :2490 append — legacy parity; the compensating un-claim's own :679
2015
+ // audit-line channel belongs to Deliverable 1e ONLY.
2016
+ evidence: allEvidence,
2017
+ };
2018
+ try {
2019
+ const releaseResult = await store.settleStep(options.runId, delta, definition);
2020
+ persistedBlockedRun = releaseResult.run;
2021
+ // applied / NOOP already_released ⇒ the block envelope exactly as today (NOOP merges
2022
+ // silently — no extra warning). ANY OTHER refusal ⇒ the same block envelope + a typed
2023
+ // warning (never STATE_CLAIM_LOST framing) — the claim survives for reclaim either way.
2024
+ if (!releaseResult.applied && releaseResult.reason !== 'already_released') {
2025
+ blockStoreWarning = `capability block not persisted: ${releaseResult.reason}`;
2026
+ }
2027
+ }
2028
+ catch (storeErr) {
2029
+ blockStoreWarning = `Failed to persist capability block: ${storeErr instanceof Error ? storeErr.message : String(storeErr)}`;
2030
+ }
1701
2031
  }
1702
- catch (storeErr) {
1703
- blockStoreWarning = `Failed to persist capability block: ${storeErr instanceof Error ? storeErr.message : String(storeErr)}`;
2032
+ else {
2033
+ // --- Legacy path (dormancy fallback byte-identical to pre-#279 behavior) ---
2034
+ try {
2035
+ persistedBlockedRun = await store.update(blockedRun);
2036
+ }
2037
+ catch (storeErr) {
2038
+ blockStoreWarning = `Failed to persist capability block: ${storeErr instanceof Error ? storeErr.message : String(storeErr)}`;
2039
+ }
2040
+ // issue #279 (increment 2, PR-D): + the ONE dormancy advisory (I16) — this IS the legacy
2041
+ // path (store.settleStep undeclared).
2042
+ dormancyWarning = DORMANCY_ADVISORY;
1704
2043
  }
1705
2044
  // issue #207 PR-2 (D3 §5): NO WAL delete belongs on this capability-block settle path — the
1706
2045
  // prior try/catch here was removed, not just gated. Contract-consistency hygiene, not a
@@ -1735,7 +2074,7 @@ export async function executeStep(store, definition, options) {
1735
2074
  status: 'error',
1736
2075
  data: {},
1737
2076
  evidence: allEvidence,
1738
- warnings: mergeWarnings(traceWarnings, blockStoreWarning),
2077
+ warnings: mergeWarnings(traceWarnings, blockStoreWarning, dormancyWarning),
1739
2078
  errors: [dispatchError.message],
1740
2079
  agent_action: blockedAction,
1741
2080
  error_code: recoverableCode,
@@ -1744,6 +2083,141 @@ export async function executeStep(store, definition, options) {
1744
2083
  next_actions: blockedNextActions,
1745
2084
  };
1746
2085
  }
2086
+ // issue #279 (increment 1, PR-B): the migrated path — settles this failure atomically against
2087
+ // FRESH state via the store's own settleStep. Dormancy: an undeclaring store falls through to
2088
+ // the byte-identical legacy path below (I16/#169 fail-closed dormancy).
2089
+ if (store.settleStep !== undefined) {
2090
+ const failClaimToken = pendingRun.claims?.[options.command]?.token;
2091
+ const delta = {
2092
+ kind: 'settle_step',
2093
+ step: options.command,
2094
+ outcome: 'fail',
2095
+ ...(failClaimToken !== undefined ? { claimToken: failClaimToken } : {}),
2096
+ evidence: allEvidence,
2097
+ failureMessage: dispatchError.message,
2098
+ };
2099
+ let result;
2100
+ try {
2101
+ result = await store.settleStep(options.runId, delta, definition);
2102
+ }
2103
+ catch (err) {
2104
+ // THROWN infra errors — the same catch shape replicated at all three migrated sites.
2105
+ if (err instanceof WorkflowError) {
2106
+ return makeErrorEnvelope(options, pendingRun, err, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
2107
+ }
2108
+ const internal = new WorkflowError('Failed to persist run update', {
2109
+ code: 'ENGINE_STORE_FAILED',
2110
+ category: 'ENGINE',
2111
+ agentAction: 'stop',
2112
+ retryable: false,
2113
+ });
2114
+ return makeErrorEnvelope(options, pendingRun, internal, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
2115
+ }
2116
+ if (!result.applied) {
2117
+ if (result.reason === 'already_settled') {
2118
+ return buildAlreadySettledEnvelope(store, definition, options, { ...result, reason: 'already_settled' }, traceWarnings);
2119
+ }
2120
+ // WAL/sealFenced gates become result.applied (BU-12): claim_lost ⇒ the WAL SURVIVES
2121
+ // (reclaim's drain owns it) — no WAL cleanup attempted on ANY refusal.
2122
+ return buildSettlementRefusalEnvelope(options, definition, result, allEvidence, traceWarnings);
2123
+ }
2124
+ let finalRun = result.run;
2125
+ let drainWarnings = [];
2126
+ const reArmWarnings = result.transitioned
2127
+ ? computeReArmWarnings(pendingRun.finalizer_ledger, result.run.finalizer_ledger)
2128
+ : [];
2129
+ if (result.transitioned) {
2130
+ try {
2131
+ const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
2132
+ finalRun = drainOutcome.run;
2133
+ drainWarnings = drainOutcome.warnings;
2134
+ }
2135
+ catch (err) {
2136
+ drainWarnings = [
2137
+ `post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`,
2138
+ ];
2139
+ }
2140
+ }
2141
+ // WAL cleanup — placement stays post-commit (unchanged), now gated on result.applied
2142
+ // (BU-12) rather than a separate persistedRun-defined check (the settle already committed
2143
+ // by the time we reach here, so there is no "did the persist succeed" ambiguity to gate on).
2144
+ let migratedWalCleanupWarning;
2145
+ {
2146
+ let performPlainDelete = true;
2147
+ if (adoptionPartition !== undefined &&
2148
+ adoptionPartition.preserved_foreign > 0 &&
2149
+ options.traceBufferStore !== undefined &&
2150
+ storeDeclaresSeal(options.traceBufferStore)) {
2151
+ try {
2152
+ const sealResult = await options.traceBufferStore.sealFenced(options.runId, options.command, buildSettleSealGuard(store, options.runId, options.command));
2153
+ if (sealResult.sealed) {
2154
+ performPlainDelete = false;
2155
+ migratedWalCleanupWarning =
2156
+ `${adoptionPartition.preserved_foreign} foreign line(s) preserved (sealed) — ` +
2157
+ 'retrieve via `realm run export`';
2158
+ }
2159
+ else if (sealResult.reason === 'capped') {
2160
+ migratedWalCleanupWarning =
2161
+ 'preservation cap reached — foreign lines destroyed, not preserved';
2162
+ }
2163
+ else {
2164
+ performPlainDelete = false;
2165
+ }
2166
+ }
2167
+ catch (err) {
2168
+ performPlainDelete = false;
2169
+ migratedWalCleanupWarning = `Failed to seal trace buffer after step failure: ${err instanceof Error ? err.message : String(err)}`;
2170
+ }
2171
+ }
2172
+ if (performPlainDelete) {
2173
+ try {
2174
+ await options.traceBufferStore?.delete(options.runId, options.command);
2175
+ }
2176
+ catch (walErr) {
2177
+ migratedWalCleanupWarning = `Failed to clean up trace buffer after step failure: ${walErr instanceof Error ? walErr.message : String(walErr)}`;
2178
+ }
2179
+ }
2180
+ }
2181
+ const migratedEffectiveAction = resolvePostDispatchAgentAction(dispatchError, finalRun.terminal_state);
2182
+ let migratedNextActions = [];
2183
+ if (migratedEffectiveAction !== 'stop') {
2184
+ try {
2185
+ migratedNextActions = buildNextActions(definition, finalRun);
2186
+ }
2187
+ catch {
2188
+ // buildNextActions can throw for unresolvable template references; fall back to [].
2189
+ }
2190
+ }
2191
+ const migratedContextHint = migratedEffectiveAction === 'stop'
2192
+ ? `Step '${options.command}' failed. Run is terminated.`
2193
+ : migratedEffectiveAction === 'wait_and_proceed'
2194
+ ? `Step '${options.command}' was rate-limited. Wait ${dispatchError.retry_after !== undefined ? `${dispatchError.retry_after} second(s)` : 'a moment'} then follow next_actions — no human intervention required.`
2195
+ : migratedEffectiveAction === 'wait_for_human'
2196
+ ? `Step '${options.command}' failed due to external service unavailability. Wait for service recovery, then proceed with the steps in next_actions.`
2197
+ : `Step '${options.command}' failed. ${result.transitioned ? 'Run is terminated.' : 'Recovery steps are available in next_actions.'}`;
2198
+ return {
2199
+ command: options.command,
2200
+ run_id: options.runId,
2201
+ run_version: finalRun.version,
2202
+ status: 'error',
2203
+ data: {},
2204
+ evidence: allEvidence,
2205
+ warnings: mergeWarnings(traceWarnings, migratedWalCleanupWarning, ...reArmWarnings, ...drainWarnings),
2206
+ errors: [dispatchError.message],
2207
+ agent_action: migratedEffectiveAction,
2208
+ error_code: dispatchError.code,
2209
+ ...(Object.keys(dispatchError.details).length > 0
2210
+ ? { error_details: dispatchError.details }
2211
+ : {}),
2212
+ ...(dispatchError.retry_after !== undefined
2213
+ ? { retry_after: dispatchError.retry_after }
2214
+ : {}),
2215
+ context_hint: migratedContextHint,
2216
+ run_phase: finalRun.run_phase,
2217
+ next_actions: migratedNextActions,
2218
+ };
2219
+ }
2220
+ // --- Legacy path (dormancy fallback — byte-identical to pre-#279 behavior) ---
1747
2221
  // Pure in-memory derivations — no I/O, no try required.
1748
2222
  const afterFail = {
1749
2223
  ...pendingRun,
@@ -1879,7 +2353,9 @@ export async function executeStep(store, definition, options) {
1879
2353
  status: 'error',
1880
2354
  data: {},
1881
2355
  evidence: allEvidence,
1882
- warnings: mergeWarnings(traceWarnings, storeCleanupWarning ?? walCleanupWarning),
2356
+ // Issue #279 (increment 1, PR-B): + the ONE dormancy advisory (I16) — this IS the legacy
2357
+ // path (store.settleStep undeclared).
2358
+ warnings: mergeWarnings(traceWarnings, storeCleanupWarning ?? walCleanupWarning, DORMANCY_ADVISORY),
1883
2359
  errors: [dispatchError.message],
1884
2360
  agent_action: effectiveAction,
1885
2361
  // issue #140 (D3 §2, discriminator OBSERVABLE): additive-optional — lets a caller
@@ -1952,37 +2428,21 @@ export async function executeStep(store, definition, options) {
1952
2428
  resolvedGateMessage = raw;
1953
2429
  }
1954
2430
  const gateConfig = stepDef.gate;
1955
- let gateRun;
1956
- try {
1957
- gateRun = await store.update({
1958
- ...pendingRun,
1959
- // Step stays in in_progress_steps while gate is open — moved to completed on submit.
1960
- evidence: [...pendingRun.evidence, ...allEvidence],
1961
- pending_gate: {
1962
- gate_id,
1963
- step_name,
1964
- preview: output,
1965
- choices,
1966
- opened_at: new Date().toISOString(),
1967
- ...(gateConfig?.owner !== undefined ? { owner: gateConfig.owner } : {}),
1968
- ...(resolvedGateMessage !== undefined ? { resolved_message: resolvedGateMessage } : {}),
1969
- ...(gateConfig?.resolution_messages !== undefined
1970
- ? { resolution_messages: gateConfig.resolution_messages }
1971
- : {}),
1972
- },
1973
- });
1974
- }
1975
- catch (err) {
1976
- if (err instanceof WorkflowError) {
1977
- return makeErrorEnvelope(options, pendingRun, err, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
1978
- }
1979
- return makeErrorEnvelope(options, pendingRun, new WorkflowError('Failed to open gate', {
1980
- code: 'ENGINE_STORE_FAILED',
1981
- category: 'ENGINE',
1982
- agentAction: 'stop',
1983
- retryable: false,
1984
- }), definition, traceWarnings.length > 0 ? traceWarnings : undefined);
1985
- }
2431
+ // The PendingGate object is built EXACTLY as before, regardless of which path commits it below
2432
+ // (issue #279, increment 2, PR-D, Deliverable 1a — the migrated `open_gate` delta carries this
2433
+ // SAME object verbatim; the legacy fallback writes it via `store.update` unchanged).
2434
+ const pendingGate = {
2435
+ gate_id,
2436
+ step_name,
2437
+ preview: output,
2438
+ choices,
2439
+ opened_at: new Date().toISOString(),
2440
+ ...(gateConfig?.owner !== undefined ? { owner: gateConfig.owner } : {}),
2441
+ ...(resolvedGateMessage !== undefined ? { resolved_message: resolvedGateMessage } : {}),
2442
+ ...(gateConfig?.resolution_messages !== undefined
2443
+ ? { resolution_messages: gateConfig.resolution_messages }
2444
+ : {}),
2445
+ };
1986
2446
  // gate.display fallback chain: gate.message resolved → step.prompt resolved → absent
1987
2447
  const resolvedGateDisplay = resolvedGateMessage !== undefined
1988
2448
  ? resolvedGateMessage
@@ -2000,19 +2460,155 @@ export async function executeStep(store, definition, options) {
2000
2460
  ...wfCtxSpreadEarly,
2001
2461
  })
2002
2462
  : undefined;
2003
- const gateNextAction = {
2004
- instruction: {
2005
- tool: 'submit_human_response',
2006
- params: { run_id: options.runId, gate_id },
2007
- call_with: {
2008
- run_id: options.runId,
2463
+ function buildGateNextAction(id, gateChoices, forStep) {
2464
+ return {
2465
+ instruction: {
2466
+ tool: 'submit_human_response',
2467
+ params: { run_id: options.runId, gate_id: id },
2468
+ call_with: {
2469
+ run_id: options.runId,
2470
+ gate_id: id,
2471
+ choice: `<${gateChoices.join('|')}>`,
2472
+ },
2473
+ },
2474
+ human_readable: `Human review required for step '${forStep}'. Present gate.display to the user, wait for their choice from gate.response_spec.choices, then call submit_human_response.`,
2475
+ orientation: `Run is paused at gate '${id}'. Available choices: ${gateChoices.join(', ')}.`,
2476
+ };
2477
+ }
2478
+ // issue #279 (increment 2, PR-D, Deliverable 1a): the migrated path — opens this gate
2479
+ // atomically against FRESH state via the store's own settleStep. Dormancy: an undeclaring
2480
+ // store falls through to the byte-identical legacy path below (I16/#169 fail-closed dormancy).
2481
+ if (store.settleStep !== undefined) {
2482
+ const openClaimToken = pendingRun.claims?.[options.command]?.token;
2483
+ const delta = {
2484
+ kind: 'open_gate',
2485
+ step: options.command,
2486
+ ...(openClaimToken !== undefined ? { claimToken: openClaimToken } : {}),
2487
+ pendingGate,
2488
+ evidence: allEvidence,
2489
+ };
2490
+ let result;
2491
+ try {
2492
+ result = await store.settleStep(options.runId, delta, definition);
2493
+ }
2494
+ catch (err) {
2495
+ // THROWN infra errors — the same catch shape replicated at every migrated site.
2496
+ if (err instanceof WorkflowError) {
2497
+ return makeErrorEnvelope(options, pendingRun, err, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
2498
+ }
2499
+ const internal = new WorkflowError('Failed to open gate', {
2500
+ code: 'ENGINE_STORE_FAILED',
2501
+ category: 'ENGINE',
2502
+ agentAction: 'stop',
2503
+ retryable: false,
2504
+ });
2505
+ return makeErrorEnvelope(options, pendingRun, internal, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
2506
+ }
2507
+ if (!result.applied) {
2508
+ if (result.reason === 'already_settled') {
2509
+ // Ok-shaped NOOP — buildAlreadySettledEnvelope's SHAPE but WITHOUT its drain clause:
2510
+ // gate-open NEVER drains (design record §6 row 1 — the crashed-drain recovery paths are
2511
+ // the resolution site's own NOOP drain and the drain verb).
2512
+ const noopRun = result.run;
2513
+ const noopNextActions = noopRun.terminal_state
2514
+ ? []
2515
+ : buildNextActions(definition, noopRun);
2516
+ return {
2517
+ command: options.command,
2518
+ run_id: options.runId,
2519
+ run_version: noopRun.version,
2520
+ status: 'ok',
2521
+ data: {},
2522
+ evidence: [],
2523
+ warnings: [...traceWarnings],
2524
+ errors: [],
2525
+ context_hint: `Step '${options.command}' was already settled (a duplicate/retried attempt) — no action was taken.`,
2526
+ run_phase: noopRun.run_phase,
2527
+ next_actions: noopNextActions,
2528
+ };
2529
+ }
2530
+ if (result.reason === 'already_open') {
2531
+ // D-1: the LIVE gate wins — rendered VERBATIM (this delta's own rebuilt gate is
2532
+ // discarded). Calm, confirm_required (the gate genuinely IS still open) — never
2533
+ // report_to_user (no `agent_action` set, matching the fresh-open confirm_required shape).
2534
+ const liveGate = result.gate;
2535
+ return {
2536
+ command: options.command,
2537
+ run_id: options.runId,
2538
+ run_version: result.run.version,
2539
+ status: 'confirm_required',
2540
+ data: liveGate.preview,
2541
+ evidence: [],
2542
+ warnings: [...traceWarnings],
2543
+ errors: [],
2544
+ context_hint: `Run is already paused at gate '${liveGate.gate_id}'. Available choices: ${liveGate.choices.join(', ')}.`,
2545
+ run_phase: result.run.run_phase,
2546
+ next_actions: [
2547
+ buildGateNextAction(liveGate.gate_id, liveGate.choices, liveGate.step_name),
2548
+ ],
2549
+ gate: {
2550
+ gate_id: liveGate.gate_id,
2551
+ step_name: liveGate.step_name,
2552
+ preview: liveGate.preview,
2553
+ choices: liveGate.choices,
2554
+ ...(liveGate.resolved_message !== undefined
2555
+ ? { display: liveGate.resolved_message }
2556
+ : {}),
2557
+ response_spec: { choices: liveGate.choices },
2558
+ },
2559
+ };
2560
+ }
2561
+ return buildSettlementRefusalEnvelope(options, definition, result, allEvidence, traceWarnings, 'open_gate');
2562
+ }
2563
+ // applied: true — never terminalizes (design record §4.1); build confirm_required off
2564
+ // result.run.
2565
+ const gateRun = result.run;
2566
+ return {
2567
+ command: options.command,
2568
+ run_id: options.runId,
2569
+ run_version: gateRun.version,
2570
+ status: 'confirm_required',
2571
+ data: output,
2572
+ evidence: allEvidence,
2573
+ warnings: [...traceWarnings],
2574
+ errors: [],
2575
+ context_hint: `Run is paused at gate '${gate_id}'. Available choices: ${choices.join(', ')}.`,
2576
+ run_phase: gateRun.run_phase,
2577
+ next_actions: [buildGateNextAction(gate_id, choices, step_name)],
2578
+ gate: {
2009
2579
  gate_id,
2010
- choice: `<${choices.join('|')}>`,
2580
+ step_name,
2581
+ preview: output,
2582
+ choices,
2583
+ ...(resolvedGateDisplay !== undefined ? { display: resolvedGateDisplay } : {}),
2584
+ ...(resolvedGateInstructions !== undefined
2585
+ ? { agent_hint: resolvedGateInstructions }
2586
+ : {}),
2587
+ response_spec: { choices },
2011
2588
  },
2012
- },
2013
- human_readable: `Human review required for step '${options.command}'. Present gate.display to the user, wait for their choice from gate.response_spec.choices, then call submit_human_response.`,
2014
- orientation: `Run is paused at gate '${gate_id}'. Available choices: ${choices.join(', ')}.`,
2015
- };
2589
+ };
2590
+ }
2591
+ // --- Legacy path (dormancy fallback byte-identical to pre-#279 behavior) ---
2592
+ let gateRun;
2593
+ try {
2594
+ gateRun = await store.update({
2595
+ ...pendingRun,
2596
+ // Step stays in in_progress_steps while gate is open — moved to completed on submit.
2597
+ evidence: [...pendingRun.evidence, ...allEvidence],
2598
+ pending_gate: pendingGate,
2599
+ });
2600
+ }
2601
+ catch (err) {
2602
+ if (err instanceof WorkflowError) {
2603
+ return makeErrorEnvelope(options, pendingRun, err, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
2604
+ }
2605
+ return makeErrorEnvelope(options, pendingRun, new WorkflowError('Failed to open gate', {
2606
+ code: 'ENGINE_STORE_FAILED',
2607
+ category: 'ENGINE',
2608
+ agentAction: 'stop',
2609
+ retryable: false,
2610
+ }), definition, traceWarnings.length > 0 ? traceWarnings : undefined);
2611
+ }
2016
2612
  return {
2017
2613
  command: options.command,
2018
2614
  run_id: options.runId,
@@ -2020,11 +2616,11 @@ export async function executeStep(store, definition, options) {
2020
2616
  status: 'confirm_required',
2021
2617
  data: output,
2022
2618
  evidence: allEvidence,
2023
- warnings: [...traceWarnings],
2619
+ warnings: mergeWarnings(traceWarnings, DORMANCY_ADVISORY),
2024
2620
  errors: [],
2025
2621
  context_hint: `Run is paused at gate '${gate_id}'. Available choices: ${choices.join(', ')}.`,
2026
2622
  run_phase: gateRun.run_phase,
2027
- next_actions: [gateNextAction],
2623
+ next_actions: [buildGateNextAction(gate_id, choices, step_name)],
2028
2624
  gate: {
2029
2625
  gate_id,
2030
2626
  step_name,
@@ -2037,6 +2633,134 @@ export async function executeStep(store, definition, options) {
2037
2633
  };
2038
2634
  }
2039
2635
  // Step 6: Move step from in_progress to completed, compute terminal state.
2636
+ // issue #279 (increment 1, PR-B): the migrated path — settles this completion atomically
2637
+ // against FRESH state via the store's own settleStep. Dormancy: an undeclaring store falls
2638
+ // through to the byte-identical legacy path below (I16/#169 fail-closed dormancy).
2639
+ if (store.settleStep !== undefined) {
2640
+ const completeClaimToken = pendingRun.claims?.[options.command]?.token;
2641
+ const delta = {
2642
+ kind: 'settle_step',
2643
+ step: options.command,
2644
+ outcome: 'complete',
2645
+ ...(completeClaimToken !== undefined ? { claimToken: completeClaimToken } : {}),
2646
+ evidence: allEvidence,
2647
+ };
2648
+ let result;
2649
+ try {
2650
+ result = await store.settleStep(options.runId, delta, definition);
2651
+ }
2652
+ catch (err) {
2653
+ // THROWN infra errors — the same catch shape replicated at all three migrated sites.
2654
+ if (err instanceof WorkflowError) {
2655
+ return makeErrorEnvelope(options, pendingRun, err, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
2656
+ }
2657
+ const internal = new WorkflowError('Failed to persist run update', {
2658
+ code: 'ENGINE_STORE_FAILED',
2659
+ category: 'ENGINE',
2660
+ agentAction: 'stop',
2661
+ retryable: false,
2662
+ });
2663
+ return makeErrorEnvelope(options, pendingRun, internal, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
2664
+ }
2665
+ if (!result.applied) {
2666
+ if (result.reason === 'already_settled') {
2667
+ return buildAlreadySettledEnvelope(store, definition, options, { ...result, reason: 'already_settled' }, traceWarnings);
2668
+ }
2669
+ // WAL/sealFenced gates become result.applied (BU-12): claim_lost ⇒ the WAL SURVIVES.
2670
+ return buildSettlementRefusalEnvelope(options, definition, result, allEvidence, traceWarnings);
2671
+ }
2672
+ let finalRun = result.run;
2673
+ let drainWarnings = [];
2674
+ const reArmWarnings = result.transitioned
2675
+ ? computeReArmWarnings(pendingRun.finalizer_ledger, result.run.finalizer_ledger)
2676
+ : [];
2677
+ if (result.transitioned) {
2678
+ try {
2679
+ const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
2680
+ finalRun = drainOutcome.run;
2681
+ drainWarnings = drainOutcome.warnings;
2682
+ }
2683
+ catch (err) {
2684
+ drainWarnings = [
2685
+ `post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`,
2686
+ ];
2687
+ }
2688
+ }
2689
+ // WAL cleanup — placement stays post-commit (unchanged), gated on result.applied (BU-12).
2690
+ let migratedSuccessWalCleanupWarning;
2691
+ {
2692
+ let performPlainDelete = true;
2693
+ if (adoptionPartition !== undefined &&
2694
+ adoptionPartition.preserved_foreign > 0 &&
2695
+ options.traceBufferStore !== undefined &&
2696
+ storeDeclaresSeal(options.traceBufferStore)) {
2697
+ try {
2698
+ const sealResult = await options.traceBufferStore.sealFenced(options.runId, options.command, buildSettleSealGuard(store, options.runId, options.command));
2699
+ if (sealResult.sealed) {
2700
+ performPlainDelete = false;
2701
+ migratedSuccessWalCleanupWarning =
2702
+ `${adoptionPartition.preserved_foreign} foreign line(s) preserved (sealed) — ` +
2703
+ 'retrieve via `realm run export`';
2704
+ }
2705
+ else if (sealResult.reason === 'capped') {
2706
+ migratedSuccessWalCleanupWarning =
2707
+ 'preservation cap reached — foreign lines destroyed, not preserved';
2708
+ }
2709
+ else {
2710
+ performPlainDelete = false;
2711
+ }
2712
+ }
2713
+ catch (err) {
2714
+ performPlainDelete = false;
2715
+ migratedSuccessWalCleanupWarning = `Failed to seal trace buffer after step completion: ${err instanceof Error ? err.message : String(err)}`;
2716
+ }
2717
+ }
2718
+ if (performPlainDelete) {
2719
+ try {
2720
+ await options.traceBufferStore?.delete(options.runId, options.command);
2721
+ }
2722
+ catch (err) {
2723
+ migratedSuccessWalCleanupWarning = `Failed to clean up trace buffer after step completion: ${err instanceof Error ? err.message : String(err)}`;
2724
+ }
2725
+ }
2726
+ }
2727
+ const migratedDefaultedStepsDurabilityWarning = finalRun.defaulted_steps !== undefined &&
2728
+ finalRun.defaulted_steps.length > 0 &&
2729
+ !persistsField(store, 'defaulted_steps')
2730
+ ? 'run-level defaultedness marker (defaulted_steps) not durable on this store'
2731
+ : undefined;
2732
+ const migratedNextActions = finalRun.terminal_state
2733
+ ? []
2734
+ : buildNextActions(definition, finalRun);
2735
+ const migratedOrientation = finalRun.terminal_state
2736
+ ? `Run completed (phase: '${finalRun.run_phase}'). Call get_run_state with run_id '${options.runId}' to retrieve the full evidence record.`
2737
+ : migratedNextActions.length > 0
2738
+ ? `Step '${options.command}' completed. ${migratedNextActions.length} step(s) now available.`
2739
+ : `Step '${options.command}' completed. Waiting for other steps to complete.`;
2740
+ return {
2741
+ command: options.command,
2742
+ run_id: options.runId,
2743
+ run_version: finalRun.version,
2744
+ status: 'ok',
2745
+ data: output,
2746
+ evidence: allEvidence,
2747
+ warnings: mergeWarnings(traceWarnings, currentWarn, migratedSuccessWalCleanupWarning, migratedDefaultedStepsDurabilityWarning, ...reArmWarnings, ...drainWarnings),
2748
+ errors: [],
2749
+ context_hint: migratedOrientation,
2750
+ run_phase: finalRun.run_phase,
2751
+ next_actions: migratedNextActions,
2752
+ ...(carriageActive && adoptionPartition !== undefined
2753
+ ? {
2754
+ adopted_own: adoptionPartition.adopted_own,
2755
+ adopted_anonymous: adoptionPartition.adopted_anonymous,
2756
+ preserved_foreign: adoptionPartition.preserved_foreign,
2757
+ }
2758
+ : {}),
2759
+ ...(settledByDefault ? { settled_by_default: true } : {}),
2760
+ ...(finalRun.defaulted_steps?.length ? { defaulted_steps: finalRun.defaulted_steps } : {}),
2761
+ };
2762
+ }
2763
+ // --- Legacy path (dormancy fallback — byte-identical to pre-#279 behavior) ---
2040
2764
  const afterComplete = {
2041
2765
  ...pendingRun,
2042
2766
  in_progress_steps: pendingRun.in_progress_steps.filter((s) => s !== options.command),
@@ -2158,7 +2882,9 @@ export async function executeStep(store, definition, options) {
2158
2882
  status: 'ok',
2159
2883
  data: output,
2160
2884
  evidence: allEvidence,
2161
- warnings: mergeWarnings(traceWarnings, currentWarn, successWalCleanupWarning, defaultedStepsDurabilityWarning),
2885
+ // Issue #279 (increment 1, PR-B): + the ONE dormancy advisory (I16) — this IS the legacy
2886
+ // path (store.settleStep undeclared).
2887
+ warnings: mergeWarnings(traceWarnings, currentWarn, successWalCleanupWarning, defaultedStepsDurabilityWarning, DORMANCY_ADVISORY),
2162
2888
  errors: [],
2163
2889
  context_hint: orientation,
2164
2890
  run_phase: savedRun.run_phase,
@@ -2187,6 +2913,39 @@ export async function executeStep(store, definition, options) {
2187
2913
  * Submits a human response for a gate-waiting run.
2188
2914
  * Validates the gate_id and choice, then moves the step to completed_steps.
2189
2915
  */
2916
+ /** Finds the settled step name for a resolved gate matching `gateId` (issue #279, increment 2,
2917
+ * PR-D) — a LOCAL mirror of settlement.ts's own `findSettledGateEntry` (not imported: this file
2918
+ * touches settlement.ts ONLY for Deliverable 3's cancel-trail `gate_id` addition). Used to recover
2919
+ * a reliable step name off `result.run.settled` for the `already_settled`/`gate_choice_conflict`
2920
+ * envelopes, since the caller's own pre-read may already be stale by the time either of those
2921
+ * fires (the gate could have resolved before this call's own Step-1 read). */
2922
+ function findGateStepName(run, gateId) {
2923
+ for (const [step, entry] of Object.entries(run.settled ?? {})) {
2924
+ if (entry.outcome === 'gate' && entry.token === gateId)
2925
+ return step;
2926
+ }
2927
+ return undefined;
2928
+ }
2929
+ /** Builds the gate_response evidence snapshot (issue #279, increment 2, PR-D, Deliverable 1b) —
2930
+ * the SAME shape submitHumanResponse's legacy path has always built (mirrors execution-loop.ts's
2931
+ * own pre-PR-D Step 5), extracted so both the migrated and legacy paths construct it identically.
2932
+ * `respondedBy`, when supplied, populates the snapshot's own `responded_by` field (design record
2933
+ * D-5) in addition to the delta's own field. */
2934
+ function buildGateResponseSnapshot(gate, choice, respondedAt, respondedBy) {
2935
+ const gateEvidence = captureEvidence({
2936
+ stepId: gate.step_name,
2937
+ startedAt: new Date(gate.opened_at),
2938
+ completedAt: respondedAt,
2939
+ input: { choice },
2940
+ output: { ...gate.preview, choice },
2941
+ });
2942
+ return {
2943
+ ...gateEvidence,
2944
+ kind: 'gate_response',
2945
+ ...(gate.resolved_message !== undefined ? { gate_message: gate.resolved_message } : {}),
2946
+ ...(respondedBy !== undefined ? { responded_by: respondedBy } : {}),
2947
+ };
2948
+ }
2190
2949
  export async function submitHumanResponse(store, definition, options) {
2191
2950
  // 1. Load run.
2192
2951
  let run;
@@ -2204,6 +2963,227 @@ export async function submitHumanResponse(store, definition, options) {
2204
2963
  });
2205
2964
  return errorEnvelope('submit_gate', options.runId, 0, e);
2206
2965
  }
2966
+ // issue #279 (increment 2, PR-D, Deliverable 1b): the migrated path — the four legacy
2967
+ // verify-arms below (1a-4) become settle_gate's OWN predicate arms; this branch skips them
2968
+ // entirely and settles atomically against FRESH state via the store's own settleStep. Dormancy:
2969
+ // an undeclaring store falls through to the byte-identical legacy path below (I16/#169
2970
+ // fail-closed dormancy).
2971
+ if (store.settleStep !== undefined) {
2972
+ const respondedAt = new Date();
2973
+ // Evidence rule (design record §6 lens-3 S2): built from the PRE-READ `pending_gate` IFF it
2974
+ // matches options.gateId — else `[]` (the arm can never APPLY against a non-matching fresh
2975
+ // read either, so an empty evidence array is inert there; a matching pre-read is guaranteed
2976
+ // fresh enough to be correct on `applied: true`, since gate_id is a per-attempt-minted UUID
2977
+ // that can never "come back" once resolved/absent).
2978
+ const gateResponseEvidence = run.pending_gate !== undefined && run.pending_gate.gate_id === options.gateId
2979
+ ? [
2980
+ buildGateResponseSnapshot(run.pending_gate, options.choice, respondedAt, options.respondedBy),
2981
+ ]
2982
+ : [];
2983
+ const delta = {
2984
+ kind: 'settle_gate',
2985
+ gateId: options.gateId,
2986
+ choice: options.choice,
2987
+ ...(options.respondedBy !== undefined ? { respondedBy: options.respondedBy } : {}),
2988
+ evidence: gateResponseEvidence,
2989
+ };
2990
+ let result;
2991
+ try {
2992
+ result = await store.settleStep(options.runId, delta, definition);
2993
+ }
2994
+ catch (err) {
2995
+ // Thrown infra errors keep the SAME shape as the legacy path's own final-persist catch,
2996
+ // below (design record §6: "thrown infra errors ALSO keep the :3563-3581 shape").
2997
+ const e = err instanceof WorkflowError
2998
+ ? err
2999
+ : new WorkflowError('Failed to persist gate response', {
3000
+ code: 'ENGINE_STORE_FAILED',
3001
+ category: 'ENGINE',
3002
+ agentAction: 'stop',
3003
+ retryable: false,
3004
+ });
3005
+ return errorEnvelope(run.pending_gate?.step_name ?? 'submit_gate', options.runId, run.version, e, `Failed to persist gate response.`, run.run_phase);
3006
+ }
3007
+ if (!result.applied) {
3008
+ switch (result.reason) {
3009
+ case 'already_settled': {
3010
+ // Calm ok envelope stating the resolution already committed (same choice) — never
3011
+ // report_to_user. Drain: on already_settled ∧ pending ledger entries non-empty — hand-
3012
+ // rolled here (buildAlreadySettledEnvelope is ExecuteStepOptions-shaped, not reusable at
3013
+ // this call site) — recovers a crashed-drain RESOLVE on a duplicate submit (design record
3014
+ // §6 row 2, the buildAlreadySettledEnvelope drain-on-NOOP pattern reused verbatim).
3015
+ const stepName = findGateStepName(result.run, options.gateId) ?? 'submit_gate';
3016
+ let noopRun = result.run;
3017
+ let noopDrainWarnings = [];
3018
+ const hasPending = Object.values(noopRun.finalizer_ledger ?? {}).some((e) => e.status === 'pending');
3019
+ if (hasPending) {
3020
+ try {
3021
+ const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
3022
+ noopRun = drainOutcome.run;
3023
+ noopDrainWarnings = drainOutcome.warnings;
3024
+ }
3025
+ catch (err) {
3026
+ noopDrainWarnings = [
3027
+ `post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`,
3028
+ ];
3029
+ }
3030
+ }
3031
+ return {
3032
+ command: stepName,
3033
+ run_id: options.runId,
3034
+ run_version: noopRun.version,
3035
+ status: 'ok',
3036
+ data: {},
3037
+ evidence: [],
3038
+ warnings: mergeWarnings([], ...noopDrainWarnings),
3039
+ errors: [],
3040
+ context_hint: `Gate '${options.gateId}' was already resolved with choice '${options.choice}' — no action was taken.`,
3041
+ run_phase: noopRun.run_phase,
3042
+ next_actions: noopRun.terminal_state ? [] : buildNextActions(definition, noopRun),
3043
+ };
3044
+ }
3045
+ case 'gate_choice_conflict': {
3046
+ const stepName = findGateStepName(result.run, options.gateId);
3047
+ const err = new WorkflowError(`Gate '${options.gateId}' was already resolved with choice '${result.winningChoice}' ` +
3048
+ `— your choice '${options.choice}' was not recorded.`, {
3049
+ code: 'STATE_BLOCKED',
3050
+ category: 'STATE',
3051
+ agentAction: 'report_to_user',
3052
+ retryable: false,
3053
+ details: {
3054
+ runId: options.runId,
3055
+ gateId: options.gateId,
3056
+ winning_choice: result.winningChoice,
3057
+ },
3058
+ });
3059
+ return errorEnvelope(stepName ?? 'submit_gate', options.runId, result.run.version, err, err.message, result.run.run_phase);
3060
+ }
3061
+ case 'choice_not_eligible': {
3062
+ // VALIDATION_INPUT_SCHEMA envelope — parity with the legacy path's own step 4 (below).
3063
+ const stepName = result.run.pending_gate.step_name; // the arm only reaches this check
3064
+ // when fresh.pending_gate.gate_id === gateId, so this is reliably the live gate's step.
3065
+ const expected = (result.choices ?? []).join(', ');
3066
+ const err = new WorkflowError(`Choice '${options.choice}' is not valid. Expected one of: ${expected}`, {
3067
+ code: 'VALIDATION_INPUT_SCHEMA',
3068
+ category: 'VALIDATION',
3069
+ agentAction: 'report_to_user',
3070
+ retryable: false,
3071
+ });
3072
+ return errorEnvelope(stepName, options.runId, result.run.version, err, `Invalid choice '${options.choice}' for gate '${stepName}'.`, result.run.run_phase);
3073
+ }
3074
+ case 'gate_mismatch': {
3075
+ const err = new WorkflowError(`Gate '${options.gateId}' is not the open gate and matches no committed resolution.`, {
3076
+ code: 'STATE_BLOCKED',
3077
+ category: 'STATE',
3078
+ agentAction: 'report_to_user',
3079
+ retryable: false,
3080
+ details: { runId: options.runId, gateId: options.gateId },
3081
+ });
3082
+ return errorEnvelope('submit_gate', options.runId, result.run.version, err, err.message, result.run.run_phase);
3083
+ }
3084
+ case 'run_terminal': {
3085
+ // Composed cancelled-predicate (design record §5 D-4/§11 N10): any gate_cancelled_by_abort
3086
+ // skip detail ⇒ the cancelled variant ("your choice was NOT recorded" + cause) — bound by
3087
+ // gate_id equality once that field is populated (PR-D+), else by presence alone (pre-PR-D
3088
+ // records, N10). No match ⇒ the zombie/grandfathered variant + the resume-clears/purge
3089
+ // pointer.
3090
+ const cancelEntry = Object.entries(result.run.skip_details ?? {}).find(([, d]) => d.kind === 'gate_cancelled_by_abort');
3091
+ const cancelDetail = cancelEntry?.[1];
3092
+ const isCancelledMatch = cancelEntry !== undefined &&
3093
+ (cancelDetail.gate_id === undefined || cancelDetail.gate_id === options.gateId);
3094
+ if (isCancelledMatch) {
3095
+ const [cancelledStep] = cancelEntry;
3096
+ const abortedBy = result.run.aborted_at?.step_id;
3097
+ const err = new WorkflowError(`Gate '${options.gateId}' on '${cancelledStep}' was cancelled when ` +
3098
+ `'${abortedBy ?? 'another step'}' aborted the run — your choice was NOT recorded.`, {
3099
+ code: 'STATE_RUN_TERMINAL',
3100
+ category: 'STATE',
3101
+ agentAction: 'report_to_user',
3102
+ retryable: false,
3103
+ details: {
3104
+ runId: options.runId,
3105
+ run_phase: result.run.run_phase,
3106
+ gate_id: options.gateId,
3107
+ step_name: cancelledStep,
3108
+ ...(abortedBy !== undefined ? { aborted_by: abortedBy } : {}),
3109
+ },
3110
+ });
3111
+ return errorEnvelope(cancelledStep, options.runId, result.run.version, err, err.message, result.run.run_phase);
3112
+ }
3113
+ // Zombie/grandfathered variant — the #282 class: a terminal record may still carry a
3114
+ // stale pending_gate (never cleared), which is the best-effort step label here.
3115
+ const zombieStep = result.run.pending_gate?.step_name ?? 'submit_gate';
3116
+ const err = new WorkflowError(`Run '${options.runId}' is terminal; cannot submit a gate response — 'realm resume' ` +
3117
+ `clears a stale pending gate on a resumable run, or 'realm run purge' removes the ` +
3118
+ `record entirely.`, {
3119
+ code: 'STATE_RUN_TERMINAL',
3120
+ category: 'STATE',
3121
+ agentAction: 'report_to_user',
3122
+ retryable: false,
3123
+ details: { runId: options.runId, run_phase: result.run.run_phase },
3124
+ });
3125
+ return errorEnvelope(zombieStep, options.runId, result.run.version, err, err.message, result.run.run_phase);
3126
+ }
3127
+ default:
3128
+ // gate_open_wait/already_open/already_released/choice_not_eligible's siblings and every
3129
+ // other kind's own reason are unreachable here — settle_gate never returns them (design
3130
+ // record §7).
3131
+ throw new Error(`submitHumanResponse: unreachable settle_gate refusal reason '${result.reason}'`);
3132
+ }
3133
+ }
3134
+ // applied: true. Reliable step name: the pre-read's pending_gate.step_name (guaranteed
3135
+ // correct whenever `applied` is true — see the evidence-rule comment above).
3136
+ const resolvedGateStepName = run.pending_gate.step_name;
3137
+ // Drain: on transitioned OR (already_settled ∧ pending ledger entries non-empty) — hand-rolled
3138
+ // (buildAlreadySettledEnvelope is ExecuteStepOptions-shaped, not reusable here). `applied:
3139
+ // true` never reaches the already_settled leg, so only the `transitioned` disjunct applies at
3140
+ // THIS call site (design record §6 row 2).
3141
+ let finalRun = result.run;
3142
+ let drainWarnings = [];
3143
+ if (result.transitioned) {
3144
+ try {
3145
+ const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
3146
+ finalRun = drainOutcome.run;
3147
+ drainWarnings = drainOutcome.warnings;
3148
+ }
3149
+ catch (err) {
3150
+ drainWarnings = [
3151
+ `post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`,
3152
+ ];
3153
+ }
3154
+ }
3155
+ // Convergence hint (design record D-2 N8 narrowing, pedestal steal — must not drop): after a
3156
+ // committed RESOLVE, when a guard is thereby eligible, append one line per eligible guard.
3157
+ // findEligibleGuardSteps self-filters terminal runs (returns [] there), so this is inert on a
3158
+ // gate-completion terminal transition.
3159
+ const convergenceHints = findEligibleGuardSteps(definition, finalRun).map((name) => `guard '${name}' now eligible — converges at the next drive`);
3160
+ const defaultedStepsDurabilityWarning = finalRun.defaulted_steps !== undefined &&
3161
+ finalRun.defaulted_steps.length > 0 &&
3162
+ !persistsField(store, 'defaulted_steps')
3163
+ ? 'run-level defaultedness marker (defaulted_steps) not durable on this store'
3164
+ : undefined;
3165
+ const migratedNextActions = finalRun.terminal_state
3166
+ ? []
3167
+ : buildNextActions(definition, finalRun);
3168
+ const migratedOrientation = finalRun.terminal_state
3169
+ ? `Run completed (phase: '${finalRun.run_phase}'). Call get_run_state with run_id '${options.runId}' to retrieve the full evidence record.`
3170
+ : `Gate '${resolvedGateStepName}' resolved with choice '${options.choice}'. ${migratedNextActions.length} step(s) now available.`;
3171
+ return {
3172
+ command: resolvedGateStepName,
3173
+ run_id: options.runId,
3174
+ run_version: finalRun.version,
3175
+ status: 'ok',
3176
+ data: { ...run.pending_gate.preview, choice: options.choice },
3177
+ evidence: [],
3178
+ warnings: mergeWarnings(convergenceHints, ...drainWarnings, defaultedStepsDurabilityWarning),
3179
+ errors: [],
3180
+ context_hint: migratedOrientation,
3181
+ run_phase: finalRun.run_phase,
3182
+ next_actions: migratedNextActions,
3183
+ ...(finalRun.defaulted_steps?.length ? { defaulted_steps: finalRun.defaulted_steps } : {}),
3184
+ };
3185
+ }
3186
+ // --- Legacy path (dormancy fallback — byte-identical to pre-#279 behavior) ---
2207
3187
  // 1a. Defensive terminal guard (mirrors #91/#95): a late gate response must never re-drive a
2208
3188
  // run that has already reached a terminal phase.
2209
3189
  if (run.terminal_state) {
@@ -2334,7 +3314,9 @@ export async function submitHumanResponse(store, definition, options) {
2334
3314
  // envelope flag (do NOT add an evidence scan to recompute it). Its disclosure surface is the
2335
3315
  // run-level `defaulted_steps` marker below, plus whatever the gate-open envelope already
2336
3316
  // warned the human with.
2337
- warnings: mergeWarnings([], defaultedStepsDurabilityWarning),
3317
+ // issue #279 (increment 2, PR-D): + the ONE dormancy advisory (I16) — this IS the legacy path
3318
+ // (store.settleStep undeclared).
3319
+ warnings: mergeWarnings([], defaultedStepsDurabilityWarning, DORMANCY_ADVISORY),
2338
3320
  errors: [],
2339
3321
  context_hint: orientation,
2340
3322
  run_phase: savedRun.run_phase,
@@ -2462,13 +3444,6 @@ async function executeGuardStep(stepName, stepDef, definition, run) {
2462
3444
  },
2463
3445
  };
2464
3446
  }
2465
- /** Normalizes a finalizer's `on_outcome` to a set of triggers. */
2466
- function finalizerTriggers(stepDef) {
2467
- const raw = stepDef.on_outcome;
2468
- if (raw === undefined)
2469
- return new Set();
2470
- return new Set(Array.isArray(raw) ? raw : [raw]);
2471
- }
2472
3447
  /**
2473
3448
  * Drains the finalizers matching a run's terminal `outcome` and returns the FINALIZED
2474
3449
  * RunRecord — the workflow-level try/catch/finally at the seal. Modeled on
@@ -2496,22 +3471,15 @@ async function buildFinalizedSeal(definition, sealDraft, outcome, registry) {
2496
3471
  const hasFinalizers = Object.values(definition.steps).some((s) => s.execution === 'finalizer');
2497
3472
  if (!hasFinalizers)
2498
3473
  return sealDraft;
3474
+ // issue #279 (increment 1, PR-A extraction): the grouping/ordering logic itself now lives in
3475
+ // settlement.ts's selectFinalizers, shared with `mintFresh` — this call passes the SAME inputs
3476
+ // the inline loop used to compute over, so the returned name list is byte-identical to what
3477
+ // `[...groupA, ...groupB]` produced before the extraction.
2499
3478
  const settled = new Set([...sealDraft.completed_steps, ...sealDraft.failed_steps]);
2500
- const groupA = [];
2501
- const groupB = [];
2502
- for (const [name, step] of Object.entries(definition.steps)) {
2503
- if (step.execution !== 'finalizer')
2504
- continue;
2505
- if (settled.has(name))
2506
- continue; // at-most-once per run (resume / re-drive safety)
2507
- const triggers = finalizerTriggers(step);
2508
- if (triggers.has(outcome))
2509
- groupA.push([name, step]);
2510
- else if (triggers.has('always'))
2511
- groupB.push([name, step]);
2512
- }
3479
+ const selected = selectFinalizers(definition, settled, outcome);
2513
3480
  let record = sealDraft;
2514
- for (const [name, step] of [...groupA, ...groupB]) {
3481
+ for (const name of selected) {
3482
+ const step = definition.steps[name];
2515
3483
  const now = new Date();
2516
3484
  const evidenceByStep = buildEvidenceByStep(record);
2517
3485
  const timeoutMs = (step.timeout_seconds ?? DRAIN_CEILING_SECONDS) * 1000;
@@ -2585,6 +3553,185 @@ async function buildFinalizedSeal(definition, sealDraft, outcome, registry) {
2585
3553
  }
2586
3554
  return { ...record, terminal_state: true };
2587
3555
  }
3556
+ /** Every currently-`'pending'` finalizer in `run`'s ledger, ascending by rank — the order the
3557
+ * drain loop below consumes (design record §6). */
3558
+ function pendingByRank(run) {
3559
+ return Object.entries(run.finalizer_ledger ?? {})
3560
+ .filter(([, e]) => e.status === 'pending')
3561
+ .sort(([, a], [, b]) => a.rank - b.rank)
3562
+ .map(([name]) => name);
3563
+ }
3564
+ /** Bounded retry count for `mark_finalizer` on a thrown `STATE_RUN_BUSY` (design record §6: "bounded
3565
+ * retry on thrown STATE_RUN_BUSY"). Small and fixed — lock contention self-heals quickly; this is
3566
+ * not a backoff schedule, just enough attempts to ride out a transient contender. */
3567
+ const DRAIN_MARK_BUSY_RETRIES = 3;
3568
+ /**
3569
+ * Post-commit finalizer drain (design record §6, D2, issue #279 increment 1 PR-B). Drains the
3570
+ * LOWEST-ranked pending finalizer, re-reading the record returned by the LAST settle/lease/mark at
3571
+ * every step — NEVER a pass-start snapshot (the DRAIN_REREADS_LEDGER pin: a snapshot taken once at
3572
+ * the top of this function would go stale the instant the first lease/mark commits, silently
3573
+ * reintroducing exactly the kind of pre-commit assumption #279 exists to eliminate).
3574
+ *
3575
+ * Lives here (not in settlement.ts) so the module-private `withTimeout`/`callHandler` — already
3576
+ * proven by `buildFinalizedSeal` above — are reused without exporting them; `settlement.ts` stays
3577
+ * pure/no-I/O (design record §7 CS-purity).
3578
+ *
3579
+ * Registry pre-check: an absent handler leaves the entry pending and discloses why, rather than
3580
+ * burning it (leasing it, failing the call, and marking it 'failed' would destroy the "recoverable
3581
+ * on a capable runner" property a still-pending entry carries). Rank-monotonic: a pass HALTS at the
3582
+ * first entry it cannot lease/execute, so a lower-ranked absent-handler or held-lease entry
3583
+ * withholds every higher-ranked (later) finalizer behind it (R11) — this is deliberate: rank order
3584
+ * is a DELIVERY order guarantee, not just a preference.
3585
+ *
3586
+ * `not_eligible` at lease OR mark time is a contract violation (an unknown finalizer id — mintFresh
3587
+ * only ever mints real workflow finalizer names) and aborts the pass LOUD (throws), distinct from
3588
+ * `ledger_not_pending` at lease time, which ADVANCES (someone else already resolved it). A
3589
+ * non-BUSY infra throw from `mark_finalizer` also aborts loud, with the remaining-pending list in
3590
+ * the message. The executed-but-not-recorded warning (three reasons: `lease_lost`,
3591
+ * `ledger_not_pending`-AFTER-execution, `run_not_terminal`) halts the pass rather than continuing —
3592
+ * each of those three reasons refuses the mark WITHOUT mutating the ledger entry's `'pending'`
3593
+ * status, so continuing would re-select the SAME entry next iteration (an infinite loop); halting
3594
+ * is the only forward-progress-safe response until an operator or a later terminal edge resolves it.
3595
+ */
3596
+ export async function drainFinalizers(store, definition, registry, runId) {
3597
+ // .bind(store): a bare `store.settleStep` reference loses its `this` binding — the store's own
3598
+ // method body (e.g. JsonFileStore's `this.ensureDir()`/`this.filePath()`) would throw on
3599
+ // `this === undefined` once called through the detached reference below.
3600
+ const settleStep = store.settleStep?.bind(store);
3601
+ if (settleStep === undefined) {
3602
+ // Defensive — every caller only invokes this once it has already confirmed the store
3603
+ // declares settleStep. A fresh read is still the correct degenerate response.
3604
+ return { run: await store.get(runId), warnings: [] };
3605
+ }
3606
+ const warnings = [];
3607
+ let run = await store.get(runId);
3608
+ for (;;) {
3609
+ const pending = pendingByRank(run);
3610
+ if (pending.length === 0)
3611
+ break;
3612
+ const finalizerName = pending[0];
3613
+ const stepDef = definition.steps[finalizerName];
3614
+ const handlerName = stepDef?.handler;
3615
+ const handler = handlerName !== undefined ? registry?.getHandler(handlerName) : undefined;
3616
+ // Registry pre-check — never burn an absent handler; rank-monotonic HALT (R11).
3617
+ if (stepDef === undefined || handlerName === undefined || handler === undefined) {
3618
+ warnings.push(`finalizer '${finalizerName}' left pending — handler not available on this surface`);
3619
+ break;
3620
+ }
3621
+ const leaseToken = crypto.randomUUID();
3622
+ const leaseSeconds = stepDef.timeout_seconds ?? DRAIN_CEILING_SECONDS;
3623
+ const leaseResult = await settleStep(runId, { kind: 'lease_finalizer', finalizer: finalizerName, leaseToken, leaseSeconds }, definition);
3624
+ if (!leaseResult.applied) {
3625
+ if (leaseResult.reason === 'lease_held' || leaseResult.reason === 'rank_blocked') {
3626
+ run = leaseResult.run;
3627
+ break; // HALT the pass — a peer holds this lease, or a lower rank is still pending.
3628
+ }
3629
+ if (leaseResult.reason === 'ledger_not_pending') {
3630
+ run = leaseResult.run;
3631
+ continue; // ADVANCE — a peer already resolved this entry.
3632
+ }
3633
+ // not_eligible — unknown finalizer id; a contract violation (mintFresh never mints one).
3634
+ throw new Error(`drainFinalizers: lease refused '${leaseResult.reason}' for finalizer '${finalizerName}' ` +
3635
+ `on run '${runId}' — remaining pending: ${pendingByRank(leaseResult.run).join(', ')}`);
3636
+ }
3637
+ run = leaseResult.run;
3638
+ // callHandler under withTimeout, OUTSIDE any critical section (the store's CS ends the
3639
+ // instant the lease-apply write above returned).
3640
+ const startedAt = new Date();
3641
+ const evidenceByStep = buildEvidenceByStep(run);
3642
+ const callOptions = {
3643
+ runId,
3644
+ command: finalizerName,
3645
+ input: {},
3646
+ dispatcher: async () => ({}),
3647
+ ...(registry !== undefined ? { registry } : {}),
3648
+ };
3649
+ let markResult;
3650
+ let evidenceSnapshot;
3651
+ try {
3652
+ const callResult = await withTimeout((signal) => callHandler(stepDef, callOptions, run, evidenceByStep, signal), leaseSeconds * 1000, finalizerName);
3653
+ if (callResult.kind === 'abort') {
3654
+ markResult = 'failed';
3655
+ evidenceSnapshot = captureEvidence({
3656
+ stepId: finalizerName,
3657
+ startedAt,
3658
+ completedAt: new Date(),
3659
+ input: {},
3660
+ output: { aborted: true, abort_message: callResult.message },
3661
+ error: `Finalizer '${finalizerName}' returned abort: ${callResult.message}`,
3662
+ });
3663
+ }
3664
+ else {
3665
+ markResult = 'completed';
3666
+ evidenceSnapshot = captureEvidence({
3667
+ stepId: finalizerName,
3668
+ startedAt,
3669
+ completedAt: new Date(),
3670
+ input: {},
3671
+ output: callResult.output,
3672
+ ...(callResult.kind === 'warn' ? { warn: callResult.message } : {}),
3673
+ });
3674
+ }
3675
+ }
3676
+ catch (err) {
3677
+ markResult = 'failed';
3678
+ const message = err instanceof Error ? err.message : String(err);
3679
+ evidenceSnapshot = captureEvidence({
3680
+ stepId: finalizerName,
3681
+ startedAt,
3682
+ completedAt: new Date(),
3683
+ input: {},
3684
+ output: {},
3685
+ error: `Finalizer '${finalizerName}' failed: ${message}`,
3686
+ });
3687
+ }
3688
+ // mark_finalizer — same lease token; bounded retry on a THROWN STATE_RUN_BUSY only.
3689
+ let markOutcome;
3690
+ for (let attempt = 1; attempt <= DRAIN_MARK_BUSY_RETRIES; attempt++) {
3691
+ try {
3692
+ markOutcome = await settleStep(runId, {
3693
+ kind: 'mark_finalizer',
3694
+ finalizer: finalizerName,
3695
+ leaseToken,
3696
+ result: markResult,
3697
+ evidence: evidenceSnapshot,
3698
+ }, definition);
3699
+ break;
3700
+ }
3701
+ catch (err) {
3702
+ const isBusy = err instanceof WorkflowError && err.code === 'STATE_RUN_BUSY';
3703
+ if (isBusy && attempt < DRAIN_MARK_BUSY_RETRIES)
3704
+ continue;
3705
+ // Non-BUSY infra throw (or BUSY exhausted) ⇒ abort the pass loud, remaining-pending named.
3706
+ throw new Error(`drainFinalizers: mark_finalizer failed for '${finalizerName}' on run '${runId}' — ` +
3707
+ `remaining pending: ${pendingByRank(run).join(', ')}: ` +
3708
+ `${err instanceof Error ? err.message : String(err)}`, { cause: err });
3709
+ }
3710
+ }
3711
+ /* istanbul ignore next -- the for-loop above always either assigns markOutcome or throws */
3712
+ if (markOutcome === undefined) {
3713
+ throw new Error(`drainFinalizers: mark_finalizer produced no outcome for '${finalizerName}' on run '${runId}'`);
3714
+ }
3715
+ if (!markOutcome.applied) {
3716
+ if (markOutcome.reason === 'not_eligible') {
3717
+ throw new Error(`drainFinalizers: mark refused 'not_eligible' for finalizer '${finalizerName}' on run ` +
3718
+ `'${runId}' — contract violation (mintFresh never mints an unknown id)`);
3719
+ }
3720
+ // lease_lost / ledger_not_pending(-after-execution) / run_not_terminal: the handler DID
3721
+ // execute, but the outcome could not be durably recorded — the three-reason
3722
+ // executed-but-not-recorded warning (design record §6). Halt: none of these three mutate
3723
+ // the entry's 'pending' status, so continuing would re-select the SAME entry forever.
3724
+ run = markOutcome.run;
3725
+ warnings.push(`finalizer '${finalizerName}' executed but its outcome may not have been recorded ` +
3726
+ `(${markOutcome.reason}) — it may re-execute at the next terminal edge`);
3727
+ break;
3728
+ }
3729
+ run = markOutcome.run;
3730
+ // result:'failed' is settled too (a recorded terminal outcome for this finalizer) — the loop
3731
+ // continues to the next rank regardless of markResult.
3732
+ }
3733
+ return { run, warnings };
3734
+ }
2588
3735
  async function executeChainInternal(store, definition, options, depth, chainedSteps,
2589
3736
  /**
2590
3737
  * issue #197 PR-2 (chain-replacement disposition, accepted): a settled DEPTH-0 step (typically
@@ -2642,15 +3789,242 @@ depth0Warnings) {
2642
3789
  // Execute any eligible guard steps inline before looking for the next auto step.
2643
3790
  // Guard steps are synchronous engine decisions — not returned to the agent.
2644
3791
  // Loop to handle cascading guards (guard A passes → guard B becomes eligible).
3792
+ // issue #279 (increment 2, PR-D, Deliverable 1c): non-abort settled_outcome_divergence warnings
3793
+ // ADVANCE the chain but must still surface somewhere — carried here and merged into whichever
3794
+ // envelope eventually returns (the guardsRan rebuild below, or a migrated terminal return).
3795
+ const guardWarnings = [];
2645
3796
  let guardEligible = findEligibleGuardSteps(definition, run);
2646
3797
  while (guardEligible.length > 0) {
2647
3798
  const guardName = guardEligible[0];
2648
3799
  const guardStepDef = definition.steps[guardName];
2649
- // Execute inline (pure in-memory; returns updated RunRecord).
3800
+ // Execute inline (pure in-memory; returns updated RunRecord). executeGuardStep stays PURE and
3801
+ // UNTOUCHED (design record §1) — both the migrated and legacy paths below call it identically.
2650
3802
  const guardResult = await executeGuardStep(guardName, guardStepDef, definition, run);
2651
3803
  // Capture the guard's OWN evidence (its last entry) BEFORE the finalizer drain appends
2652
3804
  // finalizer evidence — the terminal return below surfaces only the guard's evidence.
2653
3805
  const guardOwnEvidence = guardResult.evidence.slice(-1);
3806
+ // issue #279 (increment 2, PR-D, Deliverable 1c): the migrated path — settles this guard's
3807
+ // evaluated outcome atomically against FRESH state via the store's own settleStep. Dormancy:
3808
+ // an undeclaring store falls through to the byte-identical legacy path below (I16/#169
3809
+ // fail-closed dormancy).
3810
+ if (store.settleStep !== undefined) {
3811
+ // Extraction rule (design record §2, normative): reverse-classify guardResult's SEALED
3812
+ // output by MEMBERSHIP — NEVER the terminal_state ternary below (wrong for a non-terminal
3813
+ // pass, which never sets terminal_state at all).
3814
+ const guardSettleOutcome = guardResult.aborted_at !== undefined
3815
+ ? 'abort'
3816
+ : guardResult.failed_steps.includes(guardName)
3817
+ ? 'resolution_error'
3818
+ : 'pass'; // the only remaining membership — completed_steps.includes(guardName)
3819
+ let resolutionError;
3820
+ if (guardSettleOutcome === 'resolution_error') {
3821
+ // Rails-compliant re-derivation (normative): normalize abort_unless to string[] (the
3822
+ // executeGuardStep :3632-3635 shape) and re-run evaluateGuardConditions against the SAME
3823
+ // pre-seal `run` passed to executeGuardStep — pure + deterministic, so this reproduces the
3824
+ // discarded internal result byte-for-byte.
3825
+ const conditions = Array.isArray(guardStepDef.abort_unless)
3826
+ ? guardStepDef.abort_unless
3827
+ : [guardStepDef.abort_unless];
3828
+ const reEvaluated = evaluateGuardConditions(conditions, buildEvidenceByStep(run));
3829
+ if (reEvaluated.kind === 'resolution_error') {
3830
+ resolutionError = {
3831
+ condition: reEvaluated.condition,
3832
+ unresolvable_path: reEvaluated.unresolvable_path,
3833
+ };
3834
+ }
3835
+ }
3836
+ const delta = {
3837
+ kind: 'settle_guard',
3838
+ step: guardName,
3839
+ outcome: guardSettleOutcome,
3840
+ evidence: guardOwnEvidence[0],
3841
+ ...(resolutionError !== undefined ? { resolutionError } : {}),
3842
+ ...(guardSettleOutcome === 'abort'
3843
+ ? {
3844
+ abort: {
3845
+ conditions: guardResult.aborted_at.conditions ?? [],
3846
+ ...(guardResult.aborted_at.abort_message !== undefined
3847
+ ? { abort_message: guardResult.aborted_at.abort_message }
3848
+ : {}),
3849
+ },
3850
+ }
3851
+ : {}),
3852
+ // evaluatedAtVersion (design record §2, lane-B steal 2): the chain's OWN evaluation
3853
+ // snapshot — this iteration's pre-settle `run.version`.
3854
+ evaluatedAtVersion: run.version,
3855
+ };
3856
+ let guardSettleResult;
3857
+ try {
3858
+ guardSettleResult = await store.settleStep(options.runId, delta, definition);
3859
+ }
3860
+ catch (storeErr) {
3861
+ // Thrown infra errors — the same persist-failure envelope shape the legacy path's own
3862
+ // store.update catch (below) has always returned.
3863
+ const msg = storeErr instanceof Error ? storeErr.message : String(storeErr);
3864
+ return {
3865
+ command: options.command,
3866
+ run_id: options.runId,
3867
+ run_version: run.version,
3868
+ status: 'error',
3869
+ data: {},
3870
+ evidence: [],
3871
+ warnings: [],
3872
+ errors: [`Failed to persist guard step '${guardName}': ${msg}`],
3873
+ agent_action: 'stop',
3874
+ context_hint: `Guard step '${guardName}' could not be persisted. Run state may be inconsistent.`,
3875
+ run_phase: run.run_phase,
3876
+ next_actions: [],
3877
+ };
3878
+ }
3879
+ if (!guardSettleResult.applied) {
3880
+ // Chain-consumption table (design record §6, adjudicated):
3881
+ if (guardSettleResult.reason === 'already_settled' ||
3882
+ guardSettleResult.reason === 'gate_open_wait' ||
3883
+ (guardSettleResult.reason === 'settled_outcome_divergence' &&
3884
+ guardSettleOutcome !== 'abort')) {
3885
+ // already_settled / gate_open_wait ⇒ ADVANCE, threading result.run (findEligibleGuardSteps
3886
+ // self-filters both a now-settled guard and an open gate, so the loop naturally converges).
3887
+ // settled_outcome_divergence on a NON-abort attempt ⇒ ADVANCE + a warning line.
3888
+ if (guardSettleResult.reason === 'settled_outcome_divergence') {
3889
+ guardWarnings.push(`guard '${guardName}' outcome diverged from a concurrent settle` +
3890
+ (guardSettleResult.persisted !== undefined
3891
+ ? ` (persisted: '${guardSettleResult.persisted}')`
3892
+ : '') +
3893
+ ' — chain advanced on the persisted outcome.');
3894
+ }
3895
+ if (guardSettleResult.reason !== 'gate_open_wait') {
3896
+ // "quiet" end-of-pass for gate_open_wait only — nothing was decided, so nothing is
3897
+ // recorded; already_settled/divergence DID decide something (elsewhere), so it is.
3898
+ chainedSteps.push({ step: guardName, run_phase: guardSettleResult.run.run_phase });
3899
+ }
3900
+ run = guardSettleResult.run;
3901
+ // Drain: on already_settled ∧ pending ledger entries non-empty (design record §6, "same
3902
+ // clause" as the transitioned leg below) — recovers a crashed-drain RESOLVE that a
3903
+ // sibling's own settle committed but never drained.
3904
+ if (guardSettleResult.reason === 'already_settled') {
3905
+ const hasPending = Object.values(run.finalizer_ledger ?? {}).some((e) => e.status === 'pending');
3906
+ if (hasPending) {
3907
+ try {
3908
+ const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
3909
+ run = drainOutcome.run;
3910
+ if (drainOutcome.warnings.length > 0)
3911
+ guardWarnings.push(...drainOutcome.warnings);
3912
+ }
3913
+ catch (err) {
3914
+ guardWarnings.push(`post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`);
3915
+ }
3916
+ }
3917
+ }
3918
+ guardEligible = findEligibleGuardSteps(definition, run);
3919
+ continue;
3920
+ }
3921
+ if (guardSettleResult.reason === 'settled_outcome_divergence') {
3922
+ // ABORT leg only ⇒ report_to_user + chain-RETURN (design record §6/§7) — this attempt's
3923
+ // abort was never recorded.
3924
+ const err = new WorkflowError(`Guard step '${guardName}' was already settled` +
3925
+ (guardSettleResult.persisted !== undefined
3926
+ ? ` (persisted: '${guardSettleResult.persisted}')`
3927
+ : '') +
3928
+ ` by a different attempt — your abort was NOT recorded.`, {
3929
+ code: 'STATE_STEP_ALREADY_SETTLED',
3930
+ category: 'STATE',
3931
+ agentAction: 'report_to_user',
3932
+ retryable: false,
3933
+ details: {
3934
+ runId: options.runId,
3935
+ step: guardName,
3936
+ reason: guardSettleResult.reason,
3937
+ ...(guardSettleResult.persisted !== undefined
3938
+ ? { persisted: guardSettleResult.persisted }
3939
+ : {}),
3940
+ },
3941
+ });
3942
+ return {
3943
+ command: options.command,
3944
+ run_id: options.runId,
3945
+ run_version: guardSettleResult.run.version,
3946
+ status: 'error',
3947
+ data: {},
3948
+ evidence: [],
3949
+ warnings: [],
3950
+ errors: [err.message],
3951
+ error_code: err.code,
3952
+ ...(Object.keys(err.details).length > 0 ? { error_details: err.details } : {}),
3953
+ agent_action: 'report_to_user',
3954
+ context_hint: err.message,
3955
+ run_phase: guardSettleResult.run.run_phase,
3956
+ next_actions: [],
3957
+ };
3958
+ }
3959
+ if (guardSettleResult.reason === 'run_terminal') {
3960
+ // Terminal by OTHER (a sibling settle raced this guard's own evaluation) — INLINE
3961
+ // construction, parity with the entry-terminal envelope (executeChain's own early
3962
+ // return).
3963
+ return {
3964
+ command: options.command,
3965
+ run_id: options.runId,
3966
+ run_version: guardSettleResult.run.version,
3967
+ status: 'ok',
3968
+ data: {},
3969
+ evidence: [],
3970
+ warnings: [],
3971
+ errors: [],
3972
+ agent_action: 'stop',
3973
+ context_hint: `Run '${options.runId}' is already terminal (${guardSettleResult.run.run_phase}); guard '${guardName}' was not evaluated.`,
3974
+ run_phase: guardSettleResult.run.run_phase,
3975
+ next_actions: [],
3976
+ };
3977
+ }
3978
+ // gate_mismatch/choice_not_eligible/already_open/already_released and every other kind's
3979
+ // own reason are unreachable here — settle_guard never returns them (design record §7).
3980
+ throw new Error(`executeChainInternal: unreachable settle_guard refusal reason '${guardSettleResult.reason}'`);
3981
+ }
3982
+ // applied: true.
3983
+ chainedSteps.push({ step: guardName, run_phase: guardSettleResult.run.run_phase });
3984
+ if (guardSettleResult.transitioned) {
3985
+ // Drain IMMEDIATELY after a transitioned settle result, BEFORE building the in-loop
3986
+ // terminal envelope (design record §6/R5 — a post-loop drain would be dead code on this
3987
+ // leg: this function RETURNS before ever reaching a post-loop point).
3988
+ let finalGuardRun = guardSettleResult.run;
3989
+ let guardDrainWarnings;
3990
+ try {
3991
+ const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
3992
+ finalGuardRun = drainOutcome.run;
3993
+ guardDrainWarnings = drainOutcome.warnings;
3994
+ }
3995
+ catch (err) {
3996
+ guardDrainWarnings = [
3997
+ `post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`,
3998
+ ];
3999
+ }
4000
+ const migratedContextHint = guardSettleOutcome === 'abort'
4001
+ ? `Guard step '${guardName}' aborted the run.`
4002
+ : guardSettleOutcome === 'resolution_error'
4003
+ ? `Guard step '${guardName}' failed with a resolution error. Run is terminated.`
4004
+ : `Guard step '${guardName}' passed and completed the run.`;
4005
+ return {
4006
+ command: options.command,
4007
+ run_id: options.runId,
4008
+ run_version: finalGuardRun.version,
4009
+ status: 'ok',
4010
+ data: {},
4011
+ evidence: guardOwnEvidence,
4012
+ warnings: mergeWarnings(guardWarnings, ...guardDrainWarnings),
4013
+ errors: [],
4014
+ context_hint: migratedContextHint,
4015
+ run_phase: finalGuardRun.run_phase,
4016
+ next_actions: [],
4017
+ ...(finalGuardRun.defaulted_steps?.length
4018
+ ? { defaulted_steps: finalGuardRun.defaulted_steps }
4019
+ : {}),
4020
+ };
4021
+ }
4022
+ // Non-terminal pass — continue the chain.
4023
+ run = guardSettleResult.run;
4024
+ guardEligible = findEligibleGuardSteps(definition, run);
4025
+ continue;
4026
+ }
4027
+ // --- Legacy path (dormancy fallback — byte-identical to pre-#279 behavior) ---
2654
4028
  // Blocking fix #1: classify the terminal outcome by the SEALED record, not aborted_at
2655
4029
  // alone. executeGuardStep sets terminal_state in THREE cases — abort (aborted_at set),
2656
4030
  // resolution-error (failed, no aborted_at), and a PASS that completes the run
@@ -2719,7 +4093,9 @@ depth0Warnings) {
2719
4093
  data: {},
2720
4094
  // The guard's own evidence entry, captured before the finalizer drain appended any.
2721
4095
  evidence: guardOwnEvidence,
2722
- warnings: mergeWarnings([], guardDefaultedStepsDurabilityWarning),
4096
+ // issue #279 (increment 2, PR-D): + the ONE dormancy advisory (I16) — this IS the legacy
4097
+ // path (store.settleStep undeclared).
4098
+ warnings: mergeWarnings([], guardDefaultedStepsDurabilityWarning, DORMANCY_ADVISORY),
2723
4099
  errors: [],
2724
4100
  context_hint: contextHint,
2725
4101
  run_phase: persistedGuardRun.run_phase,
@@ -2743,6 +4119,12 @@ depth0Warnings) {
2743
4119
  ...result,
2744
4120
  run_version: run.version,
2745
4121
  next_actions: freshNextActions,
4122
+ // issue #279 (increment 2, PR-D): non-abort settled_outcome_divergence warnings accumulated
4123
+ // during the guard loop above (the ADVANCE leg) must reach whichever envelope returns —
4124
+ // this rebuild is the first point after the loop `result` is touched again.
4125
+ ...(guardWarnings.length > 0
4126
+ ? { warnings: mergeWarnings(result.warnings, ...guardWarnings) }
4127
+ : {}),
2746
4128
  };
2747
4129
  }
2748
4130
  if (run.terminal_state || run.pending_gate !== undefined) {
@@ -2799,7 +4181,13 @@ export async function executeChain(store, definition, options) {
2799
4181
  throw err;
2800
4182
  }
2801
4183
  }
2802
- if (entryRun !== undefined && isTerminalPhase(entryRun.run_phase)) {
4184
+ // issue #279 (increment 2, PR-C D-3 leg v): keyed on terminal_state, never the persisted
4185
+ // run_phase — a grandfathered terminal-with-stale-gate record (the #282 class) must still be
4186
+ // recognized as terminal here.
4187
+ if (entryRun !== undefined && entryRun.terminal_state === true) {
4188
+ // Derive-for-message (D-3 leg v): render the TRUE (derived) phase, never the possibly-stale
4189
+ // persisted one.
4190
+ const derivedPhase = deriveRunPhase(entryRun);
2803
4191
  return {
2804
4192
  command: options.command,
2805
4193
  run_id: options.runId,
@@ -2810,8 +4198,8 @@ export async function executeChain(store, definition, options) {
2810
4198
  warnings: [],
2811
4199
  errors: [],
2812
4200
  agent_action: 'stop',
2813
- context_hint: `Run '${options.runId}' is already terminal (${entryRun.run_phase}); no steps executed.`,
2814
- run_phase: entryRun.run_phase,
4201
+ context_hint: `Run '${options.runId}' is already terminal (${derivedPhase}); no steps executed.`,
4202
+ run_phase: derivedPhase,
2815
4203
  next_actions: [],
2816
4204
  };
2817
4205
  }