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}