open-standards/joule-code/joule-code-rs/crates/jwp/src/state_machine.rs
Transaction Science 08c499f8ca joule-code: port CommunicationOS transport (jwp + joule-moq) into the public standard
The MoQT transport that joule-code-collab's network rung needs lived in the
private joulesperbit repo (as inv-moq + jwp), so a public consumer couldn't
depend on it. Port both into the public open-standards joule-code workspace,
self-contained (concrete dep versions, not workspace-inherited) so a bare git
clone builds them:

- crates/jwp: Joule Wire Protocol — energy-aware binary framing (CBOR, zstd/lz4,
  TCP + optional QUIC). Self-contained, external deps only.
- crates/joule-moq: MoQT (Media-over-QUIC) wire protocol + JWP signed-receipt
  extension for CommunicationOS — Publisher/Subscriber/Relay traits, MockFabric
  (in-process) + a production QUIC path (quic feature). Renamed from inv-moq
  to drop the invisible-* namespace and match the joule-code branding; depends
  only on jwp.

Both build (default + quic) and pass their tests (joule-moq 29, jwp 152) in the
new home. The transport-moqt example now path-deps the public joule-moq instead
of reaching into joulesperbit, and its end-to-end BFT-ops-over-MoQT-with-signed-
receipts tests pass. This unblocks public consumers (agent-zed) from git-depping
the transport for cross-machine fleet coordination.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-13 21:29:05 -04:00

982 lines
37 KiB
Rust

use crate::error::JwpError;
use crate::frame::FrameType;
/// Protocol states for the JWP state machine.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ProtocolState {
/// Initial state — awaiting handshake.
Init,
/// Handshake complete — ready for queries.
Ready,
/// Actively streaming results for a query.
Streaming,
/// v2: Mid-connection capability renegotiation in progress.
Negotiating,
/// Auth: in-band authentication in progress (between Handshake and Ready).
Authenticating,
/// Agent contract negotiation in progress (propose/accept/counter).
Contracting,
/// Agent is engaged — working under a signed contract.
AgentEngaged,
}
impl std::fmt::Display for ProtocolState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Init => write!(f, "Init"),
Self::Ready => write!(f, "Ready"),
Self::Streaming => write!(f, "Streaming"),
Self::Negotiating => write!(f, "Negotiating"),
Self::Authenticating => write!(f, "Authenticating"),
Self::Contracting => write!(f, "Contracting"),
Self::AgentEngaged => write!(f, "AgentEngaged"),
}
}
}
/// Tracks protocol state and validates transitions.
///
/// ```text
/// [Init] --Handshake--> [Ready] --Query--> [Streaming]
/// [Streaming] --Result--> [Streaming]
/// [Streaming] --Done--> [Ready]
/// [Streaming] --Cancel--> [Ready]
/// [Ready] --Query--> [Streaming]
/// [*] --Error--> [Ready] (if past Init)
/// [*] --Heartbeat--> [same state]
/// ```
pub struct ProtocolStateMachine {
state: ProtocolState,
}
impl ProtocolStateMachine {
pub fn new() -> Self {
Self {
state: ProtocolState::Init,
}
}
pub fn state(&self) -> ProtocolState {
self.state
}
/// Validate and apply a state transition for the given frame type.
/// Returns the new state on success.
pub fn transition(&mut self, frame_type: FrameType) -> Result<ProtocolState, JwpError> {
let new_state = match (self.state, frame_type) {
// Heartbeat is always valid, state unchanged
(s, FrameType::Heartbeat) => s,
// Error transitions: Init stays Init, Authenticating → Init (must re-handshake),
// all others → Ready.
(ProtocolState::Init, FrameType::Error) => ProtocolState::Init,
(ProtocolState::Authenticating, FrameType::Error) => ProtocolState::Init,
(_, FrameType::Error) => ProtocolState::Ready,
// Init → Ready via Handshake
(ProtocolState::Init, FrameType::Handshake) => ProtocolState::Ready,
// Ready → Streaming via Query
(ProtocolState::Ready, FrameType::Query) => ProtocolState::Streaming,
// Streaming: Result stays Streaming
(ProtocolState::Streaming, FrameType::Result) => ProtocolState::Streaming,
// Streaming: Receipt stays Streaming (intermediate receipt)
(ProtocolState::Streaming, FrameType::Receipt) => ProtocolState::Streaming,
// Streaming → Ready via Done or Cancel
(ProtocolState::Streaming, FrameType::Done) => ProtocolState::Ready,
(ProtocolState::Streaming, FrameType::Cancel) => ProtocolState::Ready,
// Ready: Meta starts streaming (server sends Meta right after Query)
(ProtocolState::Ready, FrameType::Meta) => ProtocolState::Streaming,
// Streaming: Meta is valid (re-sent for context)
(ProtocolState::Streaming, FrameType::Meta) => ProtocolState::Streaming,
// ── v2 frame types ──────────────────────────────────
// Negotiate: Ready → Negotiating (request), Negotiating → Ready (response)
(ProtocolState::Ready, FrameType::Negotiate) => ProtocolState::Negotiating,
(ProtocolState::Negotiating, FrameType::Negotiate) => ProtocolState::Ready,
// ProfileUpdate: informational, no state change (valid from Ready or Streaming)
(ProtocolState::Ready, FrameType::ProfileUpdate) => ProtocolState::Ready,
(ProtocolState::Streaming, FrameType::ProfileUpdate) => ProtocolState::Streaming,
// EnergyGradient: informational, no state change (valid from Ready or Streaming)
(ProtocolState::Ready, FrameType::EnergyGradient) => ProtocolState::Ready,
(ProtocolState::Streaming, FrameType::EnergyGradient) => ProtocolState::Streaming,
// Batch: only valid while streaming (contains packed result sub-frames)
(ProtocolState::Streaming, FrameType::Batch) => ProtocolState::Streaming,
// StreamChunk: token-by-token text output, only valid while streaming
(ProtocolState::Streaming, FrameType::StreamChunk) => ProtocolState::Streaming,
// RateLimit: advisory, no state change (valid from Ready or Streaming)
(ProtocolState::Ready, FrameType::RateLimit) => ProtocolState::Ready,
(ProtocolState::Streaming, FrameType::RateLimit) => ProtocolState::Streaming,
// Session lifecycle: revoke/extend only valid from Ready
(ProtocolState::Ready, FrameType::SessionRevoke) => ProtocolState::Ready,
(ProtocolState::Ready, FrameType::SessionExtend) => ProtocolState::Ready,
// Command/CommandResponse: stateless request-response, only from Ready
(ProtocolState::Ready, FrameType::Command) => ProtocolState::Ready,
(ProtocolState::Ready, FrameType::CommandResponse) => ProtocolState::Ready,
// ── Auth frame types ─────────────────────────────────
// Authenticating: challenge/response stay in Authenticating
(ProtocolState::Authenticating, FrameType::AuthChallenge) => {
ProtocolState::Authenticating
}
(ProtocolState::Authenticating, FrameType::AuthResponse) => {
ProtocolState::Authenticating
}
// Authenticating → Ready via AuthSuccess
(ProtocolState::Authenticating, FrameType::AuthSuccess) => ProtocolState::Ready,
// ── Passkey ceremony frames ──────────────────────────
// Passkey register: begin enters Authenticating from Ready or Init
(ProtocolState::Ready, FrameType::PasskeyRegisterBegin) => {
ProtocolState::Authenticating
}
(ProtocolState::Init, FrameType::PasskeyRegisterBegin) => {
ProtocolState::Authenticating
}
// Challenge/complete stay in Authenticating
(ProtocolState::Authenticating, FrameType::PasskeyRegisterChallenge) => {
ProtocolState::Authenticating
}
(ProtocolState::Authenticating, FrameType::PasskeyRegisterComplete) => {
ProtocolState::Authenticating
}
// Passkey login: begin enters Authenticating from Ready or Init
(ProtocolState::Ready, FrameType::PasskeyLoginBegin) => {
ProtocolState::Authenticating
}
(ProtocolState::Init, FrameType::PasskeyLoginBegin) => {
ProtocolState::Authenticating
}
// Challenge/complete stay in Authenticating
(ProtocolState::Authenticating, FrameType::PasskeyLoginChallenge) => {
ProtocolState::Authenticating
}
(ProtocolState::Authenticating, FrameType::PasskeyLoginComplete) => {
ProtocolState::Authenticating
}
// ── Billing frames (request-response, only from Ready) ──
(ProtocolState::Ready, FrameType::BalanceQuery) => ProtocolState::Ready,
(ProtocolState::Ready, FrameType::BalanceResponse) => ProtocolState::Ready,
(ProtocolState::Ready, FrameType::TopupBegin) => ProtocolState::Ready,
(ProtocolState::Ready, FrameType::TopupResponse) => ProtocolState::Ready,
(ProtocolState::Ready, FrameType::UsageQuery) => ProtocolState::Ready,
(ProtocolState::Ready, FrameType::UsageResponse) => ProtocolState::Ready,
// ── Device-code frames ───────────────────────────────
// Device-code bootstrap: valid from Init (before auth)
(ProtocolState::Init, FrameType::DeviceCodeRequest) => ProtocolState::Init,
(ProtocolState::Init, FrameType::DeviceCodeResponse) => ProtocolState::Init,
(ProtocolState::Init, FrameType::DeviceCodePoll) => ProtocolState::Init,
(ProtocolState::Init, FrameType::DeviceCodeResult) => ProtocolState::Init,
// Also valid from Ready (re-auth)
(ProtocolState::Ready, FrameType::DeviceCodeRequest) => ProtocolState::Ready,
(ProtocolState::Ready, FrameType::DeviceCodeResponse) => ProtocolState::Ready,
(ProtocolState::Ready, FrameType::DeviceCodePoll) => ProtocolState::Ready,
(ProtocolState::Ready, FrameType::DeviceCodeResult) => ProtocolState::Ready,
// ── Agent contract lifecycle frames ──────────────────
// ContractPropose: Ready → Contracting (host proposes to agent)
(ProtocolState::Ready, FrameType::ContractPropose) => ProtocolState::Contracting,
// ContractRespond: Contracting → Contracting (counter-propose stays in negotiation)
(ProtocolState::Contracting, FrameType::ContractRespond) => {
ProtocolState::Contracting
}
// ContractPropose again: Contracting → Contracting (revised proposal after counter)
(ProtocolState::Contracting, FrameType::ContractPropose) => {
ProtocolState::Contracting
}
// ContractSigned: Contracting → AgentEngaged (mutual agreement reached)
(ProtocolState::Contracting, FrameType::ContractSigned) => {
ProtocolState::AgentEngaged
}
// ExtensionRequest/Response: only valid while AgentEngaged
(ProtocolState::AgentEngaged, FrameType::ExtensionRequest) => {
ProtocolState::AgentEngaged
}
(ProtocolState::AgentEngaged, FrameType::ExtensionResponse) => {
ProtocolState::AgentEngaged
}
// AgentReturn: AgentEngaged → Ready (agent voluntarily returns)
(ProtocolState::AgentEngaged, FrameType::AgentReturn) => ProtocolState::Ready,
// AgentRecall: AgentEngaged → Ready (host forces return)
(ProtocolState::AgentEngaged, FrameType::AgentRecall) => ProtocolState::Ready,
// Receipt during engagement (intermediate energy receipt)
(ProtocolState::AgentEngaged, FrameType::Receipt) => ProtocolState::AgentEngaged,
// Note: Error during Contracting and AgentEngaged is handled by
// the (_, FrameType::Error) => Ready wildcard above.
// Everything else is invalid
(state, ft) => {
return Err(JwpError::InvalidTransition {
from: state.to_string(),
event: format!("{ft:?}"),
});
}
};
self.state = new_state;
Ok(new_state)
}
/// Force the state machine into a specific state.
/// Used by auth layer to enter `Authenticating` after a Handshake
/// carries a credential.
pub fn force_state(&mut self, state: ProtocolState) {
self.state = state;
}
/// Reset to Init state (e.g., on connection reset).
pub fn reset(&mut self) {
self.state = ProtocolState::Init;
}
}
impl Default for ProtocolStateMachine {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn happy_path_handshake_query_results_done() {
let mut sm = ProtocolStateMachine::new();
assert_eq!(sm.state(), ProtocolState::Init);
sm.transition(FrameType::Handshake).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
sm.transition(FrameType::Query).unwrap();
assert_eq!(sm.state(), ProtocolState::Streaming);
sm.transition(FrameType::Meta).unwrap();
assert_eq!(sm.state(), ProtocolState::Streaming);
sm.transition(FrameType::Result).unwrap();
assert_eq!(sm.state(), ProtocolState::Streaming);
sm.transition(FrameType::Result).unwrap();
assert_eq!(sm.state(), ProtocolState::Streaming);
sm.transition(FrameType::Done).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
}
#[test]
fn cancel_returns_to_ready() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::Query).unwrap();
sm.transition(FrameType::Result).unwrap();
sm.transition(FrameType::Cancel).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
}
#[test]
fn heartbeat_always_valid() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Heartbeat).unwrap();
assert_eq!(sm.state(), ProtocolState::Init);
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::Heartbeat).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
sm.transition(FrameType::Query).unwrap();
sm.transition(FrameType::Heartbeat).unwrap();
assert_eq!(sm.state(), ProtocolState::Streaming);
}
#[test]
fn error_transitions_to_ready() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::Query).unwrap();
sm.transition(FrameType::Error).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
}
#[test]
fn invalid_query_from_init() {
let mut sm = ProtocolStateMachine::new();
assert!(sm.transition(FrameType::Query).is_err());
}
#[test]
fn invalid_handshake_from_ready() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
assert!(sm.transition(FrameType::Handshake).is_err());
}
#[test]
fn multiple_queries() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
// First query
sm.transition(FrameType::Query).unwrap();
sm.transition(FrameType::Done).unwrap();
// Second query
sm.transition(FrameType::Query).unwrap();
sm.transition(FrameType::Result).unwrap();
sm.transition(FrameType::Done).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
}
#[test]
fn reset() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::Query).unwrap();
sm.reset();
assert_eq!(sm.state(), ProtocolState::Init);
}
// ── v2 state machine tests ───────────────────────────────────
#[test]
fn negotiate_ready_to_negotiating_and_back() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
// Request negotiation
sm.transition(FrameType::Negotiate).unwrap();
assert_eq!(sm.state(), ProtocolState::Negotiating);
// Response completes negotiation
sm.transition(FrameType::Negotiate).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
}
#[test]
fn negotiate_error_returns_to_ready() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::Negotiate).unwrap();
assert_eq!(sm.state(), ProtocolState::Negotiating);
sm.transition(FrameType::Error).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
}
#[test]
fn heartbeat_valid_in_negotiating() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::Negotiate).unwrap();
sm.transition(FrameType::Heartbeat).unwrap();
assert_eq!(sm.state(), ProtocolState::Negotiating);
}
#[test]
fn profile_update_no_state_change() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::ProfileUpdate).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
sm.transition(FrameType::Query).unwrap();
sm.transition(FrameType::ProfileUpdate).unwrap();
assert_eq!(sm.state(), ProtocolState::Streaming);
}
#[test]
fn energy_gradient_no_state_change() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::EnergyGradient).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
sm.transition(FrameType::Query).unwrap();
sm.transition(FrameType::EnergyGradient).unwrap();
assert_eq!(sm.state(), ProtocolState::Streaming);
}
#[test]
fn batch_only_valid_in_streaming() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
// Batch from Ready should fail
assert!(sm.transition(FrameType::Batch).is_err());
// Batch from Streaming should succeed
sm.transition(FrameType::Query).unwrap();
sm.transition(FrameType::Batch).unwrap();
assert_eq!(sm.state(), ProtocolState::Streaming);
}
#[test]
fn rate_limit_no_state_change() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
// From Ready
sm.transition(FrameType::RateLimit).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
// From Streaming
sm.transition(FrameType::Query).unwrap();
sm.transition(FrameType::RateLimit).unwrap();
assert_eq!(sm.state(), ProtocolState::Streaming);
}
#[test]
fn rate_limit_invalid_from_init() {
let mut sm = ProtocolStateMachine::new();
assert!(sm.transition(FrameType::RateLimit).is_err());
}
#[test]
fn session_revoke_only_from_ready() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
// Valid from Ready
sm.transition(FrameType::SessionRevoke).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
// Invalid from Streaming
sm.transition(FrameType::Query).unwrap();
assert!(sm.transition(FrameType::SessionRevoke).is_err());
}
#[test]
fn session_extend_only_from_ready() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
// Valid from Ready
sm.transition(FrameType::SessionExtend).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
// Invalid from Init
let mut sm2 = ProtocolStateMachine::new();
assert!(sm2.transition(FrameType::SessionExtend).is_err());
}
// ── Auth state machine tests ──────────────────────────────────
#[test]
fn auth_happy_path_challenge_response_success() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
// Server detects credential in handshake → force into Authenticating
sm.force_state(ProtocolState::Authenticating);
assert_eq!(sm.state(), ProtocolState::Authenticating);
// Server sends challenge
sm.transition(FrameType::AuthChallenge).unwrap();
assert_eq!(sm.state(), ProtocolState::Authenticating);
// Client sends response
sm.transition(FrameType::AuthResponse).unwrap();
assert_eq!(sm.state(), ProtocolState::Authenticating);
// Server sends success → Ready
sm.transition(FrameType::AuthSuccess).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
// Can now proceed with normal queries
sm.transition(FrameType::Query).unwrap();
assert_eq!(sm.state(), ProtocolState::Streaming);
}
#[test]
fn auth_failure_returns_to_init() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.force_state(ProtocolState::Authenticating);
sm.transition(FrameType::AuthChallenge).unwrap();
sm.transition(FrameType::AuthResponse).unwrap();
// Auth fails → Error → Init (must re-handshake)
sm.transition(FrameType::Error).unwrap();
assert_eq!(sm.state(), ProtocolState::Init);
}
#[test]
fn auth_heartbeat_valid_during_authenticating() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.force_state(ProtocolState::Authenticating);
sm.transition(FrameType::Heartbeat).unwrap();
assert_eq!(sm.state(), ProtocolState::Authenticating);
}
#[test]
fn auth_query_invalid_during_authenticating() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.force_state(ProtocolState::Authenticating);
// Can't query while authenticating
assert!(sm.transition(FrameType::Query).is_err());
}
#[test]
fn auth_challenge_invalid_from_ready() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
// AuthChallenge only valid in Authenticating state
assert!(sm.transition(FrameType::AuthChallenge).is_err());
}
#[test]
fn unauthenticated_v1_compat_skips_auth() {
// v1 path: Init → Handshake → Ready (no auth)
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
// Goes straight to queries without touching Authenticating
sm.transition(FrameType::Query).unwrap();
assert_eq!(sm.state(), ProtocolState::Streaming);
}
// ── Command/CommandResponse state machine tests ───────────────
#[test]
fn command_valid_from_ready() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
sm.transition(FrameType::Command).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
sm.transition(FrameType::CommandResponse).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
}
#[test]
fn command_invalid_from_init() {
let mut sm = ProtocolStateMachine::new();
assert!(sm.transition(FrameType::Command).is_err());
}
#[test]
fn command_invalid_from_streaming() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::Query).unwrap();
assert_eq!(sm.state(), ProtocolState::Streaming);
assert!(sm.transition(FrameType::Command).is_err());
}
#[test]
fn command_then_query_works() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
// Command stays Ready
sm.transition(FrameType::Command).unwrap();
sm.transition(FrameType::CommandResponse).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
// Can still do queries
sm.transition(FrameType::Query).unwrap();
assert_eq!(sm.state(), ProtocolState::Streaming);
sm.transition(FrameType::Done).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
// Can do another command
sm.transition(FrameType::Command).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
}
// ── Passkey state machine tests ─────────────────────────────
#[test]
fn passkey_register_happy_path() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
// Begin registration → Authenticating
sm.transition(FrameType::PasskeyRegisterBegin).unwrap();
assert_eq!(sm.state(), ProtocolState::Authenticating);
// Server sends challenge
sm.transition(FrameType::PasskeyRegisterChallenge).unwrap();
assert_eq!(sm.state(), ProtocolState::Authenticating);
// Client completes
sm.transition(FrameType::PasskeyRegisterComplete).unwrap();
assert_eq!(sm.state(), ProtocolState::Authenticating);
// Server sends AuthSuccess → Ready
sm.transition(FrameType::AuthSuccess).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
// Can now query
sm.transition(FrameType::Query).unwrap();
assert_eq!(sm.state(), ProtocolState::Streaming);
}
#[test]
fn passkey_login_happy_path() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::PasskeyLoginBegin).unwrap();
assert_eq!(sm.state(), ProtocolState::Authenticating);
sm.transition(FrameType::PasskeyLoginChallenge).unwrap();
assert_eq!(sm.state(), ProtocolState::Authenticating);
sm.transition(FrameType::PasskeyLoginComplete).unwrap();
assert_eq!(sm.state(), ProtocolState::Authenticating);
sm.transition(FrameType::AuthSuccess).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
}
#[test]
fn passkey_register_failure_returns_to_init() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::PasskeyRegisterBegin).unwrap();
sm.transition(FrameType::PasskeyRegisterChallenge).unwrap();
// Error during auth → Init
sm.transition(FrameType::Error).unwrap();
assert_eq!(sm.state(), ProtocolState::Init);
}
#[test]
fn passkey_register_invalid_from_streaming() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::Query).unwrap();
assert_eq!(sm.state(), ProtocolState::Streaming);
// Can't start passkey from Streaming
assert!(sm.transition(FrameType::PasskeyRegisterBegin).is_err());
}
#[test]
fn passkey_login_from_init() {
// Passkey login directly from Init (no prior handshake credential)
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::PasskeyLoginBegin).unwrap();
assert_eq!(sm.state(), ProtocolState::Authenticating);
sm.transition(FrameType::PasskeyLoginChallenge).unwrap();
sm.transition(FrameType::PasskeyLoginComplete).unwrap();
sm.transition(FrameType::AuthSuccess).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
}
// ── Billing state machine tests ──────────────────────────────
#[test]
fn balance_query_from_ready() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::BalanceQuery).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
sm.transition(FrameType::BalanceResponse).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
}
#[test]
fn topup_from_ready() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::TopupBegin).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
sm.transition(FrameType::TopupResponse).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
}
#[test]
fn usage_query_from_ready() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::UsageQuery).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
sm.transition(FrameType::UsageResponse).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
}
#[test]
fn billing_invalid_from_init() {
let mut sm = ProtocolStateMachine::new();
assert!(sm.transition(FrameType::BalanceQuery).is_err());
assert!(sm.transition(FrameType::TopupBegin).is_err());
assert!(sm.transition(FrameType::UsageQuery).is_err());
}
#[test]
fn billing_invalid_from_streaming() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::Query).unwrap();
assert!(sm.transition(FrameType::BalanceQuery).is_err());
}
#[test]
fn billing_after_passkey_auth() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
// Passkey login
sm.transition(FrameType::PasskeyLoginBegin).unwrap();
sm.transition(FrameType::PasskeyLoginChallenge).unwrap();
sm.transition(FrameType::PasskeyLoginComplete).unwrap();
sm.transition(FrameType::AuthSuccess).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
// Now billing works
sm.transition(FrameType::BalanceQuery).unwrap();
sm.transition(FrameType::BalanceResponse).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
// And queries work
sm.transition(FrameType::Query).unwrap();
assert_eq!(sm.state(), ProtocolState::Streaming);
}
// ── Agent contract lifecycle state machine tests ─────────────
#[test]
fn contract_happy_path_propose_accept_return() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
// Host proposes contract
sm.transition(FrameType::ContractPropose).unwrap();
assert_eq!(sm.state(), ProtocolState::Contracting);
// Agent accepts
sm.transition(FrameType::ContractRespond).unwrap();
assert_eq!(sm.state(), ProtocolState::Contracting);
// Host signs contract
sm.transition(FrameType::ContractSigned).unwrap();
assert_eq!(sm.state(), ProtocolState::AgentEngaged);
// Agent works... then returns
sm.transition(FrameType::AgentReturn).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
}
#[test]
fn contract_counter_propose_then_accept() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
// Host proposes
sm.transition(FrameType::ContractPropose).unwrap();
assert_eq!(sm.state(), ProtocolState::Contracting);
// Agent counter-proposes
sm.transition(FrameType::ContractRespond).unwrap();
assert_eq!(sm.state(), ProtocolState::Contracting);
// Host sends revised proposal
sm.transition(FrameType::ContractPropose).unwrap();
assert_eq!(sm.state(), ProtocolState::Contracting);
// Agent accepts revised
sm.transition(FrameType::ContractRespond).unwrap();
assert_eq!(sm.state(), ProtocolState::Contracting);
// Host signs
sm.transition(FrameType::ContractSigned).unwrap();
assert_eq!(sm.state(), ProtocolState::AgentEngaged);
}
#[test]
fn contract_rejection_via_error() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::ContractPropose).unwrap();
assert_eq!(sm.state(), ProtocolState::Contracting);
// Error during negotiation → back to Ready
sm.transition(FrameType::Error).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
}
#[test]
fn contract_extension_during_engagement() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::ContractPropose).unwrap();
sm.transition(FrameType::ContractRespond).unwrap();
sm.transition(FrameType::ContractSigned).unwrap();
assert_eq!(sm.state(), ProtocolState::AgentEngaged);
// Agent requests extension
sm.transition(FrameType::ExtensionRequest).unwrap();
assert_eq!(sm.state(), ProtocolState::AgentEngaged);
// Host grants
sm.transition(FrameType::ExtensionResponse).unwrap();
assert_eq!(sm.state(), ProtocolState::AgentEngaged);
// Multiple extensions
sm.transition(FrameType::ExtensionRequest).unwrap();
sm.transition(FrameType::ExtensionResponse).unwrap();
assert_eq!(sm.state(), ProtocolState::AgentEngaged);
}
#[test]
fn contract_recall_during_engagement() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::ContractPropose).unwrap();
sm.transition(FrameType::ContractRespond).unwrap();
sm.transition(FrameType::ContractSigned).unwrap();
assert_eq!(sm.state(), ProtocolState::AgentEngaged);
// Host recalls agent
sm.transition(FrameType::AgentRecall).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
}
#[test]
fn contract_receipt_during_engagement() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::ContractPropose).unwrap();
sm.transition(FrameType::ContractRespond).unwrap();
sm.transition(FrameType::ContractSigned).unwrap();
assert_eq!(sm.state(), ProtocolState::AgentEngaged);
// Energy receipts during engagement
sm.transition(FrameType::Receipt).unwrap();
assert_eq!(sm.state(), ProtocolState::AgentEngaged);
}
#[test]
fn contract_error_during_engagement() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::ContractPropose).unwrap();
sm.transition(FrameType::ContractRespond).unwrap();
sm.transition(FrameType::ContractSigned).unwrap();
sm.transition(FrameType::Error).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
}
#[test]
fn contract_heartbeat_during_contracting() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::ContractPropose).unwrap();
sm.transition(FrameType::Heartbeat).unwrap();
assert_eq!(sm.state(), ProtocolState::Contracting);
}
#[test]
fn contract_heartbeat_during_engaged() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::ContractPropose).unwrap();
sm.transition(FrameType::ContractRespond).unwrap();
sm.transition(FrameType::ContractSigned).unwrap();
sm.transition(FrameType::Heartbeat).unwrap();
assert_eq!(sm.state(), ProtocolState::AgentEngaged);
}
#[test]
fn contract_invalid_from_init() {
let mut sm = ProtocolStateMachine::new();
assert!(sm.transition(FrameType::ContractPropose).is_err());
}
#[test]
fn contract_query_invalid_during_engaged() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
sm.transition(FrameType::ContractPropose).unwrap();
sm.transition(FrameType::ContractRespond).unwrap();
sm.transition(FrameType::ContractSigned).unwrap();
// Can't run queries while an agent is engaged
assert!(sm.transition(FrameType::Query).is_err());
}
#[test]
fn contract_then_query_after_return() {
let mut sm = ProtocolStateMachine::new();
sm.transition(FrameType::Handshake).unwrap();
// Full contract lifecycle
sm.transition(FrameType::ContractPropose).unwrap();
sm.transition(FrameType::ContractRespond).unwrap();
sm.transition(FrameType::ContractSigned).unwrap();
sm.transition(FrameType::AgentReturn).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
// Now can do normal queries
sm.transition(FrameType::Query).unwrap();
assert_eq!(sm.state(), ProtocolState::Streaming);
sm.transition(FrameType::Done).unwrap();
assert_eq!(sm.state(), ProtocolState::Ready);
// Or another contract
sm.transition(FrameType::ContractPropose).unwrap();
assert_eq!(sm.state(), ProtocolState::Contracting);
}
}