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]>
982 lines
37 KiB
Rust
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);
|
|
}
|
|
}
|