axon-lang 1.38.1

AXON v1.5.1 — first crates.io publication of the AXON language full-stack runtime. Lexer/parser/type-checker/IR generator (re-exported from axon-frontend) plus the native Rust runtime: typed channels (TypedEventBus with QoS×5, π-calculus mobility, capability extrusion via shield D8 — Fase 13.f.2), Free Monad CPS handlers (Fase 2), lease kernel + reconcile loop (Fase 3+5), Epistemic Security Kernel (ESK Fase 6), Trust Types + ReplayLog (Fase 11.a+11.c), Stateful PEM over WebSocket (Fase 11.d), Ontological Tool Synthesis (Fase 11.e), Mobile Typed Channels (Fase 13). Crate publishes as `axon-lang` to mirror the Python PyPI package; library import remains `use axon::*` so existing call sites keep working unchanged.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
//! §Fase 32.h — Replay-token binding for first-class axonendpoint routes.
//!
//! Per D9 (plan vivo numbering), every successful 2xx response to a
//! POST/PUT axonendpoint with replay enabled is registered in an
//! append-only log keyed by trace_id. Regulators / auditors retrieve
//! the original (request body, response body, metadata) tuple via
//! `GET /v1/replay/<trace_id>` — the foundation of audit-defensible AI
//! in regulated production (banking PCI DSS Req 10, government
//! FedRAMP AU-2, legal FRE 502, medicine 21 CFR Part 11).
//!
//! ## Default semantics (D9 backwards-compat)
//!
//! - `method ∈ {POST, PUT}` AND `replay:` omitted → replay enabled.
//! - `method ∈ {GET, DELETE}` AND `replay:` omitted → replay disabled
//!   (GET/DELETE are natively idempotent per HTTP spec; replaying
//!   them is a category error since the verb itself implies repeat-
//!   safe execution).
//! - `replay: true | false` explicit declaration overrides both
//!   defaults — adopters can disable replay on a sensitive POST
//!   (e.g. tokenization endpoints) or enable it on a custom GET
//!   that DOES need audit.
//!
//! ## Determinism status
//!
//! The replay entry carries a `deterministic: bool` flag. Set to
//! `true` when the runtime can prove the response was produced
//! deterministically (stub backend, locked LLM models with seed +
//! temperature=0). Set to `false` otherwise. The HTTP response of
//! `GET /v1/replay/<trace_id>` carries this as the
//! `Replay-Status: deterministic | non_deterministic` header so
//! auditors know whether they can re-execute and expect byte-identical
//! output, or whether they're inspecting the historical record only.
//!
//! ## Pillar trace per D12
//!
//! - **MATHEMATICS** — same input + same model state ⟹ same output
//!   (deterministic backends: stub, locked LLM with seed=k +
//!   temperature=0).
//! - **PHILOSOPHY** — the language honors its own declarations:
//!   adopters write `replay: true` and the runtime registers the
//!   binding without any middleware-of-middleware indirection.
//! - **LOGIC** — replay default is a total function over the
//!   method: `default(POST|PUT) = true`, `default(GET|DELETE) = false`.
//! - **COMPUTING** — regulatory replay is the foundation of audit-
//!   defensible AI in regulated production.

use std::collections::HashMap;
use std::time::{Duration, Instant};

use sha2::{Digest, Sha256};

/// Default retention window for replay entries — 30 days per plan
/// vivo §9.2. In-memory store uses a capacity-bounded LRU layered
/// on top of this; production deployments swap in the enterprise
/// persistence backend for longer retention.
pub const DEFAULT_RETENTION: Duration = Duration::from_secs(30 * 24 * 60 * 60);

/// §Fase 33.x.f — One per-step audit record captured during a
/// streaming flow's execution. Multiple records per replay entry
/// (one per step that executed). Surfaced to auditors via
/// `GET /v1/replay/<trace_id>` so regulators see the per-step
/// sequence rather than just the final response. Per-token chain
/// signature (each `axon.token` cryptographically chained) stays
/// deferred to Fase 34.
///
/// # Required for regulated verticals
///
/// - **Banking** (PCI DSS Req 10) — auditors need the per-step
///   tokens_emitted + output_hash so each LLM call in a multi-step
///   decision flow is independently auditable.
/// - **Government** (FedRAMP AU-2) — FOIA requests retrieve the
///   per-step reasoning chain; final-response replay is insufficient.
/// - **Legal** (FRE 502 waiver-doctrine) — appellate review traces
///   the per-step privilege-assessment reasoning, not just the
///   conclusion.
/// - **Medicine** (21 CFR Part 11 §11.10) — CDS clinician trails
///   require per-step recommendation provenance.
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
pub struct StepAuditRecord {
    /// Step name as declared in the source (matches
    /// `IRStep.name`). Stable across versions of the flow.
    pub step_name: String,
    /// Index of the step in flow-execution order (0-based).
    pub step_index: usize,
    /// `true` iff the step's LLM dispatch + chunk drain completed
    /// without surfacing a `BackendError` or cancellation.
    pub success: bool,
    /// Number of non-empty `StepToken` events emitted to the wire
    /// for this step. Equals the count of chunks the consumer drained
    /// from the per-step backend stream after policy enforcement.
    pub tokens_emitted: u64,
    /// SHA-256 hex of the concatenated step output text (i.e. all
    /// `chunk.delta` strings joined). Stable + content-addressable;
    /// auditors detect drift between re-executions by hash comparison.
    pub output_hash_hex: String,
    /// Closed-catalog policy slug — `Some("drop_oldest")`,
    /// `Some("degrade_quality")`, `Some("pause_upstream")`,
    /// `Some("fail")` — for steps whose tool declared
    /// `effects: <stream:<policy>>`. `None` when the step's tool
    /// declared no stream effect.
    pub effect_policy_applied: Option<String>,
    /// Number of chunks the enforcer dropped under `DropOldest`
    /// policy. Always `0` for non-`DropOldest` policies + for
    /// steps without an enforcer.
    pub chunks_dropped: u64,
    /// Number of chunks the enforcer degraded under
    /// `DegradeQuality` policy. Always `0` for other policies + for
    /// steps without an enforcer.
    pub chunks_degraded: u64,
    /// Unix-millis timestamp when the step completed. Monotonic
    /// within a single flow execution.
    pub timestamp_ms: u64,

    // ── §Fase 34.i — Tool-stream provenance fields ────────────────
    //
    // The four fields below capture per-step tool-stream provenance
    // distinct from the LLM-side `tokens_emitted` / `output_hash_hex`
    // pair. They land as **optional** to preserve D4 byte-compat:
    // legacy LLM-side rows (no tool stream) serialize byte-identical
    // to the pre-34.i shape because serde elides `None` via
    // `skip_serializing_if`. Streaming-tool rows (disjunct b apply:
    // / disjunct d Yield) carry the populated values.
    /// `Some(name)` for steps that drained a `Tool::stream()` source
    /// via [`crate::flow_dispatcher::unified_stream::unified_stream_handler`]
    /// (disjunct b — `apply: <stream-tool>`). `None` for LLM-side
    /// `output: Stream<T>` (disjunct a) + algebraic-effect
    /// `Stream.Yield` (disjunct d) since neither has a Tool trait
    /// impl backing the stream.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tool_name: Option<String>,
    /// `Some(n)` where `n` is the count of `ToolChunk`s the source
    /// stream produced (including empty-delta intermediates and the
    /// terminator chunk). Distinct from `tokens_emitted` — that
    /// counts only **non-empty deltas reaching the wire** post-
    /// policy enforcement. Auditors can compare `tool_chunks_emitted`
    /// vs `tokens_emitted` vs `chunks_dropped`/`chunks_degraded` to
    /// reconstruct the per-step policy enforcement story. `None` for
    /// non-tool-stream steps.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tool_chunks_emitted: Option<u64>,
    /// `Some(hash)` SHA-256 hex of the concatenated tool-stream
    /// deltas (same scope as `output_hash_hex` for tool-stream
    /// steps). Distinct field exists for D6 audit provenance: a
    /// future fase may diverge `tool_output_hash_hex` (raw tool
    /// chunks pre-degrader) from `output_hash_hex` (post-policy
    /// wire emission) when degrader transforms ship. `None` for
    /// non-tool-stream steps.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tool_output_hash_hex: Option<String>,
    /// `Some(slug)` closed-catalog terminator kind:
    /// `"stop"` (natural end / `ToolFinishReason::Stop`),
    /// `"error"` (tool surfaced `ToolFinishReason::Error`),
    /// `"cancelled"` (cancel observed mid-stream or
    /// `ToolFinishReason::Cancelled` terminator). `None` for non-
    /// tool-stream steps. Auditors filter on this slug to identify
    /// failure modes across a flow without re-parsing the wire.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub tool_terminator_kind: Option<String>,
}

/// One replay binding entry. Immutable once minted.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct AxonendpointReplayEntry {
    /// UUID v4 generated per dynamic-route request; the lookup key
    /// for `GET /v1/replay/<trace_id>`.
    pub trace_id: String,
    /// Unix-millis timestamp when the entry was recorded.
    pub timestamp_ms: u64,
    /// Source-declared axonendpoint name (audit-trail anchor).
    pub endpoint_name: String,
    /// `execute:` target — the flow that ran.
    pub flow_name: String,
    pub method: String,
    pub path: String,
    /// `client_id` extracted from `Authorization` header at request
    /// time (or `"anonymous"`). Same value the audit log records.
    pub client_id: String,
    /// Capability slugs the bearer held — projected into the entry
    /// so auditors can correlate the auth context.
    pub capabilities_used: Vec<String>,
    /// SHA-256 of the request body bytes (hex-encoded).
    pub request_body_hash_hex: String,
    /// Raw request body bytes (retained per audit policy; in-memory
    /// store; enterprise persistence layers encryption-at-rest).
    pub request_body: Vec<u8>,
    /// Response HTTP status code.
    pub response_status: u16,
    /// SHA-256 of the response body bytes (hex-encoded).
    pub response_body_hash_hex: String,
    /// Response Content-Type header verbatim (so the replay returns
    /// the original wire format).
    pub response_content_type: String,
    /// Raw response body bytes.
    pub response_body: Vec<u8>,
    /// Runtime version slug stored alongside the entry — production
    /// adopters bump this so replays from older versions are clearly
    /// distinguishable.
    pub model_version: String,
    /// Was the response produced deterministically? `true` for stub
    /// + locked-model backends; `false` for temperature>0 LLM calls.
    /// Surfaces in the `Replay-Status` HTTP header.
    pub deterministic: bool,
    /// §Fase 33.x.f — Per-step audit records. Populated for SSE
    /// routes whose `replay: true` declaration fired the streaming
    /// path's per-step recording. Empty for legacy JSON 2xx
    /// replay entries (Fase 32.h shape; D4 byte-compat preserved
    /// because the field is elided when empty via `skip_serializing_if`).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub step_audit: Vec<StepAuditRecord>,

    /// §Fase 33.x.g — Closed-catalog runtime warnings. Mirrors
    /// the `axon.complete.warnings` wire field so auditors who
    /// retrieve the replay entry post-hoc see the same diagnostic
    /// as the live SSE consumer. Populated when the SSE handler's
    /// `server_execute_streaming` fell back to the legacy
    /// synchronous path; empty on the happy async-streaming path.
    /// D4 byte-compat preserved (field elided when empty via
    /// `skip_serializing_if`).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub runtime_warnings: Vec<crate::runtime_warnings::RuntimeWarning>,
}

/// In-memory replay log. Indexed by `trace_id` for O(1) GET. Bounded
/// by capacity (default 10_000 entries — generous for the regulated-
/// vertical case where every POST gets replay-bound); oldest entry
/// evicted on overflow.
#[derive(Debug)]
pub struct AxonendpointReplayLog {
    entries: HashMap<String, AxonendpointReplayEntry>,
    /// Insertion-time tracker for capacity-bounded LRU. Separate from
    /// `timestamp_ms` because the latter is wall-clock (replay-readable)
    /// while this is monotonic for eviction ordering.
    inserted_at: HashMap<String, Instant>,
    capacity: usize,
    retention: Duration,
}

impl Default for AxonendpointReplayLog {
    fn default() -> Self {
        Self::new(10_000, DEFAULT_RETENTION)
    }
}

impl AxonendpointReplayLog {
    pub fn new(capacity: usize, retention: Duration) -> Self {
        Self {
            entries: HashMap::new(),
            inserted_at: HashMap::new(),
            capacity,
            retention,
        }
    }

    pub fn len(&self) -> usize {
        self.entries.len()
    }

    pub fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// SHA-256 hex of body bytes. Stable across stacks.
    pub fn hash_body_hex(body: &[u8]) -> String {
        let mut h = Sha256::new();
        h.update(body);
        let digest = h.finalize();
        let mut s = String::with_capacity(64);
        for byte in digest.iter() {
            s.push_str(&format!("{byte:02x}"));
        }
        s
    }

    /// Append an entry. If at capacity AND the key is new, evicts
    /// the oldest entry first (by `inserted_at`). Same key overwrites
    /// in place (idempotent — a retry would just refresh metadata).
    pub fn append(&mut self, entry: AxonendpointReplayEntry) {
        let key = entry.trace_id.clone();
        if !self.entries.contains_key(&key) && self.entries.len() >= self.capacity {
            // Find oldest by insertion time.
            if let Some(oldest_key) = self
                .inserted_at
                .iter()
                .min_by_key(|(_, t)| **t)
                .map(|(k, _)| k.clone())
            {
                self.entries.remove(&oldest_key);
                self.inserted_at.remove(&oldest_key);
            }
        }
        self.inserted_at.insert(key.clone(), Instant::now());
        self.entries.insert(key, entry);
    }

    pub fn get(&self, trace_id: &str) -> Option<&AxonendpointReplayEntry> {
        self.entries.get(trace_id)
    }

    /// Reap entries older than the retention window. Returns the
    /// number reaped. Intended for periodic background sweeps.
    pub fn reap_expired(&mut self) -> usize {
        let now = Instant::now();
        let retention = self.retention;
        let before = self.entries.len();
        let to_remove: Vec<String> = self
            .inserted_at
            .iter()
            .filter(|(_, t)| now.duration_since(**t) > retention)
            .map(|(k, _)| k.clone())
            .collect();
        for k in &to_remove {
            self.entries.remove(k);
            self.inserted_at.remove(k);
        }
        before - self.entries.len()
    }
}

/// §Fase 32.h — Resolve the effective `replay` boolean for a route.
///
/// Pure + total function over `(method, replay_explicit, replay)`.
/// When the source declared `replay:` explicitly, the declared value
/// wins regardless of method. Otherwise the method-default fires:
/// POST/PUT → true, GET/DELETE/PATCH/* → false.
///
/// PATCH semantically updates state but the plan vivo §9 only
/// guarantees the binding for POST/PUT. Adopters who want replay on
/// PATCH declare `replay: true` explicitly.
pub fn resolve_replay_enabled(method: &str, replay_explicit: bool, replay: bool) -> bool {
    if replay_explicit {
        return replay;
    }
    matches!(method, "POST" | "PUT")
}

/// Determine whether a response was produced deterministically given
/// the resolved backend. Stub backends are deterministic by
/// construction; production LLM calls with temperature>0 are not.
/// Locked-model backends with seed + temperature=0 are deterministic
/// per the Fase 22.g.2 locked-model machinery.
///
/// For the OSS surface this is conservatively reported as
/// `backend == "stub"` (always deterministic). The enterprise
/// surface layers locked-model + seed checks on top.
pub fn is_backend_deterministic(backend: &str) -> bool {
    backend == "stub"
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn default_post_replay_enabled() {
        assert!(resolve_replay_enabled("POST", false, false));
    }

    #[test]
    fn default_put_replay_enabled() {
        assert!(resolve_replay_enabled("PUT", false, false));
    }

    #[test]
    fn default_get_replay_disabled() {
        assert!(!resolve_replay_enabled("GET", false, false));
    }

    #[test]
    fn default_delete_replay_disabled() {
        assert!(!resolve_replay_enabled("DELETE", false, false));
    }

    #[test]
    fn explicit_false_overrides_post_default() {
        assert!(!resolve_replay_enabled("POST", true, false));
    }

    #[test]
    fn explicit_true_overrides_get_default() {
        assert!(resolve_replay_enabled("GET", true, true));
    }

    #[test]
    fn stub_backend_is_deterministic() {
        assert!(is_backend_deterministic("stub"));
    }

    #[test]
    fn llm_backend_is_not_deterministic_by_default() {
        assert!(!is_backend_deterministic("anthropic"));
        assert!(!is_backend_deterministic("openai"));
    }

    fn make_entry(trace_id: &str) -> AxonendpointReplayEntry {
        AxonendpointReplayEntry {
            trace_id: trace_id.to_string(),
            timestamp_ms: 0,
            endpoint_name: "E".to_string(),
            flow_name: "F".to_string(),
            method: "POST".to_string(),
            path: "/p".to_string(),
            client_id: "anon".to_string(),
            capabilities_used: vec![],
            request_body_hash_hex: AxonendpointReplayLog::hash_body_hex(b"{}"),
            request_body: b"{}".to_vec(),
            response_status: 200,
            response_body_hash_hex: AxonendpointReplayLog::hash_body_hex(b"ok"),
            response_content_type: "application/json".to_string(),
            response_body: b"ok".to_vec(),
            model_version: "axon.runtime.dynamic_route.v1".to_string(),
            deterministic: true,
            step_audit: Vec::new(),
            runtime_warnings: Vec::new(),
        }
    }

    #[test]
    fn log_append_and_get_round_trip() {
        let mut log = AxonendpointReplayLog::default();
        let e = make_entry("t1");
        log.append(e);
        let got = log.get("t1").expect("entry must be present");
        assert_eq!(got.trace_id, "t1");
        assert_eq!(got.response_body, b"ok");
    }

    #[test]
    fn get_unknown_trace_id_returns_none() {
        let log = AxonendpointReplayLog::default();
        assert!(log.get("nope").is_none());
    }

    #[test]
    fn same_trace_id_overwrite_in_place() {
        let mut log = AxonendpointReplayLog::default();
        let mut e1 = make_entry("t1");
        e1.response_body = b"first".to_vec();
        log.append(e1);
        let mut e2 = make_entry("t1");
        e2.response_body = b"second".to_vec();
        log.append(e2);
        assert_eq!(log.len(), 1);
        assert_eq!(log.get("t1").unwrap().response_body, b"second");
    }

    #[test]
    fn capacity_eviction_drops_oldest() {
        let mut log = AxonendpointReplayLog::new(2, DEFAULT_RETENTION);
        log.append(make_entry("a"));
        std::thread::sleep(Duration::from_millis(1));
        log.append(make_entry("b"));
        std::thread::sleep(Duration::from_millis(1));
        log.append(make_entry("c"));
        assert_eq!(log.len(), 2);
        assert!(log.get("a").is_none(), "oldest must be evicted");
        assert!(log.get("c").is_some());
    }

    #[test]
    fn reap_expired_removes_old_entries() {
        let mut log = AxonendpointReplayLog::new(10, Duration::from_millis(0));
        log.append(make_entry("t1"));
        log.append(make_entry("t2"));
        std::thread::sleep(Duration::from_millis(2));
        assert_eq!(log.reap_expired(), 2);
        assert!(log.is_empty());
    }

    #[test]
    fn hash_body_hex_is_64_chars_lowercase() {
        let h = AxonendpointReplayLog::hash_body_hex(b"hello");
        assert_eq!(h.len(), 64);
        for c in h.chars() {
            assert!(c.is_ascii_hexdigit() && !c.is_ascii_uppercase());
        }
    }

    #[test]
    fn hash_body_hex_deterministic() {
        let a = AxonendpointReplayLog::hash_body_hex(b"{\"x\":1}");
        let b = AxonendpointReplayLog::hash_body_hex(b"{\"x\":1}");
        assert_eq!(a, b);
    }
}