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}