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}
102
103impl SessionRuntime {
104    /// Create a runtime for the given role's session type. `budget`
105    /// mirrors the socket's `credit(k)`; pass `None` for the unbounded
106    /// fragment (statically equivalent to omitting `backpressure:`).
107    pub fn new(schema: SessionType, budget: Option<u64>) -> Self {
108        let cursor = schema.unfold_head();
109        Self {
110            schema,
111            cursor,
112            credit: budget.map(CreditWindow::new),
113        }
114    }
115
116    /// The original session type — useful for error messages and logs.
117    pub fn schema(&self) -> &SessionType {
118        &self.schema
119    }
120
121    /// The current residual cursor — always head-unfolded.
122    pub fn cursor(&self) -> &SessionType {
123        &self.cursor
124    }
125
126    /// The dynamic credit window snapshot (or `None` for the unbounded
127    /// fragment).
128    pub fn credit(&self) -> Option<CreditWindow> {
129        self.credit
130    }
131
132    /// `true` once the cursor reaches `end` — both sides should now close
133    /// the carrier cleanly. The runtime rejects further actions after
134    /// this point with [`ProtocolError::AlreadyComplete`].
135    pub fn is_complete(&self) -> bool {
136        matches!(self.cursor, SessionType::End)
137    }
138
139    // ── §Fase 41.g — typed reconnection via sealed snapshots ───────────
140
141    /// Serialise the live runtime state into a stable JSON envelope —
142    /// the plaintext the §40.t `cognitive_states` AAD-bound ciphertext
143    /// wraps. Carries the schema (so resume can verify the protocol
144    /// hasn't been swapped under the connection), the residual cursor,
145    /// and the live credit window snapshot.
146    ///
147    /// Symmetric with [`Self::resume`]: `runtime.seal()` then
148    /// `SessionRuntime::resume(sealed, declared_schema)` round-trips when
149    /// the declared schema matches.
150    ///
151    /// Returns `None` if and only if the cursor is already at `End` — a
152    /// completed dialogue has no residual to seal, so no snapshot is
153    /// issued (the caller should `evict()` the prior snapshot instead).
154    pub fn seal(&self) -> Option<SealedRuntime> {
155        if self.is_complete() {
156            return None;
157        }
158        Some(SealedRuntime {
159            version: SEALED_RUNTIME_VERSION,
160            schema: self.schema.clone(),
161            cursor: self.cursor.clone(),
162            credit: self.credit,
163        })
164    }
165
166    /// Reconstruct a [`SessionRuntime`] from a sealed snapshot, validating
167    /// the schema matches what the route declares now (defence against
168    /// protocol-swap attacks where an attacker reuses a sealed snapshot
169    /// against a different socket whose declaration drifted).
170    ///
171    /// On success the returned runtime resumes from the exact cursor +
172    /// credit window the disconnected one left behind. The carrier driver
173    /// then runs the producer/consumer loop as usual.
174    pub fn resume(
175        sealed: SealedRuntime,
176        declared_schema: &SessionType,
177    ) -> Result<Self, ResumeError> {
178        if sealed.version != SEALED_RUNTIME_VERSION {
179            return Err(ResumeError::UnsupportedVersion(sealed.version));
180        }
181        if !sealed.schema.equiv(declared_schema) {
182            return Err(ResumeError::SchemaMismatch);
183        }
184        // The cursor must be reachable from the schema (we cannot prove
185        // this in general — the algebra would need a labelled trace — but
186        // we DO require the cursor to be head-unfolded, which the wire
187        // form preserves because `seal()` only stores cursors set via
188        // `advance()`).
189        Ok(SessionRuntime {
190            schema: sealed.schema,
191            cursor: sealed.cursor,
192            credit: sealed.credit,
193        })
194    }
195
196    // ── Operational rules ──────────────────────────────────────────────
197
198    /// Producer step `!A.S → S`. Succeeds iff:
199    /// 1. the cursor is `Send { payload, … }` with `payload == got`;
200    /// 2. the credit window (if any) has `available > 0` — otherwise the
201    ///    §41.c "no rule at n=0" axiom fires.
202    /// On success the cursor advances (unfolded) and one credit is
203    /// consumed (when the window is present).
204    pub fn try_send(&mut self, got: &str) -> Result<(), ProtocolError> {
205        if self.is_complete() {
206            return Err(ProtocolError::AlreadyComplete { frame_kind: "send" });
207        }
208        let (expected_payload, cont) = match &self.cursor {
209            SessionType::Send { payload, cont, .. } => (payload.clone(), cont.clone()),
210            other => {
211                return Err(ProtocolError::UnexpectedFrame {
212                    cursor_kind: kind_of(other),
213                    frame_kind: "send",
214                });
215            }
216        };
217        let got_payload = Payload::new(got);
218        if expected_payload != got_payload {
219            return Err(ProtocolError::PayloadMismatch {
220                expected: expected_payload,
221                got: got_payload,
222            });
223        }
224        // Credit decrement — the dynamic witness of `!ⁿA.S, n > 0`.
225        if let Some(w) = self.credit.as_mut() {
226            if w.try_consume().is_none() {
227                return Err(ProtocolError::CreditExhausted {
228                    payload: expected_payload,
229                    budget: w.budget,
230                });
231            }
232        }
233        self.advance(*cont);
234        Ok(())
235    }
236
237    /// Consumer step `?A.S → S`. The peer just produced an `!A.S` frame.
238    /// Symmetric to [`try_send`] — payload must match, the cursor advances,
239    /// and one credit is refilled (if a window is present).
240    pub fn try_recv(&mut self, got: &str) -> Result<(), ProtocolError> {
241        if self.is_complete() {
242            return Err(ProtocolError::AlreadyComplete { frame_kind: "send" });
243        }
244        let (expected_payload, cont) = match &self.cursor {
245            SessionType::Recv { payload, cont, .. } => (payload.clone(), cont.clone()),
246            other => {
247                return Err(ProtocolError::UnexpectedFrame {
248                    cursor_kind: kind_of(other),
249                    frame_kind: "send",
250                });
251            }
252        };
253        let got_payload = Payload::new(got);
254        if expected_payload != got_payload {
255            return Err(ProtocolError::PayloadMismatch {
256                expected: expected_payload,
257                got: got_payload,
258            });
259        }
260        // Refill — the peer just delivered, the in-flight count drops by 1.
261        if let Some(w) = self.credit.as_mut() {
262            w.refill();
263        }
264        self.advance(*cont);
265        Ok(())
266    }
267
268    /// Internal choice (`⊕`) — *we* select the labelled arm. Cursor must
269    /// be `Select { arms }` containing `label`; on success the cursor
270    /// advances into that arm's continuation.
271    pub fn try_select(&mut self, label: &str) -> Result<(), ProtocolError> {
272        self.advance_into_arm(label, true)
273    }
274
275    /// External choice (`&`) — the *peer* selected this label; we accept.
276    /// Cursor must be `Branch { arms }` containing `label`.
277    pub fn try_offer(&mut self, label: &str) -> Result<(), ProtocolError> {
278        self.advance_into_arm(label, false)
279    }
280
281    /// `end` step — terminates the dialogue. Cursor must already be
282    /// `End`; otherwise the peer is signalling termination mid-protocol.
283    pub fn try_end(&mut self) -> Result<(), ProtocolError> {
284        match &self.cursor {
285            SessionType::End => Ok(()),
286            other => Err(ProtocolError::UnexpectedFrame {
287                cursor_kind: kind_of(other),
288                frame_kind: "end",
289            }),
290        }
291    }
292
293    // ── Internal helpers ───────────────────────────────────────────────
294
295    /// Set the cursor to the head-unfolded form of `next`. This is the
296    /// single invariant maintained across every step — after any advance
297    /// the cursor never has a leading `Rec` (and never a leading bare
298    /// `Var` on a *closed* type, which 41.a/b/c statically guarantee).
299    fn advance(&mut self, next: SessionType) {
300        self.cursor = next.unfold_head();
301    }
302
303    fn advance_into_arm(&mut self, label: &str, internal: bool) -> Result<(), ProtocolError> {
304        if self.is_complete() {
305            return Err(ProtocolError::AlreadyComplete {
306                frame_kind: if internal { "select" } else { "branch" },
307            });
308        }
309        let arms = match (&self.cursor, internal) {
310            (SessionType::Select(m), true) => m.clone(),
311            (SessionType::Branch(m), false) => m.clone(),
312            (other, _) => {
313                return Err(ProtocolError::UnexpectedFrame {
314                    cursor_kind: kind_of(other),
315                    frame_kind: if internal { "select" } else { "select" },
316                });
317            }
318        };
319        match arms.get(label) {
320            Some(cont) => {
321                let cont = cont.clone();
322                self.advance(cont);
323                Ok(())
324            }
325            None => Err(ProtocolError::UnknownLabel {
326                label: label.to_string(),
327                expected: keys_of(&arms),
328            }),
329        }
330    }
331}
332
333/// Symbolic name of the cursor's head constructor — used to enrich error
334/// messages without leaking the full type body.
335fn kind_of(t: &SessionType) -> &'static str {
336    match t {
337        SessionType::End => "end",
338        SessionType::Send { .. } => "send",
339        SessionType::Recv { .. } => "recv",
340        SessionType::Select(_) => "select",
341        SessionType::Branch(_) => "branch",
342        SessionType::Rec(_, _) => "rec", // never reached on a head-unfolded cursor
343        SessionType::Var(_) => "var",    // ditto, on closed types
344    }
345}
346
347fn keys_of(m: &BTreeMap<String, SessionType>) -> Vec<String> {
348    m.keys().cloned().collect()
349}
350
351// ── §Fase 41.g — sealed-snapshot envelope ────────────────────────────────
352
353/// On-wire version tag for the sealed-runtime JSON. Bumped only on a
354/// breaking schema change to the envelope shape.
355pub const SEALED_RUNTIME_VERSION: u8 = 1;
356
357/// The plaintext the §40.t `cognitive_states` AAD-bound ciphertext wraps —
358/// a stable JSON envelope containing the session-type schema, the residual
359/// cursor, and the live credit window. Issued by
360/// [`SessionRuntime::seal`]; opened by [`SessionRuntime::resume`] after the
361/// §40.k envelope decryption verifies the (tenant, session, flow) binding.
362#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
363pub struct SealedRuntime {
364    /// Envelope version — gates schema evolution.
365    pub version: u8,
366    /// The declared session-type schema. Resume validates this equals
367    /// the live socket's declaration (defence against protocol-swap).
368    pub schema: SessionType,
369    /// The residual session type (the cursor at seal time). Always
370    /// head-unfolded because `SessionRuntime::advance` enforces it.
371    pub cursor: SessionType,
372    /// The live credit window snapshot — `None` if the bound socket is
373    /// in the unbounded fragment (no `backpressure` annotation).
374    pub credit: Option<CreditWindow>,
375}
376
377impl SealedRuntime {
378    /// Serialise to bytes — the format the §Fase 40.t envelope encrypts.
379    /// Deterministic JSON via `serde_json::to_vec`.
380    pub fn to_bytes(&self) -> Vec<u8> {
381        serde_json::to_vec(self).expect("SealedRuntime ⇒ JSON is total")
382    }
383    /// Parse bytes (after envelope decryption) back into a `SealedRuntime`.
384    pub fn from_bytes(b: &[u8]) -> Result<Self, ResumeError> {
385        serde_json::from_slice(b).map_err(|e| ResumeError::Malformed(e.to_string()))
386    }
387}
388
389/// Errors raised by [`SessionRuntime::resume`].
390#[derive(Debug, Clone, PartialEq, Eq)]
391pub enum ResumeError {
392    /// The sealed envelope's version is newer than this runtime supports.
393    UnsupportedVersion(u8),
394    /// The sealed schema does not match the live socket's declared
395    /// protocol. The §40.t envelope's AAD binds tenant+session+flow, so
396    /// the *transport* can't be confused; this check catches the case
397    /// where the socket's declared session-type itself drifted between
398    /// seal + resume (e.g. a deploy bumped the protocol).
399    SchemaMismatch,
400    /// The plaintext bytes didn't deserialise into a `SealedRuntime`
401    /// envelope. Carries the parser's complaint for diagnostics.
402    Malformed(String),
403}
404
405impl std::fmt::Display for ResumeError {
406    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
407        match self {
408            ResumeError::UnsupportedVersion(v) => write!(
409                f,
410                "sealed runtime envelope version {v} is newer than this runtime supports \
411                 (current = {SEALED_RUNTIME_VERSION})"
412            ),
413            ResumeError::SchemaMismatch => f.write_str(
414                "sealed runtime's declared protocol does not match the live socket's session type \
415                 — the protocol drifted between seal and resume",
416            ),
417            ResumeError::Malformed(detail) => write!(f, "sealed runtime envelope is malformed: {detail}"),
418        }
419    }
420}
421
422impl std::error::Error for ResumeError {}
423
424#[cfg(test)]
425mod tests {
426    use super::*;
427
428    // ── CreditWindow primitives ─────────────────────────────────────────
429
430    #[test]
431    fn credit_window_decrements_and_refills_within_budget() {
432        let mut w = CreditWindow::new(2);
433        assert_eq!(w.try_consume(), Some(1));
434        assert_eq!(w.try_consume(), Some(0));
435        assert!(w.try_consume().is_none()); // exhausted
436        w.refill();
437        assert_eq!(w.available, 1);
438        w.refill();
439        assert_eq!(w.available, 2);
440        // Refill beyond budget is a no-op (capped at k).
441        w.refill();
442        assert_eq!(w.available, 2);
443    }
444
445    // ── try_send / try_recv on linear types ─────────────────────────────
446
447    #[test]
448    fn try_send_advances_on_matching_payload() {
449        // Type: !Msg.end
450        let schema = SessionType::send("Msg", SessionType::End);
451        let mut r = SessionRuntime::new(schema, None);
452        r.try_send("Msg").expect("step");
453        assert!(r.is_complete());
454    }
455
456    #[test]
457    fn try_send_rejects_wrong_payload() {
458        let schema = SessionType::send("Msg", SessionType::End);
459        let mut r = SessionRuntime::new(schema, None);
460        match r.try_send("WrongType") {
461            Err(ProtocolError::PayloadMismatch { expected, got }) => {
462                assert_eq!(expected, Payload::new("Msg"));
463                assert_eq!(got, Payload::new("WrongType"));
464            }
465            other => panic!("expected PayloadMismatch, got {other:?}"),
466        }
467        // The cursor is unchanged on error.
468        assert!(matches!(r.cursor(), SessionType::Send { .. }));
469    }
470
471    #[test]
472    fn try_recv_rejects_when_cursor_is_send() {
473        let schema = SessionType::send("Msg", SessionType::End);
474        let mut r = SessionRuntime::new(schema, None);
475        match r.try_recv("Msg") {
476            Err(ProtocolError::UnexpectedFrame { cursor_kind: "send", .. }) => {}
477            other => panic!("expected UnexpectedFrame(send→send), got {other:?}"),
478        }
479    }
480
481    // ── Credit accounting ───────────────────────────────────────────────
482
483    #[test]
484    fn credit_exhaustion_blocks_send_at_zero() {
485        // Type: !A.!B.end with budget = 1
486        let schema = SessionType::send("A", SessionType::send("B", SessionType::End));
487        let mut r = SessionRuntime::new(schema, Some(1));
488        // First send consumes the credit (budget→0).
489        r.try_send("A").expect("first send");
490        assert_eq!(r.credit().unwrap().available, 0);
491        // Second send hits the n=0 axiom.
492        match r.try_send("B") {
493            Err(ProtocolError::CreditExhausted { payload, budget: 1 }) => {
494                assert_eq!(payload, Payload::new("B"));
495            }
496            other => panic!("expected CreditExhausted, got {other:?}"),
497        }
498    }
499
500    #[test]
501    fn recv_refills_credit_capped_at_budget() {
502        // Type: !A.?Ack.!B.end with budget = 1 (sustainable: each send
503        // is followed by a refill).
504        let schema = SessionType::send(
505            "A",
506            SessionType::recv("Ack", SessionType::send("B", SessionType::End)),
507        );
508        let mut r = SessionRuntime::new(schema, Some(1));
509        r.try_send("A").expect("send A");
510        assert_eq!(r.credit().unwrap().available, 0);
511        r.try_recv("Ack").expect("recv Ack refills");
512        assert_eq!(r.credit().unwrap().available, 1);
513        r.try_send("B").expect("send B uses refilled credit");
514        assert!(r.is_complete());
515    }
516
517    // ── select / branch / recursion ─────────────────────────────────────
518
519    #[test]
520    fn select_advances_into_named_arm() {
521        let schema = SessionType::select([
522            ("ask".into(), SessionType::send("Q", SessionType::End)),
523            ("quit".into(), SessionType::End),
524        ]);
525        let mut r = SessionRuntime::new(schema, None);
526        r.try_select("ask").expect("select ask");
527        assert!(matches!(r.cursor(), SessionType::Send { .. }));
528        r.try_send("Q").expect("send Q");
529        assert!(r.is_complete());
530    }
531
532    #[test]
533    fn select_rejects_unknown_label() {
534        let schema = SessionType::select([
535            ("ask".into(), SessionType::End),
536            ("quit".into(), SessionType::End),
537        ]);
538        let mut r = SessionRuntime::new(schema, None);
539        match r.try_select("nope") {
540            Err(ProtocolError::UnknownLabel { label, expected }) => {
541                assert_eq!(label, "nope");
542                assert_eq!(expected, vec!["ask".to_string(), "quit".to_string()]);
543            }
544            other => panic!("expected UnknownLabel, got {other:?}"),
545        }
546    }
547
548    #[test]
549    fn offer_advances_into_peer_selected_arm() {
550        let schema = SessionType::branch([
551            ("ack".into(), SessionType::End),
552            ("err".into(), SessionType::End),
553        ]);
554        let mut r = SessionRuntime::new(schema, None);
555        r.try_offer("ack").expect("offer ack");
556        assert!(r.is_complete());
557    }
558
559    #[test]
560    fn recursion_unfolds_one_step_at_a_time() {
561        // rec X. !A.?Ack.X — should support unbounded iteration under
562        // budget=1 (Δ = 0 per recurring iteration).
563        let schema = SessionType::rec(
564            "X",
565            SessionType::send("A", SessionType::recv("Ack", SessionType::var("X"))),
566        );
567        let mut r = SessionRuntime::new(schema, Some(1));
568        for _ in 0..5 {
569            r.try_send("A").expect("send");
570            r.try_recv("Ack").expect("recv");
571        }
572        // The cursor is still at the start-of-iteration shape (unfolded
573        // form of `Rec(X, …)`), which is `!A.?Ack.<unfolded rec>`.
574        assert!(matches!(r.cursor(), SessionType::Send { .. }));
575        // Definitely not at `end` — the dialogue is unbounded.
576        assert!(!r.is_complete());
577    }
578
579    // ── Post-end safety net ─────────────────────────────────────────────
580
581    #[test]
582    fn post_end_traffic_is_rejected() {
583        let mut r = SessionRuntime::new(SessionType::End, None);
584        r.try_end().expect("end on End is OK");
585        match r.try_send("X") {
586            Err(ProtocolError::AlreadyComplete { frame_kind: "send" }) => {}
587            other => panic!("expected AlreadyComplete, got {other:?}"),
588        }
589    }
590
591    // ── A realistic chat dialogue runs to completion ────────────────────
592
593    // ── §Fase 41.g — sealed snapshot round-trip + resume validation ────
594
595    #[test]
596    fn seal_returns_none_at_end_and_some_otherwise() {
597        // !A.end → before sending, seal yields a snapshot.
598        let schema = SessionType::send("A", SessionType::End);
599        let r = SessionRuntime::new(schema.clone(), None);
600        let sealed = r.seal().expect("snapshot at non-End cursor");
601        assert_eq!(sealed.version, SEALED_RUNTIME_VERSION);
602        // The schema is preserved verbatim (resume needs the original).
603        assert_eq!(sealed.schema, schema);
604        // The cursor is the head-unfolded form of `Send`.
605        assert!(matches!(sealed.cursor, SessionType::Send { .. }));
606        // After advancing to End, seal returns None.
607        let mut r = SessionRuntime::new(schema, None);
608        r.try_send("A").unwrap();
609        assert!(r.is_complete());
610        assert!(r.seal().is_none(), "no snapshot once cursor is at End");
611    }
612
613    #[test]
614    fn seal_carries_live_credit_window_not_just_budget() {
615        // !A.!B.end with budget=2 — after one send the window has 1 left.
616        let schema = SessionType::send("A", SessionType::send("B", SessionType::End));
617        let mut r = SessionRuntime::new(schema, Some(2));
618        r.try_send("A").unwrap();
619        let sealed = r.seal().expect("snapshot mid-protocol");
620        assert_eq!(sealed.credit, Some(CreditWindow { budget: 2, available: 1 }));
621    }
622
623    #[test]
624    fn resume_round_trips_through_seal_then_unbinds_to_the_same_cursor() {
625        let schema = SessionType::recv(
626            "Msg",
627            SessionType::send("Ack", SessionType::End),
628        );
629        let r0 = SessionRuntime::new(schema.clone(), None);
630        let sealed = r0.seal().expect("snapshot before recv");
631        let bytes = sealed.to_bytes();
632        // Round-trip via JSON bytes (the §40.t envelope plaintext).
633        let recovered = SealedRuntime::from_bytes(&bytes).expect("parse");
634        assert_eq!(recovered, sealed);
635        // And resume → live runtime that picks up where we left off.
636        let r1 = SessionRuntime::resume(recovered, &schema).expect("resume");
637        assert_eq!(r1.cursor(), r0.cursor());
638        assert_eq!(r1.credit(), r0.credit());
639    }
640
641    #[test]
642    fn resume_after_partial_progress_continues_from_the_residual() {
643        // !A.!B.end — send A, seal, resume, send B, end.
644        let schema = SessionType::send("A", SessionType::send("B", SessionType::End));
645        let mut r0 = SessionRuntime::new(schema.clone(), Some(2));
646        r0.try_send("A").unwrap();
647        let bytes = r0.seal().unwrap().to_bytes();
648        // Wire bytes round-trip — this is what the AAD-bound ciphertext carries.
649        let recovered = SealedRuntime::from_bytes(&bytes).unwrap();
650        let mut r1 = SessionRuntime::resume(recovered, &schema).unwrap();
651        // Credit window survived the seal (1 used, 1 available).
652        assert_eq!(r1.credit().unwrap().available, 1);
653        // The cursor is exactly `!B.end` — sending B completes the dialogue.
654        r1.try_send("B").expect("send B from resumed cursor");
655        assert!(r1.is_complete());
656    }
657
658    #[test]
659    fn resume_rejects_a_schema_mismatch() {
660        // Seal a snapshot for `!A.end`, then try to resume against `!B.end`.
661        let schema_a = SessionType::send("A", SessionType::End);
662        let schema_b = SessionType::send("B", SessionType::End);
663        let r0 = SessionRuntime::new(schema_a.clone(), None);
664        let sealed = r0.seal().unwrap();
665        assert_eq!(
666            SessionRuntime::resume(sealed.clone(), &schema_b).err(),
667            Some(ResumeError::SchemaMismatch)
668        );
669        // Same-schema resume works.
670        assert!(SessionRuntime::resume(sealed, &schema_a).is_ok());
671    }
672
673    #[test]
674    fn resume_rejects_a_future_envelope_version() {
675        let schema = SessionType::send("A", SessionType::End);
676        let r = SessionRuntime::new(schema.clone(), None);
677        let mut sealed = r.seal().unwrap();
678        sealed.version = SEALED_RUNTIME_VERSION + 7;
679        assert_eq!(
680            SessionRuntime::resume(sealed, &schema).err(),
681            Some(ResumeError::UnsupportedVersion(SEALED_RUNTIME_VERSION + 7))
682        );
683    }
684
685    #[test]
686    fn resume_rejects_malformed_envelope_bytes() {
687        let garbage = b"{not valid JSON";
688        assert!(matches!(
689            SealedRuntime::from_bytes(garbage),
690            Err(ResumeError::Malformed(_))
691        ));
692    }
693
694    #[test]
695    fn resume_accepts_alpha_equivalent_schemas() {
696        // The schema match uses the §41.a regular-coinductive equality, so
697        // α-renamed recursion variables are accepted as equivalent.
698        let schema_x = SessionType::rec("X", SessionType::send("T", SessionType::var("X")));
699        let schema_y = SessionType::rec("Y", SessionType::send("T", SessionType::var("Y")));
700        let r = SessionRuntime::new(schema_x.clone(), None);
701        let sealed = r.seal().unwrap();
702        // Sealing produced a schema with binder `X`; resume against `Y` succeeds.
703        assert!(SessionRuntime::resume(sealed, &schema_y).is_ok());
704    }
705
706    #[test]
707    fn sealed_runtime_is_json_compatible_with_serde_roundtrip() {
708        // The wire shape must be JSON-deserialisable by any downstream
709        // tool (e.g. an offline forensic inspector). We check the bytes
710        // parse via the standard `serde_json::from_slice`.
711        let schema = SessionType::send("X", SessionType::End);
712        let r = SessionRuntime::new(schema, None);
713        let bytes = r.seal().unwrap().to_bytes();
714        let value: serde_json::Value =
715            serde_json::from_slice(&bytes).expect("envelope is well-formed JSON");
716        // The envelope carries the four known keys.
717        assert!(value.get("version").is_some());
718        assert!(value.get("schema").is_some());
719        assert!(value.get("cursor").is_some());
720        assert!(value.get("credit").is_some());
721    }
722
723    #[test]
724    fn realistic_chat_dialogue_runs_to_completion() {
725        // The 41.a sample type: rec X. +{ ask: !Utterance. &{ token:
726        // ?Token.X, done: end }, cancel: end }
727        let schema = SessionType::rec(
728            "X",
729            SessionType::select([
730                (
731                    "ask".into(),
732                    SessionType::send(
733                        "Utterance",
734                        SessionType::branch([
735                            ("token".into(), SessionType::recv("Token", SessionType::var("X"))),
736                            ("done".into(), SessionType::End),
737                        ]),
738                    ),
739                ),
740                ("cancel".into(), SessionType::End),
741            ]),
742        );
743        let mut client = SessionRuntime::new(schema, Some(4));
744        // Iter 1: ask → send Utt → server says token → recv Token → loop.
745        client.try_select("ask").unwrap();
746        client.try_send("Utterance").unwrap();
747        client.try_offer("token").unwrap();
748        client.try_recv("Token").unwrap();
749        // Iter 2: ask → send Utt → server says done.
750        client.try_select("ask").unwrap();
751        client.try_send("Utterance").unwrap();
752        client.try_offer("done").unwrap();
753        client.try_end().unwrap();
754        assert!(client.is_complete());
755    }
756}