Skip to main content

nmbrs_runtime/
phase_outcome.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! SRD-76 — Phase Outcome Disposition.
5//!
6//! Structured per-phase terminal-state record. One canonical
7//! shape carries both the per-phase status (Completed /
8//! Failed / Skipped / CursorSuspended) and the chronological
9//! error list, so the realtime status surface AND `nmbrs
10//! replay` consume from one projection.
11//!
12//! This module is Push 1 of the SRD-76 migration plan: it
13//! defines the data model and provides the no-side-effects
14//! API. Push 2 wires the executor's per-phase population;
15//! Push 3 adds sqlite persistence; Push 4 adds the new
16//! Readouts; Push 5 wires `nmbrs replay`.
17//!
18//! Cross-refs:
19//! - SRD-03 §"Error scoping" — error-class strings here
20//!   match the strings the `errors:` policy routes on.
21//! - SRD-44 §"Phase identity" — `PhaseIdentity` matches the
22//!   checkpoint writer's identity tuple.
23//! - SRD-68 §"I-6 Workload-load pre-flight is non-mutating"
24//!   — `op_template` is the operator's pristine YAML
25//!   verbatim; `op_resolved` is the wire-rendered form.
26//! - SRD-63 — The Readout layer that will render this in
27//!   Push 4.
28//! - SRD-75 — Phase-poll; `poll_timeout` is one of the
29//!   error classes that lands here.
30
31use serde::{Deserialize, Serialize};
32use std::sync::Arc;
33
34/// SRD-82 Part 1 — **how much** of a shell's work ran. Orthogonal to
35/// [`Validity`]: a result can be `Interrupted` yet `Succeeded`
36/// (re-usable partial progress) or `Completed` yet `Failed` (ran fully
37/// but the result is garbage).
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39#[serde(rename_all = "snake_case")]
40pub enum Disposition {
41    /// In flight (not a terminal outcome).
42    Running,
43    /// Ran to its natural end.
44    Completed,
45    /// Stopped before its natural end (a signal, a stop condition).
46    Interrupted,
47    /// Never started (filtered / unreached / resume-skipped).
48    Skipped,
49}
50
51/// SRD-82 Part 1 — whether the produced result is **usable**.
52/// Orthogonal to [`Disposition`].
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54#[serde(rename_all = "snake_case")]
55pub enum Validity {
56    /// The result is trustworthy.
57    Succeeded,
58    /// The result is not trustworthy — do not rely on it.
59    Failed,
60}
61
62/// SRD-92 / ExecUnification — the opaque return **payload** a unit may carry
63/// on its [`Outcome`], above the leaf-projection boundary.
64///
65/// A scaffold-owned **marker** (no methods yet — the loop/poll read-path is a
66/// later step): the op leaf fills it, aggregates leave it `None`. Kept neutral
67/// (not the adapter's [`crate::adapter::ResultBody`]) so the universal return
68/// type does not depend on the adapter result trait, and a future aggregate
69/// fold-summary can implement `Payload` honestly without faking row-count
70/// methods. `Send + Sync + 'static` keeps [`Outcome`] spawn / `ArcSwap` /
71/// `Mutex` safe; `Debug` keeps `Outcome`'s derive working.
72pub trait Payload: Send + Sync + std::fmt::Debug + 'static {}
73
74/// Every adapter [`crate::adapter::ResultBody`] is a [`Payload`]. A **blanket**
75/// impl (not a subtrait) so it covers `ResultBody` impls in the adapter crates
76/// and tests too with **zero cross-crate churn** — the impl lives here, where
77/// `Payload` is defined. A neutral fold-summary type may still `impl Payload`
78/// directly (no overlap, since it is not a `ResultBody`). Trade-off: a blanket
79/// gives no `dyn`-upcasting, so Step 2 lifts the *erased* `Box<dyn ResultBody>`
80/// via a thin wrapper rather than an `Arc<dyn ResultBody> -> Arc<dyn Payload>`
81/// coercion.
82impl<T: ?Sized + crate::adapter::ResultBody + 'static> Payload for T {}
83
84/// SRD-82 Part 1 — the two-axis result of any execution shell, and the
85/// `effect` of an SRD-83 stop condition. The four meaningful
86/// quadrants: `Completed+Succeeded` (clean), `Completed+Failed` (ran
87/// fully, garbage), `Interrupted+Succeeded` (partial, re-usable),
88/// `Interrupted+Failed` (partial, discard).
89///
90/// `PartialEq` is hand-written (control facts only); `Eq` is dropped — see the
91/// `impl PartialEq` below.
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct Outcome {
94    pub disposition: Disposition,
95    pub validity: Validity,
96    /// SRD-92 — optional human reason (the first failing child's message),
97    /// absorbing the parallel `(Outcome, Option<String>)` tuple the scenario
98    /// shell carries today. `None` for clean outcomes; set via
99    /// [`Outcome::with_reason`]. Dropping `Copy` (a `String` is not `Copy`)
100    /// is the cost — by-value reuses become moves/clones. Serialized only
101    /// when present, so axis-only `Outcome` JSON round-trips unchanged.
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub reason: Option<String>,
104    /// SRD-92 / ExecUnification — the optional opaque return **payload** a unit
105    /// may carry above the leaf-projection boundary (the op leaf fills it via
106    /// [`Outcome::with_payload`]; aggregates leave it `None`). `Arc` (not `Box`)
107    /// so `Outcome` stays `Clone`; `#[serde(skip)]` because it is runtime-only,
108    /// so axis-only JSON round-trips unchanged. Excluded from `PartialEq` — it
109    /// is data, not control identity. Its *contents* are the deferred data layer.
110    #[serde(skip)]
111    pub payload: Option<Arc<dyn Payload>>,
112}
113
114impl Outcome {
115    pub const fn new(disposition: Disposition, validity: Validity) -> Self {
116        Self {
117            disposition,
118            validity,
119            reason: None,
120            payload: None,
121        }
122    }
123    /// Ran fully, result trustworthy.
124    pub const fn completed() -> Self {
125        Self::new(Disposition::Completed, Validity::Succeeded)
126    }
127    /// Stopped early, result untrustworthy — the SRD-83 `fail` effect
128    /// / a `StopCause::Fault`.
129    pub const fn failed() -> Self {
130        Self::new(Disposition::Interrupted, Validity::Failed)
131    }
132    /// SRD-92 — ran fully but the result is untrustworthy (a ran-and-errored
133    /// op): `Completed+Failed`. Distinct from [`Outcome::failed`]
134    /// (`Interrupted+Failed` — stopped early by a fault).
135    pub const fn completed_failed() -> Self {
136        Self::new(Disposition::Completed, Validity::Failed)
137    }
138    /// Stopped early, partial result usable — the SRD-83 `stop` effect
139    /// / a `StopCause::Interrupt` (e.g. user Ctrl-C, budget met).
140    pub const fn interrupted() -> Self {
141        Self::new(Disposition::Interrupted, Validity::Succeeded)
142    }
143    /// Never started.
144    pub const fn skipped() -> Self {
145        Self::new(Disposition::Skipped, Validity::Succeeded)
146    }
147
148    /// SRD-92 — attach the human reason (the failing child's message). Used
149    /// by the flow-Outcome-up step so the leaf shells / aggregate fold carry
150    /// the message that the `?`-propagation path needs, retiring the parallel
151    /// `(Outcome, Option<String>)` tuple.
152    pub fn with_reason(mut self, reason: impl Into<String>) -> Self {
153        self.reason = Some(reason.into());
154        self
155    }
156
157    /// Binary pass/fail projection for session-level counting: the
158    /// only red mark is an untrustworthy result. `Interrupted +
159    /// Succeeded` (Ctrl-C, budget met, cursor-suspend) and `Skipped`
160    /// are non-failures from the operator's perspective.
161    pub fn is_failure(&self) -> bool {
162        matches!(self.validity, Validity::Failed)
163    }
164
165    /// Glyph for compact rendering. One per meaningful axis pair
166    /// (SRD-82 Part 1): ✓ ran-and-trustworthy, ✗ untrustworthy
167    /// (whether it ran fully or not), … re-usable partial progress,
168    /// ~ never started, ⋯ still in flight.
169    pub fn glyph(&self) -> char {
170        match (self.disposition, self.validity) {
171            (Disposition::Running, _) => '⋯',
172            (Disposition::Skipped, _) => '~',
173            (_, Validity::Failed) => '✗',
174            (Disposition::Completed, Validity::Succeeded) => '✓',
175            (Disposition::Interrupted, Validity::Succeeded) => '…',
176        }
177    }
178
179    /// All-lowercase short label, stable for log lines / CI greps /
180    /// the sqlite `status` column. The axes yield five terminal
181    /// labels; `interrupted` subsumes the retired `cursor_suspended`
182    /// (a resume cursor on the outcome carries the resumability
183    /// signal — SRD-82 Part 1).
184    pub fn label(&self) -> &'static str {
185        match (self.disposition, self.validity) {
186            (Disposition::Running, _) => "running",
187            (Disposition::Skipped, _) => "skipped",
188            (Disposition::Completed, Validity::Succeeded) => "completed",
189            (Disposition::Completed, Validity::Failed) => "completed_failed",
190            (Disposition::Interrupted, Validity::Succeeded) => "interrupted",
191            (Disposition::Interrupted, Validity::Failed) => "failed",
192        }
193    }
194
195    /// SRD-92 / ExecUnification — attach the opaque return [`Payload`] (the op
196    /// leaf's body, lifted at the projection boundary in a later step).
197    /// Aggregates never call this — the payload rides the leaf's `Outcome` and
198    /// is ignored by the fold.
199    pub fn with_payload(mut self, payload: Arc<dyn Payload>) -> Self {
200        self.payload = Some(payload);
201        self
202    }
203}
204
205/// SRD-92 / ExecUnification — equality over the CONTROL FACTS only
206/// (`disposition`, `validity`, `reason`). The `payload` is intentionally
207/// excluded: it is data, not control identity, so two outcomes that differ
208/// only in their payload compare equal. Do NOT "fix" this to compare payloads —
209/// `dyn Payload` is not `PartialEq`, and aggregation / tests key on control
210/// facts, never the payload. (`Eq` is dropped: it is unused — no
211/// `HashSet`/`HashMap<Outcome>` exists — and a `dyn` field can't satisfy it.)
212impl PartialEq for Outcome {
213    fn eq(&self, other: &Self) -> bool {
214        self.disposition == other.disposition
215            && self.validity == other.validity
216            && self.reason == other.reason
217    }
218}
219
220/// Session-wide pass/fail disposition. Projected from the
221/// per-phase statuses by walking every populated
222/// [`PhaseOutcome`] on the scene tree.
223///
224/// `SessionDisposition` is the single source of truth for
225/// "what happened?" — drives the process exit code, the
226/// terminating status line, the `nmbrs replay` header, and
227/// the (future) `session_disposition` readout in
228/// SRD-63's `on_session_end` slot.
229///
230/// SRD-76 §"SessionDisposition" — past callers each did
231/// their own ad-hoc walk over phase state with subtly
232/// different rules (was Skipped a pass? was Ctrl-C a
233/// failure?). This enum centralises the answer.
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
235#[serde(rename_all = "snake_case")]
236pub enum SessionDisposition {
237    /// Every phase that ran terminated cleanly
238    /// (Completed, Skipped, or CursorSuspended).
239    /// Interrupted-via-signal sessions where no phase
240    /// actually failed land here — interrupted ≠ failed
241    /// from the operator's perspective.
242    Success,
243    /// At least one phase's outcome carries `Validity::Failed`. The
244    /// realtime status surface and `nmbrs replay` render
245    /// this in red; CI / scripted callers observe via the
246    /// process exit code (non-zero) and the JSON output.
247    Failure,
248}
249
250impl SessionDisposition {
251    /// Process exit code for this disposition. 0 for
252    /// success; non-zero (today `1`) for failure.
253    pub fn exit_code(&self) -> i32 {
254        match self {
255            SessionDisposition::Success => 0,
256            SessionDisposition::Failure => 1,
257        }
258    }
259
260    /// Uppercase short label suitable for the terminating
261    /// status line: `session: idx_sweep (SUCCESS in …)`
262    /// vs. `session: idx_sweep (FAILURE: N phases failed)`.
263    pub fn label(&self) -> &'static str {
264        match self {
265            SessionDisposition::Success => "SUCCESS",
266            SessionDisposition::Failure => "FAILURE",
267        }
268    }
269}
270
271/// Phase identity tuple per SRD-44 §"Phase identity" —
272/// `(name, labels)`. The combination is unique within a
273/// session: even sweep cells that share a phase name have
274/// distinct labels (the striated coord path).
275#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
276pub struct PhaseIdentity {
277    pub name: String,
278    /// Striated label path — the `(profile=…), (sm=…), …`
279    /// form the executor produces via
280    /// `format_scope_coordinate_path`. Empty for
281    /// non-iter phases.
282    pub labels: String,
283}
284
285impl PhaseIdentity {
286    pub fn new(name: impl Into<String>, labels: impl Into<String>) -> Self {
287        Self {
288            name: name.into(),
289            labels: labels.into(),
290        }
291    }
292}
293
294/// One error recorded against a phase. Captures the
295/// originating cycle (when an op error), the dispenser's
296/// pristine + resolved op text (per SRD-68), and the
297/// class string the `errors:` policy matches on.
298///
299/// `op_name` / `cycle` / `op_template` / `op_resolved`
300/// are `None` when the error originates outside any
301/// dispenser — workload-load validation failures,
302/// phase-poll deadlines (SRD-75), missing-adapter init
303/// errors, etc.
304#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
305pub struct PhaseErrorDetail {
306    /// Error class string (matches the `errors:` policy
307    /// vocabulary): `Timeout`, `cql_error`, `poll_timeout`,
308    /// `validate_failure`, `BindError`, …
309    pub class: String,
310    /// Human-readable message — the detail an operator
311    /// needs to act. Multi-line allowed.
312    pub message: String,
313    /// Op identity (template name) that triggered this
314    /// error. `None` for phase-level errors.
315    #[serde(default, skip_serializing_if = "Option::is_none")]
316    pub op_name: Option<String>,
317    /// Cycle number for op errors. `None` for
318    /// phase-level errors.
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub cycle: Option<u64>,
321    /// Pristine op-template text per SRD-68 §"I-6".
322    /// Operator's YAML verbatim, with `{name}` placeholders
323    /// intact.
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub op_template: Option<String>,
326    /// Wire-rendered op text for the failing cycle. What
327    /// the adapter actually sent. `None` when no render
328    /// attempt happened (pre-dispense errors).
329    #[serde(default, skip_serializing_if = "Option::is_none")]
330    pub op_resolved: Option<String>,
331    /// Nanos-since-epoch when the error was recorded.
332    /// Used for chronological replay rendering and
333    /// cross-correlation with metric snapshots.
334    pub at_nanos: u64,
335    /// Whether the underlying error was classified as
336    /// retryable. Useful for diagnostics: a workload with
337    /// 100 retryable errors reads differently from one
338    /// with 1 fatal error.
339    #[serde(default)]
340    pub retryable: bool,
341}
342
343/// SRD-44 cursor-resume state. Stub for Push 1 (the
344/// resume machinery is independent of this SRD); future
345/// pushes populate it with the actual restart-cursor
346/// payload.
347#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
348pub struct ResumeCursor {
349    /// Opaque source-factory state to resume from. The
350    /// concrete shape is owned by SRD-44's checkpoint
351    /// layer; SRD-76 just carries it through. Empty when
352    /// no resume state is recorded.
353    #[serde(default)]
354    pub opaque: Vec<u8>,
355}
356
357/// SRD-76 — complete description of how a single phase
358/// ended. Built once at phase end by the executor,
359/// installed on the scene tree, optionally persisted to
360/// sqlite (Push 3), and rendered via the SRD-76 readouts
361/// (Push 4).
362/// SRD-83 (C3) — the closed vocabulary of failure-reason classes on a
363/// phase outcome. Derived from the first `PhaseErrorDetail.class` (see
364/// [`PhaseOutcome::reason_class`]); the stable strings below are the
365/// sqlite `reason_class` column values and the report-layer grouping
366/// keys.
367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
368pub enum ReasonClass {
369    /// A governance `timeout:` (or an SRD-75 poll timeout) expired —
370    /// the protocol OUT-OF-RANGE disposition.
371    Timeout,
372    /// A declared `stop_when` condition with a failing effect tripped
373    /// (includes the synthesized `error_rate_exceeded` guard).
374    StopCondition,
375    /// Op/dispatch errors stopped the phase.
376    Error,
377    /// A panic was caught and routed as an error.
378    Panic,
379    /// An operator action (Ctrl-C / stop request) cut the phase.
380    Operator,
381}
382
383impl ReasonClass {
384    /// The stable serialized token (sqlite column value, report key).
385    pub fn as_str(&self) -> &'static str {
386        match self {
387            ReasonClass::Timeout => "timeout",
388            ReasonClass::StopCondition => "stop_condition",
389            ReasonClass::Error => "error",
390            ReasonClass::Panic => "panic",
391            ReasonClass::Operator => "operator",
392        }
393    }
394
395    /// Classify an error-class string from the trip/error paths. The
396    /// class strings are the established vocabulary: `timeout` (the
397    /// synthesized governance guard), `poll_timeout` (SRD-75),
398    /// `panic` (wrapper-caught), `stop_condition: <when>` /
399    /// `error_rate_exceeded` (SRD-83 trips), `operator` (session
400    /// stop). Anything else is a plain error.
401    pub fn from_error_class(class: &str) -> Self {
402        match class {
403            "timeout" | "poll_timeout" => ReasonClass::Timeout,
404            "panic" => ReasonClass::Panic,
405            "operator" => ReasonClass::Operator,
406            "error_rate_exceeded" => ReasonClass::StopCondition,
407            c if c.starts_with("stop_condition") => ReasonClass::StopCondition,
408            _ => ReasonClass::Error,
409        }
410    }
411}
412
413#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
414#[serde(from = "PhaseOutcomeWire")]
415pub struct PhaseOutcome {
416    pub phase_id: PhaseIdentity,
417    /// SRD-82 Part 1 — the two axes ARE the stored canonical (the
418    /// storage migration the old `PhaseStatus::to_outcome` doc
419    /// promised). Legacy checkpoint records that carried a single
420    /// `status` string still deserialize via [`PhaseOutcomeWire`].
421    pub disposition: Disposition,
422    pub validity: Validity,
423    /// Wall-clock duration from `phase_starting` to this
424    /// outcome being recorded. `0.0` for skipped phases.
425    pub duration_secs: f64,
426    /// Errors collected during the phase. Empty for
427    /// `Completed` / `Skipped`. Non-empty for `Failed`.
428    /// Chronologically ordered by `at_nanos`.
429    #[serde(default, skip_serializing_if = "Vec::is_empty")]
430    pub errors: Vec<PhaseErrorDetail>,
431    /// Resume state for the next session. `None` when the
432    /// phase doesn't support cursor-resume.
433    #[serde(default, skip_serializing_if = "Option::is_none")]
434    pub resume_cursor: Option<ResumeCursor>,
435    /// SRD-77 `--scope=changed` — the phase's provenance BASE
436    /// hash (SRD-107: ancestor chain below the session node
437    /// composed with the config digest; param values live in
438    /// [`Self::params_consumed`] instead). Hex-encoded so the
439    /// storage layer can round-trip as TEXT. `None` for
440    /// outcomes recorded before the column was added (legacy
441    /// rows) or for skipped phases that never computed their
442    /// hash.
443    #[serde(default, skip_serializing_if = "Option::is_none")]
444    pub phase_hash: Option<String>,
445    /// SRD-107 — the phase's consumed-params map as canonical
446    /// JSON (`{"name":"<value sha256 hex>",…}`, sorted keys).
447    /// Skip validity's per-param leg: current values of exactly
448    /// these names must digest to these values. `None` on
449    /// legacy rows (whose base hash never matches current
450    /// formulas anyway) and on skipped phases.
451    #[serde(default, skip_serializing_if = "Option::is_none")]
452    pub params_consumed: Option<String>,
453}
454
455/// Deserialization shape for [`PhaseOutcome`]: accepts BOTH the
456/// canonical two-axis form and legacy checkpoint records that carried
457/// the retired single `status` string. The checkpoint JSONL store is
458/// event-sourced and never rewritten (SRD-44a), so records written
459/// before the axes migration must stay readable — resume-skip
460/// identity matching folds over them.
461#[derive(Deserialize)]
462struct PhaseOutcomeWire {
463    phase_id: PhaseIdentity,
464    disposition: Option<Disposition>,
465    validity: Option<Validity>,
466    /// Legacy single-status field (pre-migration records).
467    status: Option<LegacyPhaseStatus>,
468    duration_secs: f64,
469    #[serde(default)]
470    errors: Vec<PhaseErrorDetail>,
471    #[serde(default)]
472    resume_cursor: Option<ResumeCursor>,
473    #[serde(default)]
474    phase_hash: Option<String>,
475    #[serde(default)]
476    params_consumed: Option<String>,
477}
478
479/// The retired conflated status, kept ONLY as a deserialization
480/// target for legacy records. `cursor_suspended` collapses to
481/// `Interrupted + Succeeded` per SRD-82 Part 1 (the record's resume
482/// cursor carries the resumability signal); legacy `failed` maps to
483/// `Interrupted + Failed` (the single status couldn't distinguish it
484/// from `Completed + Failed`).
485#[derive(Deserialize)]
486#[serde(rename_all = "snake_case")]
487enum LegacyPhaseStatus {
488    Completed,
489    Failed,
490    Skipped,
491    CursorSuspended,
492}
493
494impl From<PhaseOutcomeWire> for PhaseOutcome {
495    fn from(w: PhaseOutcomeWire) -> Self {
496        let (disposition, validity) = match (w.disposition, w.validity, w.status) {
497            (Some(d), Some(v), _) => (d, v),
498            (_, _, Some(LegacyPhaseStatus::Completed)) => {
499                (Disposition::Completed, Validity::Succeeded)
500            }
501            (_, _, Some(LegacyPhaseStatus::Failed)) => (Disposition::Interrupted, Validity::Failed),
502            (_, _, Some(LegacyPhaseStatus::Skipped)) => (Disposition::Skipped, Validity::Succeeded),
503            (_, _, Some(LegacyPhaseStatus::CursorSuspended)) => {
504                (Disposition::Interrupted, Validity::Succeeded)
505            }
506            // Neither form present: benign default (a record this
507            // malformed predates both formats).
508            _ => (Disposition::Completed, Validity::Succeeded),
509        };
510        Self {
511            phase_id: w.phase_id,
512            disposition,
513            validity,
514            duration_secs: w.duration_secs,
515            errors: w.errors,
516            resume_cursor: w.resume_cursor,
517            phase_hash: w.phase_hash,
518            params_consumed: w.params_consumed,
519        }
520    }
521}
522
523impl PhaseOutcome {
524    /// Build a Completed outcome. Convenience for the
525    /// happy path; errors must be empty.
526    /// SRD-83 (C3) — machine-readable class of WHY this phase ended
527    /// short of a usable result. DERIVED from the first error's class
528    /// (single source of truth — the trip/error paths already stamp
529    /// it), never stored: legacy outcomes classify identically.
530    /// `None` for any Succeeded validity — a graceful stop or natural
531    /// completion has no failure reason to classify.
532    pub fn reason_class(&self) -> Option<ReasonClass> {
533        match self.validity {
534            Validity::Succeeded => None,
535            Validity::Failed => Some(ReasonClass::from_error_class(
536                self.errors
537                    .first()
538                    .map(|e| e.class.as_str())
539                    .unwrap_or("error"),
540            )),
541        }
542    }
543
544    /// SRD-83 (C3) — the testing-protocol three-way disposition
545    /// (17_/P0.5): COMPLETED (usable result, natural or graceful),
546    /// OUT-OF-RANGE (a governance timeout expired — disqualified at
547    /// this tier), FAILED (anything else), plus SKIPPED for
548    /// resume-skipped phases. Model-level, replacing report-side
549    /// string-matching conventions.
550    pub fn protocol_class(&self) -> &'static str {
551        if matches!(self.disposition, Disposition::Skipped) {
552            return "SKIPPED";
553        }
554        match self.reason_class() {
555            None => "COMPLETED",
556            Some(ReasonClass::Timeout) => "OUT-OF-RANGE",
557            Some(_) => "FAILED",
558        }
559    }
560
561    pub fn completed(phase_id: PhaseIdentity, duration_secs: f64) -> Self {
562        Self {
563            phase_id,
564            disposition: Disposition::Completed,
565            validity: Validity::Succeeded,
566            duration_secs,
567            errors: Vec::new(),
568            resume_cursor: None,
569            phase_hash: None,
570            params_consumed: None,
571        }
572    }
573
574    /// Build a Failed outcome from a non-empty error list.
575    /// Panics if `errors` is empty — `Failed` without an
576    /// error is structurally invalid per SRD-76
577    /// §"Invariants", in every build profile.
578    pub fn failed(
579        phase_id: PhaseIdentity,
580        duration_secs: f64,
581        errors: Vec<PhaseErrorDetail>,
582    ) -> Self {
583        assert!(
584            !errors.is_empty(),
585            "PhaseOutcome::failed requires at least one error"
586        );
587        Self {
588            phase_id,
589            disposition: Disposition::Interrupted,
590            validity: Validity::Failed,
591            duration_secs,
592            errors,
593            resume_cursor: None,
594            phase_hash: None,
595            params_consumed: None,
596        }
597    }
598
599    /// Build a Skipped outcome. Used by SRD-44's
600    /// resume-on-checkpoint skip path. Duration is `0.0`
601    /// because no actual work was done.
602    pub fn skipped(phase_id: PhaseIdentity) -> Self {
603        Self {
604            phase_id,
605            disposition: Disposition::Skipped,
606            validity: Validity::Succeeded,
607            duration_secs: 0.0,
608            errors: Vec::new(),
609            resume_cursor: None,
610            phase_hash: None,
611            params_consumed: None,
612        }
613    }
614
615    /// Build an Interrupted+Succeeded outcome — re-usable partial
616    /// progress (a clean early halt, a cursor suspension). The resume
617    /// cursor, when present, carries the resumability signal that the
618    /// retired `CursorSuspended` status used to encode.
619    pub fn interrupted(
620        phase_id: PhaseIdentity,
621        duration_secs: f64,
622        resume_cursor: Option<ResumeCursor>,
623    ) -> Self {
624        Self {
625            phase_id,
626            disposition: Disposition::Interrupted,
627            validity: Validity::Succeeded,
628            duration_secs,
629            errors: Vec::new(),
630            resume_cursor,
631            phase_hash: None,
632            params_consumed: None,
633        }
634    }
635
636    /// Stamp the GK chain-hash on this outcome. Builder-style
637    /// so existing callers that don't yet have the hash
638    /// available (legacy / partial paths) stay unchanged;
639    /// SRD-77-aware callers chain `.completed(...).with_hash(h)`.
640    pub fn with_phase_hash(mut self, hex_hash: String) -> Self {
641        self.phase_hash = Some(hex_hash);
642        self
643    }
644
645    /// Stamp the SRD-107 consumed-params JSON on this outcome.
646    /// Builder-style, same rationale as [`Self::with_phase_hash`].
647    pub fn with_params_consumed(mut self, json: Option<String>) -> Self {
648        self.params_consumed = json;
649        self
650    }
651
652    /// Convenience: the first error's message, or `None`
653    /// when the phase didn't fail. Used by the compact
654    /// renderer to give an at-a-glance reason.
655    pub fn first_error_message(&self) -> Option<&str> {
656        self.errors.first().map(|e| e.message.as_str())
657    }
658
659    /// SRD-82 Part 1 — the two-axis [`Outcome`], straight from the
660    /// stored axes (they ARE the canonical since the storage
661    /// migration).
662    pub fn outcome(&self) -> Outcome {
663        Outcome::new(self.disposition, self.validity)
664    }
665
666    /// See [`Outcome::is_failure`].
667    pub fn is_failure(&self) -> bool {
668        self.outcome().is_failure()
669    }
670
671    /// See [`Outcome::glyph`].
672    pub fn glyph(&self) -> char {
673        self.outcome().glyph()
674    }
675
676    /// See [`Outcome::label`].
677    pub fn label(&self) -> &'static str {
678        self.outcome().label()
679    }
680
681    /// SRD-76 Push 3 — project this outcome into the
682    /// storage-layer row shape used by
683    /// [`nmbrs_metrics::reporters::sqlite::SqliteReporter::write_phase_outcome`].
684    /// `session` / `exec_id` come from the active session
685    /// (SRD-77); `started_at_nanos` is supplied by the
686    /// caller (the executor captures it at phase entry);
687    /// `ended_at_nanos` is taken from `SystemTime::now()` so
688    /// the on-disk row matches the wall-clock moment the
689    /// outcome was sealed.
690    pub fn to_sqlite_row(
691        &self,
692        session: &str,
693        exec_id: u64,
694        started_at_nanos: i64,
695    ) -> nmbrs_metrics::reporters::sqlite::PhaseOutcomeRow {
696        let ended_at_nanos: i64 = std::time::SystemTime::now()
697            .duration_since(std::time::UNIX_EPOCH)
698            .map(|d| d.as_nanos() as i64)
699            .unwrap_or(started_at_nanos);
700        nmbrs_metrics::reporters::sqlite::PhaseOutcomeRow {
701            session: session.to_string(),
702            exec_id,
703            phase_name: self.phase_id.name.clone(),
704            phase_labels: self.phase_id.labels.clone(),
705            status: self.label().to_string(),
706            duration_secs: self.duration_secs,
707            started_at_nanos,
708            ended_at_nanos,
709            // SRD-83 (C3) — denormalized for report GROUP BY; the
710            // derived accessor is the single source of truth.
711            reason_class: self.reason_class().map(|c| c.as_str().to_string()),
712            phase_hash: self.phase_hash.clone(),
713            params_consumed: self.params_consumed.clone(),
714            errors: self
715                .errors
716                .iter()
717                .map(|e| nmbrs_metrics::reporters::sqlite::PhaseErrorRow {
718                    class: e.class.clone(),
719                    message: e.message.clone(),
720                    op_name: e.op_name.clone(),
721                    cycle: e.cycle,
722                    op_template: e.op_template.clone(),
723                    op_resolved: e.op_resolved.clone(),
724                    at_nanos: e.at_nanos as i64,
725                    retryable: e.retryable,
726                })
727                .collect(),
728        }
729    }
730}
731
732#[cfg(test)]
733mod tests {
734    use super::*;
735
736    #[test]
737    fn is_failure_keys_on_validity_alone() {
738        // SRD-82 Part 1: the red mark is an untrustworthy result,
739        // regardless of how much of the work ran.
740        assert!(!Outcome::completed().is_failure());
741        assert!(Outcome::failed().is_failure());
742        assert!(Outcome::completed_failed().is_failure());
743        assert!(!Outcome::interrupted().is_failure());
744        assert!(!Outcome::skipped().is_failure());
745    }
746
747    #[test]
748    fn glyphs_and_labels_cover_the_axis_pairs() {
749        assert_eq!(Outcome::completed().glyph(), '✓');
750        assert_eq!(Outcome::failed().glyph(), '✗');
751        assert_eq!(Outcome::completed_failed().glyph(), '✗');
752        assert_eq!(Outcome::interrupted().glyph(), '…');
753        assert_eq!(Outcome::skipped().glyph(), '~');
754        assert_eq!(Outcome::completed().label(), "completed");
755        assert_eq!(Outcome::failed().label(), "failed");
756        assert_eq!(Outcome::completed_failed().label(), "completed_failed");
757        assert_eq!(Outcome::interrupted().label(), "interrupted");
758        assert_eq!(Outcome::skipped().label(), "skipped");
759    }
760
761    #[test]
762    fn session_disposition_exit_codes() {
763        assert_eq!(SessionDisposition::Success.exit_code(), 0);
764        assert_ne!(SessionDisposition::Failure.exit_code(), 0);
765    }
766
767    #[test]
768    fn outcome_completed_has_no_errors() {
769        let o = PhaseOutcome::completed(PhaseIdentity::new("p", "x=1"), 1.5);
770        assert_eq!(o.disposition, Disposition::Completed);
771        assert_eq!(o.validity, Validity::Succeeded);
772        assert!(o.errors.is_empty());
773        assert!(o.first_error_message().is_none());
774    }
775
776    #[test]
777    fn outcome_failed_carries_errors() {
778        let errors = vec![PhaseErrorDetail {
779            class: "Timeout".into(),
780            message: "connection timed out".into(),
781            op_name: Some("read_state".into()),
782            cycle: Some(0),
783            op_template: Some("SELECT ...".into()),
784            op_resolved: Some("SELECT * FROM ks.t".into()),
785            at_nanos: 1_000_000_000,
786            retryable: true,
787        }];
788        let o = PhaseOutcome::failed(PhaseIdentity::new("p", "x=1"), 30.0, errors.clone());
789        assert_eq!(o.disposition, Disposition::Interrupted);
790        assert_eq!(o.validity, Validity::Failed);
791        assert_eq!(o.errors, errors);
792        assert_eq!(o.first_error_message(), Some("connection timed out"));
793    }
794
795    #[test]
796    #[should_panic(expected = "at least one error")]
797    fn outcome_failed_requires_non_empty_errors() {
798        let _ = PhaseOutcome::failed(PhaseIdentity::new("p", ""), 1.0, Vec::new());
799    }
800
801    #[test]
802    fn outcome_skipped_has_zero_duration_and_no_errors() {
803        let o = PhaseOutcome::skipped(PhaseIdentity::new("p", ""));
804        assert_eq!(o.disposition, Disposition::Skipped);
805        assert_eq!(o.duration_secs, 0.0);
806        assert!(o.errors.is_empty());
807    }
808
809    /// Round-trip serde so the persistence layer (Push 3
810    /// sqlite, JSON in `nmbrs replay --json`) sees a
811    /// stable shape.
812    #[test]
813    fn outcome_round_trips_through_json() {
814        let original = PhaseOutcome {
815            phase_id: PhaseIdentity::new("ensure_compacted", "(k=10)"),
816            disposition: Disposition::Interrupted,
817            validity: Validity::Failed,
818            duration_secs: 14400.0,
819            errors: vec![PhaseErrorDetail {
820                class: "poll_timeout".into(),
821                message: "deadline reached".into(),
822                op_name: None,
823                cycle: None,
824                op_template: None,
825                op_resolved: None,
826                at_nanos: 0,
827                retryable: false,
828            }],
829            resume_cursor: None,
830            phase_hash: None,
831            params_consumed: None,
832        };
833        let json = serde_json::to_string(&original).expect("serialise");
834        let parsed: PhaseOutcome = serde_json::from_str(&json).expect("deserialise");
835        assert_eq!(parsed, original);
836    }
837
838    #[test]
839    fn two_axis_outcome_projects_to_and_from_status() {
840        // The four constructors land in the right quadrants.
841        assert_eq!(
842            Outcome::completed(),
843            Outcome::new(Disposition::Completed, Validity::Succeeded)
844        );
845        assert_eq!(
846            Outcome::failed(),
847            Outcome::new(Disposition::Interrupted, Validity::Failed)
848        );
849        assert_eq!(
850            Outcome::interrupted(),
851            Outcome::new(Disposition::Interrupted, Validity::Succeeded)
852        );
853
854        // Validity drives is_failure, orthogonal to disposition.
855        assert!(Outcome::failed().is_failure());
856        assert!(!Outcome::interrupted().is_failure());
857        assert!(!Outcome::completed().is_failure());
858
859        // PhaseOutcome surfaces the two-axis view.
860        let oc = PhaseOutcome::completed(PhaseIdentity::new("p", ""), 1.0);
861        assert_eq!(oc.outcome(), Outcome::completed());
862        assert!(!oc.outcome().is_failure());
863    }
864
865    /// The checkpoint JSONL store is event-sourced and never
866    /// rewritten (SRD-44a): records written before the axes
867    /// migration carry a single `status` string and MUST keep
868    /// deserializing — resume-skip identity matching folds over
869    /// them. `cursor_suspended` collapses to Interrupted+Succeeded.
870    #[test]
871    fn legacy_status_records_still_deserialize() {
872        let cases = [
873            ("completed", Disposition::Completed, Validity::Succeeded),
874            ("failed", Disposition::Interrupted, Validity::Failed),
875            ("skipped", Disposition::Skipped, Validity::Succeeded),
876            (
877                "cursor_suspended",
878                Disposition::Interrupted,
879                Validity::Succeeded,
880            ),
881        ];
882        for (status, d, v) in cases {
883            let json = format!(
884                r#"{{"phase_id":{{"name":"p","labels":""}},"status":"{status}","duration_secs":1.0}}"#
885            );
886            let parsed: PhaseOutcome = serde_json::from_str(&json)
887                .unwrap_or_else(|e| panic!("legacy '{status}' must parse: {e}"));
888            assert_eq!(parsed.disposition, d, "disposition for '{status}'");
889            assert_eq!(parsed.validity, v, "validity for '{status}'");
890        }
891    }
892}