Skip to main content

axon/
runtime_warnings.rs

1//! §Fase 33.x.g — Closed-catalog runtime warnings for the streaming
2//! production path.
3//!
4//! D5 contract: when the production async streaming path can't
5//! activate (flow shape unsupported, backend unknown, etc.), the
6//! runtime emits a closed-catalog warning instead of silently
7//! degrading. Adopters see the warning on `axon.complete.warnings`
8//! AND on the `/v1/replay/<trace_id>` audit row, so a `Stream<T>`
9//! declaration that falls through to legacy synchronous-burst
10//! delivery is OBSERVABLE — never silent.
11//!
12//! # Closed-catalog discipline
13//!
14//! `WarningCode` is a closed enum. Adding a new variant requires:
15//!   1. Updating the enum here (compiler enforces exhaustive `slug`
16//!      match).
17//!   2. Updating the slug-uniqueness pin in
18//!      [`closed_catalog_pins`].
19//!   3. Updating the warning-surface drift gate in
20//!      `tests/fase33x_g_warning_catalog.rs`.
21//!
22//! The "axon-W001" slot is reserved for the X-Axon-Stream-Available
23//! HTTP-header diagnostic that shipped in Fase 31.e (kept as a
24//! header-level surface rather than a wire-body warning to preserve
25//! legacy adopter parsers). 33.x.g introduces "axon-W002" as the
26//! first wire-body-surfaced warning code.
27//!
28//! # Pillar trace
29//!
30//! - **MATHEMATICS** — closed catalog ⟹ exhaustive match ⟹ adding
31//!   a new code breaks the build. Compiler enforces.
32//! - **LOGIC** — every legacy-fallback case has a specific
33//!   [`FallbackMode`] tag. No "unknown" catch-all that hides
34//!   real edge cases.
35//! - **PHILOSOPHY** — no silent degradation. An adopter who declares
36//!   `Stream<T>` and ends up with synthetic chunking sees the
37//!   warning on the wire + on the audit row.
38//! - **COMPUTING** — warnings ride a side-channel on
39//!   `StreamingExecution`; the consumer reads them at FlowComplete
40//!   and projects onto the wire JSON. Zero cost on the happy path
41//!   (empty Vec, elided via `skip_serializing_if`).
42
43use serde::{Deserialize, Serialize};
44
45/// Closed catalog of runtime warning codes that can surface on the
46/// production SSE wire.
47///
48/// As of 33.x.g there is exactly one wire-body code: `AxonW002`.
49/// W001 was reserved by Fase 31.e but ships as the
50/// `X-Axon-Stream-Available` HTTP header (header-level diagnostic),
51/// NOT as a wire-body warning. Future codes (W003+) require an
52/// explicit founder sign-off and a closed-catalog drift gate
53/// update — adding a variant here is not silent.
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
55#[serde(rename_all = "kebab-case")]
56pub enum WarningCode {
57    /// `axon-W002 streaming-not-supported` — emitted when the
58    /// adopter's flow declared `output: Stream<T>` but the
59    /// production async streaming path could not activate, and
60    /// the runtime fell back to the legacy synchronous-burst
61    /// delivery. The accompanying [`FallbackMode`] tag captures
62    /// the specific reason (flow shape unsupported, backend
63    /// unknown, etc.).
64    ///
65    /// Wire surface: `axon.complete.warnings[*]` (D4-compatible
66    /// optional array, elided when empty).
67    /// Audit surface: `replay.runtime_warnings[*]` on the
68    /// `AxonendpointReplayEntry`.
69    #[serde(rename = "axon-W002")]
70    AxonW002,
71}
72
73impl WarningCode {
74    /// Stable wire slug. Closed catalog — adding a variant above
75    /// requires updating this match (compiler enforces
76    /// exhaustiveness).
77    pub fn slug(&self) -> &'static str {
78        match self {
79            Self::AxonW002 => "axon-W002",
80        }
81    }
82
83    /// Human-readable summary surfaced on the wire alongside the
84    /// slug. The accompanying `FallbackMode` provides the
85    /// machine-readable detail.
86    pub fn message(&self) -> &'static str {
87        match self {
88            Self::AxonW002 => "streaming-not-supported",
89        }
90    }
91}
92
93/// Closed catalog of "why streaming did not activate" tags. Each
94/// tag corresponds to a specific decision point in
95/// `server_execute_streaming`. Adding a variant requires updating
96/// the catalog pin tests.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
98#[serde(rename_all = "snake_case")]
99pub enum FallbackMode {
100    // §Fase 33.z.e — `UnsupportedFlowShape` variant DELETED. The
101    // per-IRFlowNode dispatcher (Fase 33.y 45/45) covers every shape
102    // the planner could previously reject; W002 cannot fire for
103    // "unsupported flow shape" because no shape is unsupported.
104    /// `resolve_streaming_backend` returned `None` for the
105    /// requested backend name (after `auto` resolution). The
106    /// dispatcher's BackendError surfaces as `axon.error`.
107    UnknownBackend,
108    /// Source could not be parsed (lex / parse / type-check / IR-
109    /// generation error). The dispatcher's compilation-error path
110    /// surfaces the diagnostic via `axon.error`.
111    SourceCompilationFailed,
112    /// Reserved for future scenarios where a custom adopter-
113    /// provided backend implements `Backend::complete()` but not
114    /// `Backend::stream()`. Not reachable today (all 8 dispatched
115    /// backends implement `stream()` per Fase 24 + 33.x.b); kept
116    /// here so the catalog covers the conceptual case adopters
117    /// hit when extending the registry.
118    BackendLacksStream,
119}
120
121impl FallbackMode {
122    /// Stable kebab-case slug for wire serialization. Mirrors
123    /// `serde(rename_all = "snake_case")` but exposed
124    /// programmatically for adopters that don't go through serde.
125    pub fn slug(&self) -> &'static str {
126        match self {
127            Self::UnknownBackend => "unknown_backend",
128            Self::SourceCompilationFailed => "source_compilation_failed",
129            Self::BackendLacksStream => "backend_lacks_stream",
130        }
131    }
132}
133
134/// One runtime warning entry. Immutable once minted. Surfaces on
135/// `axon.complete.warnings[*]` (wire) and
136/// `replay.runtime_warnings[*]` (audit).
137///
138/// # Required fields (per D5 + plan vivo §1)
139///
140/// - `code` — closed-catalog [`WarningCode`].
141/// - `flow_name` — the flow whose streaming declaration fell back.
142/// - `backend` — the backend name (post-`auto` resolution) that
143///   was attempted.
144/// - `fallback_mode` — the [`FallbackMode`] tag identifying WHY.
145/// - `step_name` — optional step name when the warning is
146///   step-scoped (e.g. one step has an unsupported feature, the
147///   others are fine). `None` for flow-scoped warnings.
148/// - `declared_output` — the adopter-source `output:` declaration
149///   (e.g. `"Stream<Token>"`) that triggered the streaming
150///   expectation. Empty when the warning is not tied to a
151///   specific declaration.
152/// - `message` — human-readable summary. Equals
153///   `code.message()` by default; adopters can include
154///   context-specific detail (e.g. the exact `IRFlowNode` variant
155///   that triggered `UnsupportedFlowShape`).
156#[derive(Debug, Clone, Serialize, Deserialize)]
157pub struct RuntimeWarning {
158    pub code: WarningCode,
159    pub flow_name: String,
160    pub backend: String,
161    pub fallback_mode: FallbackMode,
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub step_name: Option<String>,
164    #[serde(default, skip_serializing_if = "String::is_empty")]
165    pub declared_output: String,
166    pub message: String,
167    /// Unix-millis timestamp when the warning was minted.
168    /// Monotonic within a flow execution; multiple warnings
169    /// preserve insertion order via the carrying `Vec`.
170    pub timestamp_ms: u64,
171}
172
173impl RuntimeWarning {
174    /// Construct a W002 streaming-not-supported warning with the
175    /// canonical message.
176    pub fn streaming_not_supported(
177        flow_name: impl Into<String>,
178        backend: impl Into<String>,
179        fallback_mode: FallbackMode,
180        detail: impl Into<String>,
181    ) -> Self {
182        let detail = detail.into();
183        let message = if detail.is_empty() {
184            WarningCode::AxonW002.message().to_string()
185        } else {
186            format!("{}: {}", WarningCode::AxonW002.message(), detail)
187        };
188        Self {
189            code: WarningCode::AxonW002,
190            flow_name: flow_name.into(),
191            backend: backend.into(),
192            fallback_mode,
193            step_name: None,
194            declared_output: String::new(),
195            message,
196            timestamp_ms: std::time::SystemTime::now()
197                .duration_since(std::time::UNIX_EPOCH)
198                .map(|d| d.as_millis() as u64)
199                .unwrap_or(0),
200        }
201    }
202}
203
204// ────────────────────────────────────────────────────────────────────
205//  Tests — closed-catalog pins
206// ────────────────────────────────────────────────────────────────────
207
208#[cfg(test)]
209mod closed_catalog_pins {
210    use super::*;
211
212    #[test]
213    fn warning_code_catalog_has_exactly_one_variant() {
214        // Compile-time evidence: pattern-match exhaustiveness +
215        // the test ensures any addition is intentional. Update
216        // this slot when adding W003+ (deliberate founder sign-
217        // off, NOT silent).
218        let all = [WarningCode::AxonW002];
219        assert_eq!(all.len(), 1);
220    }
221
222    #[test]
223    fn warning_code_slug_is_kebab_case_with_axon_w_prefix() {
224        for code in [WarningCode::AxonW002] {
225            let slug = code.slug();
226            assert!(
227                slug.starts_with("axon-W"),
228                "WarningCode slug MUST start with 'axon-W'; got {slug:?}"
229            );
230            assert!(
231                slug.chars().all(|c| c.is_ascii_alphanumeric() || c == '-'),
232                "WarningCode slug MUST be ASCII alphanumeric + dash; got {slug:?}"
233            );
234        }
235    }
236
237    #[test]
238    fn warning_code_slugs_are_unique() {
239        let all = [WarningCode::AxonW002];
240        let mut slugs: Vec<&str> = all.iter().map(|c| c.slug()).collect();
241        slugs.sort();
242        let mut unique = slugs.clone();
243        unique.dedup();
244        assert_eq!(slugs.len(), unique.len(), "WarningCode slugs must be unique");
245    }
246
247    #[test]
248    fn fallback_mode_catalog_has_three_variants_post_33_z_e() {
249        // §Fase 33.z.e — `UnsupportedFlowShape` retired; catalog
250        // shrinks from 4 to 3. The dispatcher path covers every
251        // IRFlowNode variant; no shape is "unsupported".
252        let all = [
253            FallbackMode::UnknownBackend,
254            FallbackMode::SourceCompilationFailed,
255            FallbackMode::BackendLacksStream,
256        ];
257        assert_eq!(all.len(), 3);
258    }
259
260    #[test]
261    fn fallback_mode_slugs_are_snake_case_unique() {
262        let all = [
263            FallbackMode::UnknownBackend,
264            FallbackMode::SourceCompilationFailed,
265            FallbackMode::BackendLacksStream,
266        ];
267        let mut slugs: Vec<&str> = all.iter().map(|m| m.slug()).collect();
268        // Snake-case predicate.
269        for slug in &slugs {
270            assert!(
271                slug.chars()
272                    .all(|c| c.is_ascii_lowercase() || c == '_' || c.is_ascii_digit()),
273                "FallbackMode slug MUST be snake_case; got {slug:?}"
274            );
275        }
276        slugs.sort();
277        let mut unique = slugs.clone();
278        unique.dedup();
279        assert_eq!(slugs.len(), unique.len(), "FallbackMode slugs must be unique");
280    }
281
282    #[test]
283    fn streaming_not_supported_constructor_sets_canonical_message() {
284        let w = RuntimeWarning::streaming_not_supported(
285            "Chat",
286            "anthropic",
287            FallbackMode::UnknownBackend,
288            "",
289        );
290        assert_eq!(w.code, WarningCode::AxonW002);
291        assert_eq!(w.flow_name, "Chat");
292        assert_eq!(w.backend, "anthropic");
293        assert_eq!(w.fallback_mode, FallbackMode::UnknownBackend);
294        assert_eq!(w.message, "streaming-not-supported");
295        assert!(w.timestamp_ms > 0);
296    }
297
298    #[test]
299    fn streaming_not_supported_constructor_includes_detail_in_message() {
300        let w = RuntimeWarning::streaming_not_supported(
301            "Chat",
302            "stub",
303            FallbackMode::SourceCompilationFailed,
304            "parse: missing closing brace",
305        );
306        assert_eq!(
307            w.message,
308            "streaming-not-supported: parse: missing closing brace"
309        );
310    }
311
312    #[test]
313    fn runtime_warning_serializes_with_kebab_code_and_snake_fallback() {
314        let w = RuntimeWarning::streaming_not_supported(
315            "F",
316            "stub",
317            FallbackMode::UnknownBackend,
318            "",
319        );
320        let json = serde_json::to_value(&w).unwrap();
321        assert_eq!(json["code"], "axon-W002");
322        assert_eq!(json["fallback_mode"], "unknown_backend");
323        assert_eq!(json["flow_name"], "F");
324        assert_eq!(json["backend"], "stub");
325        // step_name + declared_output are elided when empty.
326        assert!(json.get("step_name").is_none());
327        assert!(json.get("declared_output").is_none());
328    }
329
330    #[test]
331    fn runtime_warning_round_trips_via_serde() {
332        let w = RuntimeWarning::streaming_not_supported(
333            "F",
334            "anthropic",
335            FallbackMode::BackendLacksStream,
336            "adopter's custom backend",
337        );
338        let json = serde_json::to_string(&w).unwrap();
339        let parsed: RuntimeWarning = serde_json::from_str(&json).unwrap();
340        assert_eq!(parsed.code, WarningCode::AxonW002);
341        assert_eq!(parsed.flow_name, "F");
342        assert_eq!(parsed.backend, "anthropic");
343        assert_eq!(parsed.fallback_mode, FallbackMode::BackendLacksStream);
344    }
345}