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}