Files
lanspread/crates/lanspread-peer/src/call_to_play.rs
T
ddidderr 60fd7ba0c2 feat(peer)!: cut over to authenticated catalog sharing
Replace address-only trust and pushed peer state with installation identities,
SPKI-pinned QUIC, candidate-only discovery, and bounded responder-owned
protocol-8 pulls. The runtime now owns each network generation and all admitted
work through shutdown.

Add exact bundled content identities, reproducible manifest publishing,
capability-confined downloads, streaming BLAKE3 verification, quarantine and
retry, and crash-recoverable download and install transactions. Ship generated
fixture catalogs and fail closed when production manifests are absent.

The Tauri backend exposes durable sharing policy, redacted identity state, and
attempt-keyed transfer snapshots. Frontend consumption follows in the next
commit. Repository-wide test certificates and protocol-7 paths are removed.

BREAKING CHANGE: peers must use protocol 8 and exact catalog content artifacts;
protocol-7 frames and shared-certificate identities are no longer accepted.

Test Plan:
- `just test` -- passed on the completed stack (708 workspace tests)
- `just clippy` -- passed on the completed stack
- `just build` -- passed with fixture catalogs on the completed stack
- `just catalog-check-production` -- failed closed because the external
  production manifest corpus is absent
- `git diff --cached --check` -- passed
2026-08-10 13:59:18 +02:00

2519 lines
83 KiB
Rust

//! Direct, author-owned Call-to-Play state.
use std::{
collections::{BTreeMap, HashMap, HashSet},
fmt,
time::{SystemTime, UNIX_EPOCH},
};
use lanspread_proto::{
CallId,
CallNonce,
CallToPlayAction,
CallToPlayAuthorEvent,
CallToPlayAuthorSnapshot,
ControlValidationError,
EventNonce,
MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES,
MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR,
PeerId,
RuntimeSessionId,
};
use crate::peer_db::PeerEndpointGeneration;
pub(crate) const MAX_CALL_TO_PLAY_AUTHORS: usize = 64;
pub(crate) const LOCAL_TERMINAL_EVENT_RESERVE: usize = 1;
pub(crate) const LOCAL_TERMINAL_BYTE_RESERVE: usize = 512;
const EXPIRED_RETENTION_MS: i64 = 5 * 60_000;
const TERMINAL_RETENTION_MS: i64 = 15 * 60_000;
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct CallToPlayLocalIntent {
pub(crate) call_id: Option<CallId>,
pub(crate) action: CallToPlayLocalAction,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum CallToPlayLocalAction {
Create {
game_id: String,
max_players: u16,
scheduled_for: Option<i64>,
deadline: i64,
},
Respond {
ready_at: Option<i64>,
},
Rsvp,
SendMessage {
text: String,
},
Leave,
Cancel,
Start,
AddTime {
deadline: i64,
},
}
impl CallToPlayLocalAction {
fn into_wire(self) -> CallToPlayAction {
match self {
Self::Create {
game_id,
max_players,
scheduled_for,
deadline,
} => CallToPlayAction::Create {
game_id,
max_players,
scheduled_for,
deadline,
},
Self::Respond { ready_at } => CallToPlayAction::Respond { ready_at },
Self::Rsvp => CallToPlayAction::Rsvp,
Self::SendMessage { text } => CallToPlayAction::SendMessage { text },
Self::Leave => CallToPlayAction::Leave,
Self::Cancel => CallToPlayAction::Cancel,
Self::Start => CallToPlayAction::Start,
Self::AddTime { deadline } => CallToPlayAction::AddTime { deadline },
}
}
const fn is_create(&self) -> bool {
matches!(self, Self::Create { .. })
}
const fn is_terminal(&self) -> bool {
matches!(self, Self::Cancel | Self::Start)
}
const fn requires_creator_authority(&self) -> bool {
matches!(self, Self::Cancel | Self::Start | Self::AddTime { .. })
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct CallToPlayReceipt {
pub(crate) call_id: CallId,
pub(crate) event_id: EventNonce,
pub(crate) revision: u64,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct CallToPlayMutation {
pub(crate) revision: u64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct CallToPlayPublication {
pub(crate) view: CallToPlayView,
pub(crate) local_revision: u64,
pub(crate) local_changed: bool,
}
/// A fallibility boundary captured before an atomic remote-state commit.
///
/// Preparation samples the clock and computes any fallible local-pruning
/// candidate without mutating the store. Projection after a remote slice
/// mutation is then infallible and uses this single time boundary.
#[derive(Debug)]
pub(crate) struct PreparedCallToPlayPublication {
now: i64,
base_local_revision: u64,
pruned_local: Option<CallToPlayAuthorSnapshot>,
}
impl PreparedCallToPlayPublication {
#[must_use]
pub(crate) const fn local_changed(&self) -> bool {
self.pruned_local.is_some()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct CallToPlayView {
pub(crate) events: Vec<CallToPlayViewEvent>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct CallToPlayViewEvent {
pub(crate) id: EventNonce,
pub(crate) call_id: CallId,
pub(crate) author_id: PeerId,
pub(crate) author_name: String,
pub(crate) at: i64,
pub(crate) action: CallToPlayAction,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) struct RemoteAuthorState {
pub(crate) endpoint_generation: PeerEndpointGeneration,
pub(crate) runtime_session_id: RuntimeSessionId,
pub(crate) revision: u64,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum CallToPlayValidationError {
Wire(ControlValidationError),
SnapshotTooLarge {
actual: usize,
maximum: usize,
},
SnapshotEncoding,
DuplicateEventId(EventNonce),
DuplicateCreate(CallId),
InvalidEvent {
event_id: EventNonce,
reason: &'static str,
},
UnauthorizedAction {
event_id: EventNonce,
call_id: CallId,
},
MissingCreatorRoot(CallId),
NonMonotonicAuthorHistory,
NonMonotonicCallHistory(CallId),
ActionAfterTerminal(CallId),
}
impl fmt::Display for CallToPlayValidationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Wire(error) => write!(formatter, "{error}"),
Self::SnapshotTooLarge { actual, maximum } => write!(
formatter,
"Call-to-Play author snapshot is {actual} bytes; maximum is {maximum}"
),
Self::SnapshotEncoding => {
formatter.write_str("Call-to-Play author snapshot could not be encoded")
}
Self::DuplicateEventId(event_id) => {
write!(formatter, "duplicate Call-to-Play event ID {event_id}")
}
Self::DuplicateCreate(call_id) => {
write!(formatter, "duplicate Call-to-Play creator root {call_id}")
}
Self::InvalidEvent { event_id, reason } => {
write!(formatter, "invalid Call-to-Play event {event_id}: {reason}")
}
Self::UnauthorizedAction { event_id, call_id } => write!(
formatter,
"Call-to-Play event {event_id} is not authoritative for {call_id}"
),
Self::MissingCreatorRoot(call_id) => {
write!(formatter, "Call-to-Play call {call_id} has no creator root")
}
Self::NonMonotonicAuthorHistory => {
formatter.write_str("Call-to-Play author events are not timestamp ordered")
}
Self::NonMonotonicCallHistory(call_id) => {
write!(
formatter,
"Call-to-Play call {call_id} has non-monotonic history"
)
}
Self::ActionAfterTerminal(call_id) => {
write!(
formatter,
"Call-to-Play call {call_id} has an action after termination"
)
}
}
}
}
impl std::error::Error for CallToPlayValidationError {}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum CallToPlayMutationError {
InvalidIntent(&'static str),
UnknownOrExpiredCall(CallId),
CreatorAuthorityRequired(CallId),
CallAlreadyTerminal(CallId),
EventHistoryFull,
RevisionExhausted,
ClockUnavailable,
EntropyUnavailable,
InvalidSnapshot(CallToPlayValidationError),
}
impl fmt::Display for CallToPlayMutationError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::InvalidIntent(reason) => formatter.write_str(reason),
Self::UnknownOrExpiredCall(call_id) => {
write!(
formatter,
"Call-to-Play call {call_id} is unknown or expired"
)
}
Self::CreatorAuthorityRequired(call_id) => {
write!(formatter, "creator authority is required for {call_id}")
}
Self::CallAlreadyTerminal(call_id) => {
write!(formatter, "Call-to-Play call {call_id} is already terminal")
}
Self::EventHistoryFull => {
formatter.write_str("Call-to-Play local event history is full")
}
Self::RevisionExhausted => {
formatter.write_str("Call-to-Play local revision is exhausted")
}
Self::ClockUnavailable => formatter.write_str("system clock is unavailable"),
Self::EntropyUnavailable => {
formatter.write_str("secure random number generation is unavailable")
}
Self::InvalidSnapshot(error) => write!(formatter, "{error}"),
}
}
}
impl std::error::Error for CallToPlayMutationError {}
#[derive(Debug)]
pub(crate) struct PreparedRemoteAuthor {
author_id: PeerId,
endpoint_generation: PeerEndpointGeneration,
runtime_session_id: RuntimeSessionId,
candidate: PreparedRemoteCandidate,
}
#[derive(Debug)]
enum PreparedRemoteCandidate {
Valid(CallToPlayAuthorSnapshot),
Invalid(CallToPlayValidationError),
}
impl PreparedRemoteAuthor {
/// Performs all allocation, encoding, and semantic validation without a
/// store or peer-database lock. The invalid candidate is retained so the
/// locked observation can apply session-clearing rules atomically.
pub(crate) fn prepare(
author_id: PeerId,
endpoint_generation: PeerEndpointGeneration,
runtime_session_id: RuntimeSessionId,
snapshot: CallToPlayAuthorSnapshot,
) -> Self {
let candidate = match validate_author_snapshot(author_id, &snapshot) {
Ok(()) => PreparedRemoteCandidate::Valid(snapshot),
Err(error) => PreparedRemoteCandidate::Invalid(error),
};
Self {
author_id,
endpoint_generation,
runtime_session_id,
candidate,
}
}
#[must_use]
pub(crate) fn validation_error(&self) -> Option<&CallToPlayValidationError> {
match &self.candidate {
PreparedRemoteCandidate::Valid(_) => None,
PreparedRemoteCandidate::Invalid(error) => Some(error),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) enum ObserveRemoteAuthorOutcome {
Applied {
session_changed: bool,
},
Unchanged {
generation_rebound: bool,
},
IgnoredStale {
generation_rebound: bool,
},
EqualRevisionConflict {
generation_rebound: bool,
},
InvalidCleared(CallToPlayValidationError),
InvalidPreserved {
error: CallToPlayValidationError,
generation_rebound: bool,
},
InvalidAbsent(CallToPlayValidationError),
AtCapacity,
RejectedLocalIdentity,
}
impl ObserveRemoteAuthorOutcome {
#[must_use]
pub(crate) const fn view_changed(&self) -> bool {
matches!(self, Self::Applied { .. } | Self::InvalidCleared(_))
}
}
#[derive(Clone, Debug)]
struct RemoteAuthorSlice {
endpoint_generation: PeerEndpointGeneration,
runtime_session_id: RuntimeSessionId,
snapshot: CallToPlayAuthorSnapshot,
}
#[derive(Debug)]
pub(crate) struct CallToPlayStore {
local_peer_id: PeerId,
local: CallToPlayAuthorSnapshot,
remote: BTreeMap<PeerId, RemoteAuthorSlice>,
last_publication_at: i64,
#[cfg(test)]
projection_count: usize,
}
impl CallToPlayStore {
pub(crate) fn new(
local_peer_id: PeerId,
_runtime_session_id: RuntimeSessionId,
display_name: String,
) -> Result<Self, CallToPlayMutationError> {
let local = CallToPlayAuthorSnapshot {
revision: 0,
display_name,
events: Vec::new(),
};
validate_author_snapshot(local_peer_id, &local)
.map_err(CallToPlayMutationError::InvalidSnapshot)?;
ensure_local_terminal_reserve(&local, false)?;
Ok(Self {
local_peer_id,
local,
remote: BTreeMap::new(),
last_publication_at: 0,
#[cfg(test)]
projection_count: 0,
})
}
pub(crate) fn publish_local(
&mut self,
intent: CallToPlayLocalIntent,
display_name: String,
) -> Result<(CallToPlayReceipt, CallToPlayPublication), CallToPlayMutationError> {
let now = now_ms()?.max(self.last_publication_at);
let event_nonce = EventNonce::from_bytes(random_nonce_bytes()?);
let call_nonce = intent
.action
.is_create()
.then(|| random_nonce_bytes().map(CallNonce::from_bytes))
.transpose()?;
let receipt = self.publish_local_at(intent, display_name, now, call_nonce, event_nonce)?;
Ok((receipt, self.project_publication_at(now, true)))
}
pub(crate) fn set_local_display_name(
&mut self,
display_name: String,
) -> Result<(Option<CallToPlayMutation>, CallToPlayPublication), CallToPlayMutationError> {
let now = now_ms()?.max(self.last_publication_at);
let mutation = self.set_local_display_name_at(display_name, now)?;
let publication = self.project_publication_at(now, mutation.is_some());
Ok((mutation, publication))
}
pub(crate) fn current_publication(
&mut self,
) -> Result<CallToPlayPublication, CallToPlayMutationError> {
let now = now_ms()?.max(self.last_publication_at);
self.publication_at(now)
}
pub(crate) fn current_responder_state(
&mut self,
) -> Result<(u64, Option<CallToPlayPublication>), CallToPlayMutationError> {
let now = now_ms()?.max(self.last_publication_at);
self.responder_state_at(now)
}
pub(crate) fn local_responder_snapshot(
&mut self,
) -> Result<
(CallToPlayAuthorSnapshot, u64, Option<CallToPlayPublication>),
CallToPlayMutationError,
> {
let now = now_ms()?.max(self.last_publication_at);
self.local_responder_snapshot_at(now)
}
fn local_responder_snapshot_at(
&mut self,
now: i64,
) -> Result<
(CallToPlayAuthorSnapshot, u64, Option<CallToPlayPublication>),
CallToPlayMutationError,
> {
let (revision, publication) = self.responder_state_at(now)?;
Ok((self.local.clone(), revision, publication))
}
pub(crate) fn prepare_publication(
&self,
) -> Result<PreparedCallToPlayPublication, CallToPlayMutationError> {
self.prepare_publication_at(now_ms()?.max(self.last_publication_at))
}
fn prepare_publication_at(
&self,
now: i64,
) -> Result<PreparedCallToPlayPublication, CallToPlayMutationError> {
Ok(PreparedCallToPlayPublication {
now,
base_local_revision: self.local.revision,
pruned_local: self.pruned_local_candidate_at(now)?,
})
}
#[must_use]
pub(crate) fn view_from_prepared(
&mut self,
prepared: PreparedCallToPlayPublication,
) -> CallToPlayPublication {
let PreparedCallToPlayPublication {
mut now,
base_local_revision,
pruned_local,
} = prepared;
now = now.max(self.last_publication_at);
let local_changed = if self.local.revision == base_local_revision {
if let Some(pruned_local) = pruned_local {
self.local = pruned_local;
true
} else {
false
}
} else {
false
};
self.project_publication_at(now, local_changed)
}
/// Commits a prepared candidate while the caller holds the peer-database
/// write lock before this store's write lock and has rechecked the pinned
/// endpoint generation.
pub(crate) fn observe_prepared_remote(
&mut self,
prepared: PreparedRemoteAuthor,
) -> ObserveRemoteAuthorOutcome {
let PreparedRemoteAuthor {
author_id,
endpoint_generation,
runtime_session_id,
candidate,
} = prepared;
if author_id == self.local_peer_id {
return ObserveRemoteAuthorOutcome::RejectedLocalIdentity;
}
let snapshot = match candidate {
PreparedRemoteCandidate::Valid(snapshot) => snapshot,
PreparedRemoteCandidate::Invalid(error) => {
let Some(current) = self.remote.get_mut(&author_id) else {
return ObserveRemoteAuthorOutcome::InvalidAbsent(error);
};
if current.runtime_session_id != runtime_session_id {
self.remote.remove(&author_id);
return ObserveRemoteAuthorOutcome::InvalidCleared(error);
}
let generation_rebound = current.endpoint_generation != endpoint_generation;
current.endpoint_generation = endpoint_generation;
return ObserveRemoteAuthorOutcome::InvalidPreserved {
error,
generation_rebound,
};
}
};
if let Some(current) = self.remote.get_mut(&author_id) {
let session_changed = current.runtime_session_id != runtime_session_id;
if session_changed || snapshot.revision > current.snapshot.revision {
*current = RemoteAuthorSlice {
endpoint_generation,
runtime_session_id,
snapshot,
};
return ObserveRemoteAuthorOutcome::Applied { session_changed };
}
let generation_rebound = current.endpoint_generation != endpoint_generation;
current.endpoint_generation = endpoint_generation;
if snapshot.revision < current.snapshot.revision {
return ObserveRemoteAuthorOutcome::IgnoredStale { generation_rebound };
}
return if snapshot == current.snapshot {
ObserveRemoteAuthorOutcome::Unchanged { generation_rebound }
} else {
ObserveRemoteAuthorOutcome::EqualRevisionConflict { generation_rebound }
};
}
if self.remote.len() >= MAX_CALL_TO_PLAY_AUTHORS - 1 {
return ObserveRemoteAuthorOutcome::AtCapacity;
}
self.remote.insert(
author_id,
RemoteAuthorSlice {
endpoint_generation,
runtime_session_id,
snapshot,
},
);
ObserveRemoteAuthorOutcome::Applied {
session_changed: true,
}
}
/// Clears every remote author and returns the resulting full, local-only
/// publication.
///
/// The operation is infallible: a failed clock sample or normal local
/// prune is returned as the optional diagnostic after the remote slices
/// have still been cleared and projected. Such a failure preserves the
/// local author exactly. On success, the local revision changes only when
/// ordinary retention pruning requires it.
#[must_use = "the replacement publication and any maintenance diagnostic must be handled"]
pub(crate) fn clear_remote_authors_and_project(
&mut self,
) -> (CallToPlayPublication, Option<CallToPlayMutationError>) {
self.clear_remote_authors_and_project_at(now_ms())
}
fn clear_remote_authors_and_project_at(
&mut self,
now: Result<i64, CallToPlayMutationError>,
) -> (CallToPlayPublication, Option<CallToPlayMutationError>) {
let now = match now {
Ok(now) => now.max(self.last_publication_at),
Err(error) => {
self.remote.clear();
let fallback_now = self.last_publication_at;
let publication = self.project_publication_at(fallback_now, false);
return (publication, Some(error));
}
};
let pruned_local = self.pruned_local_candidate_at(now);
self.remote.clear();
match pruned_local {
Ok(pruned_local) => {
let local_changed = pruned_local.is_some();
if let Some(pruned_local) = pruned_local {
self.local = pruned_local;
}
(self.project_publication_at(now, local_changed), None)
}
Err(error) => (self.project_publication_at(now, false), Some(error)),
}
}
pub(crate) fn remove_remote_author_if_generation(
&mut self,
author_id: PeerId,
endpoint_generation: PeerEndpointGeneration,
) -> bool {
if self
.remote
.get(&author_id)
.is_none_or(|slice| slice.endpoint_generation != endpoint_generation)
{
return false;
}
self.remote.remove(&author_id).is_some()
}
#[must_use]
pub(crate) fn remote_author_state(&self, author_id: PeerId) -> Option<RemoteAuthorState> {
self.remote.get(&author_id).map(|slice| RemoteAuthorState {
endpoint_generation: slice.endpoint_generation,
runtime_session_id: slice.runtime_session_id,
revision: slice.snapshot.revision,
})
}
#[must_use]
#[cfg(test)]
pub(crate) fn remote_author_count(&self) -> usize {
self.remote.len()
}
fn publish_local_at(
&mut self,
intent: CallToPlayLocalIntent,
display_name: String,
now: i64,
call_nonce: Option<CallNonce>,
event_nonce: EventNonce,
) -> Result<CallToPlayReceipt, CallToPlayMutationError> {
if now <= 0 {
return Err(CallToPlayMutationError::ClockUnavailable);
}
let CallToPlayLocalIntent { call_id, action } = intent;
let is_create = action.is_create();
let is_terminal = action.is_terminal();
let requires_creator_authority = action.requires_creator_authority();
let call_id = if is_create {
if call_id.is_some() {
return Err(CallToPlayMutationError::InvalidIntent(
"Create must not supply a call ID",
));
}
CallId::new(
self.local_peer_id,
call_nonce.ok_or(CallToPlayMutationError::EntropyUnavailable)?,
)
} else {
call_id.ok_or(CallToPlayMutationError::InvalidIntent(
"non-Create action requires a call ID",
))?
};
if requires_creator_authority && call_id.creator != self.local_peer_id {
return Err(CallToPlayMutationError::CreatorAuthorityRequired(call_id));
}
let retained_events = self.retained_local_events_at(now);
let event_at = retained_events
.last()
.map_or(now, |event| now.max(event.at));
if !is_create {
let Some(window) = self.call_window_at(call_id, now, &retained_events) else {
return Err(CallToPlayMutationError::UnknownOrExpiredCall(call_id));
};
if window.terminal_at.is_some() {
return Err(CallToPlayMutationError::CallAlreadyTerminal(call_id));
}
}
let mut candidate = CallToPlayAuthorSnapshot {
revision: self
.local
.revision
.checked_add(1)
.ok_or(CallToPlayMutationError::RevisionExhausted)?,
display_name,
events: retained_events,
};
candidate.events.push(CallToPlayAuthorEvent {
id: event_nonce,
call_id,
at: event_at,
action: action.into_wire(),
});
validate_author_snapshot(self.local_peer_id, &candidate)
.map_err(CallToPlayMutationError::InvalidSnapshot)?;
ensure_local_terminal_reserve(&candidate, is_terminal)?;
self.local = candidate;
Ok(CallToPlayReceipt {
call_id,
event_id: event_nonce,
revision: self.local.revision,
})
}
fn set_local_display_name_at(
&mut self,
display_name: String,
now: i64,
) -> Result<Option<CallToPlayMutation>, CallToPlayMutationError> {
let retained_events = self.retained_local_events_at(now);
if display_name == self.local.display_name && retained_events == self.local.events {
return Ok(None);
}
let candidate = CallToPlayAuthorSnapshot {
revision: self
.local
.revision
.checked_add(1)
.ok_or(CallToPlayMutationError::RevisionExhausted)?,
display_name,
events: retained_events,
};
validate_author_snapshot(self.local_peer_id, &candidate)
.map_err(CallToPlayMutationError::InvalidSnapshot)?;
self.local = candidate;
Ok(Some(CallToPlayMutation {
revision: self.local.revision,
}))
}
fn prune_local_at(
&mut self,
now: i64,
) -> Result<Option<CallToPlayMutation>, CallToPlayMutationError> {
let Some(candidate) = self.pruned_local_candidate_at(now)? else {
return Ok(None);
};
self.local = candidate;
Ok(Some(CallToPlayMutation {
revision: self.local.revision,
}))
}
fn pruned_local_candidate_at(
&self,
now: i64,
) -> Result<Option<CallToPlayAuthorSnapshot>, CallToPlayMutationError> {
let expired = self.expired_local_call_ids_at(now);
if expired.is_empty() {
return Ok(None);
}
let retained_events = self
.local
.events
.iter()
.filter(|event| !expired.contains(&event.call_id))
.cloned()
.collect();
let candidate = CallToPlayAuthorSnapshot {
revision: self
.local
.revision
.checked_add(1)
.ok_or(CallToPlayMutationError::RevisionExhausted)?,
display_name: self.local.display_name.clone(),
events: retained_events,
};
validate_author_snapshot(self.local_peer_id, &candidate)
.map_err(CallToPlayMutationError::InvalidSnapshot)?;
Ok(Some(candidate))
}
#[cfg(test)]
fn local_snapshot_at(
&mut self,
now: i64,
) -> Result<CallToPlayAuthorSnapshot, CallToPlayMutationError> {
self.prune_local_at(now)?;
Ok(self.local.clone())
}
#[cfg(test)]
fn view_at(&mut self, now: i64) -> Result<CallToPlayView, CallToPlayMutationError> {
Ok(self.publication_at(now)?.view)
}
fn publication_at(
&mut self,
now: i64,
) -> Result<CallToPlayPublication, CallToPlayMutationError> {
let local_changed = self.prune_local_at(now)?.is_some();
Ok(self.project_publication_at(now, local_changed))
}
fn responder_state_at(
&mut self,
now: i64,
) -> Result<(u64, Option<CallToPlayPublication>), CallToPlayMutationError> {
let local_changed = self.prune_local_at(now)?.is_some();
if !local_changed {
return Ok((self.local.revision, None));
}
let publication = self.project_publication_at(now, true);
Ok((publication.local_revision, Some(publication)))
}
fn project_publication_at(&mut self, now: i64, local_changed: bool) -> CallToPlayPublication {
let now = now.max(self.last_publication_at);
self.last_publication_at = now;
#[cfg(test)]
{
self.projection_count += 1;
}
CallToPlayPublication {
view: self.project_view_at(now),
local_revision: self.local.revision,
local_changed,
}
}
fn project_view_at(&self, now: i64) -> CallToPlayView {
let windows = self.call_windows();
let mut events = Vec::new();
Self::extend_visible_author_events(
self.local_peer_id,
&self.local,
now,
&windows,
&mut events,
);
for (author_id, slice) in &self.remote {
Self::extend_visible_author_events(
*author_id,
&slice.snapshot,
now,
&windows,
&mut events,
);
}
events.sort_by_key(|event| (event.at, event.call_id, event.author_id, event.id));
CallToPlayView { events }
}
fn extend_visible_author_events(
author_id: PeerId,
snapshot: &CallToPlayAuthorSnapshot,
now: i64,
windows: &HashMap<CallId, CallWindow>,
output: &mut Vec<CallToPlayViewEvent>,
) {
for event in &snapshot.events {
let Some(window) = windows.get(&event.call_id) else {
continue;
};
if !window.is_visible_at(now)
|| event.at < window.created_at
|| window
.terminal_at
.is_some_and(|terminal_at| event.at > terminal_at)
{
continue;
}
output.push(CallToPlayViewEvent {
id: event.id,
call_id: event.call_id,
author_id,
author_name: snapshot.display_name.clone(),
at: event.at,
action: event.action.clone(),
});
}
}
fn retained_local_events_at(&self, now: i64) -> Vec<CallToPlayAuthorEvent> {
let expired = self.expired_local_call_ids_at(now);
self.local
.events
.iter()
.filter(|event| !expired.contains(&event.call_id))
.cloned()
.collect()
}
fn expired_local_call_ids_at(&self, now: i64) -> HashSet<CallId> {
let local_call_ids = self
.local
.events
.iter()
.map(|event| event.call_id)
.collect::<HashSet<_>>();
if local_call_ids.is_empty() {
return HashSet::new();
}
self.call_windows()
.into_iter()
.filter_map(|(call_id, window)| {
(local_call_ids.contains(&call_id) && !window.is_visible_at(now)).then_some(call_id)
})
.collect()
}
fn call_window_at(
&self,
call_id: CallId,
now: i64,
local_events: &[CallToPlayAuthorEvent],
) -> Option<CallWindow> {
let window = if call_id.creator == self.local_peer_id {
call_window_from_creator_events(call_id, local_events)
} else {
self.remote
.get(&call_id.creator)
.and_then(|slice| call_window_from_creator_events(call_id, &slice.snapshot.events))
}?;
window.is_visible_at(now).then_some(window)
}
fn call_windows(&self) -> HashMap<CallId, CallWindow> {
let mut windows = call_windows_from_creator(self.local_peer_id, &self.local.events);
for (author_id, slice) in &self.remote {
windows.extend(call_windows_from_creator(
*author_id,
&slice.snapshot.events,
));
}
windows
}
}
fn validate_author_snapshot(
author_id: PeerId,
snapshot: &CallToPlayAuthorSnapshot,
) -> Result<(), CallToPlayValidationError> {
snapshot
.validate()
.map_err(CallToPlayValidationError::Wire)?;
let encoded_size = serde_json::to_vec(snapshot)
.map_err(|_| CallToPlayValidationError::SnapshotEncoding)?
.len();
if encoded_size > MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES {
return Err(CallToPlayValidationError::SnapshotTooLarge {
actual: encoded_size,
maximum: MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES,
});
}
let mut event_ids = HashSet::with_capacity(snapshot.events.len());
let mut creator_calls = HashMap::<CallId, CreatorValidationState>::new();
if snapshot
.events
.windows(2)
.any(|events| events[0].at > events[1].at)
{
return Err(CallToPlayValidationError::NonMonotonicAuthorHistory);
}
for event in &snapshot.events {
if !event_ids.insert(event.id) {
return Err(CallToPlayValidationError::DuplicateEventId(event.id));
}
validate_event_fields(event)?;
if is_creator_only_action(&event.action) && event.call_id.creator != author_id {
return Err(CallToPlayValidationError::UnauthorizedAction {
event_id: event.id,
call_id: event.call_id,
});
}
if let CallToPlayAction::Create { deadline, .. } = event.action
&& creator_calls
.insert(
event.call_id,
CreatorValidationState {
created_at: event.at,
last_at: event.at,
deadline,
terminal: false,
},
)
.is_some()
{
return Err(CallToPlayValidationError::DuplicateCreate(event.call_id));
}
}
let mut seen_roots = HashSet::new();
for event in &snapshot.events {
if event.call_id.creator != author_id {
continue;
}
if matches!(event.action, CallToPlayAction::Create { .. }) {
seen_roots.insert(event.call_id);
continue;
}
if !seen_roots.contains(&event.call_id) {
return Err(CallToPlayValidationError::MissingCreatorRoot(event.call_id));
}
let state = creator_calls
.get_mut(&event.call_id)
.ok_or(CallToPlayValidationError::MissingCreatorRoot(event.call_id))?;
if event.at < state.created_at || event.at < state.last_at {
return Err(CallToPlayValidationError::NonMonotonicCallHistory(
event.call_id,
));
}
if state.terminal {
return Err(CallToPlayValidationError::ActionAfterTerminal(
event.call_id,
));
}
if let CallToPlayAction::AddTime { deadline } = event.action {
if deadline <= state.deadline {
return Err(CallToPlayValidationError::InvalidEvent {
event_id: event.id,
reason: "AddTime must extend the current deadline",
});
}
state.deadline = deadline;
}
if matches!(
event.action,
CallToPlayAction::Cancel | CallToPlayAction::Start
) {
state.terminal = true;
}
state.last_at = event.at;
}
Ok(())
}
fn validate_event_fields(event: &CallToPlayAuthorEvent) -> Result<(), CallToPlayValidationError> {
event.validate().map_err(CallToPlayValidationError::Wire)?;
if event.at <= 0 {
return Err(CallToPlayValidationError::InvalidEvent {
event_id: event.id,
reason: "event timestamp must be positive",
});
}
match &event.action {
CallToPlayAction::Create {
max_players,
scheduled_for,
deadline,
..
} => {
if !(2..=64).contains(max_players) {
return Err(CallToPlayValidationError::InvalidEvent {
event_id: event.id,
reason: "max players must be between 2 and 64",
});
}
if *deadline <= event.at {
return Err(CallToPlayValidationError::InvalidEvent {
event_id: event.id,
reason: "deadline must be after creation",
});
}
if scheduled_for.is_some_and(|scheduled| scheduled != *deadline) {
return Err(CallToPlayValidationError::InvalidEvent {
event_id: event.id,
reason: "scheduled call deadline must match its start time",
});
}
checked_retention_boundary(*deadline, EXPIRED_RETENTION_MS, event.id)?;
}
CallToPlayAction::Respond { ready_at } => {
if ready_at.is_some_and(|ready| ready < event.at) {
return Err(CallToPlayValidationError::InvalidEvent {
event_id: event.id,
reason: "ready time cannot be before the response",
});
}
}
CallToPlayAction::AddTime { deadline } => {
if *deadline <= event.at {
return Err(CallToPlayValidationError::InvalidEvent {
event_id: event.id,
reason: "extended deadline must be after the action",
});
}
checked_retention_boundary(*deadline, EXPIRED_RETENTION_MS, event.id)?;
}
CallToPlayAction::Cancel | CallToPlayAction::Start => {
checked_retention_boundary(event.at, TERMINAL_RETENTION_MS, event.id)?;
}
CallToPlayAction::Rsvp | CallToPlayAction::SendMessage { .. } | CallToPlayAction::Leave => {
}
}
Ok(())
}
fn checked_retention_boundary(
timestamp: i64,
retention: i64,
event_id: EventNonce,
) -> Result<i64, CallToPlayValidationError> {
timestamp
.checked_add(retention)
.ok_or(CallToPlayValidationError::InvalidEvent {
event_id,
reason: "timestamp overflows its retention boundary",
})
}
const fn is_creator_only_action(action: &CallToPlayAction) -> bool {
matches!(
action,
CallToPlayAction::Create { .. }
| CallToPlayAction::Cancel
| CallToPlayAction::Start
| CallToPlayAction::AddTime { .. }
)
}
fn ensure_local_terminal_reserve(
snapshot: &CallToPlayAuthorSnapshot,
terminal_action: bool,
) -> Result<(), CallToPlayMutationError> {
let event_limit = if terminal_action {
MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR
} else {
MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR - LOCAL_TERMINAL_EVENT_RESERVE
};
if snapshot.events.len() > event_limit {
return Err(CallToPlayMutationError::EventHistoryFull);
}
let byte_limit = if terminal_action {
MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES
} else {
MAX_CALL_TO_PLAY_AUTHOR_SNAPSHOT_BYTES - LOCAL_TERMINAL_BYTE_RESERVE
};
let encoded_size = serde_json::to_vec(snapshot)
.map_err(|_| {
CallToPlayMutationError::InvalidSnapshot(CallToPlayValidationError::SnapshotEncoding)
})?
.len();
if encoded_size > byte_limit {
return Err(CallToPlayMutationError::EventHistoryFull);
}
Ok(())
}
#[derive(Clone, Copy, Debug)]
struct CreatorValidationState {
created_at: i64,
last_at: i64,
deadline: i64,
terminal: bool,
}
#[derive(Clone, Copy, Debug)]
struct CallWindow {
created_at: i64,
deadline: i64,
terminal_at: Option<i64>,
}
impl CallWindow {
fn is_visible_at(self, now: i64) -> bool {
let boundary = self.terminal_at.map_or_else(
|| {
self.deadline
.checked_add(EXPIRED_RETENTION_MS)
.expect("validated deadline retention cannot overflow")
},
|terminal_at| {
terminal_at
.checked_add(TERMINAL_RETENTION_MS)
.expect("validated terminal retention cannot overflow")
},
);
now <= boundary
}
}
fn call_windows_from_creator(
creator: PeerId,
events: &[CallToPlayAuthorEvent],
) -> HashMap<CallId, CallWindow> {
let mut windows = HashMap::new();
for event in events {
if event.call_id.creator != creator {
continue;
}
match event.action {
CallToPlayAction::Create { deadline, .. } => {
windows.insert(
event.call_id,
CallWindow {
created_at: event.at,
deadline,
terminal_at: None,
},
);
}
CallToPlayAction::AddTime { deadline } => {
if let Some(window) = windows.get_mut(&event.call_id) {
window.deadline = deadline;
}
}
CallToPlayAction::Cancel | CallToPlayAction::Start => {
if let Some(window) = windows.get_mut(&event.call_id) {
window.terminal_at = Some(event.at);
}
}
CallToPlayAction::Respond { .. }
| CallToPlayAction::Rsvp
| CallToPlayAction::SendMessage { .. }
| CallToPlayAction::Leave => {}
}
}
windows
}
fn call_window_from_creator_events(
call_id: CallId,
events: &[CallToPlayAuthorEvent],
) -> Option<CallWindow> {
call_windows_from_creator(call_id.creator, events).remove(&call_id)
}
fn now_ms() -> Result<i64, CallToPlayMutationError> {
let millis = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_err(|_| CallToPlayMutationError::ClockUnavailable)?
.as_millis();
i64::try_from(millis).map_err(|_| CallToPlayMutationError::ClockUnavailable)
}
fn random_nonce_bytes() -> Result<[u8; 16], CallToPlayMutationError> {
let mut bytes = [0_u8; 16];
rustls::crypto::aws_lc_rs::default_provider()
.secure_random
.fill(&mut bytes)
.map_err(|_| CallToPlayMutationError::EntropyUnavailable)?;
Ok(bytes)
}
#[cfg(test)]
mod tests {
use std::net::SocketAddr;
use lanspread_proto::{
CallToPlayAuthorSnapshot,
ControlValidationError,
LibrarySnapshot,
MAX_CALL_TO_PLAY_DISPLAY_NAME_CHARS,
PeerEndpoint,
};
use super::*;
use crate::peer_db::PeerGameDB;
const NOW: i64 = 8_000_000_000_000;
fn peer(seed: u8) -> PeerId {
PeerId::from_bytes([seed; 32])
}
fn session(seed: u8) -> RuntimeSessionId {
RuntimeSessionId::from_bytes([seed; 16])
}
fn nonce(value: u128) -> [u8; 16] {
value.to_be_bytes()
}
fn event_nonce(value: u128) -> EventNonce {
EventNonce::from_bytes(nonce(value))
}
fn call_nonce(value: u128) -> CallNonce {
CallNonce::from_bytes(nonce(value))
}
fn store(local_peer: PeerId) -> CallToPlayStore {
CallToPlayStore::new(local_peer, session(1), "Local".to_owned())
.expect("test store should be valid")
}
fn snapshot(
revision: u64,
display_name: &str,
events: Vec<CallToPlayAuthorEvent>,
) -> CallToPlayAuthorSnapshot {
CallToPlayAuthorSnapshot {
revision,
display_name: display_name.to_owned(),
events,
}
}
fn create_event(
creator: PeerId,
call_id: CallId,
id: u128,
at: i64,
deadline: i64,
) -> CallToPlayAuthorEvent {
assert_eq!(creator, call_id.creator);
CallToPlayAuthorEvent {
id: event_nonce(id),
call_id,
at,
action: CallToPlayAction::Create {
game_id: "game".to_owned(),
max_players: 4,
scheduled_for: None,
deadline,
},
}
}
fn action_event(
call_id: CallId,
id: u128,
at: i64,
action: CallToPlayAction,
) -> CallToPlayAuthorEvent {
CallToPlayAuthorEvent {
id: event_nonce(id),
call_id,
at,
action,
}
}
fn endpoint_generations(count: usize) -> Vec<PeerEndpointGeneration> {
let mut db = PeerGameDB::new();
let endpoint = PeerEndpoint::new(peer(250), SocketAddr::from(([127, 0, 0, 1], 31_337)));
(0..count)
.map(|index| {
let ticket = db
.begin_candidate_negotiation(endpoint)
.expect("candidate ticket");
db.commit_authenticated_snapshot(
endpoint,
ticket,
RuntimeSessionId::from_bytes(nonce(index as u128)),
Some(LibrarySnapshot {
revision: index as u64,
games: Vec::new(),
}),
)
.expect("commit should succeed")
.expect("ticket should remain current")
.endpoint_generation
})
.collect()
}
fn prepare(
author: PeerId,
generation: PeerEndpointGeneration,
runtime_session_id: RuntimeSessionId,
snapshot: CallToPlayAuthorSnapshot,
) -> PreparedRemoteAuthor {
PreparedRemoteAuthor::prepare(author, generation, runtime_session_id, snapshot)
}
fn create_intent(deadline: i64) -> CallToPlayLocalIntent {
CallToPlayLocalIntent {
call_id: None,
action: CallToPlayLocalAction::Create {
game_id: "game".to_owned(),
max_players: 4,
scheduled_for: None,
deadline,
},
}
}
fn intent(call_id: CallId, action: CallToPlayLocalAction) -> CallToPlayLocalIntent {
CallToPlayLocalIntent {
call_id: Some(call_id),
action,
}
}
#[test]
fn local_publish_generates_typed_ids_and_one_revision() {
let local = peer(1);
let mut store = store(local);
let receipt = store
.publish_local_at(
create_intent(NOW + 60_000),
"Alice".to_owned(),
NOW,
Some(call_nonce(7)),
event_nonce(8),
)
.expect("valid Create should publish");
assert_eq!(receipt.call_id, CallId::new(local, call_nonce(7)));
assert_eq!(receipt.event_id, event_nonce(8));
assert_eq!(receipt.revision, 1);
let local_snapshot = store.local_snapshot_at(NOW).expect("snapshot");
assert_eq!(local_snapshot.revision, 1);
assert_eq!(local_snapshot.display_name, "Alice");
assert_eq!(local_snapshot.events.len(), 1);
assert_eq!(local_snapshot.events[0].at, NOW);
let remote_call = CallId::new(peer(2), call_nonce(9));
let before = store.local_snapshot_at(NOW).expect("snapshot");
assert_eq!(
store.publish_local_at(
intent(remote_call, CallToPlayLocalAction::Start),
"Alice".to_owned(),
NOW + 1,
None,
event_nonce(10),
),
Err(CallToPlayMutationError::CreatorAuthorityRequired(
remote_call
))
);
assert_eq!(store.local_snapshot_at(NOW).expect("snapshot"), before);
for invalid in [
CallToPlayLocalIntent {
call_id: Some(receipt.call_id),
action: CallToPlayLocalAction::Create {
game_id: "game".to_owned(),
max_players: 4,
scheduled_for: None,
deadline: NOW + 60_000,
},
},
CallToPlayLocalIntent {
call_id: None,
action: CallToPlayLocalAction::Rsvp,
},
] {
assert!(matches!(
store.publish_local_at(
invalid,
"Alice".to_owned(),
NOW + 1,
Some(call_nonce(11)),
event_nonce(12),
),
Err(CallToPlayMutationError::InvalidIntent(_))
));
assert_eq!(store.local_snapshot_at(NOW).expect("snapshot"), before);
}
}
#[test]
fn display_name_only_change_is_bounded_and_published() {
let mut store = store(peer(1));
assert_eq!(store.local.revision, 0);
assert_eq!(
store
.set_local_display_name_at("Alice".to_owned(), NOW)
.expect("valid name"),
Some(CallToPlayMutation { revision: 1 })
);
assert_eq!(
store
.set_local_display_name_at("Alice".to_owned(), NOW)
.expect("same name is a no-op"),
None
);
let before = store.local_snapshot_at(NOW).expect("snapshot");
assert!(
store
.set_local_display_name_at(
"x".repeat(MAX_CALL_TO_PLAY_DISPLAY_NAME_CHARS + 1),
NOW,
)
.is_err()
);
assert_eq!(store.local_snapshot_at(NOW).expect("snapshot"), before);
}
#[test]
fn terminal_reserve_keeps_one_settlement_slot() {
for terminal in [CallToPlayLocalAction::Start, CallToPlayLocalAction::Cancel] {
let local = peer(1);
let call_id = CallId::new(local, call_nonce(1));
let mut store = store(local);
let mut events = Vec::with_capacity(MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR - 1);
events.push(create_event(local, call_id, 1, NOW, NOW + 60_000));
events.extend((2..MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR as u128).map(|id| {
action_event(
call_id,
id,
NOW + i64::try_from(id).expect("event index fits in i64"),
CallToPlayAction::Rsvp,
)
}));
store.local.events = events;
store.local.revision = 1;
validate_author_snapshot(local, &store.local).expect("full reserved history is valid");
assert_eq!(
store.publish_local_at(
intent(call_id, CallToPlayLocalAction::Rsvp),
"Local".to_owned(),
NOW + 50_000,
None,
event_nonce(10_000),
),
Err(CallToPlayMutationError::EventHistoryFull)
);
let receipt = store
.publish_local_at(
intent(call_id, terminal),
"Local".to_owned(),
NOW + 50_000,
None,
event_nonce(10_001),
)
.expect("terminal action must use the reserved slot");
assert_eq!(receipt.revision, 2);
assert_eq!(store.local.events.len(), MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR);
assert_eq!(
store
.set_local_display_name_at("Renamed".to_owned(), NOW + 50_001)
.expect("a non-event mutation does not consume another event slot"),
Some(CallToPlayMutation { revision: 3 })
);
assert_eq!(store.local.events.len(), MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR);
}
}
#[test]
fn pruning_keeps_exact_boundaries_then_removes_without_tombstones_and_bumps() {
let local = peer(1);
let mut unresolved = store(local);
let deadline = NOW + 1_000;
unresolved
.publish_local_at(
create_intent(deadline),
"Local".to_owned(),
NOW,
Some(call_nonce(1)),
event_nonce(1),
)
.expect("create");
assert_eq!(
unresolved
.local_snapshot_at(deadline + EXPIRED_RETENTION_MS)
.expect("exact boundary")
.events
.len(),
1
);
let pruned = unresolved
.local_snapshot_at(deadline + EXPIRED_RETENTION_MS + 1)
.expect("past boundary");
assert!(pruned.events.is_empty());
assert_eq!(pruned.revision, 2);
let mut terminal = store(local);
let create = terminal
.publish_local_at(
create_intent(NOW + 60_000),
"Local".to_owned(),
NOW,
Some(call_nonce(2)),
event_nonce(2),
)
.expect("create");
terminal
.publish_local_at(
intent(create.call_id, CallToPlayLocalAction::Start),
"Local".to_owned(),
NOW + 10,
None,
event_nonce(3),
)
.expect("start");
assert_eq!(
terminal
.local_snapshot_at(NOW + 10 + TERMINAL_RETENTION_MS)
.expect("exact terminal boundary")
.events
.len(),
2
);
let pruned = terminal
.local_snapshot_at(NOW + 10 + TERMINAL_RETENTION_MS + 1)
.expect("past terminal boundary");
assert!(pruned.events.is_empty(), "no terminal tombstone remains");
assert_eq!(pruned.revision, 3);
}
#[test]
fn responder_reads_project_only_when_local_pruning_changes_state() {
let local = peer(1);
let deadline = NOW + 1_000;
let boundary = deadline + EXPIRED_RETENTION_MS;
let mut store = store(local);
store
.publish_local_at(
create_intent(deadline),
"Local".to_owned(),
NOW,
Some(call_nonce(1)),
event_nonce(1),
)
.expect("create");
let (revision, publication) = store
.responder_state_at(boundary)
.expect("exact-boundary Pong state");
assert_eq!(revision, 1);
assert!(publication.is_none());
assert_eq!(store.projection_count, 0);
let (snapshot, revision, publication) = store
.local_responder_snapshot_at(boundary)
.expect("exact-boundary Hello state");
assert_eq!(snapshot.revision, revision);
assert!(publication.is_none());
assert_eq!(store.projection_count, 0);
let (revision, publication) = store
.responder_state_at(boundary + 1)
.expect("expired Pong state");
let publication = publication.expect("a local prune requires one full publication");
assert_eq!(revision, 2);
assert_eq!(publication.local_revision, revision);
assert!(publication.local_changed);
assert!(publication.view.events.is_empty());
assert_eq!(store.projection_count, 1);
let (snapshot, revision, publication) = store
.local_responder_snapshot_at(boundary + 1)
.expect("already-pruned Hello state");
assert_eq!(snapshot.revision, revision);
assert_eq!(revision, 2);
assert!(publication.is_none());
assert_eq!(store.projection_count, 1);
}
#[test]
fn responder_snapshot_excludes_remote_author_slices_from_full_local_view() {
let local = peer(1);
let participant = peer(2);
let generation = endpoint_generations(1)[0];
let mut store = store(local);
let create = store
.publish_local_at(
create_intent(NOW + 60_000),
"Alice".to_owned(),
NOW,
Some(call_nonce(1)),
event_nonce(1),
)
.expect("local Create should publish");
assert!(matches!(
store.observe_prepared_remote(prepare(
participant,
generation,
session(2),
snapshot(
1,
"Bob",
vec![action_event(
create.call_id,
2,
NOW + 1,
CallToPlayAction::Rsvp,
)],
),
)),
ObserveRemoteAuthorOutcome::Applied { .. }
));
let full_view = store.view_at(NOW + 2).expect("full local view");
assert_eq!(full_view.events.len(), 2);
assert_eq!(full_view.events[0].author_id, local);
assert_eq!(full_view.events[1].author_id, participant);
let (responder_snapshot, revision, publication) = store
.local_responder_snapshot_at(NOW + 2)
.expect("local responder snapshot");
assert_eq!(responder_snapshot.revision, revision);
assert!(publication.is_none());
assert_eq!(
responder_snapshot
.events
.iter()
.map(|event| event.id)
.collect::<Vec<_>>(),
[event_nonce(1)]
);
}
#[test]
fn bulk_remote_clear_preserves_local_author_and_projects_a_local_only_view() {
let creator = peer(1);
let participant = peer(2);
let local = peer(3);
let generations = endpoint_generations(2);
let remote_call = CallId::new(creator, call_nonce(1));
let mut store = store(local);
store.observe_prepared_remote(prepare(
creator,
generations[0],
session(2),
snapshot(
1,
"Alice",
vec![create_event(creator, remote_call, 1, NOW, NOW + 60_000)],
),
));
let local_call = store
.publish_local_at(
create_intent(NOW + 60_000),
"Local".to_owned(),
NOW + 1,
Some(call_nonce(2)),
event_nonce(2),
)
.expect("local Create")
.call_id;
store
.publish_local_at(
intent(remote_call, CallToPlayLocalAction::Rsvp),
"Local".to_owned(),
NOW + 2,
None,
event_nonce(3),
)
.expect("local RSVP to the remote call");
store.observe_prepared_remote(prepare(
participant,
generations[1],
session(3),
snapshot(
1,
"Bob",
vec![action_event(
remote_call,
4,
NOW + 3,
CallToPlayAction::Rsvp,
)],
),
));
assert_eq!(store.remote_author_count(), 2);
assert_eq!(
store.view_at(NOW + 4).expect("combined view").events.len(),
4
);
let local_before = store.local.clone();
let projections_before = store.projection_count;
let (publication, diagnostic) = store.clear_remote_authors_and_project_at(Ok(NOW + 4));
assert_eq!(diagnostic, None);
assert_eq!(store.local, local_before);
assert_eq!(publication.local_revision, local_before.revision);
assert!(!publication.local_changed);
assert_eq!(store.remote_author_count(), 0);
assert!(store.remote_author_state(creator).is_none());
assert!(store.remote_author_state(participant).is_none());
assert_eq!(store.projection_count, projections_before + 1);
assert_eq!(publication.view.events.len(), 1);
assert_eq!(publication.view.events[0].call_id, local_call);
assert_eq!(publication.view.events[0].author_id, local);
assert_eq!(
store
.local
.events
.iter()
.map(|event| event.id)
.collect::<Vec<_>>(),
[event_nonce(2), event_nonce(3)],
"the root-gated view must not erase the local author slice"
);
}
#[test]
fn bulk_remote_clear_falls_back_after_clock_or_prune_failure() {
let generation = endpoint_generations(1)[0];
let remote = peer(1);
let local = peer(2);
let other_remote = peer(3);
let remote_call = CallId::new(remote, call_nonce(1));
let mut clock_failure = store(local);
let local_call = clock_failure
.publish_local_at(
create_intent(NOW + 60_000),
"Local".to_owned(),
NOW,
Some(call_nonce(2)),
event_nonce(1),
)
.expect("local Create")
.call_id;
clock_failure.observe_prepared_remote(prepare(
remote,
generation,
session(2),
snapshot(
1,
"Remote",
vec![create_event(remote, remote_call, 2, NOW, NOW + 60_000)],
),
));
clock_failure.observe_prepared_remote(prepare(
other_remote,
generation,
session(3),
snapshot(0, "Other", Vec::new()),
));
assert_eq!(clock_failure.remote_author_count(), 2);
let local_before = clock_failure.local.clone();
let (publication, diagnostic) = clock_failure
.clear_remote_authors_and_project_at(Err(CallToPlayMutationError::ClockUnavailable));
assert_eq!(diagnostic, Some(CallToPlayMutationError::ClockUnavailable));
assert_eq!(clock_failure.local, local_before);
assert_eq!(clock_failure.remote_author_count(), 0);
assert_eq!(publication.local_revision, local_before.revision);
assert!(!publication.local_changed);
assert_eq!(publication.view.events.len(), 1);
assert_eq!(publication.view.events[0].call_id, local_call);
let deadline = NOW + 100;
let mut prune_failure = store(local);
prune_failure
.publish_local_at(
create_intent(deadline),
"Local".to_owned(),
NOW,
Some(call_nonce(3)),
event_nonce(3),
)
.expect("expiring local Create");
prune_failure.local.revision = u64::MAX;
prune_failure.observe_prepared_remote(prepare(
remote,
generation,
session(2),
snapshot(
1,
"Remote",
vec![create_event(remote, remote_call, 4, NOW, NOW + 60_000)],
),
));
prune_failure.observe_prepared_remote(prepare(
other_remote,
generation,
session(3),
snapshot(0, "Other", Vec::new()),
));
assert_eq!(prune_failure.remote_author_count(), 2);
let local_before = prune_failure.local.clone();
let (publication, diagnostic) = prune_failure
.clear_remote_authors_and_project_at(Ok(deadline + EXPIRED_RETENTION_MS + 1));
assert_eq!(diagnostic, Some(CallToPlayMutationError::RevisionExhausted));
assert_eq!(prune_failure.local, local_before);
assert_eq!(prune_failure.remote_author_count(), 0);
assert_eq!(publication.local_revision, u64::MAX);
assert!(!publication.local_changed);
assert!(publication.view.events.is_empty());
}
#[test]
fn bulk_remote_clear_is_idempotent_after_one_normal_local_prune() {
let generation = endpoint_generations(1)[0];
let remote = peer(1);
let local = peer(2);
let deadline = NOW + 100;
let mut store = store(local);
store
.publish_local_at(
create_intent(deadline),
"Local".to_owned(),
NOW,
Some(call_nonce(1)),
event_nonce(1),
)
.expect("expiring local Create");
let remote_call = CallId::new(remote, call_nonce(2));
store.observe_prepared_remote(prepare(
remote,
generation,
session(2),
snapshot(
1,
"Remote",
vec![create_event(remote, remote_call, 2, NOW, NOW + 60_000)],
),
));
let clear_at = deadline + EXPIRED_RETENTION_MS + 1;
let (first, first_diagnostic) = store.clear_remote_authors_and_project_at(Ok(clear_at));
assert_eq!(first_diagnostic, None);
assert!(first.local_changed);
assert_eq!(first.local_revision, 2);
assert!(first.view.events.is_empty());
assert_eq!(store.remote_author_count(), 0);
let local_after_first = store.local.clone();
let (second, second_diagnostic) = store.clear_remote_authors_and_project_at(Ok(clear_at));
assert_eq!(second_diagnostic, None);
assert!(!second.local_changed);
assert_eq!(second.local_revision, 2);
assert_eq!(second.view, first.view);
assert_eq!(store.local, local_after_first);
assert_eq!(store.remote_author_count(), 0);
}
#[test]
fn prepared_publication_freezes_one_boundary_and_fails_before_remote_commit() {
let local = peer(1);
let deadline = NOW + 1_000;
let mut pruning_store = store(local);
pruning_store
.publish_local_at(
create_intent(deadline),
"Local".to_owned(),
NOW,
Some(call_nonce(1)),
event_nonce(1),
)
.expect("create");
let exact = pruning_store
.prepare_publication_at(deadline + EXPIRED_RETENTION_MS)
.expect("exact boundary preparation");
assert!(!exact.local_changed());
let publication = pruning_store.view_from_prepared(exact);
assert_eq!(publication.view.events.len(), 1);
assert_eq!(publication.local_revision, 1);
let expired = pruning_store
.prepare_publication_at(deadline + EXPIRED_RETENTION_MS + 1)
.expect("past-boundary preparation");
assert!(expired.local_changed());
assert_eq!(
pruning_store.local.revision, 1,
"preparation is transactional"
);
drop(expired);
assert_eq!(
pruning_store.local.revision, 1,
"dropping a token is a no-op"
);
assert_eq!(pruning_store.local.events.len(), 1);
let expired = pruning_store
.prepare_publication_at(deadline + EXPIRED_RETENTION_MS + 1)
.expect("repeat past-boundary preparation");
let publication = pruning_store.view_from_prepared(expired);
assert!(publication.view.events.is_empty());
assert_eq!(publication.local_revision, 2);
assert!(publication.local_changed);
let mut exhausted = store(local);
exhausted
.publish_local_at(
create_intent(deadline),
"Local".to_owned(),
NOW,
Some(call_nonce(2)),
event_nonce(2),
)
.expect("create");
exhausted.local.revision = u64::MAX;
let before = exhausted.local.clone();
assert!(matches!(
exhausted.prepare_publication_at(deadline + EXPIRED_RETENTION_MS + 1),
Err(CallToPlayMutationError::RevisionExhausted)
));
assert_eq!(exhausted.local, before);
}
#[test]
fn an_old_prepared_projection_cannot_overwrite_a_newer_timer_view() {
let author = peer(1);
let generation = endpoint_generations(1)[0];
let call_id = CallId::new(author, call_nonce(1));
let deadline = NOW + 100;
let mut store = store(peer(2));
store.observe_prepared_remote(prepare(
author,
generation,
session(2),
snapshot(
1,
"Alice",
vec![create_event(author, call_id, 1, NOW, deadline)],
),
));
let old = store
.prepare_publication_at(deadline + EXPIRED_RETENTION_MS)
.expect("old projection");
let newer = store
.publication_at(deadline + EXPIRED_RETENTION_MS + 1)
.expect("newer projection");
assert!(newer.view.events.is_empty());
let replayed = store.view_from_prepared(old);
assert!(replayed.view.events.is_empty());
}
#[test]
fn add_time_recovers_during_the_five_minute_window() {
let local = peer(1);
let mut store = store(local);
let deadline = NOW + 100;
let created = store
.publish_local_at(
create_intent(deadline),
"Local".to_owned(),
NOW,
Some(call_nonce(1)),
event_nonce(1),
)
.expect("create");
let recovery_time = deadline + EXPIRED_RETENTION_MS;
store
.publish_local_at(
intent(
created.call_id,
CallToPlayLocalAction::AddTime {
deadline: recovery_time + 60_000,
},
),
"Local".to_owned(),
recovery_time,
None,
event_nonce(2),
)
.expect("AddTime at the exact recovery boundary should revive the call");
assert_eq!(
store
.local_snapshot_at(deadline + EXPIRED_RETENTION_MS + 1)
.expect("snapshot")
.events
.len(),
2
);
}
#[test]
fn participant_slice_is_retained_hidden_and_creator_departure_hides_call() {
let local = peer(3);
let creator = peer(1);
let participant = peer(2);
let call_id = CallId::new(creator, call_nonce(1));
let generations = endpoint_generations(3);
let mut store = store(local);
let participant_event = action_event(call_id, 2, NOW + 1, CallToPlayAction::Rsvp);
assert!(matches!(
store.observe_prepared_remote(prepare(
participant,
generations[0],
session(2),
snapshot(1, "Same name", vec![participant_event.clone()]),
)),
ObserveRemoteAuthorOutcome::Applied { .. }
));
assert!(store.view_at(NOW + 2).expect("view").events.is_empty());
assert!(store.remote_author_state(participant).is_some());
let root = create_event(creator, call_id, 1, NOW, NOW + 60_000);
store.observe_prepared_remote(prepare(
creator,
generations[1],
session(3),
snapshot(1, "Same name", vec![root.clone()]),
));
let view = store.view_at(NOW + 2).expect("view");
assert_eq!(view.events.len(), 2);
assert_eq!(view.events[0].author_id, creator);
assert_eq!(view.events[1].author_id, participant);
assert_eq!(view.events[0].author_name, view.events[1].author_name);
assert!(store.remove_remote_author_if_generation(participant, generations[0]));
let view = store.view_at(NOW + 2).expect("participant departure view");
assert_eq!(view.events.len(), 1);
assert_eq!(view.events[0].author_id, creator);
assert!(matches!(
store.observe_prepared_remote(prepare(
participant,
generations[0],
session(2),
snapshot(1, "Same name", vec![participant_event]),
)),
ObserveRemoteAuthorOutcome::Applied { .. }
));
assert!(!store.remove_remote_author_if_generation(creator, generations[0]));
assert!(store.remove_remote_author_if_generation(creator, generations[1]));
assert!(store.view_at(NOW + 2).expect("view").events.is_empty());
assert!(store.remote_author_state(participant).is_some());
}
#[test]
#[expect(
clippy::too_many_lines,
reason = "one state-transition matrix keeps its shared setup and assertions together"
)]
fn same_session_stale_and_invalid_preserve_and_rebind_but_new_invalid_clears() {
let local = peer(9);
let author = peer(1);
let call_id = CallId::new(author, call_nonce(1));
let generations = endpoint_generations(6);
let mut store = store(local);
let root = create_event(author, call_id, 1, NOW, NOW + 60_000);
assert_eq!(
store.observe_prepared_remote(prepare(
author,
generations[0],
session(2),
snapshot(5, "Alice", vec![root.clone()]),
)),
ObserveRemoteAuthorOutcome::Applied {
session_changed: true
}
);
assert_eq!(
store.observe_prepared_remote(prepare(
author,
generations[1],
session(2),
snapshot(4, "Lower", vec![root.clone()]),
)),
ObserveRemoteAuthorOutcome::IgnoredStale {
generation_rebound: true
}
);
assert_eq!(
store
.remote_author_state(author)
.expect("remote author should remain")
.revision,
5
);
assert_eq!(
store.observe_prepared_remote(prepare(
author,
generations[1],
session(2),
snapshot(5, "Alice", vec![root.clone()]),
)),
ObserveRemoteAuthorOutcome::Unchanged {
generation_rebound: false
}
);
assert!(matches!(
store.observe_prepared_remote(prepare(
author,
generations[2],
session(2),
snapshot(5, "Equal conflict", vec![root]),
)),
ObserveRemoteAuthorOutcome::EqualRevisionConflict {
generation_rebound: true
}
));
assert_eq!(
store.view_at(NOW).expect("view").events[0].author_name,
"Alice"
);
assert!(matches!(
store.observe_prepared_remote(prepare(
author,
generations[3],
session(2),
snapshot(6, " ", Vec::new()),
)),
ObserveRemoteAuthorOutcome::InvalidPreserved {
generation_rebound: true,
..
}
));
assert_eq!(
store
.remote_author_state(author)
.expect("preserved")
.endpoint_generation,
generations[3]
);
assert!(!store.remove_remote_author_if_generation(author, generations[2]));
assert!(matches!(
store.observe_prepared_remote(prepare(
author,
generations[4],
session(3),
snapshot(0, " ", Vec::new()),
)),
ObserveRemoteAuthorOutcome::InvalidCleared(_)
));
assert!(store.remote_author_state(author).is_none());
assert!(matches!(
store.observe_prepared_remote(prepare(
author,
generations[5],
session(3),
snapshot(0, "Restarted", Vec::new()),
)),
ObserveRemoteAuthorOutcome::Applied {
session_changed: true
}
));
assert_eq!(
store
.remote_author_state(author)
.expect("restarted remote author should exist")
.revision,
0
);
}
#[test]
#[expect(
clippy::too_many_lines,
reason = "one author-isolation matrix keeps its shared fixtures and assertions together"
)]
fn invalid_duplicates_order_authority_and_bytes_are_author_isolated() {
let generations = endpoint_generations(2);
let author = peer(1);
let other_creator = peer(2);
let own_call = CallId::new(author, call_nonce(1));
let other_call = CallId::new(other_creator, call_nonce(2));
let duplicate = action_event(other_call, 1, NOW, CallToPlayAction::Rsvp);
let prepared = prepare(
author,
generations[0],
session(2),
snapshot(1, "Alice", vec![duplicate.clone(), duplicate]),
);
assert!(matches!(
prepared.validation_error(),
Some(CallToPlayValidationError::DuplicateEventId(_))
));
let prepared = prepare(
author,
generations[0],
session(2),
snapshot(
1,
"Alice",
vec![
action_event(other_call, 1, NOW + 1, CallToPlayAction::Rsvp),
action_event(other_call, 2, NOW, CallToPlayAction::Leave),
],
),
);
assert_eq!(
prepared.validation_error(),
Some(&CallToPlayValidationError::NonMonotonicAuthorHistory)
);
let prepared = prepare(
author,
generations[0],
session(2),
snapshot(
1,
"Alice",
vec![create_event(other_creator, other_call, 1, NOW, NOW + 1_000)],
),
);
assert!(matches!(
prepared.validation_error(),
Some(CallToPlayValidationError::UnauthorizedAction { .. })
));
let overflow = prepare(
author,
generations[0],
session(2),
snapshot(
1,
"Alice",
vec![create_event(author, own_call, 9, NOW, i64::MAX)],
),
);
assert!(matches!(
overflow.validation_error(),
Some(CallToPlayValidationError::InvalidEvent {
reason: "timestamp overflows its retention boundary",
..
})
));
let mut oversized_events = Vec::with_capacity(MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR);
let text = "😀".repeat(250);
for id in 0..MAX_CALL_TO_PLAY_EVENTS_PER_AUTHOR as u128 {
oversized_events.push(action_event(
other_call,
id,
NOW + i64::try_from(id).expect("event index fits in i64"),
CallToPlayAction::SendMessage { text: text.clone() },
));
}
let oversized = prepare(
author,
generations[0],
session(2),
snapshot(1, "Alice", oversized_events),
);
assert!(matches!(
oversized.validation_error(),
Some(CallToPlayValidationError::Wire(
ControlValidationError::EncodedTooLarge { .. }
))
));
let mut store = store(peer(9));
let valid_root = create_event(author, own_call, 3, NOW, NOW + 60_000);
store.observe_prepared_remote(prepare(
author,
generations[0],
session(2),
snapshot(1, "Alice", vec![valid_root]),
));
assert!(matches!(
store.observe_prepared_remote(oversized),
ObserveRemoteAuthorOutcome::InvalidPreserved { .. }
));
assert_eq!(
store
.remote_author_state(author)
.expect("valid remote author should remain")
.revision,
1
);
}
#[test]
fn remote_expiry_hides_without_mutating_the_accepted_revision() {
let generation = endpoint_generations(1)[0];
let author = peer(1);
let call_id = CallId::new(author, call_nonce(1));
let deadline = NOW + 100;
let mut store = store(peer(2));
store.observe_prepared_remote(prepare(
author,
generation,
session(2),
snapshot(
7,
"Alice",
vec![create_event(author, call_id, 1, NOW, deadline)],
),
));
assert_eq!(
store
.view_at(deadline + EXPIRED_RETENTION_MS)
.expect("exact boundary")
.events
.len(),
1
);
assert!(
store
.view_at(deadline + EXPIRED_RETENTION_MS + 1)
.expect("expired view")
.events
.is_empty()
);
assert_eq!(
store
.remote_author_state(author)
.expect("expired remote author should remain cached")
.revision,
7
);
}
#[test]
fn remote_author_capacity_does_not_consume_local_capacity() {
let generation = endpoint_generations(1)[0];
let local = peer(200);
let mut store = store(local);
for seed in
1..u8::try_from(MAX_CALL_TO_PLAY_AUTHORS).expect("author limit fits in one byte")
{
assert!(matches!(
store.observe_prepared_remote(prepare(
peer(seed),
generation,
session(seed),
snapshot(0, "Peer", Vec::new()),
)),
ObserveRemoteAuthorOutcome::Applied { .. }
));
}
assert_eq!(
store.remote_author_count() + 1,
MAX_CALL_TO_PLAY_AUTHORS,
"the local author counts toward the total-author cap"
);
assert_eq!(
store.observe_prepared_remote(prepare(
peer(100),
generation,
session(100),
snapshot(0, "Extra", Vec::new()),
)),
ObserveRemoteAuthorOutcome::AtCapacity
);
let receipt = store
.publish_local_at(
create_intent(NOW + 60_000),
"Local".to_owned(),
NOW,
Some(call_nonce(1)),
event_nonce(1),
)
.expect("remote capacity must not block local publication");
assert_eq!(receipt.revision, 1);
}
#[test]
fn full_view_is_deterministic_and_wholly_recomputed() {
let local = peer(3);
let creator = peer(1);
let participant = peer(2);
let call_id = CallId::new(creator, call_nonce(1));
let generations = endpoint_generations(2);
let mut store = store(local);
store.observe_prepared_remote(prepare(
participant,
generations[0],
session(2),
snapshot(
1,
"Bob",
vec![
action_event(call_id, 3, NOW + 2, CallToPlayAction::Leave),
action_event(call_id, 2, NOW + 1, CallToPlayAction::Rsvp),
],
),
));
assert!(store.remote_author_state(participant).is_none());
store.observe_prepared_remote(prepare(
participant,
generations[0],
session(2),
snapshot(
2,
"Bob",
vec![
action_event(call_id, 2, NOW + 1, CallToPlayAction::Rsvp),
action_event(call_id, 3, NOW + 2, CallToPlayAction::Leave),
],
),
));
store.observe_prepared_remote(prepare(
creator,
generations[1],
session(3),
snapshot(
1,
"Alice",
vec![create_event(creator, call_id, 1, NOW, NOW + 60_000)],
),
));
let first = store.view_at(NOW + 3).expect("view");
let second = store.view_at(NOW + 3).expect("view");
assert_eq!(first, second);
assert_eq!(
first
.events
.iter()
.map(|event| event.id)
.collect::<Vec<_>>(),
[event_nonce(1), event_nonce(2), event_nonce(3)]
);
assert!(matches!(
store.observe_prepared_remote(prepare(
participant,
generations[0],
session(2),
snapshot(3, "Bob", Vec::new()),
)),
ObserveRemoteAuthorOutcome::Applied {
session_changed: false
}
));
let replaced = store.view_at(NOW + 3).expect("replacement view");
assert_eq!(replaced.events.len(), 1);
assert_eq!(replaced.events[0].author_id, creator);
assert!(store.remove_remote_author_if_generation(participant, generations[0]));
let replaced = store.view_at(NOW + 3).expect("view");
assert_eq!(replaced.events.len(), 1);
assert_eq!(replaced.events[0].author_id, creator);
}
}