Skip to main content

axon/session_runtime/
state.rs

1//! The operational state machine for a single session-typed dialogue.
2//!
3//! §Fase 41.d. A [`SessionRuntime`] is the runtime witness of a §41.a
4//! `SessionType`: it carries a **cursor** (the residual type after every
5//! step so far) and a [`CreditWindow`] (the dynamic counterpart of the
6//! §41.c index `!ⁿA.S`), and exposes one method per operational rule —
7//! `try_send`, `try_recv`, `try_select`, `try_offer`, `try_end`. Each
8//! method enforces the static discipline *defence-in-depth*: a violation
9//! (wrong-kind frame, wrong payload, exhausted credit, post-`end` traffic)
10//! returns a [`ProtocolError`] and leaves the cursor unchanged. The
11//! caller's contract is to close the transport on first error.
12//!
13//! Recursion is handled by [`SessionType::unfold_head`] — the cursor is
14//! kept in "head-unfolded" form: never a leading `Rec` or `Var`. This
15//! keeps the rule for every action a single pattern match on the cursor.
16//!
17//! The runtime is **transport-agnostic** — it knows nothing about
18//! WebSockets, JSON, or `tokio`. The [`crate::session_runtime::ws`]
19//! module is one carrier; the runtime would slot identically over QUIC,
20//! a TCP stream, or an in-process channel.
21
22use std::collections::BTreeMap;
23
24use axon_frontend::session::{Payload, SessionType};
25use serde::{Deserialize, Serialize};
26
27use super::error::ProtocolError;
28
29/// Dynamic counterpart of the §41.c credit index `!ⁿA.S`. Tracks the
30/// number of in-flight sends the producer is currently allowed; a `send`
31/// decrements `available`, a `recv` refills it (capped at `budget`,
32/// standard TCP-window semantics). The static analysis
33/// (`SessionType::credit_analyse`) has already verified the protocol is
34/// conformant under this budget — this is the runtime safety net for an
35/// off-spec peer.
36///
37/// `Serialize` + `Deserialize` — §Fase 41.g sealed-snapshot resume carries
38/// the *live* window (available count, not just the budget) so a resumed
39/// connection picks up exactly where the disconnected one left off.
40#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
41pub struct CreditWindow {
42    /// Maximum credit the producer may hold at any one time (`k` in the
43    /// `socket { backpressure: credit(k) }` annotation).
44    pub budget: u64,
45    /// Current available credit. Invariant: `0 ≤ available ≤ budget`.
46    pub available: u64,
47}
48
49impl CreditWindow {
50    /// Open a fresh window with the full budget available.
51    pub fn new(budget: u64) -> Self {
52        Self { budget, available: budget }
53    }
54    /// Try to consume one credit; returns the remaining count, or `None`
55    /// if exhausted (the runtime witness of the "no rule at n=0" axiom).
56    fn try_consume(&mut self) -> Option<u64> {
57        if self.available == 0 {
58            None
59        } else {
60            self.available -= 1;
61            Some(self.available)
62        }
63    }
64    /// Refill one credit, capped at the budget. Cannot fail.
65    fn refill(&mut self) {
66        if self.available < self.budget {
67            self.available += 1;
68        }
69    }
70}
71
72/// The session-type runtime cursor + credit window.
73///
74/// Held by **each** side of a connection — the server runtime is
75/// initialised with the server-role type, the client with the dual. Every
76/// transition is local: there is no cross-process synchronisation here;
77/// the carrier delivers frames in order and the two cursors stay in lock
78/// step because they were initialised from a duality-checked pair.
79///
80/// `Serialize` + `Deserialize` — §Fase 41.g sealed-snapshot resume. The
81/// serialised form is a stable JSON object containing the schema (so
82/// resume can verify the protocol hasn't been swapped), the residual
83/// cursor, and the live credit window. Encoded once via [`Self::seal`]
84/// into the AAD-bound `cognitive_states` ciphertext; decoded by
85/// [`Self::resume`] after the §40.k `EnvelopeEncryption::decrypt` verifies
86/// the (tenant, session, flow) binding.
87#[derive(Debug, Clone, Serialize, Deserialize)]
88pub struct SessionRuntime {
89    /// The original session type (the protocol "schema"). Kept so the
90    /// runtime can re-report it on errors and so the cursor's invariants
91    /// are documented at the type level (the cursor is always reachable
92    /// from `schema` along the trace so far). Resume validates a sealed
93    /// snapshot's `schema` matches the live socket's declared protocol.
94    schema: SessionType,
95    /// The residual type — the unfinished part of the protocol. Always
96    /// head-unfolded (never a leading `Rec` or `Var`).
97    cursor: SessionType,
98    /// The dynamic credit window, or `None` for the unbounded fragment
99    /// (no `backpressure` annotation in the socket).
100    credit: Option<CreditWindow>,
101    /// §Fase 79.d — the active interruptible region, armed by
102    /// [`Self::try_enter_interrupt`]: the declared signal + the handler to
103    /// divert to. `#[serde(skip)]` — interrupt dispatch is live-only runtime
104    /// state; the parked continuation that *survives* a reconnect is persisted
105    /// separately via the §41.g `cognitive_state` snapshot (§79.e), not here.
106    #[serde(skip, default)]
107    interrupt: Option<InterruptFrame>,
108    /// §Fase 79.d — the emit cursor (D79.10): frames flushed to the carrier so
109    /// far. Snapshotted into the parked continuation so `resume` re-opens the
110    /// body's `Stream<T>` at the exact flushed offset ("the exact word").
111    #[serde(skip, default)]
112    emit_count: u64,
113    /// §Fase 79.d — the captured **one-shot** continuation (paper κ), set by
114    /// [`Self::signal`] and consumed exactly once by [`Self::try_resume`]. A
115    /// second resume is [`ProtocolError::DoubleResume`] (D79.1 linearity).
116    #[serde(skip, default)]
117    parked: Option<ParkedContinuation>,
118}
119
120/// §Fase 79.d — the armed interruptible region: what signal fires it and what
121/// handler to divert to. Live runtime state (not serialized).
122#[derive(Debug, Clone, PartialEq, Eq)]
123struct InterruptFrame {
124    signal: Payload,
125    handler: SessionType,
126}
127
128/// §Fase 79.d — a captured one-shot continuation of an interrupted body: the
129/// reified session cursor + credit window (the paper's κ ≅ (S₍>k₎, w), D79.9)
130/// plus the emit-cursor snapshot (D79.10) and the cause that fired. Consumed
131/// exactly once by [`SessionRuntime::try_resume`].
132///
133/// `Serialize` + `Deserialize` — §Fase 79.e: a parked κ survives a reconnect by
134/// riding the §41.g `cognitive_state` sealed snapshot (no new state store). The
135/// wire shape is exactly the reified cursor + window the snapshot already seals.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub struct ParkedContinuation {
138    /// `S₍>k₎` — the body residual at the instant of interruption.
139    pub cursor: SessionType,
140    /// The body's live credit window at interruption — restored *exactly* on
141    /// resume (credit symmetry, Theorem 3).
142    pub credit: Option<CreditWindow>,
143    /// Frames flushed to the carrier at park time (the emit cursor, D79.10).
144    pub emit_count: u64,
145    /// The `CallInterruptCause` that fired the interruption.
146    pub cause: Payload,
147}
148
149impl SessionRuntime {
150    /// Create a runtime for the given role's session type. `budget`
151    /// mirrors the socket's `credit(k)`; pass `None` for the unbounded
152    /// fragment (statically equivalent to omitting `backpressure:`).
153    pub fn new(schema: SessionType, budget: Option<u64>) -> Self {
154        let cursor = schema.unfold_head();
155        Self {
156            schema,
157            cursor,
158            credit: budget.map(CreditWindow::new),
159            interrupt: None,
160            emit_count: 0,
161            parked: None,
162        }
163    }
164
165    /// The original session type — useful for error messages and logs.
166    pub fn schema(&self) -> &SessionType {
167        &self.schema
168    }
169
170    /// The current residual cursor — always head-unfolded.
171    pub fn cursor(&self) -> &SessionType {
172        &self.cursor
173    }
174
175    /// The dynamic credit window snapshot (or `None` for the unbounded
176    /// fragment).
177    pub fn credit(&self) -> Option<CreditWindow> {
178        self.credit
179    }
180
181    /// `true` once the cursor reaches `end` — both sides should now close
182    /// the carrier cleanly. The runtime rejects further actions after
183    /// this point with [`ProtocolError::AlreadyComplete`].
184    pub fn is_complete(&self) -> bool {
185        matches!(self.cursor, SessionType::End)
186    }
187
188    // ── §Fase 41.g — typed reconnection via sealed snapshots ───────────
189
190    /// Serialise the live runtime state into a stable JSON envelope —
191    /// the plaintext the §40.t `cognitive_states` AAD-bound ciphertext
192    /// wraps. Carries the schema (so resume can verify the protocol
193    /// hasn't been swapped under the connection), the residual cursor,
194    /// and the live credit window snapshot.
195    ///
196    /// Symmetric with [`Self::resume`]: `runtime.seal()` then
197    /// `SessionRuntime::resume(sealed, declared_schema)` round-trips when
198    /// the declared schema matches.
199    ///
200    /// Returns `None` if and only if the cursor is already at `End` — a
201    /// completed dialogue has no residual to seal, so no snapshot is
202    /// issued (the caller should `evict()` the prior snapshot instead).
203    pub fn seal(&self) -> Option<SealedRuntime> {
204        if self.is_complete() {
205            return None;
206        }
207        Some(SealedRuntime {
208            version: SEALED_RUNTIME_VERSION,
209            schema: self.schema.clone(),
210            cursor: self.cursor.clone(),
211            credit: self.credit,
212            // §Fase 79.e — carry the parked one-shot continuation across the
213            // reconnect, so an interrupted-but-unresumed dialogue can still
214            // `resume` into its body after the client comes back.
215            parked: self.parked.clone(),
216        })
217    }
218
219    /// Reconstruct a [`SessionRuntime`] from a sealed snapshot, validating
220    /// the schema matches what the route declares now (defence against
221    /// protocol-swap attacks where an attacker reuses a sealed snapshot
222    /// against a different socket whose declaration drifted).
223    ///
224    /// On success the returned runtime resumes from the exact cursor +
225    /// credit window the disconnected one left behind. The carrier driver
226    /// then runs the producer/consumer loop as usual.
227    pub fn resume(
228        sealed: SealedRuntime,
229        declared_schema: &SessionType,
230    ) -> Result<Self, ResumeError> {
231        if sealed.version != SEALED_RUNTIME_VERSION {
232            return Err(ResumeError::UnsupportedVersion(sealed.version));
233        }
234        if !sealed.schema.equiv(declared_schema) {
235            return Err(ResumeError::SchemaMismatch);
236        }
237        // The cursor must be reachable from the schema (we cannot prove
238        // this in general — the algebra would need a labelled trace — but
239        // we DO require the cursor to be head-unfolded, which the wire
240        // form preserves because `seal()` only stores cursors set via
241        // `advance()`).
242        Ok(SessionRuntime {
243            schema: sealed.schema,
244            cursor: sealed.cursor,
245            credit: sealed.credit,
246            interrupt: None,
247            emit_count: 0,
248            // §Fase 79.e — restore the parked κ so `resume` still works post-
249            // reconnect (the `interrupted_by_peer` carrier state survives).
250            parked: sealed.parked,
251        })
252    }
253
254    // ── Operational rules ──────────────────────────────────────────────
255
256    /// Producer step `!A.S → S`. Succeeds iff:
257    /// 1. the cursor is `Send { payload, … }` with `payload == got`;
258    /// 2. the credit window (if any) has `available > 0` — otherwise the
259    ///    §41.c "no rule at n=0" axiom fires.
260    /// On success the cursor advances (unfolded) and one credit is
261    /// consumed (when the window is present).
262    pub fn try_send(&mut self, got: &str) -> Result<(), ProtocolError> {
263        if self.is_complete() {
264            return Err(ProtocolError::AlreadyComplete { frame_kind: "send" });
265        }
266        let (expected_payload, cont) = match &self.cursor {
267            SessionType::Send { payload, cont, .. } => (payload.clone(), cont.clone()),
268            other => {
269                return Err(ProtocolError::UnexpectedFrame {
270                    cursor_kind: kind_of(other),
271                    frame_kind: "send",
272                });
273            }
274        };
275        let got_payload = Payload::new(got);
276        if expected_payload != got_payload {
277            return Err(ProtocolError::PayloadMismatch {
278                expected: expected_payload,
279                got: got_payload,
280            });
281        }
282        // Credit decrement — the dynamic witness of `!ⁿA.S, n > 0`.
283        if let Some(w) = self.credit.as_mut() {
284            if w.try_consume().is_none() {
285                return Err(ProtocolError::CreditExhausted {
286                    payload: expected_payload,
287                    budget: w.budget,
288                });
289            }
290        }
291        self.advance(*cont);
292        // §Fase 79.d — advance the emit cursor: a producer step flushes one
293        // frame to the carrier (D79.10 "delivered = flushed").
294        self.emit_count += 1;
295        Ok(())
296    }
297
298    /// Consumer step `?A.S → S`. The peer just produced an `!A.S` frame.
299    /// Symmetric to [`try_send`] — payload must match, the cursor advances,
300    /// and one credit is refilled (if a window is present).
301    pub fn try_recv(&mut self, got: &str) -> Result<(), ProtocolError> {
302        if self.is_complete() {
303            return Err(ProtocolError::AlreadyComplete { frame_kind: "send" });
304        }
305        let (expected_payload, cont) = match &self.cursor {
306            SessionType::Recv { payload, cont, .. } => (payload.clone(), cont.clone()),
307            other => {
308                return Err(ProtocolError::UnexpectedFrame {
309                    cursor_kind: kind_of(other),
310                    frame_kind: "send",
311                });
312            }
313        };
314        let got_payload = Payload::new(got);
315        if expected_payload != got_payload {
316            return Err(ProtocolError::PayloadMismatch {
317                expected: expected_payload,
318                got: got_payload,
319            });
320        }
321        // Refill — the peer just delivered, the in-flight count drops by 1.
322        if let Some(w) = self.credit.as_mut() {
323            w.refill();
324        }
325        self.advance(*cont);
326        Ok(())
327    }
328
329    /// Internal choice (`⊕`) — *we* select the labelled arm. Cursor must
330    /// be `Select { arms }` containing `label`; on success the cursor
331    /// advances into that arm's continuation.
332    pub fn try_select(&mut self, label: &str) -> Result<(), ProtocolError> {
333        self.advance_into_arm(label, true)
334    }
335
336    /// External choice (`&`) — the *peer* selected this label; we accept.
337    /// Cursor must be `Branch { arms }` containing `label`.
338    pub fn try_offer(&mut self, label: &str) -> Result<(), ProtocolError> {
339        self.advance_into_arm(label, false)
340    }
341
342    /// `end` step — terminates the dialogue. Cursor must already be
343    /// `End`; otherwise the peer is signalling termination mid-protocol.
344    pub fn try_end(&mut self) -> Result<(), ProtocolError> {
345        match &self.cursor {
346            SessionType::End => Ok(()),
347            other => Err(ProtocolError::UnexpectedFrame {
348                cursor_kind: kind_of(other),
349                frame_kind: "end",
350            }),
351        }
352    }
353
354    // ── §Fase 79.d — interruptible-session dispatch ─────────────────────
355
356    /// The emit cursor (D79.10): producer frames flushed to the carrier so far.
357    pub fn emit_count(&self) -> u64 {
358        self.emit_count
359    }
360
361    /// `true` while an interruptible region is armed (cursor inside its body).
362    pub fn interrupt_armed(&self) -> bool {
363        self.interrupt.is_some()
364    }
365
366    /// The captured one-shot continuation, if the region has been interrupted
367    /// and not yet resumed/abandoned. `None` before `signal` and after
368    /// `resume`/`abandon` (the linear consumption point).
369    pub fn parked(&self) -> Option<&ParkedContinuation> {
370        self.parked.as_ref()
371    }
372
373    /// §Fase 79.d — enter an interruptible region. The cursor must be
374    /// `Interrupt { signal, body, handler }`; advances **into the body** while
375    /// arming the region so a matching [`Self::signal`] can capture the body's
376    /// residual and divert to the handler. The connection law is preserved:
377    /// the peer enters the dual region symmetrically (Theorem 1).
378    pub fn try_enter_interrupt(&mut self) -> Result<(), ProtocolError> {
379        let (signal, body, handler) = match &self.cursor {
380            SessionType::Interrupt { signal, body, handler } => {
381                (signal.clone(), (**body).clone(), (**handler).clone())
382            }
383            other => {
384                return Err(ProtocolError::UnexpectedFrame {
385                    cursor_kind: kind_of(other),
386                    frame_kind: "interrupt",
387                })
388            }
389        };
390        self.interrupt = Some(InterruptFrame { signal, handler });
391        self.advance(body);
392        Ok(())
393    }
394
395    /// §Fase 79.d — fire the interrupt signal `cause`. Captures the body's exact
396    /// residual (cursor + credit window + emit cursor) as a **one-shot**
397    /// continuation (κ, D79.9), then diverts the cursor to the handler. A
398    /// fail-closed WCET watchdog (D79.5) asserts the reaction path completes
399    /// within `max_reaction_steps` transitions — the capture-and-divert is a
400    /// single transition, so any bound `≥ 1` holds and `0` trips the watchdog
401    /// (the fault is never silently degraded).
402    ///
403    /// Errors: [`ProtocolError::NoInterruptArmed`] if no region is armed,
404    /// [`ProtocolError::SignalMismatch`] if `cause` ≠ the declared signal,
405    /// [`ProtocolError::WatchdogBreach`] on a bound breach.
406    pub fn signal(&mut self, cause: &str, max_reaction_steps: u32) -> Result<(), ProtocolError> {
407        let frame = match self.interrupt.take() {
408            Some(f) => f,
409            None => return Err(ProtocolError::NoInterruptArmed),
410        };
411        let got = Payload::new(cause);
412        if frame.signal != got {
413            let expected = frame.signal.clone();
414            self.interrupt = Some(frame); // region stays armed; this signal wasn't ours
415            return Err(ProtocolError::SignalMismatch { expected, got });
416        }
417        // WCET watchdog (D79.5): the reaction path here is one transition
418        // (capture + divert). Fail closed on a declared bound it exceeds.
419        const REACTION_STEPS: u32 = 1;
420        if REACTION_STEPS > max_reaction_steps {
421            // Re-arm so the region isn't silently lost on a watchdog fault.
422            self.interrupt = Some(frame);
423            return Err(ProtocolError::WatchdogBreach {
424                bound: max_reaction_steps,
425                actual: REACTION_STEPS,
426            });
427        }
428        // Capture the reified one-shot continuation κ ≅ (S₍>k₎, w) + emit cursor.
429        self.parked = Some(ParkedContinuation {
430            cursor: self.cursor.clone(),
431            credit: self.credit,
432            emit_count: self.emit_count,
433            cause: got,
434        });
435        // Divert to the handler. It runs on the live window (a fork of the body
436        // window); `resume` restores the parked body window exactly, so the
437        // handler's own sends never perturb the body's credit (Theorem 3).
438        self.advance(frame.handler);
439        Ok(())
440    }
441
442    /// §Fase 79.d — the handler's **normal exit** `resume`: consume the parked
443    /// one-shot continuation EXACTLY ONCE and return the body to its exact
444    /// residual — cursor, credit window (symmetry, Theorem 3), and emit cursor
445    /// (D79.10). The cursor must be at the `Resume` leaf.
446    ///
447    /// A second `resume` (or a resume with no capture) is
448    /// [`ProtocolError::DoubleResume`] — the runtime witness of D79.1 linearity.
449    pub fn try_resume(&mut self) -> Result<(), ProtocolError> {
450        if !matches!(self.cursor, SessionType::Resume) {
451            return Err(ProtocolError::UnexpectedFrame {
452                cursor_kind: kind_of(&self.cursor),
453                frame_kind: "resume",
454            });
455        }
456        let parked = match self.parked.take() {
457            Some(p) => p,
458            None => return Err(ProtocolError::DoubleResume),
459        };
460        self.credit = parked.credit; // exact pre-interrupt window (Theorem 3)
461        self.emit_count = parked.emit_count; // re-open the stream at the flushed offset
462        self.advance(parked.cursor); // back to S₍>k₎
463        Ok(())
464    }
465
466    /// §Fase 79.d — the handler's **abandon exit** (D79.11a): the parked
467    /// continuation is discarded (released exactly once, affine-by-default) and
468    /// the region terminates at `end`. Driven on TTL expiry by the carrier
469    /// (§79.e). Safe to call whether or not a continuation is still parked.
470    pub fn abandon(&mut self) {
471        self.parked = None;
472        self.interrupt = None;
473        self.cursor = SessionType::End;
474    }
475
476    // ── Internal helpers ───────────────────────────────────────────────
477
478    /// Set the cursor to the head-unfolded form of `next`. This is the
479    /// single invariant maintained across every step — after any advance
480    /// the cursor never has a leading `Rec` (and never a leading bare
481    /// `Var` on a *closed* type, which 41.a/b/c statically guarantee).
482    fn advance(&mut self, next: SessionType) {
483        self.cursor = next.unfold_head();
484    }
485
486    fn advance_into_arm(&mut self, label: &str, internal: bool) -> Result<(), ProtocolError> {
487        if self.is_complete() {
488            return Err(ProtocolError::AlreadyComplete {
489                frame_kind: if internal { "select" } else { "branch" },
490            });
491        }
492        let arms = match (&self.cursor, internal) {
493            (SessionType::Select(m), true) => m.clone(),
494            (SessionType::Branch(m), false) => m.clone(),
495            (other, _) => {
496                return Err(ProtocolError::UnexpectedFrame {
497                    cursor_kind: kind_of(other),
498                    frame_kind: if internal { "select" } else { "select" },
499                });
500            }
501        };
502        match arms.get(label) {
503            Some(cont) => {
504                let cont = cont.clone();
505                self.advance(cont);
506                Ok(())
507            }
508            None => Err(ProtocolError::UnknownLabel {
509                label: label.to_string(),
510                expected: keys_of(&arms),
511            }),
512        }
513    }
514}
515
516/// Symbolic name of the cursor's head constructor — used to enrich error
517/// messages without leaking the full type body.
518fn kind_of(t: &SessionType) -> &'static str {
519    match t {
520        SessionType::End => "end",
521        SessionType::Send { .. } => "send",
522        SessionType::Recv { .. } => "recv",
523        SessionType::Select(_) => "select",
524        SessionType::Branch(_) => "branch",
525        SessionType::Rec(_, _) => "rec", // never reached on a head-unfolded cursor
526        SessionType::Var(_) => "var",    // ditto, on closed types
527        SessionType::Interrupt { .. } => "interrupt", // §Fase 79 — dispatch lands in 79.d
528        SessionType::Resume => "resume",
529    }
530}
531
532fn keys_of(m: &BTreeMap<String, SessionType>) -> Vec<String> {
533    m.keys().cloned().collect()
534}
535
536// ── §Fase 41.g — sealed-snapshot envelope ────────────────────────────────
537
538/// On-wire version tag for the sealed-runtime JSON. Bumped only on a
539/// breaking schema change to the envelope shape.
540pub const SEALED_RUNTIME_VERSION: u8 = 1;
541
542/// The plaintext the §40.t `cognitive_states` AAD-bound ciphertext wraps —
543/// a stable JSON envelope containing the session-type schema, the residual
544/// cursor, and the live credit window. Issued by
545/// [`SessionRuntime::seal`]; opened by [`SessionRuntime::resume`] after the
546/// §40.k envelope decryption verifies the (tenant, session, flow) binding.
547#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
548pub struct SealedRuntime {
549    /// Envelope version — gates schema evolution.
550    pub version: u8,
551    /// The declared session-type schema. Resume validates this equals
552    /// the live socket's declaration (defence against protocol-swap).
553    pub schema: SessionType,
554    /// The residual session type (the cursor at seal time). Always
555    /// head-unfolded because `SessionRuntime::advance` enforces it.
556    pub cursor: SessionType,
557    /// The live credit window snapshot — `None` if the bound socket is
558    /// in the unbounded fragment (no `backpressure` annotation).
559    pub credit: Option<CreditWindow>,
560    /// §Fase 79.e — the parked one-shot continuation, present iff the runtime
561    /// was interrupted-and-not-yet-resumed at seal time. `skip_serializing_if`
562    /// keeps every non-interrupt snapshot byte-identical to the pre-§79 wire
563    /// form (no version bump; back-compat by construction — the boot-hydrate
564    /// self-heal discipline). On resume the `interrupted_by_peer` carrier state
565    /// is restored so the handler can still `resume` into the body.
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub parked: Option<ParkedContinuation>,
568}
569
570impl SealedRuntime {
571    /// Serialise to bytes — the format the §Fase 40.t envelope encrypts.
572    /// Deterministic JSON via `serde_json::to_vec`.
573    pub fn to_bytes(&self) -> Vec<u8> {
574        serde_json::to_vec(self).expect("SealedRuntime ⇒ JSON is total")
575    }
576    /// Parse bytes (after envelope decryption) back into a `SealedRuntime`.
577    pub fn from_bytes(b: &[u8]) -> Result<Self, ResumeError> {
578        serde_json::from_slice(b).map_err(|e| ResumeError::Malformed(e.to_string()))
579    }
580}
581
582/// Errors raised by [`SessionRuntime::resume`].
583#[derive(Debug, Clone, PartialEq, Eq)]
584pub enum ResumeError {
585    /// The sealed envelope's version is newer than this runtime supports.
586    UnsupportedVersion(u8),
587    /// The sealed schema does not match the live socket's declared
588    /// protocol. The §40.t envelope's AAD binds tenant+session+flow, so
589    /// the *transport* can't be confused; this check catches the case
590    /// where the socket's declared session-type itself drifted between
591    /// seal + resume (e.g. a deploy bumped the protocol).
592    SchemaMismatch,
593    /// The plaintext bytes didn't deserialise into a `SealedRuntime`
594    /// envelope. Carries the parser's complaint for diagnostics.
595    Malformed(String),
596}
597
598impl std::fmt::Display for ResumeError {
599    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
600        match self {
601            ResumeError::UnsupportedVersion(v) => write!(
602                f,
603                "sealed runtime envelope version {v} is newer than this runtime supports \
604                 (current = {SEALED_RUNTIME_VERSION})"
605            ),
606            ResumeError::SchemaMismatch => f.write_str(
607                "sealed runtime's declared protocol does not match the live socket's session type \
608                 — the protocol drifted between seal and resume",
609            ),
610            ResumeError::Malformed(detail) => write!(f, "sealed runtime envelope is malformed: {detail}"),
611        }
612    }
613}
614
615impl std::error::Error for ResumeError {}
616
617#[cfg(test)]
618mod tests {
619    use super::*;
620
621    // ── CreditWindow primitives ─────────────────────────────────────────
622
623    #[test]
624    fn credit_window_decrements_and_refills_within_budget() {
625        let mut w = CreditWindow::new(2);
626        assert_eq!(w.try_consume(), Some(1));
627        assert_eq!(w.try_consume(), Some(0));
628        assert!(w.try_consume().is_none()); // exhausted
629        w.refill();
630        assert_eq!(w.available, 1);
631        w.refill();
632        assert_eq!(w.available, 2);
633        // Refill beyond budget is a no-op (capped at k).
634        w.refill();
635        assert_eq!(w.available, 2);
636    }
637
638    // ── try_send / try_recv on linear types ─────────────────────────────
639
640    #[test]
641    fn try_send_advances_on_matching_payload() {
642        // Type: !Msg.end
643        let schema = SessionType::send("Msg", SessionType::End);
644        let mut r = SessionRuntime::new(schema, None);
645        r.try_send("Msg").expect("step");
646        assert!(r.is_complete());
647    }
648
649    #[test]
650    fn try_send_rejects_wrong_payload() {
651        let schema = SessionType::send("Msg", SessionType::End);
652        let mut r = SessionRuntime::new(schema, None);
653        match r.try_send("WrongType") {
654            Err(ProtocolError::PayloadMismatch { expected, got }) => {
655                assert_eq!(expected, Payload::new("Msg"));
656                assert_eq!(got, Payload::new("WrongType"));
657            }
658            other => panic!("expected PayloadMismatch, got {other:?}"),
659        }
660        // The cursor is unchanged on error.
661        assert!(matches!(r.cursor(), SessionType::Send { .. }));
662    }
663
664    #[test]
665    fn try_recv_rejects_when_cursor_is_send() {
666        let schema = SessionType::send("Msg", SessionType::End);
667        let mut r = SessionRuntime::new(schema, None);
668        match r.try_recv("Msg") {
669            Err(ProtocolError::UnexpectedFrame { cursor_kind: "send", .. }) => {}
670            other => panic!("expected UnexpectedFrame(send→send), got {other:?}"),
671        }
672    }
673
674    // ── Credit accounting ───────────────────────────────────────────────
675
676    #[test]
677    fn credit_exhaustion_blocks_send_at_zero() {
678        // Type: !A.!B.end with budget = 1
679        let schema = SessionType::send("A", SessionType::send("B", SessionType::End));
680        let mut r = SessionRuntime::new(schema, Some(1));
681        // First send consumes the credit (budget→0).
682        r.try_send("A").expect("first send");
683        assert_eq!(r.credit().unwrap().available, 0);
684        // Second send hits the n=0 axiom.
685        match r.try_send("B") {
686            Err(ProtocolError::CreditExhausted { payload, budget: 1 }) => {
687                assert_eq!(payload, Payload::new("B"));
688            }
689            other => panic!("expected CreditExhausted, got {other:?}"),
690        }
691    }
692
693    #[test]
694    fn recv_refills_credit_capped_at_budget() {
695        // Type: !A.?Ack.!B.end with budget = 1 (sustainable: each send
696        // is followed by a refill).
697        let schema = SessionType::send(
698            "A",
699            SessionType::recv("Ack", SessionType::send("B", SessionType::End)),
700        );
701        let mut r = SessionRuntime::new(schema, Some(1));
702        r.try_send("A").expect("send A");
703        assert_eq!(r.credit().unwrap().available, 0);
704        r.try_recv("Ack").expect("recv Ack refills");
705        assert_eq!(r.credit().unwrap().available, 1);
706        r.try_send("B").expect("send B uses refilled credit");
707        assert!(r.is_complete());
708    }
709
710    // ── select / branch / recursion ─────────────────────────────────────
711
712    #[test]
713    fn select_advances_into_named_arm() {
714        let schema = SessionType::select([
715            ("ask".into(), SessionType::send("Q", SessionType::End)),
716            ("quit".into(), SessionType::End),
717        ]);
718        let mut r = SessionRuntime::new(schema, None);
719        r.try_select("ask").expect("select ask");
720        assert!(matches!(r.cursor(), SessionType::Send { .. }));
721        r.try_send("Q").expect("send Q");
722        assert!(r.is_complete());
723    }
724
725    #[test]
726    fn select_rejects_unknown_label() {
727        let schema = SessionType::select([
728            ("ask".into(), SessionType::End),
729            ("quit".into(), SessionType::End),
730        ]);
731        let mut r = SessionRuntime::new(schema, None);
732        match r.try_select("nope") {
733            Err(ProtocolError::UnknownLabel { label, expected }) => {
734                assert_eq!(label, "nope");
735                assert_eq!(expected, vec!["ask".to_string(), "quit".to_string()]);
736            }
737            other => panic!("expected UnknownLabel, got {other:?}"),
738        }
739    }
740
741    #[test]
742    fn offer_advances_into_peer_selected_arm() {
743        let schema = SessionType::branch([
744            ("ack".into(), SessionType::End),
745            ("err".into(), SessionType::End),
746        ]);
747        let mut r = SessionRuntime::new(schema, None);
748        r.try_offer("ack").expect("offer ack");
749        assert!(r.is_complete());
750    }
751
752    #[test]
753    fn recursion_unfolds_one_step_at_a_time() {
754        // rec X. !A.?Ack.X — should support unbounded iteration under
755        // budget=1 (Δ = 0 per recurring iteration).
756        let schema = SessionType::rec(
757            "X",
758            SessionType::send("A", SessionType::recv("Ack", SessionType::var("X"))),
759        );
760        let mut r = SessionRuntime::new(schema, Some(1));
761        for _ in 0..5 {
762            r.try_send("A").expect("send");
763            r.try_recv("Ack").expect("recv");
764        }
765        // The cursor is still at the start-of-iteration shape (unfolded
766        // form of `Rec(X, …)`), which is `!A.?Ack.<unfolded rec>`.
767        assert!(matches!(r.cursor(), SessionType::Send { .. }));
768        // Definitely not at `end` — the dialogue is unbounded.
769        assert!(!r.is_complete());
770    }
771
772    // ── Post-end safety net ─────────────────────────────────────────────
773
774    #[test]
775    fn post_end_traffic_is_rejected() {
776        let mut r = SessionRuntime::new(SessionType::End, None);
777        r.try_end().expect("end on End is OK");
778        match r.try_send("X") {
779            Err(ProtocolError::AlreadyComplete { frame_kind: "send" }) => {}
780            other => panic!("expected AlreadyComplete, got {other:?}"),
781        }
782    }
783
784    // ── A realistic chat dialogue runs to completion ────────────────────
785
786    // ── §Fase 41.g — sealed snapshot round-trip + resume validation ────
787
788    #[test]
789    fn seal_returns_none_at_end_and_some_otherwise() {
790        // !A.end → before sending, seal yields a snapshot.
791        let schema = SessionType::send("A", SessionType::End);
792        let r = SessionRuntime::new(schema.clone(), None);
793        let sealed = r.seal().expect("snapshot at non-End cursor");
794        assert_eq!(sealed.version, SEALED_RUNTIME_VERSION);
795        // The schema is preserved verbatim (resume needs the original).
796        assert_eq!(sealed.schema, schema);
797        // The cursor is the head-unfolded form of `Send`.
798        assert!(matches!(sealed.cursor, SessionType::Send { .. }));
799        // After advancing to End, seal returns None.
800        let mut r = SessionRuntime::new(schema, None);
801        r.try_send("A").unwrap();
802        assert!(r.is_complete());
803        assert!(r.seal().is_none(), "no snapshot once cursor is at End");
804    }
805
806    #[test]
807    fn seal_carries_live_credit_window_not_just_budget() {
808        // !A.!B.end with budget=2 — after one send the window has 1 left.
809        let schema = SessionType::send("A", SessionType::send("B", SessionType::End));
810        let mut r = SessionRuntime::new(schema, Some(2));
811        r.try_send("A").unwrap();
812        let sealed = r.seal().expect("snapshot mid-protocol");
813        assert_eq!(sealed.credit, Some(CreditWindow { budget: 2, available: 1 }));
814    }
815
816    #[test]
817    fn resume_round_trips_through_seal_then_unbinds_to_the_same_cursor() {
818        let schema = SessionType::recv(
819            "Msg",
820            SessionType::send("Ack", SessionType::End),
821        );
822        let r0 = SessionRuntime::new(schema.clone(), None);
823        let sealed = r0.seal().expect("snapshot before recv");
824        let bytes = sealed.to_bytes();
825        // Round-trip via JSON bytes (the §40.t envelope plaintext).
826        let recovered = SealedRuntime::from_bytes(&bytes).expect("parse");
827        assert_eq!(recovered, sealed);
828        // And resume → live runtime that picks up where we left off.
829        let r1 = SessionRuntime::resume(recovered, &schema).expect("resume");
830        assert_eq!(r1.cursor(), r0.cursor());
831        assert_eq!(r1.credit(), r0.credit());
832    }
833
834    #[test]
835    fn resume_after_partial_progress_continues_from_the_residual() {
836        // !A.!B.end — send A, seal, resume, send B, end.
837        let schema = SessionType::send("A", SessionType::send("B", SessionType::End));
838        let mut r0 = SessionRuntime::new(schema.clone(), Some(2));
839        r0.try_send("A").unwrap();
840        let bytes = r0.seal().unwrap().to_bytes();
841        // Wire bytes round-trip — this is what the AAD-bound ciphertext carries.
842        let recovered = SealedRuntime::from_bytes(&bytes).unwrap();
843        let mut r1 = SessionRuntime::resume(recovered, &schema).unwrap();
844        // Credit window survived the seal (1 used, 1 available).
845        assert_eq!(r1.credit().unwrap().available, 1);
846        // The cursor is exactly `!B.end` — sending B completes the dialogue.
847        r1.try_send("B").expect("send B from resumed cursor");
848        assert!(r1.is_complete());
849    }
850
851    #[test]
852    fn resume_rejects_a_schema_mismatch() {
853        // Seal a snapshot for `!A.end`, then try to resume against `!B.end`.
854        let schema_a = SessionType::send("A", SessionType::End);
855        let schema_b = SessionType::send("B", SessionType::End);
856        let r0 = SessionRuntime::new(schema_a.clone(), None);
857        let sealed = r0.seal().unwrap();
858        assert_eq!(
859            SessionRuntime::resume(sealed.clone(), &schema_b).err(),
860            Some(ResumeError::SchemaMismatch)
861        );
862        // Same-schema resume works.
863        assert!(SessionRuntime::resume(sealed, &schema_a).is_ok());
864    }
865
866    #[test]
867    fn resume_rejects_a_future_envelope_version() {
868        let schema = SessionType::send("A", SessionType::End);
869        let r = SessionRuntime::new(schema.clone(), None);
870        let mut sealed = r.seal().unwrap();
871        sealed.version = SEALED_RUNTIME_VERSION + 7;
872        assert_eq!(
873            SessionRuntime::resume(sealed, &schema).err(),
874            Some(ResumeError::UnsupportedVersion(SEALED_RUNTIME_VERSION + 7))
875        );
876    }
877
878    #[test]
879    fn resume_rejects_malformed_envelope_bytes() {
880        let garbage = b"{not valid JSON";
881        assert!(matches!(
882            SealedRuntime::from_bytes(garbage),
883            Err(ResumeError::Malformed(_))
884        ));
885    }
886
887    #[test]
888    fn resume_accepts_alpha_equivalent_schemas() {
889        // The schema match uses the §41.a regular-coinductive equality, so
890        // α-renamed recursion variables are accepted as equivalent.
891        let schema_x = SessionType::rec("X", SessionType::send("T", SessionType::var("X")));
892        let schema_y = SessionType::rec("Y", SessionType::send("T", SessionType::var("Y")));
893        let r = SessionRuntime::new(schema_x.clone(), None);
894        let sealed = r.seal().unwrap();
895        // Sealing produced a schema with binder `X`; resume against `Y` succeeds.
896        assert!(SessionRuntime::resume(sealed, &schema_y).is_ok());
897    }
898
899    #[test]
900    fn sealed_runtime_is_json_compatible_with_serde_roundtrip() {
901        // The wire shape must be JSON-deserialisable by any downstream
902        // tool (e.g. an offline forensic inspector). We check the bytes
903        // parse via the standard `serde_json::from_slice`.
904        let schema = SessionType::send("X", SessionType::End);
905        let r = SessionRuntime::new(schema, None);
906        let bytes = r.seal().unwrap().to_bytes();
907        let value: serde_json::Value =
908            serde_json::from_slice(&bytes).expect("envelope is well-formed JSON");
909        // The envelope carries the four known keys.
910        assert!(value.get("version").is_some());
911        assert!(value.get("schema").is_some());
912        assert!(value.get("cursor").is_some());
913        assert!(value.get("credit").is_some());
914    }
915
916    #[test]
917    fn realistic_chat_dialogue_runs_to_completion() {
918        // The 41.a sample type: rec X. +{ ask: !Utterance. &{ token:
919        // ?Token.X, done: end }, cancel: end }
920        let schema = SessionType::rec(
921            "X",
922            SessionType::select([
923                (
924                    "ask".into(),
925                    SessionType::send(
926                        "Utterance",
927                        SessionType::branch([
928                            ("token".into(), SessionType::recv("Token", SessionType::var("X"))),
929                            ("done".into(), SessionType::End),
930                        ]),
931                    ),
932                ),
933                ("cancel".into(), SessionType::End),
934            ]),
935        );
936        let mut client = SessionRuntime::new(schema, Some(4));
937        // Iter 1: ask → send Utt → server says token → recv Token → loop.
938        client.try_select("ask").unwrap();
939        client.try_send("Utterance").unwrap();
940        client.try_offer("token").unwrap();
941        client.try_recv("Token").unwrap();
942        // Iter 2: ask → send Utt → server says done.
943        client.try_select("ask").unwrap();
944        client.try_send("Utterance").unwrap();
945        client.try_offer("done").unwrap();
946        client.try_end().unwrap();
947        assert!(client.is_complete());
948    }
949}