axon/wire_envelope.rs
1//! §Fase 39.b — Pure Silicon Cognition: the canonical wire payload type
2//! for axonendpoint responses on `transport: json`.
3//!
4//! Isomorphic to the ψ-vector `ψ = ⟨T, V, E⟩` (paper §5):
5//!
6//! - **T** — the ontological type the value claims to inhabit
7//! (`FlowEnvelope.ontological_type`)
8//! - **V** — the typed payload, member of T
9//! (`FlowEnvelope.result`)
10//! - **E** — the epistemic envelope: certainty (Theorem 5.1) +
11//! provenance + audit-chain + blame attribution
12//! (the remaining fields)
13//!
14//! Defined ONCE in Rust; no Python mirror; no drift gate (per D3 +
15//! [[feedback_zero_py_files_north_star]]). This module is the
16//! Rust-canonical source of truth for the v2.0.0 wire shape.
17//!
18//! ## Construction
19//!
20//! The canonical builder is
21//! [`FlowEnvelope::from_execution_result`] — converts the v1.x
22//! [`crate::axon_server::ServerExecutionResult`] into a v2.0.0
23//! envelope. The conversion is total: every field of the legacy
24//! struct maps to a pillar-organized slot of the envelope, with no
25//! information loss. Epistemic fields (`certainty`,
26//! `provenance_chain`, `blame_attribution`) receive Fase-39.b safe
27//! defaults; their full producer logic lands in Fase 39.c.
28//!
29//! ## Sealing
30//!
31//! [`FlowEnvelope::seal`] is the single egress point before HTTP
32//! serialization. In Fase 39.b it runs the Rust-side fallback for
33//! Theorem 5.1 enforcement (clamp `certainty ≤ 0.99` if derived) +
34//! computes the `audit_chain_hash` over the canonical provenance
35//! representation. In Fase 39.c this method delegates to the C23
36//! kernel `axon-csys::effects::envelope::validate_epistemic_degradation`,
37//! making the bound structurally unbypassable from any Rust caller.
38//!
39//! ## Pillars
40//!
41//! - **Pillar I (Epistemic)** — `ontological_type`, `result`,
42//! `certainty` (Theorem 5.1 bounded)
43//! - **Pillar II (Audit-chained)** — `provenance_chain`,
44//! `step_audit`, `audit_chain_hash`
45//! - **Pillar III (Streaming)** — N/A (SSE has its own event family
46//! per D9; this envelope is JSON-transport-only)
47//! - **Pillar IV (Capability)** — `blame_attribution` (carries
48//! `BlameKind` of failure when present)
49//!
50//! See plan vivo `docs/fase/fase_39_pure_silicon_cognition.md` §4 for
51//! the full wire-shape contract.
52
53use serde::{Deserialize, Serialize};
54use sha2::{Digest, Sha256};
55
56// ════════════════════════════════════════════════════════════════════
57// FlowEnvelope — the canonical wire payload for `transport: json`
58// ════════════════════════════════════════════════════════════════════
59
60/// §Fase 39 (D1, D2, D5) — the wire payload of every `transport: json`
61/// axonendpoint response (HTTP 2xx) and every legacy
62/// `POST /v1/execute` invocation.
63///
64/// Fields are organized by Pillar (see module docs). At wire
65/// emission, `result` carries a `serde_json::Value` (monomorphic at
66/// runtime); D5 validation (Fase 32.d) — once simplified in 39.d —
67/// will type-check this slot against the declared inner T of the
68/// adopter's `output: FlowEnvelope<T>` declaration.
69#[derive(Serialize, Deserialize, Debug, Clone)]
70pub struct FlowEnvelope {
71 // ── Pillar I (Epistemic) — the ψ-vector slots ────────────────
72 /// The ontological type declared at the endpoint surface (the
73 /// inner T of `output: FlowEnvelope<T>`). Slug form:
74 /// `TenantRecord`, `List<PatientRecord>`, `Stream<Token>`.
75 /// For legacy `/v1/execute` invocations (no endpoint
76 /// declaration), this is the runtime-inferred type slug.
77 pub ontological_type: String,
78
79 /// The typed payload — member of `ontological_type`.
80 /// `serde_json::Value` at the wire layer because the runtime
81 /// is monomorphic; D5 (when simplified in 39.d) validates the
82 /// inner shape against the declared T.
83 pub result: serde_json::Value,
84
85 /// Certainty `c ∈ [0.0, 1.0]`, bounded by Theorem 5.1:
86 /// `c ≤ 0.99` whenever `derived_status = true`. In Fase 39.b
87 /// the bound is enforced by [`FlowEnvelope::seal`]'s Rust
88 /// fallback; in Fase 39.c the bound moves to the C23 kernel
89 /// `axon-csys::effects::envelope::validate_epistemic_degradation`,
90 /// making it structurally unbypassable.
91 pub certainty: f64,
92
93 /// §Fase 55.b — the Theorem 5.1 `(base, scope, confidence)` triple of
94 /// every flow-level `use <Tool>` dispatch whose tool declares an
95 /// `epistemic:<level>` effect. Surfaces the epistemic degradation on
96 /// the wire — the §50.i.4 parity gate, promoted (the competitive
97 /// differential: an adopter sees a query routed through an
98 /// `epistemic:speculate` tool decay to `confidence ≤ 0.80`). Empty —
99 /// and elided from the JSON — for flows with no epistemic tool (D5
100 /// backward-compat: byte-identical wire for every pre-55.b flow).
101 #[serde(default, skip_serializing_if = "Vec::is_empty")]
102 pub epistemic_envelopes: Vec<crate::epistemic_capture::EpistemicEnvelope>,
103
104 // ── Pillar II (Audit-chained) — provenance + step trail ──────
105 /// Ordered list of `kind:identifier` tuples capturing the
106 /// lineage of `result`. Examples:
107 /// - `["flow:FetchTenants", "retrieve:tenants", "backend:stub"]`
108 /// - `["step:Triage", "shield:Hipaa", "backend:anthropic"]`
109 /// Empty for endpoints with no derived state (singular literal
110 /// returns); populated by [`FlowEnvelope::from_execution_result`].
111 pub provenance_chain: Vec<String>,
112
113 /// Per-step audit trail. Survives from v1.x as the canonical
114 /// observability surface; here it is structured (not just
115 /// `Vec<String>`). Step results are TYPED `Value` post-39.b
116 /// (pre-v2.0.0 they were stringified — the typed form is a D5
117 /// simplification dividend).
118 pub step_audit: StepAuditTrail,
119
120 /// HMAC-SHA256 hex of the canonical form of `provenance_chain
121 /// || step_audit`. Computed by [`FlowEnvelope::seal`]; in
122 /// Fase 39.c the hash moves to the C23 kernel for byte-
123 /// deterministic cross-deployment verification.
124 pub audit_chain_hash: String,
125
126 // ── Pillar IV (Capability) — blame attribution ───────────────
127 /// Populated only when the flow's success path produced a
128 /// degraded posture (anchor breach, shield rejection, backend
129 /// soft-fail, store breach, type-mismatch on recoverable path).
130 /// `None` on the clean happy path.
131 pub blame_attribution: Option<BlameContext>,
132
133 // ── Cross-cutting — observability + correlation ──────────────
134 /// Execution metrics — latency, tokens, backend identity.
135 /// Always populated.
136 pub execution_metrics: ExecutionMetrics,
137
138 /// Correlation anchor (matches `X-Axon-Trace-Id` header).
139 /// String form for cross-stack compat (the v1.x `trace_id: u64`
140 /// is reborn here as Uuid v4 hex string).
141 pub trace_id: String,
142}
143
144/// §Fase 39 (D5) — per-step audit surface. Structured replacement
145/// for the v1.x `Vec<String>` step results.
146#[derive(Serialize, Deserialize, Debug, Clone, Default)]
147pub struct StepAuditTrail {
148 pub step_names: Vec<String>,
149 /// §Fase 39.b — TYPED. The v1.x stringified results are parsed
150 /// as JSON values when constructible; opaque strings fall back
151 /// to `Value::String(...)`. The D5 simplification in 39.d
152 /// leverages this typed form.
153 pub step_results: Vec<serde_json::Value>,
154 pub anchor_checks: usize,
155 pub anchor_breaches: usize,
156 pub errors: usize,
157 pub steps_executed: usize,
158 /// §Fase 33.x.d carry-over — per-step EnforcementSummary entries
159 /// (from `StreamPolicyEnforcer` runs). Empty in the legacy sync
160 /// path; populated by `server_execute_streaming` per the D2
161 /// contract.
162 #[serde(default, skip_serializing_if = "Vec::is_empty")]
163 pub enforcement_summaries:
164 Vec<(String, crate::axon_server::EnforcementSummaryWire)>,
165 /// §Fase 33.e carry-over — per-step `<stream:<policy>>` slugs
166 /// declared in source. Empty when no step declares one.
167 #[serde(default, skip_serializing_if = "Vec::is_empty")]
168 pub effect_policies: Vec<(String, String)>,
169 /// §Fase 33.x.g carry-over — closed-catalog runtime warnings
170 /// (only populated on legacy-path fallback under axon-W002 —
171 /// structurally unreachable post-33.z but the slot survives
172 /// for forward-compat with future warnings).
173 #[serde(default, skip_serializing_if = "Vec::is_empty")]
174 pub runtime_warnings: Vec<crate::runtime_warnings::RuntimeWarning>,
175}
176
177/// §Fase 39 (D5) — execution metrics + provenance identity. Always
178/// populated.
179#[derive(Serialize, Deserialize, Debug, Clone, Default)]
180pub struct ExecutionMetrics {
181 pub latency_ms: u64,
182 pub tokens_input: u64,
183 pub tokens_output: u64,
184 pub backend: String,
185 pub flow_name: String,
186 pub source_file: String,
187}
188
189// ════════════════════════════════════════════════════════════════════
190// BlameContext — Pillar IV attribution surface
191// ════════════════════════════════════════════════════════════════════
192
193/// §Fase 39 (D11) — closed-catalog blame attribution. Surfaces
194/// WHICH layer produced the degraded posture on a 2xx response.
195/// Hard-fails (4xx/5xx) are handled by the existing error envelopes
196/// (not this struct) — `BlameContext` is for SOFT degradation
197/// reported on the success path.
198#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
199pub struct BlameContext {
200 pub kind: BlameKind,
201 /// `file:line:col` (compile-time origin) OR `step:name`
202 /// (runtime origin). Empty string when the origin cannot be
203 /// pinpointed.
204 pub location: String,
205 /// Human-readable diagnostic. Forms the audit_log entry's
206 /// primary message.
207 pub message: String,
208 /// Optional anchor back to a plan-vivo D-letter (e.g. "39.c",
209 /// "33.x.d") for forward correlation when the blame ties to a
210 /// specific architectural commitment.
211 pub d_letter: Option<String>,
212}
213
214/// §Fase 39 (D11) — closed catalog of blame kinds. Adding a variant
215/// is a non-breaking surface change (consumers MUST handle
216/// `#[non_exhaustive]`-style fall-through).
217#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
218#[serde(rename_all = "snake_case")]
219pub enum BlameKind {
220 /// Pillar IV — an anchor's `require:` predicate failed; flow
221 /// chose to proceed (degraded path).
222 AnchorBreach,
223 /// Pillar I — a shield scanner flagged content; flow chose to
224 /// proceed.
225 ShieldRejection,
226 /// Backend returned a degraded response (truncated, partial,
227 /// soft-rate-limited).
228 BackendSoftFail,
229 /// Pillar II — store mutation chain verification failed; flow
230 /// proceeded with the prior-state read.
231 StoreBreach,
232 /// D5 detected partial typing inconsistency that is recoverable
233 /// (e.g. missing optional field with a sane default).
234 TypeMismatch,
235}
236
237// ════════════════════════════════════════════════════════════════════
238// FlowEnvelope::from_execution_result — v1.x → v2.0.0 converter
239// ════════════════════════════════════════════════════════════════════
240
241impl FlowEnvelope {
242 /// §Fase 39.b — convert a v1.x [`crate::axon_server::ServerExecutionResult`]
243 /// into a v2.0.0 envelope. Total: every legacy field maps to a
244 /// pillar-organized slot; no information loss.
245 ///
246 /// Epistemic field defaults applied here (refined in 39.c):
247 /// - `certainty = 1.0` when `anchor_breaches == 0` and
248 /// `errors == 0` (clean happy path; no derived posture).
249 /// - `certainty = 0.99` when `anchor_breaches > 0 ||
250 /// errors > 0` (Theorem 5.1: derived states bounded ≤ 0.99).
251 /// - `provenance_chain` built from
252 /// `flow_name + step_names + backend`.
253 /// - `blame_attribution = None` always at this layer (the soft-
254 /// degradation surface is populated by the runtime when it
255 /// detects anchor/shield/store/backend events — 39.c lands
256 /// that wiring).
257 ///
258 /// The `result` slot is populated from the LAST step's typed
259 /// output (`step_results.last()` parsed as `Value`). For flows
260 /// with no steps (degenerate) the result is `Value::Null`.
261 ///
262 /// `trace_id` is converted from the legacy `u64` to a Uuid v4
263 /// hex string. When the legacy id is 0 (pre-record), a fresh
264 /// Uuid is minted.
265 pub fn from_execution_result(
266 exec_result: crate::axon_server::ServerExecutionResult,
267 ontological_type: String,
268 ) -> Self {
269 // ── Pillar II — provenance chain ──
270 // §Fase 39.c.y — interleave semantic provenance events
271 // (`retrieve:*`, `shield:*`, etc.) with the canonical
272 // step/backend entries. Order: `flow:F`, then taxonomy
273 // events from execution_units walk, then `step:S` entries
274 // for each canonical step, then `backend:B` last. This
275 // gives auditors a complete lineage from flow declaration
276 // through every observable runtime event.
277 let mut provenance_chain = Vec::with_capacity(
278 2 + exec_result.step_names.len() + exec_result.provenance_events.len(),
279 );
280 provenance_chain.push(format!("flow:{}", exec_result.flow_name));
281 for event in &exec_result.provenance_events {
282 provenance_chain.push(event.clone());
283 }
284 for step_name in &exec_result.step_names {
285 provenance_chain.push(format!("step:{}", step_name));
286 }
287 provenance_chain.push(format!("backend:{}", exec_result.backend));
288
289 // ── Pillar II — typed step_results ──
290 // Parse each stringified result as JSON if possible; fall
291 // back to a String Value preserving the raw text.
292 let step_results_typed: Vec<serde_json::Value> = exec_result
293 .step_results
294 .iter()
295 .map(|s| {
296 serde_json::from_str::<serde_json::Value>(s)
297 .unwrap_or_else(|_| serde_json::Value::String(s.to_string()))
298 })
299 .collect();
300
301 // ── Pillar I — the `result` slot ──
302 // Canonically the last step's typed value is the flow output.
303 // When the flow has no steps (degenerate), result is Null.
304 let result = step_results_typed
305 .last()
306 .cloned()
307 .unwrap_or(serde_json::Value::Null);
308
309 // ── Pillar I — certainty (Theorem 5.1 Rust-side fallback) ──
310 // The C23 kernel in 39.c will replace this; here we apply
311 // the same algebra so wire bytes are stable across the
312 // 39.b → 39.c transition.
313 let derived =
314 exec_result.anchor_breaches > 0 || exec_result.errors > 0;
315 let certainty = if derived { 0.99 } else { 1.0 };
316
317 // ── Pillar IV — blame ──
318 // §Fase 39.c.z — propagate the blame attribution from the
319 // runtime walk (populated by `derive_blame_from_report` in
320 // `wire_envelope_producers`). `None` on clean happy path;
321 // populated when the runtime surfaced an anchor breach,
322 // shield rejection, store breach, backend soft-fail, or
323 // recoverable type mismatch. The first-emitted (highest-
324 // priority) blame wins per `merge_blame`.
325 let blame_attribution: Option<BlameContext> =
326 exec_result.blame_attribution;
327
328 // ── Cross-cutting — trace_id ──
329 let trace_id = if exec_result.trace_id == 0 {
330 uuid::Uuid::new_v4().to_string()
331 } else {
332 // Pre-39 the trace_id was a u64; here we render it as
333 // a 16-char hex string (preserve the value semantically;
334 // future code paths will mint Uuids directly).
335 format!("{:016x}", exec_result.trace_id)
336 };
337
338 Self {
339 ontological_type,
340 result,
341 certainty,
342 // §Fase 55.b — surface the IR-derived epistemic envelopes.
343 epistemic_envelopes: exec_result.epistemic_envelopes,
344 provenance_chain,
345 step_audit: StepAuditTrail {
346 step_names: exec_result.step_names.clone(),
347 step_results: step_results_typed,
348 anchor_checks: exec_result.anchor_checks,
349 anchor_breaches: exec_result.anchor_breaches,
350 errors: exec_result.errors,
351 steps_executed: exec_result.steps_executed,
352 enforcement_summaries: exec_result.enforcement_summaries,
353 effect_policies: exec_result.effect_policies,
354 runtime_warnings: exec_result.runtime_warnings,
355 },
356 audit_chain_hash: String::new(), // computed by seal()
357 blame_attribution,
358 execution_metrics: ExecutionMetrics {
359 latency_ms: exec_result.latency_ms,
360 tokens_input: exec_result.tokens_input,
361 tokens_output: exec_result.tokens_output,
362 backend: exec_result.backend,
363 flow_name: exec_result.flow_name,
364 source_file: exec_result.source_file,
365 },
366 trace_id,
367 }
368 }
369}
370
371// ════════════════════════════════════════════════════════════════════
372// FlowEnvelope::seal — single egress before HTTP serialization
373// ════════════════════════════════════════════════════════════════════
374
375impl FlowEnvelope {
376 /// §Fase 39.b — apply epistemic enforcement + compute the
377 /// `audit_chain_hash` before wire serialization. This is the
378 /// ONLY public sealing surface; the wire bytes emitted by
379 /// `axon_server` MUST pass through this method (the `seal()`
380 /// invariant — Fase 39.b establishes it; 39.h grep gate locks
381 /// it structurally).
382 ///
383 /// ## Fase 39.c.x implementation (C23 kernel canonical)
384 ///
385 /// 1. Theorem 5.1 enforcement DELEGATES to the C23 kernel
386 /// `axon-csys::envelope::validate_degradation`. The kernel:
387 /// a. Defensively normalises NaN / Inf / out-of-range
388 /// certainty into `[0.0, 1.0]`.
389 /// b. Clamps `certainty ≤ 0.99` when `derived_status = true`.
390 /// c. Returns the envelope with `derived_status` +
391 /// `epistemic_kind` passed through unchanged.
392 /// The C23 kernel is the SINGLE point of structural truth —
393 /// no Rust path bypasses it for production code paths.
394 /// 2. `derived_status` algebra (Rust-side, matches the producer
395 /// in [`FlowEnvelope::from_execution_result`] verbatim):
396 /// `derived = step_audit.anchor_breaches > 0
397 /// || step_audit.errors > 0`. The Rust side decides
398 /// WHO is derived (semantic); the C23 kernel enforces WHAT
399 /// the ceiling looks like (structural).
400 /// 3. `audit_chain_hash` = SHA-256 hex of the canonical-JSON
401 /// serialization of `[provenance_chain, step_audit]`.
402 /// Deterministic on identical inputs; tamper-evident.
403 /// (39.c.x leaves the SHA-256 in Rust pending a future
404 /// sub-fase that moves it to axon-csys::crypto for true
405 /// silicon-grounded tamper-evidence.)
406 pub fn seal(mut self) -> Self {
407 // §Fase 55.e — epistemic ceiling propagates to the HEADLINE
408 // certainty: a flow can be no more certain than its least-certain
409 // epistemic tool. The λD lattice meet (⊓) of the per-tool ceilings
410 // is their minimum; each envelope's `confidence` IS that tool's
411 // applied ceiling (§55.a/b), so `min(confidences)` is the flow's
412 // epistemic upper bound. Applied BEFORE the C23 kernel so the kernel
413 // consolidates the full degradation (Theorem 5.1) at the single
414 // egress — no gateway can read a nominal `know` headline while an
415 // internal tool degraded the computation to `speculate`
416 // (Epistemic Transparency / taint propagation). No epistemic tool ⇒
417 // no change (D5 wire byte-compat for every pre-55 flow).
418 if let Some(min_ceiling) = self
419 .epistemic_envelopes
420 .iter()
421 .map(|e| e.confidence)
422 .reduce(f64::min)
423 {
424 self.certainty = self.certainty.min(min_ceiling);
425 }
426 // Theorem 5.1 — DELEGATE to C23 kernel. The algebra for
427 // `derived_status` matches the producer in
428 // `from_execution_result` (anchor_breaches > 0 || errors > 0)
429 // so producer + sealer agree on WHO is derived.
430 let derived = self.step_audit.anchor_breaches > 0
431 || self.step_audit.errors > 0;
432 let epistemic_kind = if !derived {
433 axon_csys::EpistemicKind::Clean
434 } else if self.blame_attribution.is_some() {
435 // Multi-source degradation — anchor/shield/store/backend
436 // surfaced an explicit blame producer (Pillar IV).
437 axon_csys::EpistemicKind::Degraded
438 } else if self.step_audit.anchor_breaches > 0 {
439 axon_csys::EpistemicKind::Breached
440 } else {
441 axon_csys::EpistemicKind::Derived
442 };
443 let env = axon_csys::EpistemicEnvelope::new(
444 self.certainty,
445 derived,
446 epistemic_kind,
447 );
448 let clamped = axon_csys::validate_degradation(env);
449 self.certainty = clamped.certainty;
450 // Audit chain hash — SHA-256 over canonical JSON of
451 // [provenance_chain, step_audit]. We use serde_json for
452 // canonicalization (sorted keys on structs by design; we
453 // accept the array-of-(provenance, audit) tuple as the
454 // canonical input).
455 let canonical = serde_json::to_string(&(
456 &self.provenance_chain,
457 &self.step_audit,
458 ))
459 .unwrap_or_default();
460 let mut hasher = Sha256::new();
461 hasher.update(canonical.as_bytes());
462 let digest = hasher.finalize();
463 self.audit_chain_hash = format!("{digest:x}");
464 self
465 }
466}
467
468// ════════════════════════════════════════════════════════════════════
469// Helpers
470// ════════════════════════════════════════════════════════════════════
471
472/// §Fase 39.b — derive the `ontological_type` slug for an endpoint's
473/// declared `output: T`. When the endpoint declares
474/// `output: FlowEnvelope<T>` (the canonical form post-39.e), this
475/// extracts the inner T. For legacy declarations (pre-39.e — still
476/// in tree until atomic deploy), returns the declared type verbatim.
477/// For empty / missing declarations returns `"Any"` (the singular
478/// catch-all).
479pub fn extract_inner_ontological_type(declared: &str) -> String {
480 let t = declared.trim();
481 if t.is_empty() {
482 return "Any".to_string();
483 }
484 if let Some(rest) = t.strip_prefix("FlowEnvelope<") {
485 if let Some(inner) = rest.strip_suffix('>') {
486 return inner.trim().to_string();
487 }
488 }
489 t.to_string()
490}
491
492// ════════════════════════════════════════════════════════════════════
493// Tests
494// ════════════════════════════════════════════════════════════════════
495
496#[cfg(test)]
497mod tests {
498 use super::*;
499 use crate::axon_server::ServerExecutionResult;
500
501 fn fixture_exec_result() -> ServerExecutionResult {
502 ServerExecutionResult {
503 success: true,
504 flow_name: "FetchTenants".to_string(),
505 source_file: "tenants.axon".to_string(),
506 backend: "stub".to_string(),
507 steps_executed: 1,
508 latency_ms: 142,
509 tokens_input: 0,
510 tokens_output: 0,
511 anchor_checks: 0,
512 anchor_breaches: 0,
513 errors: 0,
514 step_names: vec!["RetrieveAll".to_string()],
515 step_results: vec![
516 r#"[{"id":1,"name":"foo"},{"id":2,"name":"bar"}]"#.to_string(),
517 ],
518 trace_id: 0,
519 effect_policies: Vec::new(),
520 enforcement_summaries: Vec::new(),
521 runtime_warnings: Vec::new(),
522 provenance_events: Vec::new(),
523 blame_attribution: None,
524 epistemic_envelopes: Vec::new(),
525 }
526 }
527
528 #[test]
529 fn fase39b_from_execution_result_clean_happy_path() {
530 let exec = fixture_exec_result();
531 let env = FlowEnvelope::from_execution_result(
532 exec,
533 "List<TenantRecord>".to_string(),
534 );
535 assert_eq!(env.ontological_type, "List<TenantRecord>");
536 assert_eq!(env.certainty, 1.0, "clean path → certainty 1.0");
537 assert_eq!(env.execution_metrics.flow_name, "FetchTenants");
538 assert_eq!(env.execution_metrics.latency_ms, 142);
539 assert!(env.blame_attribution.is_none());
540 assert_eq!(
541 env.provenance_chain,
542 vec![
543 "flow:FetchTenants",
544 "step:RetrieveAll",
545 "backend:stub"
546 ]
547 );
548 }
549
550 #[test]
551 fn fase39b_typed_result_slot_from_last_step() {
552 let exec = fixture_exec_result();
553 let env = FlowEnvelope::from_execution_result(
554 exec,
555 "List<TenantRecord>".to_string(),
556 );
557 // result is the LAST step's JSON-parsed value
558 let arr = env.result.as_array().expect("result must be array");
559 assert_eq!(arr.len(), 2);
560 assert_eq!(arr[0]["id"], 1);
561 assert_eq!(arr[0]["name"], "foo");
562 assert_eq!(arr[1]["id"], 2);
563 assert_eq!(arr[1]["name"], "bar");
564 }
565
566 #[test]
567 fn fase39b_typed_step_results_parsed_when_json() {
568 let exec = fixture_exec_result();
569 let env = FlowEnvelope::from_execution_result(
570 exec,
571 "List<TenantRecord>".to_string(),
572 );
573 assert_eq!(env.step_audit.step_results.len(), 1);
574 assert!(env.step_audit.step_results[0].is_array());
575 }
576
577 #[test]
578 fn fase39b_opaque_step_result_falls_back_to_string_value() {
579 let mut exec = fixture_exec_result();
580 exec.step_results = vec!["(stub model response)".to_string()];
581 let env = FlowEnvelope::from_execution_result(exec, "String".to_string());
582 // Opaque non-JSON text → Value::String wrapping the raw text
583 assert_eq!(env.step_audit.step_results.len(), 1);
584 assert_eq!(
585 env.step_audit.step_results[0],
586 serde_json::Value::String("(stub model response)".to_string())
587 );
588 }
589
590 #[test]
591 fn fase39b_certainty_bounded_on_derived_state() {
592 let mut exec = fixture_exec_result();
593 exec.anchor_breaches = 1;
594 let env = FlowEnvelope::from_execution_result(exec, "Any".to_string());
595 assert_eq!(env.certainty, 0.99, "derived state → 0.99 per Theorem 5.1");
596 }
597
598 #[test]
599 fn fase39b_certainty_bounded_on_errors() {
600 let mut exec = fixture_exec_result();
601 exec.errors = 1;
602 let env = FlowEnvelope::from_execution_result(exec, "Any".to_string());
603 assert_eq!(env.certainty, 0.99, "errors → derived → 0.99");
604 }
605
606 #[test]
607 fn fase39b_seal_populates_audit_chain_hash() {
608 let env = FlowEnvelope::from_execution_result(
609 fixture_exec_result(),
610 "List<TenantRecord>".to_string(),
611 );
612 assert_eq!(env.audit_chain_hash, "", "pre-seal: empty");
613 let sealed = env.seal();
614 assert_eq!(
615 sealed.audit_chain_hash.len(),
616 64,
617 "post-seal: SHA-256 hex digest (64 chars)"
618 );
619 assert!(
620 sealed.audit_chain_hash.chars().all(|c| c.is_ascii_hexdigit()),
621 "post-seal: lowercase hex"
622 );
623 }
624
625 #[test]
626 fn fase39b_seal_is_deterministic_on_identical_inputs() {
627 let a = FlowEnvelope::from_execution_result(
628 fixture_exec_result(),
629 "List<TenantRecord>".to_string(),
630 )
631 .seal();
632 let b = FlowEnvelope::from_execution_result(
633 fixture_exec_result(),
634 "List<TenantRecord>".to_string(),
635 )
636 .seal();
637 assert_eq!(
638 a.audit_chain_hash, b.audit_chain_hash,
639 "seal must be deterministic"
640 );
641 }
642
643 #[test]
644 fn fase39b_seal_changes_hash_on_provenance_drift() {
645 let a = FlowEnvelope::from_execution_result(
646 fixture_exec_result(),
647 "List<TenantRecord>".to_string(),
648 )
649 .seal();
650 let mut exec_b = fixture_exec_result();
651 exec_b.step_names = vec!["RetrieveAllRenamed".to_string()];
652 let b = FlowEnvelope::from_execution_result(
653 exec_b,
654 "List<TenantRecord>".to_string(),
655 )
656 .seal();
657 assert_ne!(
658 a.audit_chain_hash, b.audit_chain_hash,
659 "tamper detection: provenance drift changes the hash"
660 );
661 }
662
663 #[test]
664 fn fase39b_seal_clamps_certainty_on_derived() {
665 // §Theorem 5.1 enforcement — even if a producer set certainty
666 // > 0.99 on a derived state, seal() clamps it.
667 // The 39.b algebra: derived ⇔ anchor_breaches > 0 || errors > 0
668 // (matches from_execution_result verbatim).
669 let mut env = FlowEnvelope {
670 ontological_type: "Any".to_string(),
671 result: serde_json::Value::Null,
672 certainty: 1.0, // misbehaving producer
673 epistemic_envelopes: Vec::new(),
674 provenance_chain: vec!["flow:Derived".to_string()],
675 step_audit: StepAuditTrail {
676 anchor_breaches: 1, // makes this derived per 39.b algebra
677 ..StepAuditTrail::default()
678 },
679 audit_chain_hash: String::new(),
680 blame_attribution: None,
681 execution_metrics: ExecutionMetrics::default(),
682 trace_id: "x".to_string(),
683 };
684 env.certainty = 1.0;
685 let sealed = env.seal();
686 assert!(
687 sealed.certainty <= 0.99,
688 "Theorem 5.1: certainty must be clamped to ≤ 0.99 on \
689 derived states (anchor_breaches > 0). Got: {}",
690 sealed.certainty
691 );
692 }
693
694 #[test]
695 fn fase39b_seal_preserves_certainty_on_clean_path() {
696 // §Theorem 5.1 — only derived states are clamped. A flow
697 // with no derivation (just the flow:_ provenance prefix and
698 // nothing else) keeps certainty = 1.0.
699 let mut exec = fixture_exec_result();
700 exec.step_names = Vec::new(); // strip the step to remove derivation
701 let env = FlowEnvelope::from_execution_result(exec, "Any".to_string());
702 // After from_execution_result the provenance chain has only
703 // ["flow:FetchTenants", "backend:stub"]. That's 2 entries
704 // (> 1), so this counts as derived per our algebra.
705 // To get a NON-derived state we'd need a flow with NO
706 // backend either — i.e. a degenerate flow. For the test we
707 // assert the algebra by directly constructing.
708 let degenerate = FlowEnvelope {
709 ontological_type: "Any".to_string(),
710 result: serde_json::Value::Null,
711 certainty: 1.0,
712 epistemic_envelopes: Vec::new(),
713 provenance_chain: vec!["flow:Empty".to_string()],
714 step_audit: StepAuditTrail::default(),
715 audit_chain_hash: String::new(),
716 blame_attribution: None,
717 execution_metrics: ExecutionMetrics::default(),
718 trace_id: "x".to_string(),
719 };
720 let sealed = degenerate.seal();
721 assert_eq!(sealed.certainty, 1.0);
722 let _ = env;
723 }
724
725 #[test]
726 fn fase39b_extract_inner_ontological_type_unwraps_envelope() {
727 assert_eq!(
728 extract_inner_ontological_type("FlowEnvelope<List<TenantRecord>>"),
729 "List<TenantRecord>"
730 );
731 assert_eq!(
732 extract_inner_ontological_type("FlowEnvelope<TenantRecord>"),
733 "TenantRecord"
734 );
735 // Legacy: bare type — returned verbatim (pre-39.e tolerance).
736 assert_eq!(extract_inner_ontological_type("TenantRecord"), "TenantRecord");
737 assert_eq!(extract_inner_ontological_type("List<X>"), "List<X>");
738 // Missing / empty — defaults to Any (singular catch-all).
739 assert_eq!(extract_inner_ontological_type(""), "Any");
740 assert_eq!(extract_inner_ontological_type(" "), "Any");
741 }
742
743 #[test]
744 fn fase39b_serialization_round_trip() {
745 let env = FlowEnvelope::from_execution_result(
746 fixture_exec_result(),
747 "List<TenantRecord>".to_string(),
748 )
749 .seal();
750 let serialized = serde_json::to_string(&env).expect("serialize");
751 let parsed: FlowEnvelope =
752 serde_json::from_str(&serialized).expect("deserialize");
753 assert_eq!(parsed.ontological_type, env.ontological_type);
754 assert_eq!(parsed.certainty, env.certainty);
755 assert_eq!(parsed.audit_chain_hash, env.audit_chain_hash);
756 assert_eq!(parsed.trace_id, env.trace_id);
757 assert_eq!(parsed.provenance_chain, env.provenance_chain);
758 }
759
760 #[test]
761 fn fase39b_wire_shape_has_canonical_field_order() {
762 // §Fase 39 §4 — the wire is the ψ-vector. Verify the
763 // serialized form carries every field the contract names.
764 let env = FlowEnvelope::from_execution_result(
765 fixture_exec_result(),
766 "List<TenantRecord>".to_string(),
767 )
768 .seal();
769 let json = serde_json::to_value(&env).expect("to_value");
770 let obj = json.as_object().expect("envelope is a JSON object");
771 // ψ = ⟨T, V, E⟩ — every component MUST be present.
772 assert!(obj.contains_key("ontological_type"), "T component");
773 assert!(obj.contains_key("result"), "V component");
774 assert!(obj.contains_key("certainty"), "E: epistemic");
775 assert!(obj.contains_key("provenance_chain"), "E: audit");
776 assert!(obj.contains_key("step_audit"), "E: audit detail");
777 assert!(obj.contains_key("audit_chain_hash"), "E: tamper-evidence");
778 assert!(obj.contains_key("blame_attribution"), "E: blame");
779 assert!(obj.contains_key("execution_metrics"), "observability");
780 assert!(obj.contains_key("trace_id"), "correlation");
781 }
782
783 #[test]
784 fn fase39b_blame_kind_serializes_snake_case() {
785 // Wire-shape contract: BlameKind serializes as snake_case.
786 let blame = BlameContext {
787 kind: BlameKind::AnchorBreach,
788 location: "step:Triage".to_string(),
789 message: "Confidence below threshold".to_string(),
790 d_letter: Some("39.c".to_string()),
791 };
792 let json = serde_json::to_value(&blame).expect("to_value");
793 assert_eq!(json["kind"], "anchor_breach");
794
795 let blame2 = BlameContext {
796 kind: BlameKind::BackendSoftFail,
797 location: String::new(),
798 message: "Truncated".to_string(),
799 d_letter: None,
800 };
801 let json2 = serde_json::to_value(&blame2).expect("to_value");
802 assert_eq!(json2["kind"], "backend_soft_fail");
803 }
804
805 #[test]
806 fn fase39b_trace_id_minted_when_legacy_is_zero() {
807 let exec = fixture_exec_result(); // trace_id = 0
808 let env = FlowEnvelope::from_execution_result(exec, "Any".to_string());
809 // Uuid v4 is 36 chars with dashes; legacy hex (16) is 16.
810 assert!(
811 env.trace_id.len() == 36 || env.trace_id.len() == 16,
812 "trace_id length must be Uuid (36) or legacy hex (16). \
813 Got len={}: {}",
814 env.trace_id.len(),
815 env.trace_id
816 );
817 assert_ne!(env.trace_id, "0");
818 }
819
820 #[test]
821 fn fase39b_trace_id_carries_legacy_value_when_nonzero() {
822 let mut exec = fixture_exec_result();
823 exec.trace_id = 0xDEADBEEF;
824 let env = FlowEnvelope::from_execution_result(exec, "Any".to_string());
825 assert_eq!(env.trace_id, "00000000deadbeef");
826 }
827}