@sensigo/realm 0.38.0 → 0.40.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 (70) hide show
  1. package/dist/engine/abandon-run.d.ts.map +1 -1
  2. package/dist/engine/abandon-run.js +5 -7
  3. package/dist/engine/abandon-run.js.map +1 -1
  4. package/dist/engine/apply-resume.d.ts.map +1 -1
  5. package/dist/engine/apply-resume.js +17 -1
  6. package/dist/engine/apply-resume.js.map +1 -1
  7. package/dist/engine/eligibility.d.ts +85 -2
  8. package/dist/engine/eligibility.d.ts.map +1 -1
  9. package/dist/engine/eligibility.js +271 -14
  10. package/dist/engine/eligibility.js.map +1 -1
  11. package/dist/engine/execution-loop.d.ts.map +1 -1
  12. package/dist/engine/execution-loop.js +172 -37
  13. package/dist/engine/execution-loop.js.map +1 -1
  14. package/dist/engine/run-health.d.ts +1 -1
  15. package/dist/engine/run-health.d.ts.map +1 -1
  16. package/dist/engine/run-health.js +89 -0
  17. package/dist/engine/run-health.js.map +1 -1
  18. package/dist/engine/settlement.d.ts +61 -6
  19. package/dist/engine/settlement.d.ts.map +1 -1
  20. package/dist/engine/settlement.js +190 -25
  21. package/dist/engine/settlement.js.map +1 -1
  22. package/dist/index.d.ts +4 -3
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +9 -2
  25. package/dist/index.js.map +1 -1
  26. package/dist/store/failed-attempt-store.d.ts +9 -1
  27. package/dist/store/failed-attempt-store.d.ts.map +1 -1
  28. package/dist/store/failed-attempt-store.js +12 -1
  29. package/dist/store/failed-attempt-store.js.map +1 -1
  30. package/dist/store/json-file-store.d.ts +28 -4
  31. package/dist/store/json-file-store.d.ts.map +1 -1
  32. package/dist/store/json-file-store.js +133 -5
  33. package/dist/store/json-file-store.js.map +1 -1
  34. package/dist/store/per-run-artifact-store.d.ts +42 -1
  35. package/dist/store/per-run-artifact-store.d.ts.map +1 -1
  36. package/dist/store/seal-integrity.d.ts +9 -0
  37. package/dist/store/seal-integrity.d.ts.map +1 -0
  38. package/dist/store/seal-integrity.js +249 -0
  39. package/dist/store/seal-integrity.js.map +1 -0
  40. package/dist/store/store-interface.d.ts +47 -2
  41. package/dist/store/store-interface.d.ts.map +1 -1
  42. package/dist/store/trace-buffer-store.d.ts +32 -6
  43. package/dist/store/trace-buffer-store.d.ts.map +1 -1
  44. package/dist/store/trace-buffer-store.js +39 -6
  45. package/dist/store/trace-buffer-store.js.map +1 -1
  46. package/dist/types/response-envelope.d.ts +0 -1
  47. package/dist/types/response-envelope.d.ts.map +1 -1
  48. package/dist/types/run-record.d.ts +201 -6
  49. package/dist/types/run-record.d.ts.map +1 -1
  50. package/dist/types/run-record.js +25 -1
  51. package/dist/types/run-record.js.map +1 -1
  52. package/dist/types/workflow-definition.d.ts +11 -1
  53. package/dist/types/workflow-definition.d.ts.map +1 -1
  54. package/dist/types/workflow-definition.js +1 -0
  55. package/dist/types/workflow-definition.js.map +1 -1
  56. package/dist/types/workflow-error.d.ts +1 -1
  57. package/dist/types/workflow-error.d.ts.map +1 -1
  58. package/dist/types/workflow-error.js.map +1 -1
  59. package/dist/workflow/diagnostics.d.ts +39 -3
  60. package/dist/workflow/diagnostics.d.ts.map +1 -1
  61. package/dist/workflow/diagnostics.js +37 -10
  62. package/dist/workflow/diagnostics.js.map +1 -1
  63. package/dist/workflow/source-positions.d.ts +39 -0
  64. package/dist/workflow/source-positions.d.ts.map +1 -0
  65. package/dist/workflow/source-positions.js +107 -0
  66. package/dist/workflow/source-positions.js.map +1 -0
  67. package/dist/workflow/yaml-loader.d.ts.map +1 -1
  68. package/dist/workflow/yaml-loader.js +436 -117
  69. package/dist/workflow/yaml-loader.js.map +1 -1
  70. package/package.json +1 -1
@@ -5,7 +5,7 @@ import { storeDeclaresSeal, storeDeclaresNonceCarriage } from '../store/trace-bu
5
5
  import { partitionBufferedEntries } from './trace-adoption.js';
6
6
  import { deriveDefaultedSteps } from './defaulted-steps.js';
7
7
  import { computeGateDueState } from './gate-timing.js';
8
- import { selectFinalizers, deriveEffectiveTriggers, applySettlement } from './settlement.js';
8
+ import { selectFinalizers, deriveEffectiveTriggers, applySettlement, renderFailCause, failureMessagesFromEvidence, failureMessagesWithOverlay, } from './settlement.js';
9
9
  import { captureEvidence } from '../evidence/snapshot.js';
10
10
  import { validateInputSchema, validateOutputSchema, validateTraceSchema, } from '../validation/input-schema.js';
11
11
  import { normalizeTrace } from './trace-normalizer.js';
@@ -18,7 +18,7 @@ import { createDefaultRegistry } from '../extensions/default-registry.js';
18
18
  import { renderTemplate, resolvePath, UnknownFilterError } from './render-template.js';
19
19
  import { generateSchemaSkeleton } from '../utils/schema-skeleton.js';
20
20
  import { loadWorkflowContext } from './workflow-context-loader.js';
21
- import { findEligibleSteps, findEligibleGuardSteps, isWorkflowComplete, buildEvidenceByStep, propagateSkips, deriveRunPhase, } from './eligibility.js';
21
+ import { findEligibleSteps, findEligibleGuardSteps, isWorkflowComplete, buildEvidenceByStep, propagateSkips, deriveRunPhase, armToOutcome, assertSealMarkersAgree, } from './eligibility.js';
22
22
  import { requirementForStep } from './capability.js';
23
23
  import { resolvePreExecutionAgentAction, resolvePostDispatchAgentAction, } from './error-resolution.js';
24
24
  function delayMs(ms) {
@@ -370,7 +370,12 @@ function stepToNextAction(stepName, step, context) {
370
370
  : {}),
371
371
  human_readable: `Execute step '${stepName}': ${step.description}`,
372
372
  orientation: `Run is active. Next step ready: '${stepName}'.`,
373
- ...(step.timeout_seconds !== undefined ? { expected_timeout: `${step.timeout_seconds}s` } : {}),
373
+ // issue #412: the `expected_timeout` display is GONE, not scoped. It rendered wherever a
374
+ // NextAction was built — agent steps, and handler-bearing steps of any kind — and on both
375
+ // the instructed action is outside anything that enforced the number: an agent step's
376
+ // dispatch is never wrapped in a timeout, and a handler NextAction tells the reader to call
377
+ // the handler themselves, which no withTimeout covers. A displayed bound that never governs
378
+ // the action it is displayed beside is worse than no display.
374
379
  ...(resolvedPrompt !== undefined ? { prompt: resolvedPrompt } : {}),
375
380
  };
376
381
  }
@@ -1764,6 +1769,8 @@ export async function executeStep(store, definition, options) {
1764
1769
  const abortDraft = {
1765
1770
  ...withAllSkipped,
1766
1771
  terminal_state: true,
1772
+ // issue #367: the seal fields live in the SAME object literal as the terminal flip.
1773
+ sealed_by: { arm: 'handler_abort', step: options.command },
1767
1774
  terminal_reason: `Handler '${options.command}' aborted the run: ${abortMessage}`,
1768
1775
  aborted_at: {
1769
1776
  step_id: options.command,
@@ -2394,14 +2401,34 @@ export async function executeStep(store, definition, options) {
2394
2401
  (withSkippedFail.in_progress_steps.length === 0 &&
2395
2402
  findEligibleSteps(definition, withSkippedFail).length === 0 &&
2396
2403
  findEligibleGuardSteps(definition, withSkippedFail).length === 0);
2397
- const failDraft = {
2398
- ...withSkippedFail,
2399
- evidence: [...pendingRun.evidence, ...allEvidence],
2400
- terminal_state: isComplete,
2401
- ...(isComplete
2402
- ? { terminal_reason: `Step '${options.command}' failed: ${dispatchError.message}` }
2403
- : {}),
2404
- };
2404
+ // Hoisted so the #373 message walk below reads the SAME evidence the record will carry —
2405
+ // `withSkippedFail` does not yet include this attempt's snapshots.
2406
+ const failEvidence = [...pendingRun.evidence, ...allEvidence];
2407
+ // issue #373 — twin of settlement.ts's fail seal; both layers must emit byte-identical
2408
+ // sentences for identical inputs. Single-failure shape unchanged (this site has never had the
2409
+ // `?? 'unknown error'` fallback `dispatchError.message` is always a string).
2410
+ const failCause = new Set(withSkippedFail.failed_steps).size > 1
2411
+ ? renderFailCause(withSkippedFail.failed_steps, failureMessagesWithOverlay(failEvidence, options.command, dispatchError.message))
2412
+ : `Step '${options.command}' failed: ${dispatchError.message}`;
2413
+ // issue #367: seal fields staged FIRST in the SAME object literal, and a stale prior seal
2414
+ // never survives either fork. #373's `failCause` (computed above) is untouched — the arm sits
2415
+ // BESIDE the rendered sentence, never inside it. Validation exhaustion is deliberately NOT a
2416
+ // distinct arm: a VALIDATION_EXHAUSTED failure seals 'step_failure' like any other terminal
2417
+ // step failure, and the distinction lives in defaulted_steps/diagnostics.
2418
+ const { sealed_by: _priorFailSeal, ...withSkippedFailBase } = withSkippedFail;
2419
+ const failDraft = isComplete
2420
+ ? {
2421
+ ...withSkippedFailBase,
2422
+ terminal_state: true,
2423
+ sealed_by: { arm: 'step_failure', step: options.command },
2424
+ evidence: failEvidence,
2425
+ terminal_reason: failCause,
2426
+ }
2427
+ : {
2428
+ ...withSkippedFailBase,
2429
+ terminal_state: false,
2430
+ evidence: failEvidence,
2431
+ };
2405
2432
  // On the terminal transition, drain the fail/always finalizers before the single seal.
2406
2433
  // Non-terminal failures (recovery steps remain) run no finalizers.
2407
2434
  const failedRun = isComplete
@@ -2979,11 +3006,16 @@ export async function executeStep(store, definition, options) {
2979
3006
  (withSkippedComplete.in_progress_steps.length === 0 &&
2980
3007
  findEligibleSteps(definition, withSkippedComplete).length === 0 &&
2981
3008
  findEligibleGuardSteps(definition, withSkippedComplete).length === 0);
2982
- const completeDraft = {
2983
- ...withSkippedComplete,
2984
- terminal_state: isComplete,
2985
- ...(isComplete ? { terminal_reason: `Workflow completed.` } : {}),
2986
- };
3009
+ // issue #367: seal fields staged first in the SAME object literal; stale prior seal stripped.
3010
+ const { sealed_by: _priorCompleteSeal, ...withSkippedCompleteBase } = withSkippedComplete;
3011
+ const completeDraft = isComplete
3012
+ ? {
3013
+ ...withSkippedCompleteBase,
3014
+ terminal_state: true,
3015
+ sealed_by: { arm: 'complete', step: options.command },
3016
+ terminal_reason: `Workflow completed.`,
3017
+ }
3018
+ : { ...withSkippedCompleteBase, terminal_state: false };
2987
3019
  // On the terminal transition, drain the complete/always finalizers before the single seal.
2988
3020
  // issue #220 PR-2 (D6): stamp defaulted_steps onto the SEALED terminal record only — the
2989
3021
  // non-terminal branch (`completeDraft`) is never stamped (FM-5 guard: it must never leak onto a
@@ -3244,7 +3276,7 @@ async function composeExpiredGateEnvelope(store, definition, registry, originalG
3244
3276
  // — never attribute the outcome to THIS gate's expiry (no matched entry exists to attribute it
3245
3277
  // to), and never claim "expired" at all, since nothing here witnessed this gate expiring.
3246
3278
  const err = new WorkflowError(`Gate '${originalGateId}': the run reached a terminal outcome concurrently — your choice ` +
3247
- `was NOT recorded. 'realm resume' clears a stale pending gate on a resumable run, or ` +
3279
+ `was NOT recorded. 'realm run resume' clears a stale pending gate on a resumable run, or ` +
3248
3280
  `'realm run purge' removes the record entirely.`, {
3249
3281
  code: 'STATE_RUN_TERMINAL',
3250
3282
  category: 'STATE',
@@ -3454,7 +3486,7 @@ export async function submitHumanResponse(store, definition, options) {
3454
3486
  // Zombie/grandfathered variant — the #282 class: a terminal record may still carry a
3455
3487
  // stale pending_gate (never cleared), which is the best-effort step label here.
3456
3488
  const zombieStep = result.run.pending_gate?.step_name ?? 'submit_gate';
3457
- const err = new WorkflowError(`Run '${options.runId}' is terminal; cannot submit a gate response — 'realm resume' ` +
3489
+ const err = new WorkflowError(`Run '${options.runId}' is terminal; cannot submit a gate response — 'realm run resume' ` +
3458
3490
  `clears a stale pending gate on a resumable run, or 'realm run purge' removes the ` +
3459
3491
  `record entirely.`, {
3460
3492
  code: 'STATE_RUN_TERMINAL',
@@ -3619,7 +3651,11 @@ export async function submitHumanResponse(store, definition, options) {
3619
3651
  ? { gate_message: run.pending_gate.resolved_message }
3620
3652
  : {}),
3621
3653
  };
3622
- const { pending_gate: _pg, terminal_reason: _tr, ...rest } = run;
3654
+ // issue #367: `sealed_by` joins the strip list this write re-derives the run's liveness from
3655
+ // scratch, so a stale seal (a grandfathered/mixed-fleet record) must not survive it. The
3656
+ // explicit `terminal_state: false` below keeps the strip inside the store boundary's ORPHANED
3657
+ // exemption: a strip site flips non-terminal in the SAME write.
3658
+ const { pending_gate: _pg, terminal_reason: _tr, sealed_by: _sb, ...rest } = run;
3623
3659
  const afterGate = {
3624
3660
  ...rest,
3625
3661
  in_progress_steps: rest.in_progress_steps.filter((s) => s !== gateStepName),
@@ -3643,11 +3679,16 @@ export async function submitHumanResponse(store, definition, options) {
3643
3679
  (withSkippedGate.in_progress_steps.length === 0 &&
3644
3680
  findEligibleSteps(definition, withSkippedGate).length === 0 &&
3645
3681
  findEligibleGuardSteps(definition, withSkippedGate).length === 0);
3646
- const gateDraft = {
3647
- ...withSkippedGate,
3648
- terminal_state: isComplete,
3649
- ...(isComplete ? { terminal_reason: `Workflow completed.` } : {}),
3650
- };
3682
+ // issue #367: seal fields staged first (the prior seal was already stripped at the `afterGate`
3683
+ // construction above, so this fork starts clean by construction).
3684
+ const gateDraft = isComplete
3685
+ ? {
3686
+ ...withSkippedGate,
3687
+ terminal_state: true,
3688
+ sealed_by: { arm: 'gate_resolution_complete', step: gateStepName },
3689
+ terminal_reason: `Workflow completed.`,
3690
+ }
3691
+ : { ...withSkippedGate, terminal_state: false };
3651
3692
  // On the gate-completion terminal transition, drain complete/always finalizers before seal.
3652
3693
  // issue #220 PR-2 (D6): stamp defaulted_steps onto the SEALED terminal record only (never the
3653
3694
  // non-terminal `gateDraft` — the FM-5 guard).
@@ -3736,7 +3777,19 @@ async function executeGuardStep(stepName, stepDef, definition, run) {
3736
3777
  completedAt: now,
3737
3778
  input: {},
3738
3779
  output: { error: `Unresolvable path: ${outcome.unresolvable_path}` },
3739
- error: `Guard resolution error on condition: ${outcome.condition}`,
3780
+ // issue #373 correction: the path is the DIAGNOSTIC, and it used to live only in
3781
+ // `output_summary` + a transient seal-time overlay — so the post-drain re-render, which
3782
+ // rebuilds the cause from evidence alone, replaced it with the generic condition text.
3783
+ // Carrying it here makes every downstream read of this failure lossless. Sole production
3784
+ // mint: the settlement delta reuses this exact snapshot via `guardOwnEvidence`.
3785
+ //
3786
+ // The path goes FIRST because the per-message cap slices from the head: with the path last,
3787
+ // a long enough condition pushed it off the tail and the diagnostic vanished again. Honest
3788
+ // bound: a pathological PATH over ~230 chars still truncates itself, which is accepted —
3789
+ // head-first truncation keeps its prefix, and the prefix is the orienting part. ASCII
3790
+ // parenthetical, not an em dash, for the same reason the truncation marker is ASCII (logs,
3791
+ // terminals, a Postgres text column) — and a cut-off parenthetical reads as obviously partial.
3792
+ error: `Guard resolution error: unresolvable path '${outcome.unresolvable_path}' (condition: ${outcome.condition})`,
3740
3793
  });
3741
3794
  const withFailed = {
3742
3795
  ...run,
@@ -3749,10 +3802,18 @@ async function executeGuardStep(stepName, stepDef, definition, run) {
3749
3802
  skipped_steps: resolutionErrorPropagated.skipped,
3750
3803
  skip_details: resolutionErrorPropagated.details,
3751
3804
  };
3805
+ // issue #373 — twin of settlement.ts's guard seal. The overlay is DEFENSIVE once evidence
3806
+ // carries the path (issue #373 correction, the `error` above); kept against caller-shaped
3807
+ // evidence that arrives without it.
3808
+ const guardPath = `unresolvable path '${outcome.unresolvable_path}'`;
3752
3809
  return {
3753
3810
  ...withSkipped,
3754
3811
  terminal_state: true,
3755
- terminal_reason: `Guard step '${stepName}' failed: unresolvable path '${outcome.unresolvable_path}'`,
3812
+ // issue #367: the arm sits BESIDE #373's rendered sentence, never inside it.
3813
+ sealed_by: { arm: 'guard_resolution_error', step: stepName },
3814
+ terminal_reason: new Set(withSkipped.failed_steps).size > 1
3815
+ ? renderFailCause(withSkipped.failed_steps, failureMessagesWithOverlay(withSkipped.evidence, stepName, guardPath))
3816
+ : `Guard step '${stepName}' failed: ${guardPath}`,
3756
3817
  };
3757
3818
  }
3758
3819
  if (outcome.kind === 'pass') {
@@ -3779,11 +3840,16 @@ async function executeGuardStep(stepName, stepDef, definition, run) {
3779
3840
  (withSkipped.in_progress_steps.length === 0 &&
3780
3841
  findEligibleSteps(definition, withSkipped).length === 0 &&
3781
3842
  findEligibleGuardSteps(definition, withSkipped).length === 0);
3782
- return {
3783
- ...withSkipped,
3784
- terminal_state: isComplete,
3785
- ...(isComplete ? { terminal_reason: 'Workflow completed.' } : {}),
3786
- };
3843
+ // issue #367: the stamp lives INSIDE the isComplete arm ONLY — a non-terminal guard pass must
3844
+ // never carry a seal.
3845
+ return isComplete
3846
+ ? {
3847
+ ...withSkipped,
3848
+ terminal_state: true,
3849
+ sealed_by: { arm: 'guard_pass_complete', step: stepName },
3850
+ terminal_reason: 'Workflow completed.',
3851
+ }
3852
+ : { ...withSkipped, terminal_state: false };
3787
3853
  }
3788
3854
  // Guard fired — one or more conditions false; abort the run.
3789
3855
  const evidenceEntry = captureEvidence({
@@ -3816,6 +3882,10 @@ async function executeGuardStep(stepName, stepDef, definition, run) {
3816
3882
  return {
3817
3883
  ...withAllSkipped,
3818
3884
  terminal_state: true,
3885
+ // issue #367: same object literal as the terminal flip. terminal_reason stays ABSENT here (a
3886
+ // guard abort is the one reason-less seal) — the phase now derives from the arm, and
3887
+ // `aborted_at` is asserted congruent rather than consulted.
3888
+ sealed_by: { arm: 'guard_abort', step: stepName },
3819
3889
  aborted_at: {
3820
3890
  step_id: stepName,
3821
3891
  conditions: outcome.conditions,
@@ -3841,8 +3911,10 @@ async function executeGuardStep(stepName, stepDef, definition, run) {
3841
3911
  * `withTimeout` honoring `timeout_seconds` (default `DRAIN_CEILING_SECONDS`). Success →
3842
3912
  * evidence + completed_steps; thrown error / STEP_TIMEOUT / handler `{ abort }` → evidence
3843
3913
  * marked failed + failed_steps, NON-FATAL (the drain continues). A finalizer NEVER mutates
3844
- * `aborted_at`, `terminal_reason`, `terminal_state`, `skipped_steps`, or emits next_actions
3845
- * the terminal marks come from `sealDraft` and `deriveRunPhase` precedence keeps the phase.
3914
+ * `aborted_at`, `terminal_state`, `skipped_steps`, or emits next_actions, and never changes the
3915
+ * sealed OUTCOME — the terminal marks come from `sealDraft` and `deriveRunPhase` precedence keeps
3916
+ * the phase. issue #373: a finalizer whose OWN failure grows `failed_steps` on a fail-class seal
3917
+ * DOES re-render `terminal_reason`, so the one-line cause keeps agreeing with the record.
3846
3918
  */
3847
3919
  async function buildFinalizedSeal(definition, sealDraft, outcome, registry) {
3848
3920
  // Zero-finalizer fast path: no finalizer steps declared ⇒ return the seal draft
@@ -3858,6 +3930,13 @@ async function buildFinalizedSeal(definition, sealDraft, outcome, registry) {
3858
3930
  // legacy seal path for a non-declaring external store (the dormancy fallback).
3859
3931
  const settled = new Set([...sealDraft.completed_steps, ...sealDraft.failed_steps]);
3860
3932
  const selected = selectFinalizers(definition, settled, deriveEffectiveTriggers(outcome, sealDraft));
3933
+ // issue #367: the draft already carries sealed_by (every caller stamps at construction), so the
3934
+ // fast path above can never bypass the stamp.
3935
+ // issue #373: the post-drain re-render fires only if the drain ACTUALLY appended a failure.
3936
+ // The legacy seal is reached on every fail-class outcome, including one where every finalizer
3937
+ // succeeds — re-rendering there would rebuild the sentence without the seal site's in-hand
3938
+ // overlay (the guard sites' unresolvable-path text) and silently drop it.
3939
+ const failedBeforeDrain = sealDraft.failed_steps.length;
3861
3940
  let record = sealDraft;
3862
3941
  for (const name of selected) {
3863
3942
  const step = definition.steps[name];
@@ -3876,8 +3955,10 @@ async function buildFinalizedSeal(definition, sealDraft, outcome, registry) {
3876
3955
  try {
3877
3956
  const result = await withTimeout((signal) => callHandler(step, options, record, evidenceByStep, signal), timeoutMs, name);
3878
3957
  if (result.kind === 'abort') {
3879
- // A finalizer handler returning { abort } is a recorded NON-FATAL failure — it must
3880
- // never mutate aborted_at/terminal_reason (that would corrupt the sealed outcome).
3958
+ // A finalizer handler returning { abort } is a recorded NON-FATAL failure — it must never
3959
+ // mutate aborted_at or the sealed OUTCOME. (issue #373: it does join `failed_steps`, and
3960
+ // the post-drain re-render below folds it into the one-line cause — that reports the
3961
+ // outcome faithfully, it does not change it.)
3881
3962
  record = {
3882
3963
  ...record,
3883
3964
  evidence: [
@@ -3932,7 +4013,44 @@ async function buildFinalizedSeal(definition, sealDraft, outcome, registry) {
3932
4013
  };
3933
4014
  }
3934
4015
  }
3935
- return { ...record, terminal_state: true };
4016
+ // issue #367: `sealed_by` is INHERITED from sealDraft, never re-derived here — a finalizer never
4017
+ // changes which arm sealed the run, and re-deriving from `outcome` would re-couple the arm to
4018
+ // mintOutcome, the exact coupling the recorded fact exists to break. `sealDraft` is terminal at
4019
+ // every call site (all of them sit inside an isComplete/abort branch), so this narrow is total;
4020
+ // it throws rather than fabricating an arm if a future caller violates that.
4021
+ if (sealDraft.terminal_state !== true) {
4022
+ throw new Error('buildFinalizedSeal called with a non-terminal sealDraft');
4023
+ }
4024
+ // Conditional spread, NOT `sealed_by: sealDraft.sealed_by`: under the flat optional plus
4025
+ // exactOptionalPropertyTypes the unconditional form does not compile. An unstamped draft passes
4026
+ // through honest-absent, and refusing it is the store boundary's job.
4027
+ /* eslint-disable-next-line no-restricted-syntax --
4028
+ * issue #367 (part 2), AUTHORIZED: the stamp IS in this literal, as a conditional spread —
4029
+ * `exactOptionalPropertyTypes` forbids the unconditional `sealed_by: sealDraft.sealed_by` form,
4030
+ * and a spread is not a `sealed_by` Property the selector can see. An unstamped draft passes
4031
+ * through honest-absent on purpose; refusing it is the store boundary's job, per the comment
4032
+ * directly above.
4033
+ */
4034
+ const drained = {
4035
+ ...record,
4036
+ terminal_state: true,
4037
+ ...(sealDraft.sealed_by !== undefined ? { sealed_by: sealDraft.sealed_by } : {}),
4038
+ };
4039
+ // issue #367: SEAL_MARKERS_AGREE — transform-scoped assertion on the record THIS function
4040
+ // produces (the second of the two transform homes; never universal).
4041
+ assertSealMarkersAgree(drained);
4042
+ // Twin of applyMarkFinalizer's failed-arm re-render. `deriveRunPhase`, not `outcome`: an aborted
4043
+ // run whose finalizer also failed keeps its abort sentence (aborted_at wins), and a
4044
+ // complete-outcome run keeps the complete-seal literal untouched.
4045
+ if (record.failed_steps.length > failedBeforeDrain &&
4046
+ deriveRunPhase(drained) === 'failed' &&
4047
+ new Set(record.failed_steps).size > 1) {
4048
+ return {
4049
+ ...drained,
4050
+ terminal_reason: renderFailCause(record.failed_steps, failureMessagesFromEvidence(record.evidence)),
4051
+ };
4052
+ }
4053
+ return drained;
3936
4054
  }
3937
4055
  /** Every currently-`'pending'` finalizer in `run`'s ledger, ascending by rank — the order the
3938
4056
  * drain loop below consumes (design record §6). */
@@ -4412,13 +4530,30 @@ depth0Warnings) {
4412
4530
  // (terminal_reason 'Workflow completed.', no aborted_at). `aborted_at ? 'abort' : 'fail'`
4413
4531
  // would wrongly run the catch finalizers on that success. When terminal, drain the
4414
4532
  // matching finalizers before the single seal write; non-terminal guard passes persist as-is.
4415
- const guardOutcome = guardResult.terminal_state
4533
+ const guardProse = guardResult.terminal_state
4416
4534
  ? guardResult.aborted_at !== undefined
4417
4535
  ? 'abort'
4418
4536
  : guardResult.terminal_reason === 'Workflow completed.'
4419
4537
  ? 'complete'
4420
4538
  : 'fail'
4421
4539
  : undefined;
4540
+ // issue #367: this classifier stays the PRODUCER of guardOutcome (it feeds buildFinalizedSeal,
4541
+ // so a wrong answer here silently drains the wrong finalizers — the executed harm). It now
4542
+ // prefers the RECORDED arm, keeps the prose branch as the fallback for an unstamped record,
4543
+ // and throws if the two disagree: a future writer gap here is loud, never a silent loss.
4544
+ const guardArmOutcome = guardResult.terminal_state && guardResult.sealed_by !== undefined
4545
+ ? armToOutcome(guardResult.sealed_by.arm)
4546
+ : undefined;
4547
+ if (guardArmOutcome === 'abandon') {
4548
+ throw new Error(`guard seal on '${guardName}' produced a non-guard arm`);
4549
+ }
4550
+ if (guardArmOutcome !== undefined &&
4551
+ guardProse !== undefined &&
4552
+ guardArmOutcome !== guardProse) {
4553
+ throw new Error(`sealed_by.arm (${guardArmOutcome}) disagrees with the prose classifier (${guardProse}) ` +
4554
+ `on guard '${guardName}'`);
4555
+ }
4556
+ const guardOutcome = guardArmOutcome ?? guardProse;
4422
4557
  // issue #220 PR-2 (D6): stamp defaulted_steps ONLY on the 'complete' seal — a guard that FAILS
4423
4558
  // or ABORTS the run does not get the qualifier (the FM-5 guard: never on a non-complete
4424
4559
  // terminal, and never on the non-terminal `guardResult` passthrough).