@sensigo/realm 0.32.0 → 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 (38) 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 +11 -2
  5. package/dist/engine/apply-resume.d.ts.map +1 -1
  6. package/dist/engine/apply-resume.js +15 -4
  7. package/dist/engine/apply-resume.js.map +1 -1
  8. package/dist/engine/eligibility.d.ts +20 -2
  9. package/dist/engine/eligibility.d.ts.map +1 -1
  10. package/dist/engine/eligibility.js +46 -24
  11. package/dist/engine/eligibility.js.map +1 -1
  12. package/dist/engine/execution-loop.d.ts +7 -4
  13. package/dist/engine/execution-loop.d.ts.map +1 -1
  14. package/dist/engine/execution-loop.js +810 -87
  15. package/dist/engine/execution-loop.js.map +1 -1
  16. package/dist/engine/run-health.d.ts +1 -1
  17. package/dist/engine/run-health.d.ts.map +1 -1
  18. package/dist/engine/run-health.js +62 -1
  19. package/dist/engine/run-health.js.map +1 -1
  20. package/dist/engine/settlement.d.ts +11 -0
  21. package/dist/engine/settlement.d.ts.map +1 -1
  22. package/dist/engine/settlement.js +441 -30
  23. package/dist/engine/settlement.js.map +1 -1
  24. package/dist/index.d.ts +2 -2
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +2 -2
  27. package/dist/index.js.map +1 -1
  28. package/dist/store/json-file-store.d.ts +6 -4
  29. package/dist/store/json-file-store.d.ts.map +1 -1
  30. package/dist/store/json-file-store.js +26 -9
  31. package/dist/store/json-file-store.js.map +1 -1
  32. package/dist/store/store-interface.d.ts +11 -8
  33. package/dist/store/store-interface.d.ts.map +1 -1
  34. package/dist/types/run-record.d.ts +26 -3
  35. package/dist/types/run-record.d.ts.map +1 -1
  36. package/dist/types/settlement.d.ts +135 -11
  37. package/dist/types/settlement.d.ts.map +1 -1
  38. package/package.json +1 -1
@@ -8,7 +8,7 @@ import { selectFinalizers } from './settlement.js';
8
8
  import { captureEvidence } from '../evidence/snapshot.js';
9
9
  import { validateInputSchema, validateOutputSchema, validateTraceSchema, } from '../validation/input-schema.js';
10
10
  import { normalizeTrace } from './trace-normalizer.js';
11
- import { TERMINAL_PHASES, isTerminalPhase, DRAIN_CEILING_SECONDS } from './lifecycle.js';
11
+ import { TERMINAL_PHASES, DRAIN_CEILING_SECONDS } from './lifecycle.js';
12
12
  import { omitClaim, shouldEnforceTimeout, DEFAULT_EXECUTION_TIMEOUT_SECONDS, resolveCapMs, sleepWouldExceedCap, } from './claim-liveness.js';
13
13
  import { computeBackoff } from './backoff.js';
14
14
  import { checkPreconditions, evaluateAllPreconditions, evaluateGuardConditions, } from './precondition.js';
@@ -401,6 +401,25 @@ function stampDefaultedSteps(sealDraft) {
401
401
  return sealDraft;
402
402
  return { ...sealDraft, defaulted_steps: steps };
403
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
+ }
404
423
  /**
405
424
  * Compensating un-claim (issue #207 PR-2, D3 §5): built from `pendingRun` — the record OUR OWN
406
425
  * `claimStep` call returned, never a fresh get — removing the step from `in_progress_steps` AND
@@ -414,17 +433,7 @@ function stampDefaultedSteps(sealDraft) {
414
433
  * naturally idempotent) is simply a no-op mutation, not a special case.
415
434
  */
416
435
  function buildCompensatingUnclaim(pendingRun, stepName, now) {
417
- const auditEvidence = captureEvidence({
418
- stepId: stepName,
419
- startedAt: now,
420
- completedAt: now,
421
- input: {},
422
- output: {
423
- compensating_unclaim: true,
424
- reason: 'adoption-read failure after claim',
425
- unclaimed_at: now.toISOString(),
426
- },
427
- });
436
+ const auditEvidence = buildCompensatingUnclaimEvidence(stepName, now);
428
437
  return {
429
438
  ...pendingRun,
430
439
  in_progress_steps: pendingRun.in_progress_steps.filter((s) => s !== stepName),
@@ -624,23 +633,32 @@ function computeReArmWarnings(before, after) {
624
633
  return warnings;
625
634
  }
626
635
  /**
627
- * Builds the ResponseEnvelope for a `settle_step` REFUSAL (design record §7's result/code table) —
628
- * shared by all three migrated seal sites (issue #279, increment 1, PR-B). `allEvidence` is
629
- * attached ONLY for `claim_lost`: the dispatch DID run and produce evidence; it just was not
630
- * recorded, so the caller should still see what happened. The four other reasons `settle_step` can
631
- * actually return are enumerated explicitly; the remaining nine `SettlementRefusalReason` members
632
- * are lease/mark-only and structurally unreachable here (a `default` throws rather than silently
633
- * mis-rendering one).
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).
634
648
  */
635
- function buildSettlementRefusalEnvelope(options, definition, result, allEvidence, traceWarnings) {
649
+ function buildSettlementRefusalEnvelope(options, definition, result, allEvidence, traceWarnings, kind = 'settle_step') {
636
650
  const extraWarnings = traceWarnings.length > 0 ? traceWarnings : undefined;
637
651
  switch (result.reason) {
638
652
  case 'already_settled_by_other':
639
653
  case 'settled_outcome_divergence': {
640
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';
641
659
  const err = new WorkflowError(`Step '${options.command}' was already settled` +
642
660
  (persisted !== undefined ? ` with outcome '${persisted}'` : '') +
643
- ` by a different attempt.`, {
661
+ ` ${settledByText}.`, {
644
662
  code: 'STATE_STEP_ALREADY_SETTLED',
645
663
  category: 'STATE',
646
664
  agentAction: 'resolve_precondition',
@@ -679,8 +697,15 @@ function buildSettlementRefusalEnvelope(options, definition, result, allEvidence
679
697
  return makeErrorEnvelope(options, result.run, err, definition, extraWarnings);
680
698
  }
681
699
  case 'gate_mismatch': {
682
- const err = new WorkflowError(`Step '${options.command}' is the currently open gate; resolve it via ` +
683
- `submit_human_response instead of settling it directly.`, {
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, {
684
709
  code: 'STATE_BLOCKED',
685
710
  category: 'STATE',
686
711
  agentAction: 'resolve_precondition',
@@ -691,9 +716,12 @@ function buildSettlementRefusalEnvelope(options, definition, result, allEvidence
691
716
  }
692
717
  default:
693
718
  // run_not_terminal / ledger_not_pending / lease_held / lease_lost / rank_blocked /
694
- // not_eligible / already_leased / already_marked are lease_finalizer/mark_finalizer-only
695
- // settle_step never returns them (design record §7).
696
- throw new Error(`buildSettlementRefusalEnvelope: unreachable settle_step refusal reason '${result.reason}'`);
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}'`);
697
725
  }
698
726
  }
699
727
  /**
@@ -1175,13 +1203,43 @@ export async function executeStep(store, definition, options) {
1175
1203
  : [];
1176
1204
  }
1177
1205
  catch (err) {
1178
- try {
1179
- 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
+ }
1180
1227
  }
1181
- catch {
1182
- // CAS mismatch (someone else already resolved the claim) or any other failure to even
1183
- // 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;
1184
1241
  }
1242
+ const unclaimEnvelopeWarnings = mergeWarnings(traceWarnings, unclaimDormancyWarning);
1185
1243
  return makeErrorEnvelope(options, pendingRun, new WorkflowError('Failed to read trace buffer after claiming step', {
1186
1244
  code: 'ENGINE_STORE_FAILED',
1187
1245
  category: 'ENGINE',
@@ -1191,7 +1249,7 @@ export async function executeStep(store, definition, options) {
1191
1249
  step_id: options.command,
1192
1250
  cause: err instanceof Error ? err.message : String(err),
1193
1251
  },
1194
- }), definition, traceWarnings.length > 0 ? traceWarnings : undefined);
1252
+ }), definition, unclaimEnvelopeWarnings.length > 0 ? unclaimEnvelopeWarnings : undefined);
1195
1253
  }
1196
1254
  // issue #197 PR-2 (design §2): the SAME predicate as the pre-claim pass, now over the
1197
1255
  // complete post-claim set. Lifted to `adoptionPartition` (outer scope) — read again at the
@@ -1927,13 +1985,61 @@ export async function executeStep(store, definition, options) {
1927
1985
  // Non-terminal: recompute the phase so the store-fail fallback below is correct too
1928
1986
  // (on the happy path store.update recomputes it identically via deriveRunPhase).
1929
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).
1930
1996
  let persistedBlockedRun;
1931
1997
  let blockStoreWarning;
1932
- try {
1933
- 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
+ }
1934
2031
  }
1935
- catch (storeErr) {
1936
- 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;
1937
2043
  }
1938
2044
  // issue #207 PR-2 (D3 §5): NO WAL delete belongs on this capability-block settle path — the
1939
2045
  // prior try/catch here was removed, not just gated. Contract-consistency hygiene, not a
@@ -1968,7 +2074,7 @@ export async function executeStep(store, definition, options) {
1968
2074
  status: 'error',
1969
2075
  data: {},
1970
2076
  evidence: allEvidence,
1971
- warnings: mergeWarnings(traceWarnings, blockStoreWarning),
2077
+ warnings: mergeWarnings(traceWarnings, blockStoreWarning, dormancyWarning),
1972
2078
  errors: [dispatchError.message],
1973
2079
  agent_action: blockedAction,
1974
2080
  error_code: recoverableCode,
@@ -2322,37 +2428,21 @@ export async function executeStep(store, definition, options) {
2322
2428
  resolvedGateMessage = raw;
2323
2429
  }
2324
2430
  const gateConfig = stepDef.gate;
2325
- let gateRun;
2326
- try {
2327
- gateRun = await store.update({
2328
- ...pendingRun,
2329
- // Step stays in in_progress_steps while gate is open — moved to completed on submit.
2330
- evidence: [...pendingRun.evidence, ...allEvidence],
2331
- pending_gate: {
2332
- gate_id,
2333
- step_name,
2334
- preview: output,
2335
- choices,
2336
- opened_at: new Date().toISOString(),
2337
- ...(gateConfig?.owner !== undefined ? { owner: gateConfig.owner } : {}),
2338
- ...(resolvedGateMessage !== undefined ? { resolved_message: resolvedGateMessage } : {}),
2339
- ...(gateConfig?.resolution_messages !== undefined
2340
- ? { resolution_messages: gateConfig.resolution_messages }
2341
- : {}),
2342
- },
2343
- });
2344
- }
2345
- catch (err) {
2346
- if (err instanceof WorkflowError) {
2347
- return makeErrorEnvelope(options, pendingRun, err, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
2348
- }
2349
- return makeErrorEnvelope(options, pendingRun, new WorkflowError('Failed to open gate', {
2350
- code: 'ENGINE_STORE_FAILED',
2351
- category: 'ENGINE',
2352
- agentAction: 'stop',
2353
- retryable: false,
2354
- }), definition, traceWarnings.length > 0 ? traceWarnings : undefined);
2355
- }
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
+ };
2356
2446
  // gate.display fallback chain: gate.message resolved → step.prompt resolved → absent
2357
2447
  const resolvedGateDisplay = resolvedGateMessage !== undefined
2358
2448
  ? resolvedGateMessage
@@ -2370,19 +2460,155 @@ export async function executeStep(store, definition, options) {
2370
2460
  ...wfCtxSpreadEarly,
2371
2461
  })
2372
2462
  : undefined;
2373
- const gateNextAction = {
2374
- instruction: {
2375
- tool: 'submit_human_response',
2376
- params: { run_id: options.runId, gate_id },
2377
- call_with: {
2378
- 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: {
2379
2579
  gate_id,
2380
- 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 },
2381
2588
  },
2382
- },
2383
- 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.`,
2384
- orientation: `Run is paused at gate '${gate_id}'. Available choices: ${choices.join(', ')}.`,
2385
- };
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
+ }
2386
2612
  return {
2387
2613
  command: options.command,
2388
2614
  run_id: options.runId,
@@ -2390,11 +2616,11 @@ export async function executeStep(store, definition, options) {
2390
2616
  status: 'confirm_required',
2391
2617
  data: output,
2392
2618
  evidence: allEvidence,
2393
- warnings: [...traceWarnings],
2619
+ warnings: mergeWarnings(traceWarnings, DORMANCY_ADVISORY),
2394
2620
  errors: [],
2395
2621
  context_hint: `Run is paused at gate '${gate_id}'. Available choices: ${choices.join(', ')}.`,
2396
2622
  run_phase: gateRun.run_phase,
2397
- next_actions: [gateNextAction],
2623
+ next_actions: [buildGateNextAction(gate_id, choices, step_name)],
2398
2624
  gate: {
2399
2625
  gate_id,
2400
2626
  step_name,
@@ -2687,6 +2913,39 @@ export async function executeStep(store, definition, options) {
2687
2913
  * Submits a human response for a gate-waiting run.
2688
2914
  * Validates the gate_id and choice, then moves the step to completed_steps.
2689
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
+ }
2690
2949
  export async function submitHumanResponse(store, definition, options) {
2691
2950
  // 1. Load run.
2692
2951
  let run;
@@ -2704,6 +2963,227 @@ export async function submitHumanResponse(store, definition, options) {
2704
2963
  });
2705
2964
  return errorEnvelope('submit_gate', options.runId, 0, e);
2706
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) ---
2707
3187
  // 1a. Defensive terminal guard (mirrors #91/#95): a late gate response must never re-drive a
2708
3188
  // run that has already reached a terminal phase.
2709
3189
  if (run.terminal_state) {
@@ -2834,7 +3314,9 @@ export async function submitHumanResponse(store, definition, options) {
2834
3314
  // envelope flag (do NOT add an evidence scan to recompute it). Its disclosure surface is the
2835
3315
  // run-level `defaulted_steps` marker below, plus whatever the gate-open envelope already
2836
3316
  // warned the human with.
2837
- 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),
2838
3320
  errors: [],
2839
3321
  context_hint: orientation,
2840
3322
  run_phase: savedRun.run_phase,
@@ -3307,15 +3789,242 @@ depth0Warnings) {
3307
3789
  // Execute any eligible guard steps inline before looking for the next auto step.
3308
3790
  // Guard steps are synchronous engine decisions — not returned to the agent.
3309
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 = [];
3310
3796
  let guardEligible = findEligibleGuardSteps(definition, run);
3311
3797
  while (guardEligible.length > 0) {
3312
3798
  const guardName = guardEligible[0];
3313
3799
  const guardStepDef = definition.steps[guardName];
3314
- // 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.
3315
3802
  const guardResult = await executeGuardStep(guardName, guardStepDef, definition, run);
3316
3803
  // Capture the guard's OWN evidence (its last entry) BEFORE the finalizer drain appends
3317
3804
  // finalizer evidence — the terminal return below surfaces only the guard's evidence.
3318
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) ---
3319
4028
  // Blocking fix #1: classify the terminal outcome by the SEALED record, not aborted_at
3320
4029
  // alone. executeGuardStep sets terminal_state in THREE cases — abort (aborted_at set),
3321
4030
  // resolution-error (failed, no aborted_at), and a PASS that completes the run
@@ -3384,7 +4093,9 @@ depth0Warnings) {
3384
4093
  data: {},
3385
4094
  // The guard's own evidence entry, captured before the finalizer drain appended any.
3386
4095
  evidence: guardOwnEvidence,
3387
- 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),
3388
4099
  errors: [],
3389
4100
  context_hint: contextHint,
3390
4101
  run_phase: persistedGuardRun.run_phase,
@@ -3408,6 +4119,12 @@ depth0Warnings) {
3408
4119
  ...result,
3409
4120
  run_version: run.version,
3410
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
+ : {}),
3411
4128
  };
3412
4129
  }
3413
4130
  if (run.terminal_state || run.pending_gate !== undefined) {
@@ -3464,7 +4181,13 @@ export async function executeChain(store, definition, options) {
3464
4181
  throw err;
3465
4182
  }
3466
4183
  }
3467
- 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);
3468
4191
  return {
3469
4192
  command: options.command,
3470
4193
  run_id: options.runId,
@@ -3475,8 +4198,8 @@ export async function executeChain(store, definition, options) {
3475
4198
  warnings: [],
3476
4199
  errors: [],
3477
4200
  agent_action: 'stop',
3478
- context_hint: `Run '${options.runId}' is already terminal (${entryRun.run_phase}); no steps executed.`,
3479
- run_phase: entryRun.run_phase,
4201
+ context_hint: `Run '${options.runId}' is already terminal (${derivedPhase}); no steps executed.`,
4202
+ run_phase: derivedPhase,
3480
4203
  next_actions: [],
3481
4204
  };
3482
4205
  }