@sensigo/realm 0.33.0 → 0.35.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 (49) hide show
  1. package/dist/engine/execution-loop.d.ts +22 -1
  2. package/dist/engine/execution-loop.d.ts.map +1 -1
  3. package/dist/engine/execution-loop.js +352 -6
  4. package/dist/engine/execution-loop.js.map +1 -1
  5. package/dist/engine/gate-timing.d.ts +17 -0
  6. package/dist/engine/gate-timing.d.ts.map +1 -0
  7. package/dist/engine/gate-timing.js +19 -0
  8. package/dist/engine/gate-timing.js.map +1 -0
  9. package/dist/engine/reclaim-step.d.ts +8 -0
  10. package/dist/engine/reclaim-step.d.ts.map +1 -1
  11. package/dist/engine/reclaim-step.js +48 -3
  12. package/dist/engine/reclaim-step.js.map +1 -1
  13. package/dist/engine/run-health.d.ts +1 -1
  14. package/dist/engine/run-health.d.ts.map +1 -1
  15. package/dist/engine/run-health.js +99 -2
  16. package/dist/engine/run-health.js.map +1 -1
  17. package/dist/engine/settlement.d.ts +52 -14
  18. package/dist/engine/settlement.d.ts.map +1 -1
  19. package/dist/engine/settlement.js +258 -16
  20. package/dist/engine/settlement.js.map +1 -1
  21. package/dist/index.d.ts +5 -1
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +4 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/store/json-file-store.d.ts.map +1 -1
  26. package/dist/store/json-file-store.js +1 -2
  27. package/dist/store/json-file-store.js.map +1 -1
  28. package/dist/types/response-envelope.d.ts +16 -0
  29. package/dist/types/response-envelope.d.ts.map +1 -1
  30. package/dist/types/run-record.d.ts +108 -0
  31. package/dist/types/run-record.d.ts.map +1 -1
  32. package/dist/types/settlement.d.ts +31 -2
  33. package/dist/types/settlement.d.ts.map +1 -1
  34. package/dist/types/workflow-definition.d.ts +113 -32
  35. package/dist/types/workflow-definition.d.ts.map +1 -1
  36. package/dist/types/workflow-definition.js +20 -0
  37. package/dist/types/workflow-definition.js.map +1 -1
  38. package/dist/workflow/diagnostics.d.ts +1 -1
  39. package/dist/workflow/diagnostics.d.ts.map +1 -1
  40. package/dist/workflow/diagnostics.js +3 -0
  41. package/dist/workflow/diagnostics.js.map +1 -1
  42. package/dist/workflow/structured-output-eligibility.d.ts +81 -0
  43. package/dist/workflow/structured-output-eligibility.d.ts.map +1 -0
  44. package/dist/workflow/structured-output-eligibility.js +359 -0
  45. package/dist/workflow/structured-output-eligibility.js.map +1 -0
  46. package/dist/workflow/yaml-loader.d.ts.map +1 -1
  47. package/dist/workflow/yaml-loader.js +145 -2
  48. package/dist/workflow/yaml-loader.js.map +1 -1
  49. package/package.json +2 -4
@@ -1,4 +1,4 @@
1
- import type { RunRecord, AgentTraceEntry } from '../types/run-record.js';
1
+ import type { RunRecord, AgentTraceEntry, StructuredOutputMeta } from '../types/run-record.js';
2
2
  import type { ToolCallRecord } from '../types/mcp-types.js';
3
3
  import type { ResponseEnvelope, NextAction } from '../types/response-envelope.js';
4
4
  import { WorkflowError } from '../types/workflow-error.js';
@@ -22,9 +22,14 @@ export interface ExecuteStepOptions {
22
22
  * Tool calls produced by callStepWithTools for this step.
23
23
  * Absent on the callStep path (no tools configured).
24
24
  * Present (possibly []) when tools were declared — threads through to captureEvidence.
25
+ *
26
+ * `structuredOutput` (issue #236): the caller's own `structured_output: 'strict'` attempt
27
+ * disclosure for THIS attempt — threads into the diagnostics literal at every evidence-capture
28
+ * site. Independent of `toolCalls` (either alone must still cause `stepMeta` to be passed).
25
29
  */
26
30
  stepMeta?: {
27
31
  toolCalls?: ToolCallRecord[];
32
+ structuredOutput?: StructuredOutputMeta;
28
33
  };
29
34
  /**
30
35
  * Optional agent-submitted trace entries for this step.
@@ -50,6 +55,11 @@ export interface ExecuteStepOptions {
50
55
  * today's byte-identical behavior.
51
56
  */
52
57
  writerNonce?: string;
58
+ /**
59
+ * Issue #291: injectable clock for deterministic expiry tests (the execute_step pre-refusal
60
+ * enact-then-proceed check reads this). Defaults to `new Date()` — real callers never set it.
61
+ */
62
+ now?: Date;
53
63
  }
54
64
  export interface SubmitGateOptions {
55
65
  runId: string;
@@ -70,6 +80,12 @@ export interface SubmitGateOptions {
70
80
  * credential model stays authoritative); no arm ever reads it.
71
81
  */
72
82
  respondedBy?: string;
83
+ /**
84
+ * Issue #291: injectable clock for deterministic expiry tests (the F3 write-free
85
+ * `gate_expired_pending` refusal + the caller-issued `expire_gate` follow-up both read this).
86
+ * Defaults to `new Date()` — real callers never set it.
87
+ */
88
+ now?: Date;
73
89
  }
74
90
  export interface ExecuteChainOptions {
75
91
  runId: string;
@@ -82,9 +98,14 @@ export interface ExecuteChainOptions {
82
98
  * Tool calls produced by callStepWithTools for this step.
83
99
  * Absent on the callStep path (no tools configured).
84
100
  * Present (possibly []) when tools were declared — threads through to captureEvidence.
101
+ *
102
+ * `structuredOutput` (issue #236): the caller's own `structured_output: 'strict'` attempt
103
+ * disclosure for THIS attempt — threads into the diagnostics literal at every evidence-capture
104
+ * site. Independent of `toolCalls` (either alone must still cause `stepMeta` to be passed).
85
105
  */
86
106
  stepMeta?: {
87
107
  toolCalls?: ToolCallRecord[];
108
+ structuredOutput?: StructuredOutputMeta;
88
109
  };
89
110
  /** @see ExecuteStepOptions.trace */
90
111
  trace?: AgentTraceEntry[];
@@ -1 +1 @@
1
- {"version":3,"file":"execution-loop.d.ts","sourceRoot":"","sources":["../../src/engine/execution-loop.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"execution-loop.d.ts","sourceRoot":"","sources":["../../src/engine/execution-loop.ts"],"names":[],"mappings":"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"}
@@ -4,7 +4,8 @@ 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';
@@ -770,6 +771,72 @@ async function buildAlreadySettledEnvelope(store, definition, options, result, t
770
771
  */
771
772
  const DORMANCY_ADVISORY = 'settled via the legacy compatibility path — this store does not declare atomic settlement ' +
772
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
+ }
773
840
  /**
774
841
  * Validates eligibility, claims the step, executes it through the dispatcher with retry
775
842
  * and timeout support, captures evidence, persists the updated run record, and returns
@@ -793,6 +860,18 @@ export async function executeStep(store, definition, options) {
793
860
  });
794
861
  return makeErrorEnvelope(options, null, internal);
795
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
+ }
796
875
  // Step 2: Check eligibility.
797
876
  const eligible = findEligibleSteps(definition, run);
798
877
  if (!eligible.includes(options.command)) {
@@ -804,7 +883,7 @@ export async function executeStep(store, definition, options) {
804
883
  status: 'blocked',
805
884
  data: {},
806
885
  evidence: [],
807
- warnings: [],
886
+ warnings: gateExpiryDisclosure !== undefined ? [gateExpiryDisclosure] : [],
808
887
  errors: [],
809
888
  agent_action: 'resolve_precondition',
810
889
  context_hint: `Step '${options.command}' is not eligible in the current run state.`,
@@ -1022,6 +1101,10 @@ export async function executeStep(store, definition, options) {
1022
1101
  // walEntries is declared at this outer scope because it is REASSIGNED to the post-claim read
1023
1102
  // below and referenced at the captureEvidence call site further down this function.
1024
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);
1025
1108
  let preNormalizedTrace;
1026
1109
  let walEntries = [];
1027
1110
  let preClaimSchemaResult;
@@ -1724,6 +1807,20 @@ export async function executeStep(store, definition, options) {
1724
1807
  ...(attemptError === null && (run.validation_rejections?.[options.command] ?? 0) > 0
1725
1808
  ? { validation_rejections: run.validation_rejections[options.command] }
1726
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
+ : {}),
1727
1824
  },
1728
1825
  ...(profileData !== undefined
1729
1826
  ? { agentProfile: profile, agentProfileHash: profileData.content_hash }
@@ -1830,6 +1927,17 @@ export async function executeStep(store, definition, options) {
1830
1927
  precondition_trace: preconditionTrace,
1831
1928
  settled_by_default: true,
1832
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
+ : {}),
1833
1941
  },
1834
1942
  ...(defaultProfileData !== undefined
1835
1943
  ? { agentProfile: defaultProfile, agentProfileHash: defaultProfileData.content_hash }
@@ -1887,6 +1995,17 @@ export async function executeStep(store, definition, options) {
1887
1995
  input_token_estimate: inputTokenEstimate,
1888
1996
  precondition_trace: preconditionTrace,
1889
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
+ : {}),
1890
2009
  },
1891
2010
  ...(exhaustedProfileData !== undefined
1892
2011
  ? { agentProfile: exhaustedProfile, agentProfileHash: exhaustedProfileData.content_hash }
@@ -2428,6 +2547,17 @@ export async function executeStep(store, definition, options) {
2428
2547
  resolvedGateMessage = raw;
2429
2548
  }
2430
2549
  const gateConfig = stepDef.gate;
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;
2431
2561
  // The PendingGate object is built EXACTLY as before, regardless of which path commits it below
2432
2562
  // (issue #279, increment 2, PR-D, Deliverable 1a — the migrated `open_gate` delta carries this
2433
2563
  // SAME object verbatim; the legacy fallback writes it via `store.update` unchanged).
@@ -2436,13 +2566,30 @@ export async function executeStep(store, definition, options) {
2436
2566
  step_name,
2437
2567
  preview: output,
2438
2568
  choices,
2439
- opened_at: new Date().toISOString(),
2569
+ opened_at: openedAt.toISOString(),
2440
2570
  ...(gateConfig?.owner !== undefined ? { owner: gateConfig.owner } : {}),
2441
2571
  ...(resolvedGateMessage !== undefined ? { resolved_message: resolvedGateMessage } : {}),
2442
2572
  ...(gateConfig?.resolution_messages !== undefined
2443
2573
  ? { resolution_messages: gateConfig.resolution_messages }
2444
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
+ : {}),
2445
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);
2446
2593
  // gate.display fallback chain: gate.message resolved → step.prompt resolved → absent
2447
2594
  const resolvedGateDisplay = resolvedGateMessage !== undefined
2448
2595
  ? resolvedGateMessage
@@ -2532,6 +2679,7 @@ export async function executeStep(store, definition, options) {
2532
2679
  // discarded). Calm, confirm_required (the gate genuinely IS still open) — never
2533
2680
  // report_to_user (no `agent_action` set, matching the fresh-open confirm_required shape).
2534
2681
  const liveGate = result.gate;
2682
+ const liveGateDueState = computeGateDueState(liveGate, new Date());
2535
2683
  return {
2536
2684
  command: options.command,
2537
2685
  run_id: options.runId,
@@ -2555,6 +2703,10 @@ export async function executeStep(store, definition, options) {
2555
2703
  ? { display: liveGate.resolved_message }
2556
2704
  : {}),
2557
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
+ : {}),
2558
2710
  },
2559
2711
  };
2560
2712
  }
@@ -2585,6 +2737,10 @@ export async function executeStep(store, definition, options) {
2585
2737
  ? { agent_hint: resolvedGateInstructions }
2586
2738
  : {}),
2587
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
+ : {}),
2588
2744
  },
2589
2745
  };
2590
2746
  }
@@ -2629,6 +2785,10 @@ export async function executeStep(store, definition, options) {
2629
2785
  ...(resolvedGateDisplay !== undefined ? { display: resolvedGateDisplay } : {}),
2630
2786
  ...(resolvedGateInstructions !== undefined ? { agent_hint: resolvedGateInstructions } : {}),
2631
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
+ : {}),
2632
2792
  },
2633
2793
  };
2634
2794
  }
@@ -2946,6 +3106,119 @@ function buildGateResponseSnapshot(gate, choice, respondedAt, respondedBy) {
2946
3106
  ...(respondedBy !== undefined ? { responded_by: respondedBy } : {}),
2947
3107
  };
2948
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
+ // issue #319: rare-by-construction, not unreachable. Reachable ONLY in the migrated-path race
3203
+ // window where a concurrent writer terminalized the run BETWEEN the F3 gate_expired_pending
3204
+ // refusal and this caller's own expire delta being applied — neither search above finds a
3205
+ // matching entry because the concurrent settlement recorded a DIFFERENT gate/step, never this
3206
+ // one (the #317-correction discriminator: this cell is proven reachable only with a terminal
3207
+ // `finalRun`, since the legacy snapshot path answers both searches from one snapshot and can
3208
+ // never fall through to here). The honest answer is a terminal disclosure, not an engine fault
3209
+ // — never attribute the outcome to THIS gate's expiry (no matched entry exists to attribute it
3210
+ // to), and never claim "expired" at all, since nothing here witnessed this gate expiring.
3211
+ const err = new WorkflowError(`Gate '${originalGateId}': the run reached a terminal outcome concurrently — your choice ` +
3212
+ `was NOT recorded. 'realm resume' clears a stale pending gate on a resumable run, or ` +
3213
+ `'realm run purge' removes the record entirely.`, {
3214
+ code: 'STATE_RUN_TERMINAL',
3215
+ category: 'STATE',
3216
+ agentAction: 'report_to_user',
3217
+ retryable: false,
3218
+ details: { runId: finalRun.id, gateId: originalGateId },
3219
+ });
3220
+ return errorEnvelope('submit_gate', finalRun.id, finalRun.version, err, err.message, finalRun.run_phase);
3221
+ }
2949
3222
  export async function submitHumanResponse(store, definition, options) {
2950
3223
  // 1. Load run.
2951
3224
  let run;
@@ -2963,13 +3236,16 @@ export async function submitHumanResponse(store, definition, options) {
2963
3236
  });
2964
3237
  return errorEnvelope('submit_gate', options.runId, 0, e);
2965
3238
  }
3239
+ // issue #291: injectable clock, hoisted here so BOTH the migrated and legacy paths below use
3240
+ // the SAME instant for their expiry checks.
3241
+ const now = options.now ?? new Date();
2966
3242
  // issue #279 (increment 2, PR-D, Deliverable 1b): the migrated path — the four legacy
2967
3243
  // verify-arms below (1a-4) become settle_gate's OWN predicate arms; this branch skips them
2968
3244
  // entirely and settles atomically against FRESH state via the store's own settleStep. Dormancy:
2969
3245
  // an undeclaring store falls through to the byte-identical legacy path below (I16/#169
2970
3246
  // fail-closed dormancy).
2971
3247
  if (store.settleStep !== undefined) {
2972
- const respondedAt = new Date();
3248
+ const respondedAt = now;
2973
3249
  // Evidence rule (design record §6 lens-3 S2): built from the PRE-READ `pending_gate` IFF it
2974
3250
  // matches options.gateId — else `[]` (the arm can never APPLY against a non-matching fresh
2975
3251
  // read either, so an empty evidence array is inert there; a matching pre-read is guaranteed
@@ -2989,7 +3265,9 @@ export async function submitHumanResponse(store, definition, options) {
2989
3265
  };
2990
3266
  let result;
2991
3267
  try {
2992
- result = await store.settleStep(options.runId, delta, definition);
3268
+ // issue #291 ([F3]): the injectable `now` reaches applySettleGate's write-free
3269
+ // gate_expired_pending arm through this SAME options.now plumbing.
3270
+ result = await store.settleStep(options.runId, delta, definition, { now });
2993
3271
  }
2994
3272
  catch (err) {
2995
3273
  // Thrown infra errors keep the SAME shape as the legacy path's own final-persist catch,
@@ -3006,6 +3284,34 @@ export async function submitHumanResponse(store, definition, options) {
3006
3284
  }
3007
3285
  if (!result.applied) {
3008
3286
  switch (result.reason) {
3287
+ case 'gate_expired_pending': {
3288
+ // issue #291 ([F3] shape c): the live gate has already expired unresolved — issue the
3289
+ // caller-composed expire_gate settleStep ([F1]'s arms make this idempotent even under a
3290
+ // race with another enactment point) and compose the honest late-response envelope from
3291
+ // whatever the enactment result actually committed.
3292
+ const overdueMs = run.pending_gate?.expires_at !== undefined
3293
+ ? now.getTime() - new Date(run.pending_gate.expires_at).getTime()
3294
+ : 0;
3295
+ const expireDelta = { kind: 'expire_gate', gateId: options.gateId };
3296
+ let expireResult;
3297
+ try {
3298
+ expireResult = await store.settleStep(options.runId, expireDelta, definition, {
3299
+ now,
3300
+ });
3301
+ }
3302
+ catch (err) {
3303
+ const e = err instanceof WorkflowError
3304
+ ? err
3305
+ : new WorkflowError("Failed to enact the gate's expiry", {
3306
+ code: 'ENGINE_STORE_FAILED',
3307
+ category: 'ENGINE',
3308
+ agentAction: 'stop',
3309
+ retryable: false,
3310
+ });
3311
+ 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);
3312
+ }
3313
+ return composeExpiredGateEnvelope(store, definition, options.registry, options.gateId, options.choice, overdueMs, expireResult);
3314
+ }
3009
3315
  case 'already_settled': {
3010
3316
  // Calm ok envelope stating the resolution already committed (same choice) — never
3011
3317
  // report_to_user. Drain: on already_settled ∧ pending ledger entries non-empty — hand-
@@ -3213,6 +3519,44 @@ export async function submitHumanResponse(store, definition, options) {
3213
3519
  retryable: false,
3214
3520
  }), `Gate ID mismatch on run '${options.runId}'.`);
3215
3521
  }
3522
+ // 3.5. issue #291 ([F4] legacy-store expiry — the ONE enactment point F4 explicitly gives a
3523
+ // legacy-CAS fallback, since it already owns one): the gate has expired AND has an enactable
3524
+ // disposition (on_expiry frozen — a finding-only gate, expires_at with no on_expiry, is
3525
+ // excluded exactly like applySettleGate's own F3 gating, so a finding-only gate's human
3526
+ // response resolves normally below, however overdue). Enacted via the SAME pure
3527
+ // `applySettlement` transform this store's declaring siblings use through `settleStep` — this
3528
+ // store has no `settleStep` of its own, so the result is persisted through the version-CAS
3529
+ // `store.update()` this legacy path already owns. A CAS-mismatch (a genuine race) surfaces as
3530
+ // an honest error, matching this path's existing no-retry risk profile everywhere else.
3531
+ if (run.pending_gate.expires_at !== undefined &&
3532
+ run.pending_gate.on_expiry !== undefined &&
3533
+ now.getTime() >= new Date(run.pending_gate.expires_at).getTime()) {
3534
+ const overdueMs = now.getTime() - new Date(run.pending_gate.expires_at).getTime();
3535
+ const expireOutcome = applySettlement(run, { kind: 'expire_gate', gateId: options.gateId }, definition, { now });
3536
+ if (!expireOutcome.applied) {
3537
+ // In-contract for this snapshot-based path: the local `run` might already reflect a prior
3538
+ // enactment (e.g. a same-process retry) — already_settled composes the honest envelope the
3539
+ // same way the migrated path's race leg does, reading disposition off `expireOutcome.run`
3540
+ // (the very snapshot the refusal matched against).
3541
+ return composeExpiredGateEnvelope(store, definition, options.registry, options.gateId, options.choice, overdueMs, expireOutcome);
3542
+ }
3543
+ let persistedExpiry;
3544
+ try {
3545
+ persistedExpiry = await store.update(expireOutcome.run);
3546
+ }
3547
+ catch (err) {
3548
+ const e = err instanceof WorkflowError
3549
+ ? err
3550
+ : new WorkflowError("Failed to persist the gate's expiry", {
3551
+ code: 'ENGINE_STORE_FAILED',
3552
+ category: 'ENGINE',
3553
+ agentAction: 'stop',
3554
+ retryable: false,
3555
+ });
3556
+ return errorEnvelope(run.pending_gate.step_name, options.runId, run.version, e, "Failed to enact the gate's expiry.", run.run_phase);
3557
+ }
3558
+ return composeExpiredGateEnvelope(store, definition, options.registry, options.gateId, options.choice, overdueMs, { ...expireOutcome, run: persistedExpiry });
3559
+ }
3216
3560
  // 4. Validate choice.
3217
3561
  if (!run.pending_gate.choices.includes(options.choice)) {
3218
3562
  const expected = run.pending_gate.choices.join(', ');
@@ -3475,8 +3819,10 @@ async function buildFinalizedSeal(definition, sealDraft, outcome, registry) {
3475
3819
  // settlement.ts's selectFinalizers, shared with `mintFresh` — this call passes the SAME inputs
3476
3820
  // the inline loop used to compute over, so the returned name list is byte-identical to what
3477
3821
  // `[...groupA, ...groupB]` produced before the extraction.
3822
+ // issue #302 (chokepoint 2 of 2): derive the full effective trigger set from sealDraft — the
3823
+ // legacy seal path for a non-declaring external store (the dormancy fallback).
3478
3824
  const settled = new Set([...sealDraft.completed_steps, ...sealDraft.failed_steps]);
3479
- const selected = selectFinalizers(definition, settled, outcome);
3825
+ const selected = selectFinalizers(definition, settled, deriveEffectiveTriggers(outcome, sealDraft));
3480
3826
  let record = sealDraft;
3481
3827
  for (const name of selected) {
3482
3828
  const step = definition.steps[name];