@sensigo/realm 0.32.0 → 0.34.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 (63) 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 +29 -5
  13. package/dist/engine/execution-loop.d.ts.map +1 -1
  14. package/dist/engine/execution-loop.js +1152 -90
  15. package/dist/engine/execution-loop.js.map +1 -1
  16. package/dist/engine/gate-timing.d.ts +17 -0
  17. package/dist/engine/gate-timing.d.ts.map +1 -0
  18. package/dist/engine/gate-timing.js +19 -0
  19. package/dist/engine/gate-timing.js.map +1 -0
  20. package/dist/engine/reclaim-step.d.ts +8 -0
  21. package/dist/engine/reclaim-step.d.ts.map +1 -1
  22. package/dist/engine/reclaim-step.js +48 -3
  23. package/dist/engine/reclaim-step.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 +100 -2
  27. package/dist/engine/run-health.js.map +1 -1
  28. package/dist/engine/settlement.d.ts +56 -7
  29. package/dist/engine/settlement.d.ts.map +1 -1
  30. package/dist/engine/settlement.js +690 -37
  31. package/dist/engine/settlement.js.map +1 -1
  32. package/dist/index.d.ts +6 -2
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +5 -2
  35. package/dist/index.js.map +1 -1
  36. package/dist/store/json-file-store.d.ts +6 -4
  37. package/dist/store/json-file-store.d.ts.map +1 -1
  38. package/dist/store/json-file-store.js +26 -9
  39. package/dist/store/json-file-store.js.map +1 -1
  40. package/dist/store/store-interface.d.ts +11 -8
  41. package/dist/store/store-interface.d.ts.map +1 -1
  42. package/dist/types/response-envelope.d.ts +16 -0
  43. package/dist/types/response-envelope.d.ts.map +1 -1
  44. package/dist/types/run-record.d.ts +132 -3
  45. package/dist/types/run-record.d.ts.map +1 -1
  46. package/dist/types/settlement.d.ts +164 -11
  47. package/dist/types/settlement.d.ts.map +1 -1
  48. package/dist/types/workflow-definition.d.ts +113 -32
  49. package/dist/types/workflow-definition.d.ts.map +1 -1
  50. package/dist/types/workflow-definition.js +20 -0
  51. package/dist/types/workflow-definition.js.map +1 -1
  52. package/dist/workflow/diagnostics.d.ts +1 -1
  53. package/dist/workflow/diagnostics.d.ts.map +1 -1
  54. package/dist/workflow/diagnostics.js +3 -0
  55. package/dist/workflow/diagnostics.js.map +1 -1
  56. package/dist/workflow/structured-output-eligibility.d.ts +81 -0
  57. package/dist/workflow/structured-output-eligibility.d.ts.map +1 -0
  58. package/dist/workflow/structured-output-eligibility.js +359 -0
  59. package/dist/workflow/structured-output-eligibility.js.map +1 -0
  60. package/dist/workflow/yaml-loader.d.ts.map +1 -1
  61. package/dist/workflow/yaml-loader.js +145 -2
  62. package/dist/workflow/yaml-loader.js.map +1 -1
  63. package/package.json +1 -1
@@ -4,11 +4,12 @@ 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
6
  import { deriveDefaultedSteps } from './defaulted-steps.js';
7
- import { selectFinalizers } from './settlement.js';
7
+ import { computeGateDueState } from './gate-timing.js';
8
+ import { selectFinalizers, deriveEffectiveTriggers, applySettlement } from './settlement.js';
8
9
  import { captureEvidence } from '../evidence/snapshot.js';
9
10
  import { validateInputSchema, validateOutputSchema, validateTraceSchema, } from '../validation/input-schema.js';
10
11
  import { normalizeTrace } from './trace-normalizer.js';
11
- import { TERMINAL_PHASES, isTerminalPhase, DRAIN_CEILING_SECONDS } from './lifecycle.js';
12
+ import { TERMINAL_PHASES, DRAIN_CEILING_SECONDS } from './lifecycle.js';
12
13
  import { omitClaim, shouldEnforceTimeout, DEFAULT_EXECUTION_TIMEOUT_SECONDS, resolveCapMs, sleepWouldExceedCap, } from './claim-liveness.js';
13
14
  import { computeBackoff } from './backoff.js';
14
15
  import { checkPreconditions, evaluateAllPreconditions, evaluateGuardConditions, } from './precondition.js';
@@ -401,6 +402,25 @@ function stampDefaultedSteps(sealDraft) {
401
402
  return sealDraft;
402
403
  return { ...sealDraft, defaulted_steps: steps };
403
404
  }
405
+ /**
406
+ * The compensating un-claim's own audit-evidence entry (issue #207 PR-2, D3 §5; extracted issue
407
+ * #279, increment 2, PR-D, Deliverable 1e — the `:679` semantics both the legacy
408
+ * `buildCompensatingUnclaim` below AND the migrated `release_step` delta's `evidence` field share
409
+ * verbatim).
410
+ */
411
+ function buildCompensatingUnclaimEvidence(stepName, now) {
412
+ return captureEvidence({
413
+ stepId: stepName,
414
+ startedAt: now,
415
+ completedAt: now,
416
+ input: {},
417
+ output: {
418
+ compensating_unclaim: true,
419
+ reason: 'adoption-read failure after claim',
420
+ unclaimed_at: now.toISOString(),
421
+ },
422
+ });
423
+ }
404
424
  /**
405
425
  * Compensating un-claim (issue #207 PR-2, D3 §5): built from `pendingRun` — the record OUR OWN
406
426
  * `claimStep` call returned, never a fresh get — removing the step from `in_progress_steps` AND
@@ -414,17 +434,7 @@ function stampDefaultedSteps(sealDraft) {
414
434
  * naturally idempotent) is simply a no-op mutation, not a special case.
415
435
  */
416
436
  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
- });
437
+ const auditEvidence = buildCompensatingUnclaimEvidence(stepName, now);
428
438
  return {
429
439
  ...pendingRun,
430
440
  in_progress_steps: pendingRun.in_progress_steps.filter((s) => s !== stepName),
@@ -624,23 +634,32 @@ function computeReArmWarnings(before, after) {
624
634
  return warnings;
625
635
  }
626
636
  /**
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).
637
+ * Builds the ResponseEnvelope for a `settle_step`/`open_gate` REFUSAL (design record §7's
638
+ * result/code table) — shared by the three migrated `settle_step` seal sites (issue #279,
639
+ * increment 1, PR-B) AND the migrated gate-open site (issue #279, increment 2, PR-D; `kind:
640
+ * 'open_gate'`). `allEvidence` is attached ONLY for `claim_lost`: the dispatch DID run and produce
641
+ * evidence; it just was not recorded, so the caller should still see what happened. The reasons
642
+ * both callers can actually return are enumerated explicitly; every OTHER `SettlementRefusalReason`
643
+ * member is lease/mark/settle_gate/settle_guard/release_step-only and structurally unreachable
644
+ * here (a `default` throws rather than silently mis-rendering one) — `choice_not_eligible` +
645
+ * `gate_choice_conflict` + the settle_gate `gate_mismatch`/`run_terminal` variants are consumed at
646
+ * 1b's own `errorEnvelope` (submitHumanResponse), never here; `gate_open_wait` is chain-consumed
647
+ * (executeChainInternal's guard loop); `already_released` is site-handled at 1d/1e (never routed
648
+ * through this shared builder).
634
649
  */
635
- function buildSettlementRefusalEnvelope(options, definition, result, allEvidence, traceWarnings) {
650
+ function buildSettlementRefusalEnvelope(options, definition, result, allEvidence, traceWarnings, kind = 'settle_step') {
636
651
  const extraWarnings = traceWarnings.length > 0 ? traceWarnings : undefined;
637
652
  switch (result.reason) {
638
653
  case 'already_settled_by_other':
639
654
  case 'settled_outcome_divergence': {
640
655
  const persisted = result.run.settled?.[options.command]?.outcome;
656
+ // N1 (design record §2/§11): neutral wording — never amplify a "by_other" white lie. When
657
+ // the persisted entry's outcome is 'gate', the step was settled by a COMPLETED GATE (a
658
+ // human decision resolved elsewhere), not literally "a different attempt".
659
+ const settledByText = persisted === 'gate' ? 'by a completed gate' : 'by a different attempt';
641
660
  const err = new WorkflowError(`Step '${options.command}' was already settled` +
642
661
  (persisted !== undefined ? ` with outcome '${persisted}'` : '') +
643
- ` by a different attempt.`, {
662
+ ` ${settledByText}.`, {
644
663
  code: 'STATE_STEP_ALREADY_SETTLED',
645
664
  category: 'STATE',
646
665
  agentAction: 'resolve_precondition',
@@ -679,8 +698,15 @@ function buildSettlementRefusalEnvelope(options, definition, result, allEvidence
679
698
  return makeErrorEnvelope(options, result.run, err, definition, extraWarnings);
680
699
  }
681
700
  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.`, {
701
+ // kind-discriminated (Deliverable 2): open_gate's gate_mismatch means a DIFFERENT step's
702
+ // gate is open (this step stays claimed — L13); settle_step's means THIS step IS the open
703
+ // gate and must be resolved via submit_human_response instead.
704
+ const message = kind === 'open_gate'
705
+ ? `Step '${options.command}': a gate is open on another step — wait for its resolution; ` +
706
+ `this step stays claimed.`
707
+ : `Step '${options.command}' is the currently open gate; resolve it via ` +
708
+ `submit_human_response instead of settling it directly.`;
709
+ const err = new WorkflowError(message, {
684
710
  code: 'STATE_BLOCKED',
685
711
  category: 'STATE',
686
712
  agentAction: 'resolve_precondition',
@@ -691,9 +717,12 @@ function buildSettlementRefusalEnvelope(options, definition, result, allEvidence
691
717
  }
692
718
  default:
693
719
  // 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}'`);
720
+ // not_eligible / already_leased / already_marked / already_open / already_released /
721
+ // gate_choice_conflict / choice_not_eligible / gate_open_wait are all consumed elsewhere
722
+ // (lease_finalizer/mark_finalizer's own drain loop; open_gate's own NOOP arms at the 1a call
723
+ // site; 1b's own errorEnvelope; 1d/1e's own site-handling; the guard chain) — settle_step and
724
+ // open_gate never return them here (design record §7).
725
+ throw new Error(`buildSettlementRefusalEnvelope: unreachable '${kind}' refusal reason '${result.reason}'`);
697
726
  }
698
727
  }
699
728
  /**
@@ -742,6 +771,72 @@ async function buildAlreadySettledEnvelope(store, definition, options, result, t
742
771
  */
743
772
  const DORMANCY_ADVISORY = 'settled via the legacy compatibility path — this store does not declare atomic settlement ' +
744
773
  '(RunStore.settleStep); upgrade the store to close the fan-out seal race (issue #279)';
774
+ /**
775
+ * issue #291 (D1 "execute_step pre-refusal" enactment point — enact-then-proceed): if `run`
776
+ * carries an expired, enactable gate (`expires_at` past, `on_expiry` frozen), enacts it via the
777
+ * SAME dormancy-discriminated pattern `submitHumanResponse` uses (settleStep when declared, else
778
+ * the pure `applySettlement` transform + `store.update`'s CAS write) and returns the resulting
779
+ * (possibly unchanged) run plus any disclosure line for the caller's `warnings` array. A
780
+ * finding-only gate (no `on_expiry`) is a fast no-op — nothing to enact, never touched. Any
781
+ * refusal from the enactment attempt (a benign race: `already_settled`/`not_expired`/
782
+ * `gate_mismatch`/`run_terminal`) is absorbed silently — the caller's own subsequent eligibility
783
+ * re-check against the returned `run` is what actually matters, and a NOOP correctly leaves `run`
784
+ * as the fresh state the refusal matched against. [F4] advisory-not-crash: an external store's
785
+ * OWN `settleStep` throwing on the `expire_gate` kind (a pre-#291 re-implementing store
786
+ * honoring the union-openness contract's refuse-loud mandate) degrades to a console advisory and
787
+ * proceeds with `run` UNCHANGED — never crashes the caller's verb.
788
+ */
789
+ async function enactExpiredGateIfDue(store, definition, run, registry, now) {
790
+ const gate = run.pending_gate;
791
+ if (gate === undefined ||
792
+ gate.expires_at === undefined ||
793
+ gate.on_expiry === undefined ||
794
+ now.getTime() < new Date(gate.expires_at).getTime()) {
795
+ return { run };
796
+ }
797
+ const delta = { kind: 'expire_gate', gateId: gate.gate_id };
798
+ let expireOutcome;
799
+ try {
800
+ if (store.settleStep !== undefined) {
801
+ expireOutcome = await store.settleStep(run.id, delta, definition, { now });
802
+ }
803
+ else {
804
+ const pure = applySettlement(run, delta, definition, { now });
805
+ if (!pure.applied) {
806
+ return { run: pure.run };
807
+ }
808
+ const persisted = await store.update(pure.run);
809
+ expireOutcome = { ...pure, run: persisted };
810
+ }
811
+ }
812
+ catch (err) {
813
+ console.warn(`⚠ realm: could not enact run '${run.id}''s expired gate '${gate.gate_id}' (${err instanceof Error ? err.message : String(err)}) — proceeding with the pre-enactment state.`);
814
+ return { run };
815
+ }
816
+ if (!expireOutcome.applied) {
817
+ return { run: expireOutcome.run };
818
+ }
819
+ let finalRun = expireOutcome.run;
820
+ const disclosureParts = [];
821
+ const disposition = finalRun.settled?.[gate.step_name]?.resolved_by === 'timeout' ? 'settle_default' : 'abort';
822
+ disclosureParts.push(`gate '${gate.gate_id}' on '${gate.step_name}' had expired — enacted declared ${disposition} before this execute_step call (enacted_via: execute_step).`);
823
+ if (expireOutcome.transitioned) {
824
+ try {
825
+ const drainOutcome = await drainFinalizers(store, definition, registry, run.id);
826
+ finalRun = drainOutcome.run;
827
+ disclosureParts.push(...drainOutcome.warnings);
828
+ }
829
+ catch (err) {
830
+ disclosureParts.push(`post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`);
831
+ }
832
+ }
833
+ const disclosure = disclosureParts.join(' ');
834
+ // Printed unconditionally (never silently dropped, regardless of which downstream envelope
835
+ // path the caller's own request takes) — the caller ALSO threads this into whichever
836
+ // response-envelope warnings array is in scope at its own return point.
837
+ console.warn(`⚠ ${disclosure}`);
838
+ return { run: finalRun, disclosure };
839
+ }
745
840
  /**
746
841
  * Validates eligibility, claims the step, executes it through the dispatcher with retry
747
842
  * and timeout support, captures evidence, persists the updated run record, and returns
@@ -765,6 +860,18 @@ export async function executeStep(store, definition, options) {
765
860
  });
766
861
  return makeErrorEnvelope(options, null, internal);
767
862
  }
863
+ // Step 1.5 (issue #291, D1 "execute_step pre-refusal" enactment point): if this run's gate has
864
+ // expired with an enactable disposition, enact it BEFORE the eligibility check below — a
865
+ // finding-only or non-expired gate is an immediate no-op (same `run` reference back). Level-
866
+ // triggering: the requested step may become newly eligible right here (settle_default/abort
867
+ // both clear `pending_gate`, un-blocking `findEligibleSteps`'s gate-serialization exclusion).
868
+ const gateExpiryCheckNow = options.now ?? new Date();
869
+ let gateExpiryDisclosure;
870
+ if (run.pending_gate !== undefined) {
871
+ const enacted = await enactExpiredGateIfDue(store, definition, run, options.registry, gateExpiryCheckNow);
872
+ run = enacted.run;
873
+ gateExpiryDisclosure = enacted.disclosure;
874
+ }
768
875
  // Step 2: Check eligibility.
769
876
  const eligible = findEligibleSteps(definition, run);
770
877
  if (!eligible.includes(options.command)) {
@@ -776,7 +883,7 @@ export async function executeStep(store, definition, options) {
776
883
  status: 'blocked',
777
884
  data: {},
778
885
  evidence: [],
779
- warnings: [],
886
+ warnings: gateExpiryDisclosure !== undefined ? [gateExpiryDisclosure] : [],
780
887
  errors: [],
781
888
  agent_action: 'resolve_precondition',
782
889
  context_hint: `Step '${options.command}' is not eligible in the current run state.`,
@@ -994,6 +1101,10 @@ export async function executeStep(store, definition, options) {
994
1101
  // walEntries is declared at this outer scope because it is REASSIGNED to the post-claim read
995
1102
  // below and referenced at the captureEvidence call site further down this function.
996
1103
  const traceWarnings = [];
1104
+ // issue #291: the Step-1.5 gate-expiry disclosure (if any) now rides every downstream envelope
1105
+ // this function's own `traceWarnings` threading already reaches.
1106
+ if (gateExpiryDisclosure !== undefined)
1107
+ traceWarnings.push(gateExpiryDisclosure);
997
1108
  let preNormalizedTrace;
998
1109
  let walEntries = [];
999
1110
  let preClaimSchemaResult;
@@ -1175,13 +1286,43 @@ export async function executeStep(store, definition, options) {
1175
1286
  : [];
1176
1287
  }
1177
1288
  catch (err) {
1178
- try {
1179
- await store.update(buildCompensatingUnclaim(pendingRun, options.command, new Date()));
1289
+ // issue #279 (increment 2, PR-D, Deliverable 1e): the migrated path — settles this release
1290
+ // atomically against FRESH state via the store's own settleStep, evidence = the SAME
1291
+ // compensating_unclaim audit line (:679 semantics). LOG-ONLY for ALL results (applied / NOOP
1292
+ // / any refusal / a thrown infra error) — no envelope change on any settle outcome; the
1293
+ // ENGINE_STORE_FAILED envelope below is the disclosure regardless. Dormancy: an undeclaring
1294
+ // store falls through to the byte-identical legacy path (I16/#169 fail-closed dormancy).
1295
+ let unclaimDormancyWarning;
1296
+ if (store.settleStep !== undefined) {
1297
+ const unclaimToken = pendingRun.claims?.[options.command]?.token;
1298
+ const delta = {
1299
+ kind: 'release_step',
1300
+ step: options.command,
1301
+ ...(unclaimToken !== undefined ? { claimToken: unclaimToken } : {}),
1302
+ evidence: [buildCompensatingUnclaimEvidence(options.command, new Date())],
1303
+ };
1304
+ try {
1305
+ await store.settleStep(options.runId, delta, definition);
1306
+ }
1307
+ catch {
1308
+ // Log-only — see the comment above; never surfaces as its own error.
1309
+ }
1180
1310
  }
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.
1311
+ else {
1312
+ // --- Legacy path (dormancy fallback byte-identical to pre-#279 behavior) ---
1313
+ try {
1314
+ await store.update(buildCompensatingUnclaim(pendingRun, options.command, new Date()));
1315
+ }
1316
+ catch {
1317
+ // CAS mismatch (someone else already resolved the claim) or any other failure to even
1318
+ // un-claim: stop immediately, leave the claim exactly as it is — never retry here.
1319
+ }
1320
+ // issue #279 (increment 2, PR-D): + the ONE dormancy advisory (I16) — this IS the legacy
1321
+ // path (store.settleStep undeclared); the ENGINE_STORE_FAILED envelope below is its only
1322
+ // carrier since this release is log-only.
1323
+ unclaimDormancyWarning = DORMANCY_ADVISORY;
1184
1324
  }
1325
+ const unclaimEnvelopeWarnings = mergeWarnings(traceWarnings, unclaimDormancyWarning);
1185
1326
  return makeErrorEnvelope(options, pendingRun, new WorkflowError('Failed to read trace buffer after claiming step', {
1186
1327
  code: 'ENGINE_STORE_FAILED',
1187
1328
  category: 'ENGINE',
@@ -1191,7 +1332,7 @@ export async function executeStep(store, definition, options) {
1191
1332
  step_id: options.command,
1192
1333
  cause: err instanceof Error ? err.message : String(err),
1193
1334
  },
1194
- }), definition, traceWarnings.length > 0 ? traceWarnings : undefined);
1335
+ }), definition, unclaimEnvelopeWarnings.length > 0 ? unclaimEnvelopeWarnings : undefined);
1195
1336
  }
1196
1337
  // issue #197 PR-2 (design §2): the SAME predicate as the pre-claim pass, now over the
1197
1338
  // complete post-claim set. Lifted to `adoptionPartition` (outer scope) — read again at the
@@ -1666,6 +1807,20 @@ export async function executeStep(store, definition, options) {
1666
1807
  ...(attemptError === null && (run.validation_rejections?.[options.command] ?? 0) > 0
1667
1808
  ? { validation_rejections: run.validation_rejections[options.command] }
1668
1809
  : {}),
1810
+ // issue #236: the attempt's structured_output disclosure. External-agent stamp [Rv6 +
1811
+ // R2-3]: a step that DECLARED structured_output but arrives with no
1812
+ // options.stepMeta.structuredOutput at all was driven by something other than
1813
+ // run-agent (e.g. an external agent calling execute_step over MCP directly) — realm
1814
+ // cannot know whether strict was honored, so it says so rather than staying silent.
1815
+ ...(stepDef?.structured_output !== undefined
1816
+ ? {
1817
+ structured_output: options.stepMeta?.structuredOutput ?? {
1818
+ requested: true,
1819
+ sent: false,
1820
+ downgrade_reason: 'external_agent',
1821
+ },
1822
+ }
1823
+ : {}),
1669
1824
  },
1670
1825
  ...(profileData !== undefined
1671
1826
  ? { agentProfile: profile, agentProfileHash: profileData.content_hash }
@@ -1772,6 +1927,17 @@ export async function executeStep(store, definition, options) {
1772
1927
  precondition_trace: preconditionTrace,
1773
1928
  settled_by_default: true,
1774
1929
  validation_rejections: exhaustion.details['rejections'],
1930
+ // issue #236: same disclosure/external-agent-stamp rule as the real dispatch-loop
1931
+ // capture above.
1932
+ ...(stepDef?.structured_output !== undefined
1933
+ ? {
1934
+ structured_output: options.stepMeta?.structuredOutput ?? {
1935
+ requested: true,
1936
+ sent: false,
1937
+ downgrade_reason: 'external_agent',
1938
+ },
1939
+ }
1940
+ : {}),
1775
1941
  },
1776
1942
  ...(defaultProfileData !== undefined
1777
1943
  ? { agentProfile: defaultProfile, agentProfileHash: defaultProfileData.content_hash }
@@ -1829,6 +1995,17 @@ export async function executeStep(store, definition, options) {
1829
1995
  input_token_estimate: inputTokenEstimate,
1830
1996
  precondition_trace: preconditionTrace,
1831
1997
  validation_rejections: exhaustion.details['rejections'],
1998
+ // issue #236: same disclosure/external-agent-stamp rule as the real dispatch-loop
1999
+ // capture above.
2000
+ ...(stepDef?.structured_output !== undefined
2001
+ ? {
2002
+ structured_output: options.stepMeta?.structuredOutput ?? {
2003
+ requested: true,
2004
+ sent: false,
2005
+ downgrade_reason: 'external_agent',
2006
+ },
2007
+ }
2008
+ : {}),
1832
2009
  },
1833
2010
  ...(exhaustedProfileData !== undefined
1834
2011
  ? { agentProfile: exhaustedProfile, agentProfileHash: exhaustedProfileData.content_hash }
@@ -1927,13 +2104,61 @@ export async function executeStep(store, definition, options) {
1927
2104
  // Non-terminal: recompute the phase so the store-fail fallback below is correct too
1928
2105
  // (on the happy path store.update recomputes it identically via deriveRunPhase).
1929
2106
  const blockedRun = { ...blockedDraft, run_phase: deriveRunPhase(blockedDraft) };
2107
+ // issue #279 (increment 2, PR-D, Deliverable 1d): the migrated path — settles this release
2108
+ // atomically against FRESH state via the store's own settleStep. This site NEVER calls the
2109
+ // shared buildSettlementRefusalEnvelope: regardless of write outcome (applied / NOOP
2110
+ // already_released / any OTHER refusal / a thrown infra error), the RETURNED envelope is
2111
+ // ALWAYS this SAME capability-block report — only whether the internal capability_blocks
2112
+ // marker got durably persisted varies, disclosed via blockStoreWarning. Dormancy: an
2113
+ // undeclaring store falls through to the byte-identical legacy path below (I16/#169
2114
+ // fail-closed dormancy).
1930
2115
  let persistedBlockedRun;
1931
2116
  let blockStoreWarning;
1932
- try {
1933
- persistedBlockedRun = await store.update(blockedRun);
2117
+ let dormancyWarning;
2118
+ if (store.settleStep !== undefined) {
2119
+ const releaseClaimToken = pendingRun.claims?.[options.command]?.token;
2120
+ const delta = {
2121
+ kind: 'release_step',
2122
+ step: options.command,
2123
+ ...(releaseClaimToken !== undefined ? { claimToken: releaseClaimToken } : {}),
2124
+ capabilityBlock: {
2125
+ requirement: requirement !== undefined
2126
+ ? { kind: requirement.kind, name: requirement.name }
2127
+ : {
2128
+ kind: recoverableCode === 'ENGINE_HANDLER_NOT_REGISTERED' ? 'handler' : 'adapter',
2129
+ name: 'unknown',
2130
+ },
2131
+ code: recoverableCode,
2132
+ },
2133
+ // The current :2490 append — legacy parity; the compensating un-claim's own :679
2134
+ // audit-line channel belongs to Deliverable 1e ONLY.
2135
+ evidence: allEvidence,
2136
+ };
2137
+ try {
2138
+ const releaseResult = await store.settleStep(options.runId, delta, definition);
2139
+ persistedBlockedRun = releaseResult.run;
2140
+ // applied / NOOP already_released ⇒ the block envelope exactly as today (NOOP merges
2141
+ // silently — no extra warning). ANY OTHER refusal ⇒ the same block envelope + a typed
2142
+ // warning (never STATE_CLAIM_LOST framing) — the claim survives for reclaim either way.
2143
+ if (!releaseResult.applied && releaseResult.reason !== 'already_released') {
2144
+ blockStoreWarning = `capability block not persisted: ${releaseResult.reason}`;
2145
+ }
2146
+ }
2147
+ catch (storeErr) {
2148
+ blockStoreWarning = `Failed to persist capability block: ${storeErr instanceof Error ? storeErr.message : String(storeErr)}`;
2149
+ }
1934
2150
  }
1935
- catch (storeErr) {
1936
- blockStoreWarning = `Failed to persist capability block: ${storeErr instanceof Error ? storeErr.message : String(storeErr)}`;
2151
+ else {
2152
+ // --- Legacy path (dormancy fallback byte-identical to pre-#279 behavior) ---
2153
+ try {
2154
+ persistedBlockedRun = await store.update(blockedRun);
2155
+ }
2156
+ catch (storeErr) {
2157
+ blockStoreWarning = `Failed to persist capability block: ${storeErr instanceof Error ? storeErr.message : String(storeErr)}`;
2158
+ }
2159
+ // issue #279 (increment 2, PR-D): + the ONE dormancy advisory (I16) — this IS the legacy
2160
+ // path (store.settleStep undeclared).
2161
+ dormancyWarning = DORMANCY_ADVISORY;
1937
2162
  }
1938
2163
  // issue #207 PR-2 (D3 §5): NO WAL delete belongs on this capability-block settle path — the
1939
2164
  // prior try/catch here was removed, not just gated. Contract-consistency hygiene, not a
@@ -1968,7 +2193,7 @@ export async function executeStep(store, definition, options) {
1968
2193
  status: 'error',
1969
2194
  data: {},
1970
2195
  evidence: allEvidence,
1971
- warnings: mergeWarnings(traceWarnings, blockStoreWarning),
2196
+ warnings: mergeWarnings(traceWarnings, blockStoreWarning, dormancyWarning),
1972
2197
  errors: [dispatchError.message],
1973
2198
  agent_action: blockedAction,
1974
2199
  error_code: recoverableCode,
@@ -2322,37 +2547,49 @@ export async function executeStep(store, definition, options) {
2322
2547
  resolvedGateMessage = raw;
2323
2548
  }
2324
2549
  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
- }
2550
+ const openedAt = new Date();
2551
+ // issue #291 (mint-time freeze, [F2]): the gate's OWN enforce/notify clock fields, frozen
2552
+ // into the record HERE — never re-read from the definition by any later enactment/
2553
+ // notification/read-side surface (the definition-drift-livelock cure). `expires_at` derives
2554
+ // from THIS `openedAt` instant, never a second `new Date()` call. `reminder_max` defaults to
2555
+ // 3 at mint when `reminder_seconds` is declared and the author gave no explicit value — so
2556
+ // every later reader can treat the frozen field as authoritative without re-applying a
2557
+ // default itself.
2558
+ const expiresAt = gateConfig?.timeout_seconds !== undefined
2559
+ ? new Date(openedAt.getTime() + gateConfig.timeout_seconds * 1000).toISOString()
2560
+ : undefined;
2561
+ // The PendingGate object is built EXACTLY as before, regardless of which path commits it below
2562
+ // (issue #279, increment 2, PR-D, Deliverable 1a the migrated `open_gate` delta carries this
2563
+ // SAME object verbatim; the legacy fallback writes it via `store.update` unchanged).
2564
+ const pendingGate = {
2565
+ gate_id,
2566
+ step_name,
2567
+ preview: output,
2568
+ choices,
2569
+ opened_at: openedAt.toISOString(),
2570
+ ...(gateConfig?.owner !== undefined ? { owner: gateConfig.owner } : {}),
2571
+ ...(resolvedGateMessage !== undefined ? { resolved_message: resolvedGateMessage } : {}),
2572
+ ...(gateConfig?.resolution_messages !== undefined
2573
+ ? { resolution_messages: gateConfig.resolution_messages }
2574
+ : {}),
2575
+ // issue #291: the mint-frozen enforce clock.
2576
+ ...(expiresAt !== undefined ? { expires_at: expiresAt } : {}),
2577
+ ...(gateConfig?.on_expiry !== undefined ? { on_expiry: gateConfig.on_expiry } : {}),
2578
+ ...(gateConfig?.default_choice !== undefined
2579
+ ? { default_choice: gateConfig.default_choice }
2580
+ : {}),
2581
+ // issue #291: the mint-frozen notify clock (standalone-legal — independent of expiresAt).
2582
+ ...(gateConfig?.reminder_seconds !== undefined
2583
+ ? {
2584
+ reminder_seconds: gateConfig.reminder_seconds,
2585
+ reminder_max: gateConfig.reminder_max ?? 3,
2586
+ }
2587
+ : {}),
2588
+ };
2589
+ // issue #291 ([F-A2-6]): computed ONCE for the whole gate-open envelope (migrated + legacy
2590
+ // both read it) — the absolute first-due notify-clock timestamp, when reminder_seconds was
2591
+ // declared.
2592
+ const gateOpenDueState = computeGateDueState(pendingGate, openedAt);
2356
2593
  // gate.display fallback chain: gate.message resolved → step.prompt resolved → absent
2357
2594
  const resolvedGateDisplay = resolvedGateMessage !== undefined
2358
2595
  ? resolvedGateMessage
@@ -2370,19 +2607,164 @@ export async function executeStep(store, definition, options) {
2370
2607
  ...wfCtxSpreadEarly,
2371
2608
  })
2372
2609
  : 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,
2610
+ function buildGateNextAction(id, gateChoices, forStep) {
2611
+ return {
2612
+ instruction: {
2613
+ tool: 'submit_human_response',
2614
+ params: { run_id: options.runId, gate_id: id },
2615
+ call_with: {
2616
+ run_id: options.runId,
2617
+ gate_id: id,
2618
+ choice: `<${gateChoices.join('|')}>`,
2619
+ },
2620
+ },
2621
+ 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.`,
2622
+ orientation: `Run is paused at gate '${id}'. Available choices: ${gateChoices.join(', ')}.`,
2623
+ };
2624
+ }
2625
+ // issue #279 (increment 2, PR-D, Deliverable 1a): the migrated path — opens this gate
2626
+ // atomically against FRESH state via the store's own settleStep. Dormancy: an undeclaring
2627
+ // store falls through to the byte-identical legacy path below (I16/#169 fail-closed dormancy).
2628
+ if (store.settleStep !== undefined) {
2629
+ const openClaimToken = pendingRun.claims?.[options.command]?.token;
2630
+ const delta = {
2631
+ kind: 'open_gate',
2632
+ step: options.command,
2633
+ ...(openClaimToken !== undefined ? { claimToken: openClaimToken } : {}),
2634
+ pendingGate,
2635
+ evidence: allEvidence,
2636
+ };
2637
+ let result;
2638
+ try {
2639
+ result = await store.settleStep(options.runId, delta, definition);
2640
+ }
2641
+ catch (err) {
2642
+ // THROWN infra errors — the same catch shape replicated at every migrated site.
2643
+ if (err instanceof WorkflowError) {
2644
+ return makeErrorEnvelope(options, pendingRun, err, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
2645
+ }
2646
+ const internal = new WorkflowError('Failed to open gate', {
2647
+ code: 'ENGINE_STORE_FAILED',
2648
+ category: 'ENGINE',
2649
+ agentAction: 'stop',
2650
+ retryable: false,
2651
+ });
2652
+ return makeErrorEnvelope(options, pendingRun, internal, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
2653
+ }
2654
+ if (!result.applied) {
2655
+ if (result.reason === 'already_settled') {
2656
+ // Ok-shaped NOOP — buildAlreadySettledEnvelope's SHAPE but WITHOUT its drain clause:
2657
+ // gate-open NEVER drains (design record §6 row 1 — the crashed-drain recovery paths are
2658
+ // the resolution site's own NOOP drain and the drain verb).
2659
+ const noopRun = result.run;
2660
+ const noopNextActions = noopRun.terminal_state
2661
+ ? []
2662
+ : buildNextActions(definition, noopRun);
2663
+ return {
2664
+ command: options.command,
2665
+ run_id: options.runId,
2666
+ run_version: noopRun.version,
2667
+ status: 'ok',
2668
+ data: {},
2669
+ evidence: [],
2670
+ warnings: [...traceWarnings],
2671
+ errors: [],
2672
+ context_hint: `Step '${options.command}' was already settled (a duplicate/retried attempt) — no action was taken.`,
2673
+ run_phase: noopRun.run_phase,
2674
+ next_actions: noopNextActions,
2675
+ };
2676
+ }
2677
+ if (result.reason === 'already_open') {
2678
+ // D-1: the LIVE gate wins — rendered VERBATIM (this delta's own rebuilt gate is
2679
+ // discarded). Calm, confirm_required (the gate genuinely IS still open) — never
2680
+ // report_to_user (no `agent_action` set, matching the fresh-open confirm_required shape).
2681
+ const liveGate = result.gate;
2682
+ const liveGateDueState = computeGateDueState(liveGate, new Date());
2683
+ return {
2684
+ command: options.command,
2685
+ run_id: options.runId,
2686
+ run_version: result.run.version,
2687
+ status: 'confirm_required',
2688
+ data: liveGate.preview,
2689
+ evidence: [],
2690
+ warnings: [...traceWarnings],
2691
+ errors: [],
2692
+ context_hint: `Run is already paused at gate '${liveGate.gate_id}'. Available choices: ${liveGate.choices.join(', ')}.`,
2693
+ run_phase: result.run.run_phase,
2694
+ next_actions: [
2695
+ buildGateNextAction(liveGate.gate_id, liveGate.choices, liveGate.step_name),
2696
+ ],
2697
+ gate: {
2698
+ gate_id: liveGate.gate_id,
2699
+ step_name: liveGate.step_name,
2700
+ preview: liveGate.preview,
2701
+ choices: liveGate.choices,
2702
+ ...(liveGate.resolved_message !== undefined
2703
+ ? { display: liveGate.resolved_message }
2704
+ : {}),
2705
+ response_spec: { choices: liveGate.choices },
2706
+ ...(liveGate.expires_at !== undefined ? { expires_at: liveGate.expires_at } : {}),
2707
+ ...(liveGateDueState.next_reminder_due_at !== undefined
2708
+ ? { first_reminder_due_at: liveGateDueState.next_reminder_due_at }
2709
+ : {}),
2710
+ },
2711
+ };
2712
+ }
2713
+ return buildSettlementRefusalEnvelope(options, definition, result, allEvidence, traceWarnings, 'open_gate');
2714
+ }
2715
+ // applied: true — never terminalizes (design record §4.1); build confirm_required off
2716
+ // result.run.
2717
+ const gateRun = result.run;
2718
+ return {
2719
+ command: options.command,
2720
+ run_id: options.runId,
2721
+ run_version: gateRun.version,
2722
+ status: 'confirm_required',
2723
+ data: output,
2724
+ evidence: allEvidence,
2725
+ warnings: [...traceWarnings],
2726
+ errors: [],
2727
+ context_hint: `Run is paused at gate '${gate_id}'. Available choices: ${choices.join(', ')}.`,
2728
+ run_phase: gateRun.run_phase,
2729
+ next_actions: [buildGateNextAction(gate_id, choices, step_name)],
2730
+ gate: {
2379
2731
  gate_id,
2380
- choice: `<${choices.join('|')}>`,
2732
+ step_name,
2733
+ preview: output,
2734
+ choices,
2735
+ ...(resolvedGateDisplay !== undefined ? { display: resolvedGateDisplay } : {}),
2736
+ ...(resolvedGateInstructions !== undefined
2737
+ ? { agent_hint: resolvedGateInstructions }
2738
+ : {}),
2739
+ response_spec: { choices },
2740
+ ...(pendingGate.expires_at !== undefined ? { expires_at: pendingGate.expires_at } : {}),
2741
+ ...(gateOpenDueState.next_reminder_due_at !== undefined
2742
+ ? { first_reminder_due_at: gateOpenDueState.next_reminder_due_at }
2743
+ : {}),
2381
2744
  },
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
- };
2745
+ };
2746
+ }
2747
+ // --- Legacy path (dormancy fallback byte-identical to pre-#279 behavior) ---
2748
+ let gateRun;
2749
+ try {
2750
+ gateRun = await store.update({
2751
+ ...pendingRun,
2752
+ // Step stays in in_progress_steps while gate is open — moved to completed on submit.
2753
+ evidence: [...pendingRun.evidence, ...allEvidence],
2754
+ pending_gate: pendingGate,
2755
+ });
2756
+ }
2757
+ catch (err) {
2758
+ if (err instanceof WorkflowError) {
2759
+ return makeErrorEnvelope(options, pendingRun, err, definition, traceWarnings.length > 0 ? traceWarnings : undefined);
2760
+ }
2761
+ return makeErrorEnvelope(options, pendingRun, new WorkflowError('Failed to open gate', {
2762
+ code: 'ENGINE_STORE_FAILED',
2763
+ category: 'ENGINE',
2764
+ agentAction: 'stop',
2765
+ retryable: false,
2766
+ }), definition, traceWarnings.length > 0 ? traceWarnings : undefined);
2767
+ }
2386
2768
  return {
2387
2769
  command: options.command,
2388
2770
  run_id: options.runId,
@@ -2390,11 +2772,11 @@ export async function executeStep(store, definition, options) {
2390
2772
  status: 'confirm_required',
2391
2773
  data: output,
2392
2774
  evidence: allEvidence,
2393
- warnings: [...traceWarnings],
2775
+ warnings: mergeWarnings(traceWarnings, DORMANCY_ADVISORY),
2394
2776
  errors: [],
2395
2777
  context_hint: `Run is paused at gate '${gate_id}'. Available choices: ${choices.join(', ')}.`,
2396
2778
  run_phase: gateRun.run_phase,
2397
- next_actions: [gateNextAction],
2779
+ next_actions: [buildGateNextAction(gate_id, choices, step_name)],
2398
2780
  gate: {
2399
2781
  gate_id,
2400
2782
  step_name,
@@ -2403,6 +2785,10 @@ export async function executeStep(store, definition, options) {
2403
2785
  ...(resolvedGateDisplay !== undefined ? { display: resolvedGateDisplay } : {}),
2404
2786
  ...(resolvedGateInstructions !== undefined ? { agent_hint: resolvedGateInstructions } : {}),
2405
2787
  response_spec: { choices },
2788
+ ...(pendingGate.expires_at !== undefined ? { expires_at: pendingGate.expires_at } : {}),
2789
+ ...(gateOpenDueState.next_reminder_due_at !== undefined
2790
+ ? { first_reminder_due_at: gateOpenDueState.next_reminder_due_at }
2791
+ : {}),
2406
2792
  },
2407
2793
  };
2408
2794
  }
@@ -2687,6 +3073,145 @@ export async function executeStep(store, definition, options) {
2687
3073
  * Submits a human response for a gate-waiting run.
2688
3074
  * Validates the gate_id and choice, then moves the step to completed_steps.
2689
3075
  */
3076
+ /** Finds the settled step name for a resolved gate matching `gateId` (issue #279, increment 2,
3077
+ * PR-D) — a LOCAL mirror of settlement.ts's own `findSettledGateEntry` (not imported: this file
3078
+ * touches settlement.ts ONLY for Deliverable 3's cancel-trail `gate_id` addition). Used to recover
3079
+ * a reliable step name off `result.run.settled` for the `already_settled`/`gate_choice_conflict`
3080
+ * envelopes, since the caller's own pre-read may already be stale by the time either of those
3081
+ * fires (the gate could have resolved before this call's own Step-1 read). */
3082
+ function findGateStepName(run, gateId) {
3083
+ for (const [step, entry] of Object.entries(run.settled ?? {})) {
3084
+ if (entry.outcome === 'gate' && entry.token === gateId)
3085
+ return step;
3086
+ }
3087
+ return undefined;
3088
+ }
3089
+ /** Builds the gate_response evidence snapshot (issue #279, increment 2, PR-D, Deliverable 1b) —
3090
+ * the SAME shape submitHumanResponse's legacy path has always built (mirrors execution-loop.ts's
3091
+ * own pre-PR-D Step 5), extracted so both the migrated and legacy paths construct it identically.
3092
+ * `respondedBy`, when supplied, populates the snapshot's own `responded_by` field (design record
3093
+ * D-5) in addition to the delta's own field. */
3094
+ function buildGateResponseSnapshot(gate, choice, respondedAt, respondedBy) {
3095
+ const gateEvidence = captureEvidence({
3096
+ stepId: gate.step_name,
3097
+ startedAt: new Date(gate.opened_at),
3098
+ completedAt: respondedAt,
3099
+ input: { choice },
3100
+ output: { ...gate.preview, choice },
3101
+ });
3102
+ return {
3103
+ ...gateEvidence,
3104
+ kind: 'gate_response',
3105
+ ...(gate.resolved_message !== undefined ? { gate_message: gate.resolved_message } : {}),
3106
+ ...(respondedBy !== undefined ? { responded_by: respondedBy } : {}),
3107
+ };
3108
+ }
3109
+ /** Renders a millisecond duration as a compact human-readable string ("3m", "2h 15m", "1d 4h") —
3110
+ * issue #291 [F8] overdue-delta disclosure. Local to this file (core has no CLI dependency);
3111
+ * mirrors the CLI's own `formatGateAge` shape but is independently maintained — no cross-package
3112
+ * import for a two-branch formatter. */
3113
+ function formatOverdueDuration(ms) {
3114
+ const totalMinutes = Math.floor(ms / 60_000);
3115
+ const totalHours = Math.floor(totalMinutes / 60);
3116
+ const totalDays = Math.floor(totalHours / 24);
3117
+ if (totalMinutes < 60)
3118
+ return `${totalMinutes}m`;
3119
+ if (totalHours < 24)
3120
+ return `${totalHours}h ${totalMinutes % 60}m`;
3121
+ return `${totalDays}d ${totalHours % 24}h`;
3122
+ }
3123
+ /**
3124
+ * issue #291 ([F3] shape c / [F8] / [F12]): composes the honest envelope for a late gate response
3125
+ * that lost the race to the enforce clock — called AFTER an `expire_gate` settleStep attempt,
3126
+ * regardless of whether THIS call enacted it (`applied: true`) or a racing enactment point already
3127
+ * had (`applied: false, reason: 'already_settled'` — F1's arms make both paths land on the SAME
3128
+ * committed disposition, read from `finalRun`). `finalRun` must be the state that actually reflects
3129
+ * the enactment (either `expireResult.run` directly). Drains post-commit finalizers when the
3130
+ * enactment itself transitioned the run (F7 — submit's existing transitioned-drain plumbing,
3131
+ * extended to the expire result); a store lacking `settleStep`'s companion `drainFinalizers`
3132
+ * capability is not a concern here — `drainFinalizers` works off any `RunStore`.
3133
+ */
3134
+ async function composeExpiredGateEnvelope(store, definition, registry, originalGateId, originalChoice, overdueMs, expireResult) {
3135
+ let finalRun = expireResult.run;
3136
+ let drainWarnings = [];
3137
+ if (expireResult.applied && expireResult.transitioned) {
3138
+ try {
3139
+ const drainOutcome = await drainFinalizers(store, definition, registry, finalRun.id);
3140
+ finalRun = drainOutcome.run;
3141
+ drainWarnings = drainOutcome.warnings;
3142
+ }
3143
+ catch (err) {
3144
+ drainWarnings = [
3145
+ `post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`,
3146
+ ];
3147
+ }
3148
+ }
3149
+ const overdueLabel = formatOverdueDuration(Math.max(0, overdueMs));
3150
+ // settle_default disposition: a 'gate' settled entry bearing this gateId, resolved_by:'timeout'.
3151
+ const settledEntry = Object.entries(finalRun.settled ?? {}).find(([, e]) => e.outcome === 'gate' && e.token === originalGateId && e.resolved_by === 'timeout');
3152
+ if (settledEntry !== undefined) {
3153
+ const [stepName, entry] = settledEntry;
3154
+ const enactedDisclosure = `gate '${originalGateId}' expired ${overdueLabel} ago and was enacted (settle_default: '${entry.choice}') before this response arrived — enacted_via: submit.`;
3155
+ if (entry.choice === originalChoice) {
3156
+ // [F12]'s own pinned string — same choice, still honestly not "your" recorded response.
3157
+ return {
3158
+ command: stepName,
3159
+ run_id: finalRun.id,
3160
+ run_version: finalRun.version,
3161
+ status: 'ok',
3162
+ data: {},
3163
+ evidence: [],
3164
+ warnings: mergeWarnings([], enactedDisclosure, ...drainWarnings),
3165
+ errors: [],
3166
+ context_hint: 'the outcome matches your choice, but it was settled by timeout; your response was not recorded.',
3167
+ run_phase: finalRun.run_phase,
3168
+ next_actions: finalRun.terminal_state ? [] : buildNextActions(definition, finalRun),
3169
+ };
3170
+ }
3171
+ const err = new WorkflowError(`Gate '${originalGateId}' was settled by timeout with choice '${entry.choice}' — your choice '${originalChoice}' was not recorded.`, {
3172
+ code: 'STATE_BLOCKED',
3173
+ category: 'STATE',
3174
+ agentAction: 'report_to_user',
3175
+ retryable: false,
3176
+ details: {
3177
+ runId: finalRun.id,
3178
+ gateId: originalGateId,
3179
+ winning_choice: entry.choice,
3180
+ resolved_by: 'timeout',
3181
+ },
3182
+ });
3183
+ const envelope = errorEnvelope(stepName, finalRun.id, finalRun.version, err, err.message, finalRun.run_phase);
3184
+ return { ...envelope, warnings: mergeWarnings([], enactedDisclosure, ...drainWarnings) };
3185
+ }
3186
+ // abort disposition: a skip_details entry kind 'gate_expired' bearing this gateId.
3187
+ const abortEntry = Object.entries(finalRun.skip_details ?? {}).find(([, d]) => d.kind === 'gate_expired' && d.gate_id === originalGateId);
3188
+ if (abortEntry !== undefined) {
3189
+ const [stepName] = abortEntry;
3190
+ const enactedDisclosure = `gate '${originalGateId}' expired ${overdueLabel} ago and was enacted (abort) before this response arrived — enacted_via: submit.`;
3191
+ const err = new WorkflowError(`Gate '${originalGateId}' on '${stepName}' expired and the run aborted per the ` +
3192
+ `workflow's declared on_expiry — your choice was NOT recorded.`, {
3193
+ code: 'STATE_RUN_TERMINAL',
3194
+ category: 'STATE',
3195
+ agentAction: 'report_to_user',
3196
+ retryable: false,
3197
+ details: { runId: finalRun.id, gateId: originalGateId, step_name: stepName },
3198
+ });
3199
+ const envelope = errorEnvelope(stepName, finalRun.id, finalRun.version, err, err.message, finalRun.run_phase);
3200
+ return { ...envelope, warnings: mergeWarnings([], enactedDisclosure, ...drainWarnings) };
3201
+ }
3202
+ // Unreachable in-contract (the expire delta either enacted it here or a racing enactment point
3203
+ // already had — one of the two branches above always matches). Defensive fallback: never throw
3204
+ // out of a response-envelope-returning function; report the ambiguity honestly instead.
3205
+ const err = new WorkflowError(`Gate '${originalGateId}' expired, but its enacted disposition could not be determined from ` +
3206
+ `the resulting record — this indicates a store or engine defect, not a normal refusal.`, {
3207
+ code: 'ENGINE_INTERNAL',
3208
+ category: 'ENGINE',
3209
+ agentAction: 'stop',
3210
+ retryable: false,
3211
+ details: { runId: finalRun.id, gateId: originalGateId },
3212
+ });
3213
+ return errorEnvelope('submit_gate', finalRun.id, finalRun.version, err, err.message, finalRun.run_phase);
3214
+ }
2690
3215
  export async function submitHumanResponse(store, definition, options) {
2691
3216
  // 1. Load run.
2692
3217
  let run;
@@ -2704,6 +3229,260 @@ export async function submitHumanResponse(store, definition, options) {
2704
3229
  });
2705
3230
  return errorEnvelope('submit_gate', options.runId, 0, e);
2706
3231
  }
3232
+ // issue #291: injectable clock, hoisted here so BOTH the migrated and legacy paths below use
3233
+ // the SAME instant for their expiry checks.
3234
+ const now = options.now ?? new Date();
3235
+ // issue #279 (increment 2, PR-D, Deliverable 1b): the migrated path — the four legacy
3236
+ // verify-arms below (1a-4) become settle_gate's OWN predicate arms; this branch skips them
3237
+ // entirely and settles atomically against FRESH state via the store's own settleStep. Dormancy:
3238
+ // an undeclaring store falls through to the byte-identical legacy path below (I16/#169
3239
+ // fail-closed dormancy).
3240
+ if (store.settleStep !== undefined) {
3241
+ const respondedAt = now;
3242
+ // Evidence rule (design record §6 lens-3 S2): built from the PRE-READ `pending_gate` IFF it
3243
+ // matches options.gateId — else `[]` (the arm can never APPLY against a non-matching fresh
3244
+ // read either, so an empty evidence array is inert there; a matching pre-read is guaranteed
3245
+ // fresh enough to be correct on `applied: true`, since gate_id is a per-attempt-minted UUID
3246
+ // that can never "come back" once resolved/absent).
3247
+ const gateResponseEvidence = run.pending_gate !== undefined && run.pending_gate.gate_id === options.gateId
3248
+ ? [
3249
+ buildGateResponseSnapshot(run.pending_gate, options.choice, respondedAt, options.respondedBy),
3250
+ ]
3251
+ : [];
3252
+ const delta = {
3253
+ kind: 'settle_gate',
3254
+ gateId: options.gateId,
3255
+ choice: options.choice,
3256
+ ...(options.respondedBy !== undefined ? { respondedBy: options.respondedBy } : {}),
3257
+ evidence: gateResponseEvidence,
3258
+ };
3259
+ let result;
3260
+ try {
3261
+ // issue #291 ([F3]): the injectable `now` reaches applySettleGate's write-free
3262
+ // gate_expired_pending arm through this SAME options.now plumbing.
3263
+ result = await store.settleStep(options.runId, delta, definition, { now });
3264
+ }
3265
+ catch (err) {
3266
+ // Thrown infra errors keep the SAME shape as the legacy path's own final-persist catch,
3267
+ // below (design record §6: "thrown infra errors ALSO keep the :3563-3581 shape").
3268
+ const e = err instanceof WorkflowError
3269
+ ? err
3270
+ : new WorkflowError('Failed to persist gate response', {
3271
+ code: 'ENGINE_STORE_FAILED',
3272
+ category: 'ENGINE',
3273
+ agentAction: 'stop',
3274
+ retryable: false,
3275
+ });
3276
+ return errorEnvelope(run.pending_gate?.step_name ?? 'submit_gate', options.runId, run.version, e, `Failed to persist gate response.`, run.run_phase);
3277
+ }
3278
+ if (!result.applied) {
3279
+ switch (result.reason) {
3280
+ case 'gate_expired_pending': {
3281
+ // issue #291 ([F3] shape c): the live gate has already expired unresolved — issue the
3282
+ // caller-composed expire_gate settleStep ([F1]'s arms make this idempotent even under a
3283
+ // race with another enactment point) and compose the honest late-response envelope from
3284
+ // whatever the enactment result actually committed.
3285
+ const overdueMs = run.pending_gate?.expires_at !== undefined
3286
+ ? now.getTime() - new Date(run.pending_gate.expires_at).getTime()
3287
+ : 0;
3288
+ const expireDelta = { kind: 'expire_gate', gateId: options.gateId };
3289
+ let expireResult;
3290
+ try {
3291
+ expireResult = await store.settleStep(options.runId, expireDelta, definition, {
3292
+ now,
3293
+ });
3294
+ }
3295
+ catch (err) {
3296
+ const e = err instanceof WorkflowError
3297
+ ? err
3298
+ : new WorkflowError("Failed to enact the gate's expiry", {
3299
+ code: 'ENGINE_STORE_FAILED',
3300
+ category: 'ENGINE',
3301
+ agentAction: 'stop',
3302
+ retryable: false,
3303
+ });
3304
+ return errorEnvelope(run.pending_gate?.step_name ?? 'submit_gate', options.runId, result.run.version, e, "Failed to enact the gate's expiry.", result.run.run_phase);
3305
+ }
3306
+ return composeExpiredGateEnvelope(store, definition, options.registry, options.gateId, options.choice, overdueMs, expireResult);
3307
+ }
3308
+ case 'already_settled': {
3309
+ // Calm ok envelope stating the resolution already committed (same choice) — never
3310
+ // report_to_user. Drain: on already_settled ∧ pending ledger entries non-empty — hand-
3311
+ // rolled here (buildAlreadySettledEnvelope is ExecuteStepOptions-shaped, not reusable at
3312
+ // this call site) — recovers a crashed-drain RESOLVE on a duplicate submit (design record
3313
+ // §6 row 2, the buildAlreadySettledEnvelope drain-on-NOOP pattern reused verbatim).
3314
+ const stepName = findGateStepName(result.run, options.gateId) ?? 'submit_gate';
3315
+ let noopRun = result.run;
3316
+ let noopDrainWarnings = [];
3317
+ const hasPending = Object.values(noopRun.finalizer_ledger ?? {}).some((e) => e.status === 'pending');
3318
+ if (hasPending) {
3319
+ try {
3320
+ const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
3321
+ noopRun = drainOutcome.run;
3322
+ noopDrainWarnings = drainOutcome.warnings;
3323
+ }
3324
+ catch (err) {
3325
+ noopDrainWarnings = [
3326
+ `post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`,
3327
+ ];
3328
+ }
3329
+ }
3330
+ return {
3331
+ command: stepName,
3332
+ run_id: options.runId,
3333
+ run_version: noopRun.version,
3334
+ status: 'ok',
3335
+ data: {},
3336
+ evidence: [],
3337
+ warnings: mergeWarnings([], ...noopDrainWarnings),
3338
+ errors: [],
3339
+ context_hint: `Gate '${options.gateId}' was already resolved with choice '${options.choice}' — no action was taken.`,
3340
+ run_phase: noopRun.run_phase,
3341
+ next_actions: noopRun.terminal_state ? [] : buildNextActions(definition, noopRun),
3342
+ };
3343
+ }
3344
+ case 'gate_choice_conflict': {
3345
+ const stepName = findGateStepName(result.run, options.gateId);
3346
+ const err = new WorkflowError(`Gate '${options.gateId}' was already resolved with choice '${result.winningChoice}' ` +
3347
+ `— your choice '${options.choice}' was not recorded.`, {
3348
+ code: 'STATE_BLOCKED',
3349
+ category: 'STATE',
3350
+ agentAction: 'report_to_user',
3351
+ retryable: false,
3352
+ details: {
3353
+ runId: options.runId,
3354
+ gateId: options.gateId,
3355
+ winning_choice: result.winningChoice,
3356
+ },
3357
+ });
3358
+ return errorEnvelope(stepName ?? 'submit_gate', options.runId, result.run.version, err, err.message, result.run.run_phase);
3359
+ }
3360
+ case 'choice_not_eligible': {
3361
+ // VALIDATION_INPUT_SCHEMA envelope — parity with the legacy path's own step 4 (below).
3362
+ const stepName = result.run.pending_gate.step_name; // the arm only reaches this check
3363
+ // when fresh.pending_gate.gate_id === gateId, so this is reliably the live gate's step.
3364
+ const expected = (result.choices ?? []).join(', ');
3365
+ const err = new WorkflowError(`Choice '${options.choice}' is not valid. Expected one of: ${expected}`, {
3366
+ code: 'VALIDATION_INPUT_SCHEMA',
3367
+ category: 'VALIDATION',
3368
+ agentAction: 'report_to_user',
3369
+ retryable: false,
3370
+ });
3371
+ return errorEnvelope(stepName, options.runId, result.run.version, err, `Invalid choice '${options.choice}' for gate '${stepName}'.`, result.run.run_phase);
3372
+ }
3373
+ case 'gate_mismatch': {
3374
+ const err = new WorkflowError(`Gate '${options.gateId}' is not the open gate and matches no committed resolution.`, {
3375
+ code: 'STATE_BLOCKED',
3376
+ category: 'STATE',
3377
+ agentAction: 'report_to_user',
3378
+ retryable: false,
3379
+ details: { runId: options.runId, gateId: options.gateId },
3380
+ });
3381
+ return errorEnvelope('submit_gate', options.runId, result.run.version, err, err.message, result.run.run_phase);
3382
+ }
3383
+ case 'run_terminal': {
3384
+ // Composed cancelled-predicate (design record §5 D-4/§11 N10): any gate_cancelled_by_abort
3385
+ // skip detail ⇒ the cancelled variant ("your choice was NOT recorded" + cause) — bound by
3386
+ // gate_id equality once that field is populated (PR-D+), else by presence alone (pre-PR-D
3387
+ // records, N10). No match ⇒ the zombie/grandfathered variant + the resume-clears/purge
3388
+ // pointer.
3389
+ const cancelEntry = Object.entries(result.run.skip_details ?? {}).find(([, d]) => d.kind === 'gate_cancelled_by_abort');
3390
+ const cancelDetail = cancelEntry?.[1];
3391
+ const isCancelledMatch = cancelEntry !== undefined &&
3392
+ (cancelDetail.gate_id === undefined || cancelDetail.gate_id === options.gateId);
3393
+ if (isCancelledMatch) {
3394
+ const [cancelledStep] = cancelEntry;
3395
+ const abortedBy = result.run.aborted_at?.step_id;
3396
+ const err = new WorkflowError(`Gate '${options.gateId}' on '${cancelledStep}' was cancelled when ` +
3397
+ `'${abortedBy ?? 'another step'}' aborted the run — your choice was NOT recorded.`, {
3398
+ code: 'STATE_RUN_TERMINAL',
3399
+ category: 'STATE',
3400
+ agentAction: 'report_to_user',
3401
+ retryable: false,
3402
+ details: {
3403
+ runId: options.runId,
3404
+ run_phase: result.run.run_phase,
3405
+ gate_id: options.gateId,
3406
+ step_name: cancelledStep,
3407
+ ...(abortedBy !== undefined ? { aborted_by: abortedBy } : {}),
3408
+ },
3409
+ });
3410
+ return errorEnvelope(cancelledStep, options.runId, result.run.version, err, err.message, result.run.run_phase);
3411
+ }
3412
+ // Zombie/grandfathered variant — the #282 class: a terminal record may still carry a
3413
+ // stale pending_gate (never cleared), which is the best-effort step label here.
3414
+ const zombieStep = result.run.pending_gate?.step_name ?? 'submit_gate';
3415
+ const err = new WorkflowError(`Run '${options.runId}' is terminal; cannot submit a gate response — 'realm resume' ` +
3416
+ `clears a stale pending gate on a resumable run, or 'realm run purge' removes the ` +
3417
+ `record entirely.`, {
3418
+ code: 'STATE_RUN_TERMINAL',
3419
+ category: 'STATE',
3420
+ agentAction: 'report_to_user',
3421
+ retryable: false,
3422
+ details: { runId: options.runId, run_phase: result.run.run_phase },
3423
+ });
3424
+ return errorEnvelope(zombieStep, options.runId, result.run.version, err, err.message, result.run.run_phase);
3425
+ }
3426
+ default:
3427
+ // gate_open_wait/already_open/already_released/choice_not_eligible's siblings and every
3428
+ // other kind's own reason are unreachable here — settle_gate never returns them (design
3429
+ // record §7).
3430
+ throw new Error(`submitHumanResponse: unreachable settle_gate refusal reason '${result.reason}'`);
3431
+ }
3432
+ }
3433
+ // applied: true. Reliable step name: the pre-read's pending_gate.step_name (guaranteed
3434
+ // correct whenever `applied` is true — see the evidence-rule comment above).
3435
+ const resolvedGateStepName = run.pending_gate.step_name;
3436
+ // Drain: on transitioned OR (already_settled ∧ pending ledger entries non-empty) — hand-rolled
3437
+ // (buildAlreadySettledEnvelope is ExecuteStepOptions-shaped, not reusable here). `applied:
3438
+ // true` never reaches the already_settled leg, so only the `transitioned` disjunct applies at
3439
+ // THIS call site (design record §6 row 2).
3440
+ let finalRun = result.run;
3441
+ let drainWarnings = [];
3442
+ if (result.transitioned) {
3443
+ try {
3444
+ const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
3445
+ finalRun = drainOutcome.run;
3446
+ drainWarnings = drainOutcome.warnings;
3447
+ }
3448
+ catch (err) {
3449
+ drainWarnings = [
3450
+ `post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`,
3451
+ ];
3452
+ }
3453
+ }
3454
+ // Convergence hint (design record D-2 N8 narrowing, pedestal steal — must not drop): after a
3455
+ // committed RESOLVE, when a guard is thereby eligible, append one line per eligible guard.
3456
+ // findEligibleGuardSteps self-filters terminal runs (returns [] there), so this is inert on a
3457
+ // gate-completion terminal transition.
3458
+ const convergenceHints = findEligibleGuardSteps(definition, finalRun).map((name) => `guard '${name}' now eligible — converges at the next drive`);
3459
+ const defaultedStepsDurabilityWarning = finalRun.defaulted_steps !== undefined &&
3460
+ finalRun.defaulted_steps.length > 0 &&
3461
+ !persistsField(store, 'defaulted_steps')
3462
+ ? 'run-level defaultedness marker (defaulted_steps) not durable on this store'
3463
+ : undefined;
3464
+ const migratedNextActions = finalRun.terminal_state
3465
+ ? []
3466
+ : buildNextActions(definition, finalRun);
3467
+ const migratedOrientation = finalRun.terminal_state
3468
+ ? `Run completed (phase: '${finalRun.run_phase}'). Call get_run_state with run_id '${options.runId}' to retrieve the full evidence record.`
3469
+ : `Gate '${resolvedGateStepName}' resolved with choice '${options.choice}'. ${migratedNextActions.length} step(s) now available.`;
3470
+ return {
3471
+ command: resolvedGateStepName,
3472
+ run_id: options.runId,
3473
+ run_version: finalRun.version,
3474
+ status: 'ok',
3475
+ data: { ...run.pending_gate.preview, choice: options.choice },
3476
+ evidence: [],
3477
+ warnings: mergeWarnings(convergenceHints, ...drainWarnings, defaultedStepsDurabilityWarning),
3478
+ errors: [],
3479
+ context_hint: migratedOrientation,
3480
+ run_phase: finalRun.run_phase,
3481
+ next_actions: migratedNextActions,
3482
+ ...(finalRun.defaulted_steps?.length ? { defaulted_steps: finalRun.defaulted_steps } : {}),
3483
+ };
3484
+ }
3485
+ // --- Legacy path (dormancy fallback — byte-identical to pre-#279 behavior) ---
2707
3486
  // 1a. Defensive terminal guard (mirrors #91/#95): a late gate response must never re-drive a
2708
3487
  // run that has already reached a terminal phase.
2709
3488
  if (run.terminal_state) {
@@ -2733,6 +3512,44 @@ export async function submitHumanResponse(store, definition, options) {
2733
3512
  retryable: false,
2734
3513
  }), `Gate ID mismatch on run '${options.runId}'.`);
2735
3514
  }
3515
+ // 3.5. issue #291 ([F4] legacy-store expiry — the ONE enactment point F4 explicitly gives a
3516
+ // legacy-CAS fallback, since it already owns one): the gate has expired AND has an enactable
3517
+ // disposition (on_expiry frozen — a finding-only gate, expires_at with no on_expiry, is
3518
+ // excluded exactly like applySettleGate's own F3 gating, so a finding-only gate's human
3519
+ // response resolves normally below, however overdue). Enacted via the SAME pure
3520
+ // `applySettlement` transform this store's declaring siblings use through `settleStep` — this
3521
+ // store has no `settleStep` of its own, so the result is persisted through the version-CAS
3522
+ // `store.update()` this legacy path already owns. A CAS-mismatch (a genuine race) surfaces as
3523
+ // an honest error, matching this path's existing no-retry risk profile everywhere else.
3524
+ if (run.pending_gate.expires_at !== undefined &&
3525
+ run.pending_gate.on_expiry !== undefined &&
3526
+ now.getTime() >= new Date(run.pending_gate.expires_at).getTime()) {
3527
+ const overdueMs = now.getTime() - new Date(run.pending_gate.expires_at).getTime();
3528
+ const expireOutcome = applySettlement(run, { kind: 'expire_gate', gateId: options.gateId }, definition, { now });
3529
+ if (!expireOutcome.applied) {
3530
+ // In-contract for this snapshot-based path: the local `run` might already reflect a prior
3531
+ // enactment (e.g. a same-process retry) — already_settled composes the honest envelope the
3532
+ // same way the migrated path's race leg does, reading disposition off `expireOutcome.run`
3533
+ // (the very snapshot the refusal matched against).
3534
+ return composeExpiredGateEnvelope(store, definition, options.registry, options.gateId, options.choice, overdueMs, expireOutcome);
3535
+ }
3536
+ let persistedExpiry;
3537
+ try {
3538
+ persistedExpiry = await store.update(expireOutcome.run);
3539
+ }
3540
+ catch (err) {
3541
+ const e = err instanceof WorkflowError
3542
+ ? err
3543
+ : new WorkflowError("Failed to persist the gate's expiry", {
3544
+ code: 'ENGINE_STORE_FAILED',
3545
+ category: 'ENGINE',
3546
+ agentAction: 'stop',
3547
+ retryable: false,
3548
+ });
3549
+ return errorEnvelope(run.pending_gate.step_name, options.runId, run.version, e, "Failed to enact the gate's expiry.", run.run_phase);
3550
+ }
3551
+ return composeExpiredGateEnvelope(store, definition, options.registry, options.gateId, options.choice, overdueMs, { ...expireOutcome, run: persistedExpiry });
3552
+ }
2736
3553
  // 4. Validate choice.
2737
3554
  if (!run.pending_gate.choices.includes(options.choice)) {
2738
3555
  const expected = run.pending_gate.choices.join(', ');
@@ -2834,7 +3651,9 @@ export async function submitHumanResponse(store, definition, options) {
2834
3651
  // envelope flag (do NOT add an evidence scan to recompute it). Its disclosure surface is the
2835
3652
  // run-level `defaulted_steps` marker below, plus whatever the gate-open envelope already
2836
3653
  // warned the human with.
2837
- warnings: mergeWarnings([], defaultedStepsDurabilityWarning),
3654
+ // issue #279 (increment 2, PR-D): + the ONE dormancy advisory (I16) — this IS the legacy path
3655
+ // (store.settleStep undeclared).
3656
+ warnings: mergeWarnings([], defaultedStepsDurabilityWarning, DORMANCY_ADVISORY),
2838
3657
  errors: [],
2839
3658
  context_hint: orientation,
2840
3659
  run_phase: savedRun.run_phase,
@@ -2993,8 +3812,10 @@ async function buildFinalizedSeal(definition, sealDraft, outcome, registry) {
2993
3812
  // settlement.ts's selectFinalizers, shared with `mintFresh` — this call passes the SAME inputs
2994
3813
  // the inline loop used to compute over, so the returned name list is byte-identical to what
2995
3814
  // `[...groupA, ...groupB]` produced before the extraction.
3815
+ // issue #302 (chokepoint 2 of 2): derive the full effective trigger set from sealDraft — the
3816
+ // legacy seal path for a non-declaring external store (the dormancy fallback).
2996
3817
  const settled = new Set([...sealDraft.completed_steps, ...sealDraft.failed_steps]);
2997
- const selected = selectFinalizers(definition, settled, outcome);
3818
+ const selected = selectFinalizers(definition, settled, deriveEffectiveTriggers(outcome, sealDraft));
2998
3819
  let record = sealDraft;
2999
3820
  for (const name of selected) {
3000
3821
  const step = definition.steps[name];
@@ -3307,15 +4128,242 @@ depth0Warnings) {
3307
4128
  // Execute any eligible guard steps inline before looking for the next auto step.
3308
4129
  // Guard steps are synchronous engine decisions — not returned to the agent.
3309
4130
  // Loop to handle cascading guards (guard A passes → guard B becomes eligible).
4131
+ // issue #279 (increment 2, PR-D, Deliverable 1c): non-abort settled_outcome_divergence warnings
4132
+ // ADVANCE the chain but must still surface somewhere — carried here and merged into whichever
4133
+ // envelope eventually returns (the guardsRan rebuild below, or a migrated terminal return).
4134
+ const guardWarnings = [];
3310
4135
  let guardEligible = findEligibleGuardSteps(definition, run);
3311
4136
  while (guardEligible.length > 0) {
3312
4137
  const guardName = guardEligible[0];
3313
4138
  const guardStepDef = definition.steps[guardName];
3314
- // Execute inline (pure in-memory; returns updated RunRecord).
4139
+ // Execute inline (pure in-memory; returns updated RunRecord). executeGuardStep stays PURE and
4140
+ // UNTOUCHED (design record §1) — both the migrated and legacy paths below call it identically.
3315
4141
  const guardResult = await executeGuardStep(guardName, guardStepDef, definition, run);
3316
4142
  // Capture the guard's OWN evidence (its last entry) BEFORE the finalizer drain appends
3317
4143
  // finalizer evidence — the terminal return below surfaces only the guard's evidence.
3318
4144
  const guardOwnEvidence = guardResult.evidence.slice(-1);
4145
+ // issue #279 (increment 2, PR-D, Deliverable 1c): the migrated path — settles this guard's
4146
+ // evaluated outcome atomically against FRESH state via the store's own settleStep. Dormancy:
4147
+ // an undeclaring store falls through to the byte-identical legacy path below (I16/#169
4148
+ // fail-closed dormancy).
4149
+ if (store.settleStep !== undefined) {
4150
+ // Extraction rule (design record §2, normative): reverse-classify guardResult's SEALED
4151
+ // output by MEMBERSHIP — NEVER the terminal_state ternary below (wrong for a non-terminal
4152
+ // pass, which never sets terminal_state at all).
4153
+ const guardSettleOutcome = guardResult.aborted_at !== undefined
4154
+ ? 'abort'
4155
+ : guardResult.failed_steps.includes(guardName)
4156
+ ? 'resolution_error'
4157
+ : 'pass'; // the only remaining membership — completed_steps.includes(guardName)
4158
+ let resolutionError;
4159
+ if (guardSettleOutcome === 'resolution_error') {
4160
+ // Rails-compliant re-derivation (normative): normalize abort_unless to string[] (the
4161
+ // executeGuardStep :3632-3635 shape) and re-run evaluateGuardConditions against the SAME
4162
+ // pre-seal `run` passed to executeGuardStep — pure + deterministic, so this reproduces the
4163
+ // discarded internal result byte-for-byte.
4164
+ const conditions = Array.isArray(guardStepDef.abort_unless)
4165
+ ? guardStepDef.abort_unless
4166
+ : [guardStepDef.abort_unless];
4167
+ const reEvaluated = evaluateGuardConditions(conditions, buildEvidenceByStep(run));
4168
+ if (reEvaluated.kind === 'resolution_error') {
4169
+ resolutionError = {
4170
+ condition: reEvaluated.condition,
4171
+ unresolvable_path: reEvaluated.unresolvable_path,
4172
+ };
4173
+ }
4174
+ }
4175
+ const delta = {
4176
+ kind: 'settle_guard',
4177
+ step: guardName,
4178
+ outcome: guardSettleOutcome,
4179
+ evidence: guardOwnEvidence[0],
4180
+ ...(resolutionError !== undefined ? { resolutionError } : {}),
4181
+ ...(guardSettleOutcome === 'abort'
4182
+ ? {
4183
+ abort: {
4184
+ conditions: guardResult.aborted_at.conditions ?? [],
4185
+ ...(guardResult.aborted_at.abort_message !== undefined
4186
+ ? { abort_message: guardResult.aborted_at.abort_message }
4187
+ : {}),
4188
+ },
4189
+ }
4190
+ : {}),
4191
+ // evaluatedAtVersion (design record §2, lane-B steal 2): the chain's OWN evaluation
4192
+ // snapshot — this iteration's pre-settle `run.version`.
4193
+ evaluatedAtVersion: run.version,
4194
+ };
4195
+ let guardSettleResult;
4196
+ try {
4197
+ guardSettleResult = await store.settleStep(options.runId, delta, definition);
4198
+ }
4199
+ catch (storeErr) {
4200
+ // Thrown infra errors — the same persist-failure envelope shape the legacy path's own
4201
+ // store.update catch (below) has always returned.
4202
+ const msg = storeErr instanceof Error ? storeErr.message : String(storeErr);
4203
+ return {
4204
+ command: options.command,
4205
+ run_id: options.runId,
4206
+ run_version: run.version,
4207
+ status: 'error',
4208
+ data: {},
4209
+ evidence: [],
4210
+ warnings: [],
4211
+ errors: [`Failed to persist guard step '${guardName}': ${msg}`],
4212
+ agent_action: 'stop',
4213
+ context_hint: `Guard step '${guardName}' could not be persisted. Run state may be inconsistent.`,
4214
+ run_phase: run.run_phase,
4215
+ next_actions: [],
4216
+ };
4217
+ }
4218
+ if (!guardSettleResult.applied) {
4219
+ // Chain-consumption table (design record §6, adjudicated):
4220
+ if (guardSettleResult.reason === 'already_settled' ||
4221
+ guardSettleResult.reason === 'gate_open_wait' ||
4222
+ (guardSettleResult.reason === 'settled_outcome_divergence' &&
4223
+ guardSettleOutcome !== 'abort')) {
4224
+ // already_settled / gate_open_wait ⇒ ADVANCE, threading result.run (findEligibleGuardSteps
4225
+ // self-filters both a now-settled guard and an open gate, so the loop naturally converges).
4226
+ // settled_outcome_divergence on a NON-abort attempt ⇒ ADVANCE + a warning line.
4227
+ if (guardSettleResult.reason === 'settled_outcome_divergence') {
4228
+ guardWarnings.push(`guard '${guardName}' outcome diverged from a concurrent settle` +
4229
+ (guardSettleResult.persisted !== undefined
4230
+ ? ` (persisted: '${guardSettleResult.persisted}')`
4231
+ : '') +
4232
+ ' — chain advanced on the persisted outcome.');
4233
+ }
4234
+ if (guardSettleResult.reason !== 'gate_open_wait') {
4235
+ // "quiet" end-of-pass for gate_open_wait only — nothing was decided, so nothing is
4236
+ // recorded; already_settled/divergence DID decide something (elsewhere), so it is.
4237
+ chainedSteps.push({ step: guardName, run_phase: guardSettleResult.run.run_phase });
4238
+ }
4239
+ run = guardSettleResult.run;
4240
+ // Drain: on already_settled ∧ pending ledger entries non-empty (design record §6, "same
4241
+ // clause" as the transitioned leg below) — recovers a crashed-drain RESOLVE that a
4242
+ // sibling's own settle committed but never drained.
4243
+ if (guardSettleResult.reason === 'already_settled') {
4244
+ const hasPending = Object.values(run.finalizer_ledger ?? {}).some((e) => e.status === 'pending');
4245
+ if (hasPending) {
4246
+ try {
4247
+ const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
4248
+ run = drainOutcome.run;
4249
+ if (drainOutcome.warnings.length > 0)
4250
+ guardWarnings.push(...drainOutcome.warnings);
4251
+ }
4252
+ catch (err) {
4253
+ guardWarnings.push(`post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`);
4254
+ }
4255
+ }
4256
+ }
4257
+ guardEligible = findEligibleGuardSteps(definition, run);
4258
+ continue;
4259
+ }
4260
+ if (guardSettleResult.reason === 'settled_outcome_divergence') {
4261
+ // ABORT leg only ⇒ report_to_user + chain-RETURN (design record §6/§7) — this attempt's
4262
+ // abort was never recorded.
4263
+ const err = new WorkflowError(`Guard step '${guardName}' was already settled` +
4264
+ (guardSettleResult.persisted !== undefined
4265
+ ? ` (persisted: '${guardSettleResult.persisted}')`
4266
+ : '') +
4267
+ ` by a different attempt — your abort was NOT recorded.`, {
4268
+ code: 'STATE_STEP_ALREADY_SETTLED',
4269
+ category: 'STATE',
4270
+ agentAction: 'report_to_user',
4271
+ retryable: false,
4272
+ details: {
4273
+ runId: options.runId,
4274
+ step: guardName,
4275
+ reason: guardSettleResult.reason,
4276
+ ...(guardSettleResult.persisted !== undefined
4277
+ ? { persisted: guardSettleResult.persisted }
4278
+ : {}),
4279
+ },
4280
+ });
4281
+ return {
4282
+ command: options.command,
4283
+ run_id: options.runId,
4284
+ run_version: guardSettleResult.run.version,
4285
+ status: 'error',
4286
+ data: {},
4287
+ evidence: [],
4288
+ warnings: [],
4289
+ errors: [err.message],
4290
+ error_code: err.code,
4291
+ ...(Object.keys(err.details).length > 0 ? { error_details: err.details } : {}),
4292
+ agent_action: 'report_to_user',
4293
+ context_hint: err.message,
4294
+ run_phase: guardSettleResult.run.run_phase,
4295
+ next_actions: [],
4296
+ };
4297
+ }
4298
+ if (guardSettleResult.reason === 'run_terminal') {
4299
+ // Terminal by OTHER (a sibling settle raced this guard's own evaluation) — INLINE
4300
+ // construction, parity with the entry-terminal envelope (executeChain's own early
4301
+ // return).
4302
+ return {
4303
+ command: options.command,
4304
+ run_id: options.runId,
4305
+ run_version: guardSettleResult.run.version,
4306
+ status: 'ok',
4307
+ data: {},
4308
+ evidence: [],
4309
+ warnings: [],
4310
+ errors: [],
4311
+ agent_action: 'stop',
4312
+ context_hint: `Run '${options.runId}' is already terminal (${guardSettleResult.run.run_phase}); guard '${guardName}' was not evaluated.`,
4313
+ run_phase: guardSettleResult.run.run_phase,
4314
+ next_actions: [],
4315
+ };
4316
+ }
4317
+ // gate_mismatch/choice_not_eligible/already_open/already_released and every other kind's
4318
+ // own reason are unreachable here — settle_guard never returns them (design record §7).
4319
+ throw new Error(`executeChainInternal: unreachable settle_guard refusal reason '${guardSettleResult.reason}'`);
4320
+ }
4321
+ // applied: true.
4322
+ chainedSteps.push({ step: guardName, run_phase: guardSettleResult.run.run_phase });
4323
+ if (guardSettleResult.transitioned) {
4324
+ // Drain IMMEDIATELY after a transitioned settle result, BEFORE building the in-loop
4325
+ // terminal envelope (design record §6/R5 — a post-loop drain would be dead code on this
4326
+ // leg: this function RETURNS before ever reaching a post-loop point).
4327
+ let finalGuardRun = guardSettleResult.run;
4328
+ let guardDrainWarnings;
4329
+ try {
4330
+ const drainOutcome = await drainFinalizers(store, definition, options.registry, options.runId);
4331
+ finalGuardRun = drainOutcome.run;
4332
+ guardDrainWarnings = drainOutcome.warnings;
4333
+ }
4334
+ catch (err) {
4335
+ guardDrainWarnings = [
4336
+ `post-commit finalizer drain failed: ${err instanceof Error ? err.message : String(err)}`,
4337
+ ];
4338
+ }
4339
+ const migratedContextHint = guardSettleOutcome === 'abort'
4340
+ ? `Guard step '${guardName}' aborted the run.`
4341
+ : guardSettleOutcome === 'resolution_error'
4342
+ ? `Guard step '${guardName}' failed with a resolution error. Run is terminated.`
4343
+ : `Guard step '${guardName}' passed and completed the run.`;
4344
+ return {
4345
+ command: options.command,
4346
+ run_id: options.runId,
4347
+ run_version: finalGuardRun.version,
4348
+ status: 'ok',
4349
+ data: {},
4350
+ evidence: guardOwnEvidence,
4351
+ warnings: mergeWarnings(guardWarnings, ...guardDrainWarnings),
4352
+ errors: [],
4353
+ context_hint: migratedContextHint,
4354
+ run_phase: finalGuardRun.run_phase,
4355
+ next_actions: [],
4356
+ ...(finalGuardRun.defaulted_steps?.length
4357
+ ? { defaulted_steps: finalGuardRun.defaulted_steps }
4358
+ : {}),
4359
+ };
4360
+ }
4361
+ // Non-terminal pass — continue the chain.
4362
+ run = guardSettleResult.run;
4363
+ guardEligible = findEligibleGuardSteps(definition, run);
4364
+ continue;
4365
+ }
4366
+ // --- Legacy path (dormancy fallback — byte-identical to pre-#279 behavior) ---
3319
4367
  // Blocking fix #1: classify the terminal outcome by the SEALED record, not aborted_at
3320
4368
  // alone. executeGuardStep sets terminal_state in THREE cases — abort (aborted_at set),
3321
4369
  // resolution-error (failed, no aborted_at), and a PASS that completes the run
@@ -3384,7 +4432,9 @@ depth0Warnings) {
3384
4432
  data: {},
3385
4433
  // The guard's own evidence entry, captured before the finalizer drain appended any.
3386
4434
  evidence: guardOwnEvidence,
3387
- warnings: mergeWarnings([], guardDefaultedStepsDurabilityWarning),
4435
+ // issue #279 (increment 2, PR-D): + the ONE dormancy advisory (I16) — this IS the legacy
4436
+ // path (store.settleStep undeclared).
4437
+ warnings: mergeWarnings([], guardDefaultedStepsDurabilityWarning, DORMANCY_ADVISORY),
3388
4438
  errors: [],
3389
4439
  context_hint: contextHint,
3390
4440
  run_phase: persistedGuardRun.run_phase,
@@ -3408,6 +4458,12 @@ depth0Warnings) {
3408
4458
  ...result,
3409
4459
  run_version: run.version,
3410
4460
  next_actions: freshNextActions,
4461
+ // issue #279 (increment 2, PR-D): non-abort settled_outcome_divergence warnings accumulated
4462
+ // during the guard loop above (the ADVANCE leg) must reach whichever envelope returns —
4463
+ // this rebuild is the first point after the loop `result` is touched again.
4464
+ ...(guardWarnings.length > 0
4465
+ ? { warnings: mergeWarnings(result.warnings, ...guardWarnings) }
4466
+ : {}),
3411
4467
  };
3412
4468
  }
3413
4469
  if (run.terminal_state || run.pending_gate !== undefined) {
@@ -3464,7 +4520,13 @@ export async function executeChain(store, definition, options) {
3464
4520
  throw err;
3465
4521
  }
3466
4522
  }
3467
- if (entryRun !== undefined && isTerminalPhase(entryRun.run_phase)) {
4523
+ // issue #279 (increment 2, PR-C D-3 leg v): keyed on terminal_state, never the persisted
4524
+ // run_phase — a grandfathered terminal-with-stale-gate record (the #282 class) must still be
4525
+ // recognized as terminal here.
4526
+ if (entryRun !== undefined && entryRun.terminal_state === true) {
4527
+ // Derive-for-message (D-3 leg v): render the TRUE (derived) phase, never the possibly-stale
4528
+ // persisted one.
4529
+ const derivedPhase = deriveRunPhase(entryRun);
3468
4530
  return {
3469
4531
  command: options.command,
3470
4532
  run_id: options.runId,
@@ -3475,8 +4537,8 @@ export async function executeChain(store, definition, options) {
3475
4537
  warnings: [],
3476
4538
  errors: [],
3477
4539
  agent_action: 'stop',
3478
- context_hint: `Run '${options.runId}' is already terminal (${entryRun.run_phase}); no steps executed.`,
3479
- run_phase: entryRun.run_phase,
4540
+ context_hint: `Run '${options.runId}' is already terminal (${derivedPhase}); no steps executed.`,
4541
+ run_phase: derivedPhase,
3480
4542
  next_actions: [],
3481
4543
  };
3482
4544
  }